diff --git a/frontend/client/components/CreateFlow/Preview.tsx b/frontend/client/components/CreateFlow/Preview.tsx index 2b46302f..a101d0c4 100644 --- a/frontend/client/components/CreateFlow/Preview.tsx +++ b/frontend/client/components/CreateFlow/Preview.tsx @@ -25,6 +25,7 @@ class CreateFlowPreview extends React.Component { banner /> null} proposal={proposal} diff --git a/frontend/client/components/Placeholder/style.less b/frontend/client/components/Placeholder/style.less index 609c83a2..080e2f46 100644 --- a/frontend/client/components/Placeholder/style.less +++ b/frontend/client/components/Placeholder/style.less @@ -3,6 +3,7 @@ flex-direction: column; justify-content: center; align-items: center; + text-align: center; border: 2px dashed #d9d9d9; padding: 3rem; border-radius: 8px; diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index a7cc3636..2790ddb6 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -89,7 +89,9 @@ export class ProposalCampaignBlock extends React.Component { if (proposal) { const { crowdFund } = proposal; const isFundingOver = - crowdFund.isRaiseGoalReached || crowdFund.deadline < Date.now(); + crowdFund.isRaiseGoalReached || + crowdFund.deadline < Date.now() || + crowdFund.isFrozen; const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview; const remainingEthNum = parseFloat( web3.utils.fromWei(crowdFund.target.sub(crowdFund.funded), 'ether'), @@ -150,7 +152,7 @@ export class ProposalCampaignBlock extends React.Component { ) : ( <> - Proposal didn’t reach target + Proposal didn’t get funded )} diff --git a/frontend/client/components/Proposal/CancelModal.tsx b/frontend/client/components/Proposal/CancelModal.tsx new file mode 100644 index 00000000..2ddd8532 --- /dev/null +++ b/frontend/client/components/Proposal/CancelModal.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Modal, Alert } from 'antd'; +import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; +import { web3Actions } from 'modules/web3'; +import { AppState } from 'store/reducers'; + +interface OwnProps { + proposal: ProposalWithCrowdFund; + isVisible: boolean; + handleClose(): void; +} + +interface StateProps { + isRefundActionPending: AppState['web3']['isRefundActionPending']; + refundActionError: AppState['web3']['refundActionError']; +} + +interface DispatchProps { + triggerRefund: typeof web3Actions['triggerRefund']; +} + +type Props = StateProps & DispatchProps & OwnProps; + +class CancelModal extends React.Component { + componentDidUpdate() { + if (this.props.proposal.crowdFund.isFrozen) { + this.props.handleClose(); + } + } + + render() { + const { proposal, isVisible, isRefundActionPending, refundActionError } = this.props; + const hasBeenFunded = proposal.crowdFund.isRaiseGoalReached; + const hasContributors = !!proposal.crowdFund.contributors.length; + const disabled = isRefundActionPending; + + return ( + Cancel proposal} + visible={isVisible} + okText="Confirm" + cancelText="Never mind" + onOk={this.cancelProposal} + onCancel={this.closeModal} + okButtonProps={{ type: 'danger', loading: disabled }} + cancelButtonProps={{ disabled }} + > + {hasBeenFunded ? ( +

+ Are you sure you would like to issue a refund?{' '} + This cannot be undone. Once you issue a refund, all + contributors will be able to receive a refund of the remaining proposal + balance. +

+ ) : ( +

+ Are you sure you would like to cancel this proposal?{' '} + This cannot be undone. Once you cancel it, all contributors + will be able to receive refunds. +

+ )} +

+ Canceled proposals cannot be deleted and will still be viewable by contributors + or anyone with a direct link. However, they will be de-listed everywhere else on + Grant.io. +

+ {hasContributors && ( +

+ Should you choose to cancel, we highly recommend posting an update to let your + contributors know why you’ve decided to do so. +

+ )} + {refundActionError && ( + + )} +
+ ); + } + + private closeModal = () => { + if (!this.props.isRefundActionPending) { + this.props.handleClose(); + } + }; + + private cancelProposal = () => { + this.props.triggerRefund(this.props.proposal.crowdFundContract); + }; +} + +export default connect( + state => ({ + isRefundActionPending: state.web3.isRefundActionPending, + refundActionError: state.web3.refundActionError, + }), + { + triggerRefund: web3Actions.triggerRefund, + }, +)(CancelModal); diff --git a/frontend/client/components/Proposal/Governance/Milestones.tsx b/frontend/client/components/Proposal/Governance/Milestones.tsx index b4a3641a..0831f537 100644 --- a/frontend/client/components/Proposal/Governance/Milestones.tsx +++ b/frontend/client/components/Proposal/Governance/Milestones.tsx @@ -7,6 +7,7 @@ import { web3Actions } from 'modules/web3'; import { AppState } from 'store/reducers'; import Web3Container, { Web3RenderProps } from 'lib/Web3Container'; import UnitDisplay from 'components/UnitDisplay'; +import Placeholder from 'components/Placeholder'; interface OwnProps { proposal: ProposalWithCrowdFund; @@ -38,6 +39,19 @@ export class Milestones extends React.Component { milestoneActionError, } = this.props; const { crowdFund } = proposal; + + if (!crowdFund.isRaiseGoalReached) { + return ( + + ); + } + const contributor = crowdFund.contributors.find(c => c.address === accounts[0]); const isTrustee = crowdFund.trustees.includes(accounts[0]); const firstMilestone = crowdFund.milestones[0]; diff --git a/frontend/client/components/Proposal/Governance/Refunds.tsx b/frontend/client/components/Proposal/Governance/Refunds.tsx index e13133eb..eb5b2573 100644 --- a/frontend/client/components/Proposal/Governance/Refunds.tsx +++ b/frontend/client/components/Proposal/Governance/Refunds.tsx @@ -6,6 +6,7 @@ import Web3Container, { Web3RenderProps } from 'lib/Web3Container'; import { web3Actions } from 'modules/web3'; import { AppState } from 'store/reducers'; import classnames from 'classnames'; +import Placeholder from 'components/Placeholder'; interface OwnProps { proposal: ProposalWithCrowdFund; @@ -32,16 +33,63 @@ class GovernanceRefunds extends React.Component { render() { const { proposal, account, isRefundActionPending, refundActionError } = this.props; const { crowdFund } = proposal; + const isStillFunding = + !crowdFund.isRaiseGoalReached && crowdFund.deadline > Date.now(); + + if (isStillFunding && !crowdFund.isFrozen) { + return ( + + ); + } + + // TODO: Cyclomatic complexity is too damn high here. This state should be + // figured out on the backend and enumerated, not calculated in this component. 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 = crowdFund.percentVotingForRefund; + const didFundingFail = + !crowdFund.isRaiseGoalReached && crowdFund.deadline < Date.now(); let text; let button; if (!isTrustee && contributor) { - if (refundPct < 50) { + let canRefund = false; + if (hasRefunded) { + return ( + + ); + } else if (refundPct > 50) { + text = ` + The majority of funders have voted for a refund. Click below + to receive your refund. + `; + canRefund = true; + } else if (didFundingFail || crowdFund.isFrozen) { + text = ` + The project was either canceled, or failed to reach its funding + target before the deadline. Click below to receive a refund of + your contribution. + `; + canRefund = true; + } else { 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 @@ -60,47 +108,38 @@ class GovernanceRefunds extends React.Component { 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 && ( - + {text} + {!crowdFund.isFrozen && ( + - )} - - ); - button = { - text: 'Get your refund', - type: 'primary', - onClick: () => this.withdrawRefund(), - }; - } + /> + )} + + ); + button = { + text: 'Get your refund', + type: 'primary', + onClick: () => this.withdrawRefund(), + }; } } else { - if (refundPct < 50) { + if (crowdFund.isFrozen || didFundingFail) { + text = ` + The project failed to receive funding or was canceled. Contributors are + open to refund their contributions. + `; + } 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 diff --git a/frontend/client/components/Proposal/Governance/index.tsx b/frontend/client/components/Proposal/Governance/index.tsx index 9e8faf33..2036efa6 100644 --- a/frontend/client/components/Proposal/Governance/index.tsx +++ b/frontend/client/components/Proposal/Governance/index.tsx @@ -2,7 +2,6 @@ import React from 'react'; import GovernanceMilestones from './Milestones'; import GovernanceRefunds from './Refunds'; import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; -import Placeholder from 'components/Placeholder'; import './style.less'; interface Props { @@ -12,29 +11,15 @@ interface Props { export default class ProposalGovernance extends React.Component { render() { const { proposal } = this.props; - - if (!proposal.crowdFund.isRaiseGoalReached) { - return ( - - ); - } - return (
-
-

Milestone Voting

+
+

Milestone Voting

-
-

Refunds

+
+

Refunds

diff --git a/frontend/client/components/Proposal/Governance/style.less b/frontend/client/components/Proposal/Governance/style.less index 900988ce..59c737f2 100644 --- a/frontend/client/components/Proposal/Governance/style.less +++ b/frontend/client/components/Proposal/Governance/style.less @@ -8,6 +8,15 @@ flex-direction: column; } + &-section { + flex: 1; + + &-title { + font-weight: bold; + margin-bottom: 1rem; + } + } + &-divider { width: 1px; background: rgba(0, 0, 0, 0.05); diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index 47623ca6..e321ef5e 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -7,16 +7,16 @@ import { bindActionCreators, Dispatch } from 'redux'; import { AppState } from 'store/reducers'; import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; import { getProposal } from 'modules/proposals/selectors'; -import { Spin, Tabs, Icon } from 'antd'; +import { Spin, Tabs, Icon, Dropdown, Menu, Button } from 'antd'; import CampaignBlock from './CampaignBlock'; import TeamBlock from './TeamBlock'; import Milestones from './Milestones'; - import CommentsTab from './Comments'; import UpdatesTab from './Updates'; import GovernanceTab from './Governance'; import ContributorsTab from './Contributors'; // import CommunityTab from './Community'; +import CancelModal from './CancelModal'; import './style.less'; import classnames from 'classnames'; import { withRouter } from 'react-router'; @@ -36,11 +36,16 @@ interface DispatchProps { fetchProposal: proposalActions.TFetchProposal; } -type Props = StateProps & DispatchProps & OwnProps; +interface Web3Props { + account: string; +} + +type Props = StateProps & DispatchProps & Web3Props & OwnProps; interface State { isBodyExpanded: boolean; isBodyOverflowing: boolean; + isCancelOpen: boolean; bodyId: string; } @@ -48,6 +53,7 @@ export class ProposalDetail extends React.Component { state: State = { isBodyExpanded: false, isBodyOverflowing: false, + isCancelOpen: false, bodyId: `body-${Math.floor(Math.random() * 1000000)}`, }; @@ -71,14 +77,37 @@ export class ProposalDetail extends React.Component { } render() { - const { proposal, isPreview } = this.props; - const { isBodyExpanded, isBodyOverflowing, bodyId } = this.state; + const { proposal, isPreview, account } = this.props; + const { isBodyExpanded, isBodyOverflowing, isCancelOpen, bodyId } = this.state; const showExpand = !isBodyExpanded && isBodyOverflowing; if (!proposal) { return ; } else { const { crowdFund } = proposal; + const isTrustee = crowdFund.trustees.includes(account); + const hasBeenFunded = crowdFund.isRaiseGoalReached; + const isProposalActive = !hasBeenFunded && crowdFund.deadline > Date.now(); + const canRefund = (hasBeenFunded || isProposalActive) && !crowdFund.isFrozen; + + const adminMenu = isTrustee && ( + + alert('Sorry, not yet implemented!')} + disabled={!isProposalActive} + > + Edit proposal + + + {hasBeenFunded ? 'Refund contributors' : 'Cancel proposal'} + + + ); + return (
@@ -105,6 +134,20 @@ export class ProposalDetail extends React.Component { )}
+ {isTrustee && ( +
+ + + +
+ )}
@@ -134,6 +177,13 @@ export class ProposalDetail extends React.Component { )} + {isTrustee && ( + + )}
); } @@ -161,6 +211,9 @@ export class ProposalDetail extends React.Component { this.setState({ isBodyOverflowing: true }); } }; + + private openCancelModal = () => this.setState({ isCancelOpen: true }); + private closeCancelModal = () => this.setState({ isCancelOpen: false }); } function mapStateToProps(state: AppState, ownProps: OwnProps) { @@ -178,7 +231,7 @@ const withConnect = connect( mapDispatchToProps, ); -const ConnectedProposal = compose( +const ConnectedProposal = compose( withRouter, withConnect, )(ProposalDetail); @@ -194,6 +247,6 @@ export default (props: OwnProps) => (
)} - render={() => } + render={({ accounts }) => } /> ); diff --git a/frontend/client/components/Proposal/style.less b/frontend/client/components/Proposal/style.less index c1148092..9ce7c606 100644 --- a/frontend/client/components/Proposal/style.less +++ b/frontend/client/components/Proposal/style.less @@ -15,6 +15,7 @@ } &-main { + position: relative; display: flex; flex-direction: column; width: calc(100% - 19rem); @@ -104,6 +105,12 @@ } } } + + &-menu { + position: absolute; + top: 1rem; + right: 0; + } } &-side { diff --git a/frontend/client/lib/Web3Container.tsx b/frontend/client/lib/Web3Container.tsx index 301e6cae..f71d378c 100644 --- a/frontend/client/lib/Web3Container.tsx +++ b/frontend/client/lib/Web3Container.tsx @@ -14,7 +14,7 @@ export interface Web3RenderProps { } interface OwnProps { - render(props: Web3RenderProps & any): React.ReactNode; + render(props: Web3RenderProps & { props: any }): React.ReactNode; renderLoading(): React.ReactNode; } diff --git a/frontend/client/modules/web3/actions.ts b/frontend/client/modules/web3/actions.ts index c425e6c3..c92a653a 100644 --- a/frontend/client/modules/web3/actions.ts +++ b/frontend/client/modules/web3/actions.ts @@ -332,6 +332,49 @@ export function voteRefund(crowdFundContract: any, vote: boolean) { }; } +async function freezeContract(crowdFundContract: any, account: string) { + let isFrozen = await crowdFundContract.methods.frozen().call({ from: account }); + // Already frozen, all good here + if (isFrozen) { + return; + } + + await new Promise((resolve, reject) => { + crowdFundContract.methods + .refund() + .send({ from: account }) + .once('confirmation', async () => { + await sleep(5000); + isFrozen = await crowdFundContract.methods.frozen().call({ from: account }); + resolve(); + }) + .catch((err: Error) => reject(err)); + }); + if (!isFrozen) { + throw new Error('Proposal isn’t in a refundable state yet.'); + } +} + +export function triggerRefund(crowdFundContract: any) { + return async (dispatch: Dispatch, getState: GetState) => { + dispatch({ type: types.WITHDRAW_REFUND_PENDING }); + const state = getState(); + const account = state.web3.accounts[0]; + + try { + await freezeContract(crowdFundContract, account); + await dispatch(fetchProposal(crowdFundContract._address)); + dispatch({ type: types.TRIGGER_REFUND_FULFILLED }); + } catch (err) { + dispatch({ + type: types.TRIGGER_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 }); @@ -339,24 +382,7 @@ export function withdrawRefund(crowdFundContract: any, address: string) { 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 freezeContract(crowdFundContract, account); await crowdFundContract.methods .withdraw(address) .send({ from: account }) diff --git a/frontend/client/modules/web3/reducers.ts b/frontend/client/modules/web3/reducers.ts index d3f6231c..78e7443d 100644 --- a/frontend/client/modules/web3/reducers.ts +++ b/frontend/client/modules/web3/reducers.ts @@ -214,6 +214,7 @@ export default (state = INITIAL_STATE, action: any): Web3State => { case types.VOTE_REFUND_PENDING: case types.WITHDRAW_REFUND_PENDING: + case types.TRIGGER_REFUND_PENDING: return { ...state, isRefundActionPending: true, @@ -221,12 +222,14 @@ export default (state = INITIAL_STATE, action: any): Web3State => { }; case types.VOTE_REFUND_FULFILLED: case types.WITHDRAW_REFUND_FULFILLED: + case types.TRIGGER_REFUND_FULFILLED: return { ...state, isRefundActionPending: false, }; case types.VOTE_REFUND_REJECTED: case types.WITHDRAW_REFUND_REJECTED: + case types.TRIGGER_REFUND_REJECTED: return { ...state, refundActionError: payload, diff --git a/frontend/client/modules/web3/types.ts b/frontend/client/modules/web3/types.ts index 8b8a33a4..eddc1e4d 100644 --- a/frontend/client/modules/web3/types.ts +++ b/frontend/client/modules/web3/types.ts @@ -46,6 +46,11 @@ enum web3Types { WITHDRAW_REFUND_REJECTED = 'WITHDRAW_REFUND_REJECTED', WITHDRAW_REFUND_PENDING = 'WITHDRAW_REFUND_PENDING', + TRIGGER_REFUND = 'TRIGGER_REFUND', + TRIGGER_REFUND_FULFILLED = 'TRIGGER_REFUND_FULFILLED', + TRIGGER_REFUND_REJECTED = 'TRIGGER_REFUND_REJECTED', + TRIGGER_REFUND_PENDING = 'TRIGGER_REFUND_PENDING', + ACCOUNTS = 'ACCOUNTS', ACCOUNTS_FULFILLED = 'ACCOUNTS_FULFILLED', ACCOUNTS_REJECTED = 'ACCOUNTS_REJECTED',