Proposal Lifecycle & Crowdfunding (#23)

* add proposal versioning

* remove deadlines

* update proposal lifecycle for admin

* update proposal lifecycle for backend

* update proposal lifecycle for frontend

* fix tests

* remove acceptedWithFunding

* fix lint, remove commented code

* remove commented code

* refactor backend to provide isVersionTwo

* refactor backend to provide isVersionTwo

* Revert "refactor backend to provide isVersionTwo"

This reverts commit e3b9bc661081e482326f83fa6aa517cf6bdebe6c.

* use isVersionTwo in admin

* add acceptedWithFunding

* trigger ci

* remove "version"

* remove "version"

* remove rejected from campaign block
This commit is contained in:
Danny Skubak 2019-10-16 23:43:20 -04:00 committed by Daniel Ternyak
parent 701a2f95a9
commit fb6b9b5af7
18 changed files with 195 additions and 331 deletions

View File

@ -30,10 +30,11 @@ class ArbiterControlNaked extends React.Component<Props, State> {
}, 1000);
render() {
const { arbiter } = this.props;
const { arbiter, isVersionTwo, acceptedWithFunding } = this.props;
const { showSearch, searching } = this.state;
const { results, search, error } = store.arbitersSearch;
const showEmpty = !results.length && !searching;
const buttonDisabled = isVersionTwo && acceptedWithFunding === false
const disp = {
[PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter',
@ -51,6 +52,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
type="primary"
onClick={this.handleShowSearch}
{...this.props.buttonProps}
disabled={buttonDisabled}
>
{disp[arbiter.status]}
</Button>

View File

@ -27,8 +27,9 @@
.ant-collapse {
margin-bottom: 16px;
button + button {
margin-left: 0.5rem;
button {
margin-right: 0.5rem;
margin-bottom: 0.25rem;
}
}

View File

@ -11,7 +11,6 @@ import {
Collapse,
Popconfirm,
Input,
Switch,
Tag,
message,
} from 'antd';
@ -26,7 +25,6 @@ import {
} from 'src/types';
import { Link } from 'react-router-dom';
import Back from 'components/Back';
import Info from 'components/Info';
import Markdown from 'components/Markdown';
import ArbiterControl from 'components/ArbiterControl';
import { toZat, fromZat } from 'src/util/units';
@ -65,6 +63,12 @@ class ProposalDetailNaked extends React.Component<Props, State> {
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
}, 100);
const { isVersionTwo } = p
const shouldShowArbiter =
!isVersionTwo ||
(isVersionTwo && p.acceptedWithFunding === true);
const cancelButtonText = isVersionTwo ? 'Cancel' : 'Cancel & refund'
const renderCancelControl = () => {
const disabled = this.getCancelAndRefundDisabled();
@ -95,7 +99,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
disabled={disabled}
block
>
Cancel & refund
{ cancelButtonText }
</Button>
</Popconfirm>
);
@ -116,68 +120,6 @@ class ProposalDetailNaked extends React.Component<Props, State> {
/>
);
const renderMatchingControl = () => (
<div className="ProposalDetail-controls-control">
<Popconfirm
overlayClassName="ProposalDetail-popover-overlay"
onConfirm={this.handleToggleMatching}
title={
<>
<div>
Turn {p.contributionMatching ? 'off' : 'on'} contribution matching?
</div>
{p.status === PROPOSAL_STATUS.LIVE && (
<div>
This is a LIVE proposal, this will alter the funding state of the
proposal!
</div>
)}
</>
}
okText="ok"
cancelText="cancel"
>
<Switch
checked={p.contributionMatching === 1}
loading={store.proposalDetailUpdating}
disabled={
p.isFailed ||
[PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
}
/>{' '}
</Popconfirm>
<span>
matching{' '}
<Info
placement="right"
content={
<span>
<b>Contribution matching</b>
<br /> Funded amount will be multiplied by 2.
<br /> <i>Disabled after proposal is fully-funded.</i>
</span>
}
/>
</span>
</div>
);
const renderBountyControl = () => (
<div className="ProposalDetail-controls-control">
<Button
icon="dollar"
className="ProposalDetail-controls-control"
loading={store.proposalDetailUpdating}
onClick={this.handleSetBounty}
disabled={
p.isFailed || [PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
}
block
>
Set bounty
</Button>
</div>
);
const renderApproved = () =>
p.status === PROPOSAL_STATUS.APPROVED && (
@ -205,9 +147,17 @@ class ProposalDetailNaked extends React.Component<Props, State> {
loading={store.proposalDetailApproving}
icon="check"
type="primary"
onClick={this.handleApprove}
onClick={() => this.handleApprove(true)}
>
Approve
Approve With Funding
</Button>
<Button
loading={store.proposalDetailApproving}
icon="check"
type="default"
onClick={() => this.handleApprove(false)}
>
Approve Without Funding
</Button>
<Button
loading={store.proposalDetailApproving}
@ -250,7 +200,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
);
const renderNominateArbiter = () =>
needsArbiter && (
needsArbiter && shouldShowArbiter && (
<Alert
showIcon
type="warning"
@ -381,7 +331,6 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderMilestoneAccepted()}
{renderFailed()}
<Collapse defaultActiveKey={['brief', 'content', 'milestones']}>
<Collapse.Panel key="brief" header="brief">
{p.brief}
</Collapse.Panel>
@ -391,24 +340,25 @@ class ProposalDetailNaked extends React.Component<Props, State> {
</Collapse.Panel>
<Collapse.Panel key="milestones" header="milestones">
{
p.milestones.map((milestone, i) =>
<Card title={
<>
{milestone.title + ' '}
{milestone.immediatePayout && <Tag color="magenta">Immediate Payout</Tag>}
</>
}
extra={`${milestone.payoutPercent}% Payout`}
key={i}
>
<p><b>Estimated Date:</b> {formatDateSeconds(milestone.dateEstimated )} </p>
<p>{milestone.content}</p>
</Card>
)
}
{p.milestones.map((milestone, i) => (
<Card
title={
<>
{milestone.title + ' '}
{milestone.immediatePayout && (
<Tag color="magenta">Immediate Payout</Tag>
)}
</>
}
extra={`${milestone.payoutPercent}% Payout`}
key={i}
>
<p>
<b>Estimated Date:</b> {formatDateSeconds(milestone.dateEstimated)}{' '}
</p>
<p>{milestone.content}</p>
</Card>
))}
</Collapse.Panel>
<Collapse.Panel key="json" header="json">
@ -423,8 +373,6 @@ class ProposalDetailNaked extends React.Component<Props, State> {
<Card size="small" className="ProposalDetail-controls">
{renderCancelControl()}
{renderArbiterControl()}
{renderBountyControl()}
{renderMatchingControl()}
</Card>
{/* DETAILS */}
@ -454,6 +402,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderDeetItem('matching', p.contributionMatching)}
{renderDeetItem('bounty', p.contributionBounty)}
{renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))}
{renderDeetItem('acceptedWithFunding', JSON.stringify(p.acceptedWithFunding))}
{renderDeetItem(
'arbiter',
<>
@ -526,46 +475,15 @@ class ProposalDetailNaked extends React.Component<Props, State> {
this.setState({ showCancelAndRefundPopover: false });
};
private handleApprove = () => {
store.approveProposal(true);
private handleApprove = (withFunding: boolean) => {
store.approveProposal(true, withFunding);
};
private handleReject = async (reason: string) => {
await store.approveProposal(false, reason);
await store.approveProposal(false, false, reason);
message.info('Proposal rejected');
};
private handleToggleMatching = async () => {
if (store.proposalDetail) {
// we lock this to be 1 or 0 for now, we may support more values later on
const contributionMatching =
store.proposalDetail.contributionMatching === 0 ? 1 : 0;
await store.updateProposalDetail({ contributionMatching });
message.success('Updated matching');
}
};
private handleSetBounty = async () => {
if (store.proposalDetail) {
FeedbackModal.open({
title: 'Set bounty?',
content:
'Set the bounty for this proposal. The bounty will count towards the funding goal.',
type: 'input',
inputProps: {
addonBefore: 'Amount',
addonAfter: 'ZEC',
placeholder: '1.5',
},
okText: 'Set bounty',
onOk: async contributionBounty => {
await store.updateProposalDetail({ contributionBounty });
message.success('Updated bounty');
},
});
}
};
private handlePaidMilestone = async () => {
const pid = store.proposalDetail!.proposalId;
const mid = store.proposalDetail!.currentMilestone!.id;

View File

@ -11,7 +11,6 @@ import {
Button,
message,
Spin,
Checkbox,
Row,
Col,
DatePicker,
@ -208,17 +207,6 @@ class RFPForm extends React.Component<Props, State> {
size="large"
/>,
)}
{getFieldDecorator('matching', {
initialValue: defaults.matching,
})(
<Checkbox
className="RFPForm-bounty-matching"
name="matching"
defaultChecked={defaults.matching}
>
Match community contributions for approved proposals
</Checkbox>,
)}
</Form.Item>
</Col>
<Col sm={12} xs={24}>

View File

@ -129,9 +129,15 @@ async function deleteProposal(id: number) {
return data;
}
async function approveProposal(id: number, isApprove: boolean, rejectReason?: string) {
const { data } = await api.put(`/admin/proposals/${id}/approve`, {
isApprove,
async function approveProposal(
id: number,
isAccepted: boolean,
withFunding: boolean,
rejectReason?: string,
) {
const { data } = await api.put(`/admin/proposals/${id}/accept`, {
isAccepted,
withFunding,
rejectReason,
});
return data;
@ -536,7 +542,7 @@ const app = store({
}
},
async approveProposal(isApprove: boolean, rejectReason?: string) {
async approveProposal(isAccepted: boolean, withFunding: boolean, rejectReason?: string) {
if (!app.proposalDetail) {
const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
app.generalError.push(m);
@ -546,7 +552,7 @@ const app = store({
app.proposalDetailApproving = true;
try {
const { proposalId } = app.proposalDetail;
const res = await approveProposal(proposalId, isApprove, rejectReason);
const res = await approveProposal(proposalId, isAccepted, withFunding, rejectReason);
app.updateProposalInStore(res);
} catch (e) {
handleApiError(e);

View File

@ -116,6 +116,7 @@ export interface Proposal {
rfpOptIn: null | boolean;
rfp?: RFP;
arbiter: ProposalArbiter;
acceptedWithFunding: boolean | null;
isVersionTwo: boolean;
}
export interface Comment {

View File

@ -352,39 +352,17 @@ def delete_proposal(id):
return {"message": "Not implemented."}, 400
@blueprint.route('/proposals/<id>', methods=['PUT'])
@blueprint.route('/proposals/<id>/accept', methods=['PUT'])
@body({
"contributionMatching": fields.Int(required=False, missing=None),
"contributionBounty": fields.Str(required=False, missing=None)
})
@admin.admin_auth_required
def update_proposal(id, contribution_matching, contribution_bounty):
proposal = Proposal.query.filter(Proposal.id == id).first()
if not proposal:
return {"message": f"Could not find proposal with id {id}"}, 404
if contribution_matching is not None:
proposal.set_contribution_matching(contribution_matching)
if contribution_bounty is not None:
proposal.set_contribution_bounty(contribution_bounty)
db.session.add(proposal)
db.session.commit()
return proposal_schema.dump(proposal)
@blueprint.route('/proposals/<id>/approve', methods=['PUT'])
@body({
"isApprove": fields.Bool(required=True),
"isAccepted": fields.Bool(required=True),
"withFunding": fields.Bool(required=True),
"rejectReason": fields.Str(required=False, missing=None)
})
@admin.admin_auth_required
def approve_proposal(id, is_approve, reject_reason=None):
def approve_proposal(id, is_accepted, with_funding, reject_reason=None):
proposal = Proposal.query.filter_by(id=id).first()
if proposal:
proposal.approve_pending(is_approve, reject_reason)
proposal.approve_pending(is_accepted, with_funding, reject_reason)
db.session.commit()
return proposal_schema.dump(proposal)
@ -587,8 +565,8 @@ def create_contribution(proposal_id, user_id, status, amount, tx_id):
db.session.add(contribution)
db.session.flush()
#TODO: should this stay?
contribution.proposal.set_pending_when_ready()
contribution.proposal.set_funded_when_ready()
db.session.commit()
return admin_proposal_contribution_schema.dump(contribution), 200
@ -660,8 +638,8 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
db.session.add(contribution)
db.session.flush()
# TODO: should this stay?
contribution.proposal.set_pending_when_ready()
contribution.proposal.set_funded_when_ready()
db.session.commit()
return admin_proposal_contribution_schema.dump(contribution), 200

View File

@ -35,7 +35,7 @@ def create_proposals(count):
user = User.query.filter_by().first()
for i in range(count):
if i < 5:
stage = ProposalStageEnum.FUNDING_REQUIRED
stage = ProposalStageEnum.WIP
else:
stage = ProposalStageEnum.COMPLETED
p = Proposal.create(

View File

@ -230,6 +230,7 @@ class Proposal(db.Model):
date_approved = db.Column(db.DateTime)
date_published = db.Column(db.DateTime)
reject_reason = db.Column(db.String())
accepted_with_funding = db.Column(db.Boolean(), nullable=True)
# Payment info
target = db.Column(db.String(255), nullable=False)
@ -411,13 +412,6 @@ class Proposal(db.Model):
def update_rfp_opt_in(self, opt_in: bool):
self.rfp_opt_in = opt_in
# add/remove matching and/or bounty values from RFP
if opt_in and self.rfp:
self.set_contribution_matching(1 if self.rfp.matching else 0)
self.set_contribution_bounty(self.rfp.bounty or '0')
else:
self.set_contribution_matching(0)
self.set_contribution_bounty('0')
def create_contribution(
self,
@ -501,7 +495,7 @@ class Proposal(db.Model):
db.session.flush()
# state: status PENDING -> (APPROVED || REJECTED)
def approve_pending(self, is_approve, reject_reason=None):
def approve_pending(self, is_approve, with_funding, reject_reason=None):
self.validate_publishable()
# specific validation
if not self.status == ProposalStatus.PENDING:
@ -510,12 +504,17 @@ class Proposal(db.Model):
if is_approve:
self.status = ProposalStatus.APPROVED
self.date_approved = datetime.datetime.now()
self.accepted_with_funding = with_funding
with_or_out = 'without'
if with_funding:
self.fully_fund_contibution_bounty()
with_or_out = 'with'
for t in self.team:
send_email(t.email_address, 'proposal_approved', {
'user': t,
'proposal': self,
'proposal_url': make_url(f'/proposals/{self.id}'),
'admin_note': 'Congratulations! Your proposal has been approved.'
'admin_note': f'Congratulations! Your proposal has been accepted {with_or_out} funding.'
})
else:
if not reject_reason:
@ -538,28 +537,7 @@ class Proposal(db.Model):
raise ValidationException(f"Proposal status must be approved")
self.date_published = datetime.datetime.now()
self.status = ProposalStatus.LIVE
self.stage = ProposalStage.FUNDING_REQUIRED
# If we had a bounty that pushed us into funding, skip straight into WIP
self.set_funded_when_ready()
def set_funded_when_ready(self):
if self.status == ProposalStatus.LIVE and self.stage == ProposalStage.FUNDING_REQUIRED and self.is_funded:
self.set_funded()
# state: stage FUNDING_REQUIRED -> WIP
def set_funded(self):
if self.status != ProposalStatus.LIVE:
raise ValidationException(f"Proposal status must be live in order transition to funded state")
if self.stage != ProposalStage.FUNDING_REQUIRED:
raise ValidationException(f"Proposal stage must be funding_required in order transition to funded state")
if not self.is_funded:
raise ValidationException(f"Proposal is not fully funded, cannot set to funded state")
self.send_admin_email('admin_arbiter')
self.stage = ProposalStage.WIP
db.session.add(self)
db.session.flush()
# check the first step, if immediate payout bump it to accepted
self.current_milestone.accept_immediate()
def set_contribution_bounty(self, bounty: str):
# do not allow changes on funded/WIP proposals
@ -569,20 +547,9 @@ class Proposal(db.Model):
self.contribution_bounty = str(Decimal(bounty))
db.session.add(self)
db.session.flush()
self.set_funded_when_ready()
def set_contribution_matching(self, matching: float):
# do not allow on funded/WIP proposals
if self.is_funded:
raise ValidationException("Cannot set contribution matching on fully-funded proposal")
# enforce 1 or 0 for now
if matching == 0.0 or matching == 1.0:
self.contribution_matching = matching
db.session.add(self)
db.session.flush()
self.set_funded_when_ready()
else:
raise ValidationException("Bad value for contribution_matching, must be 1 or 0")
def fully_fund_contibution_bounty(self):
self.set_contribution_bounty(self.target)
def cancel(self):
if self.status != ProposalStatus.LIVE:
@ -706,6 +673,7 @@ class ProposalSchema(ma.Schema):
"rfp",
"rfp_opt_in",
"arbiter",
"accepted_with_funding",
"is_version_two"
)

View File

@ -570,9 +570,6 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
'contributor_url': make_url(f'/profile/{contribution.user.id}') if contribution.user else '',
})
# on funding target reached.
contribution.proposal.set_funded_when_ready()
db.session.commit()
return {"message": "ok"}, 200

View File

@ -34,7 +34,6 @@ ProposalSort = ProposalSortEnum()
class ProposalStageEnum(CustomEnum):
PREVIEW = 'PREVIEW'
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
WIP = 'WIP'
COMPLETED = 'COMPLETED'
FAILED = 'FAILED'

View File

@ -242,30 +242,8 @@ class TestAdminAPI(BaseProposalCreatorConfig):
# 2 proposals created by BaseProposalCreatorConfig
self.assertEqual(len(resp.json['items']), 2)
def test_update_proposal(self):
self.login_admin()
# set to 1 (on)
resp_on = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}",
data=json.dumps({"contributionMatching": 1}))
self.assert200(resp_on)
self.assertEqual(resp_on.json['contributionMatching'], 1)
resp_off = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}",
data=json.dumps({"contributionMatching": 0}))
self.assert200(resp_off)
self.assertEqual(resp_off.json['contributionMatching'], 0)
def test_update_proposal_no_auth(self):
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data=json.dumps({"contributionMatching": 1}))
self.assert401(resp)
def test_update_proposal_bad_matching(self):
self.login_admin()
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data=json.dumps({"contributionMatching": 2}))
self.assert400(resp)
self.assertTrue(resp.json['message'])
@patch('requests.get', side_effect=mock_blockchain_api_requests)
def test_approve_proposal(self, mock_get):
def test_accept_proposal_with_funding(self, mock_get):
self.login_admin()
# proposal needs to be PENDING
@ -273,11 +251,33 @@ class TestAdminAPI(BaseProposalCreatorConfig):
# approve
resp = self.app.put(
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
data=json.dumps({"isApprove": True})
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
data=json.dumps({"isAccepted": True, "withFunding": True})
)
print(resp.json)
self.assert200(resp)
self.assertEqual(resp.json["status"], ProposalStatus.APPROVED)
self.assertEqual(resp.json["acceptedWithFunding"], True)
self.assertEqual(resp.json["target"], resp.json["contributionBounty"])
@patch('requests.get', side_effect=mock_blockchain_api_requests)
def test_accept_proposal_without_funding(self, mock_get):
self.login_admin()
# proposal needs to be PENDING
self.proposal.status = ProposalStatus.PENDING
# approve
resp = self.app.put(
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
data=json.dumps({"isAccepted": True, "withFunding": False})
)
print(resp.json)
self.assert200(resp)
self.assertEqual(resp.json["status"], ProposalStatus.APPROVED)
self.assertEqual(resp.json["acceptedWithFunding"], False)
self.assertEqual(resp.json["contributionBounty"], "0")
@patch('requests.get', side_effect=mock_blockchain_api_requests)
def test_reject_proposal(self, mock_get):
@ -288,8 +288,8 @@ class TestAdminAPI(BaseProposalCreatorConfig):
# reject
resp = self.app.put(
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
data=json.dumps({"isApprove": False, "rejectReason": "Funnzies."})
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
data=json.dumps({"isAccepted": False, "withFunding": False, "rejectReason": "Funnzies."})
)
self.assert200(resp)
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)

View File

@ -84,8 +84,8 @@ const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = {
},
[CREATE_STEP.PAYMENT]: {
short: 'Payment',
title: 'Choose how you get paid',
subtitle: 'Youll only be paid if your funding target is reached',
title: 'Set your payout address',
subtitle: '',
help:
'Double check your address, and make sure its secure. Once sent, payments are irreversible!',
component: Payment,

View File

@ -1,18 +1,14 @@
import React from 'react';
import moment from 'moment';
import { Form, Input, Button, Icon, Popover, Tooltip, Radio } from 'antd';
import { RadioChangeEvent } from 'antd/lib/radio';
import { Icon, Popover } from 'antd';
import { Proposal, STATUS } from 'types';
import classnames from 'classnames';
import { fromZat } from 'utils/units';
import { connect } from 'react-redux';
import { compose } from 'recompose';
import { AppState } from 'store/reducers';
import { withRouter } from 'react-router';
import UnitDisplay from 'components/UnitDisplay';
import ContributionModal from 'components/ContributionModal';
import Loader from 'components/Loader';
import { getAmountError } from 'utils/validators';
import { CATEGORY_UI, PROPOSAL_STAGE } from 'api/constants';
import './style.less';
@ -46,12 +42,10 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
}
render() {
const { proposal, isPreview, authUser } = this.props;
const { amountToRaise, amountError, isPrivate, isContributing } = this.state;
const amountFloat = parseFloat(amountToRaise) || 0;
const { proposal } = this.props;
let content;
if (proposal) {
const { target, funded, percentFunded } = proposal;
const { target, funded, percentFunded, isVersionTwo } = proposal;
const datePublished = proposal.datePublished || Date.now() / 1000;
const isRaiseGoalReached = funded.gte(target);
const deadline = proposal.deadlineDuration
@ -62,9 +56,9 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
proposal.stage === PROPOSAL_STAGE.CANCELED;
const isLive = proposal.status === STATUS.LIVE;
const isFundingOver = isRaiseGoalReached || deadline < Date.now() || isFrozen;
const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview;
const remainingTargetNum = parseFloat(fromZat(target.sub(funded)));
const isFundingOver = deadline
? isRaiseGoalReached || deadline < Date.now() || isFrozen
: null;
// Get bounty from RFP. If it exceeds proposal target, show bounty as full amount
let bounty;
@ -74,6 +68,15 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
: proposal.contributionBounty;
}
const isAcceptedWithFunding = proposal.acceptedWithFunding === true;
const isAcceptedWithoutFunding = proposal.acceptedWithFunding === false;
const isAccepted = isAcceptedWithFunding || isAcceptedWithoutFunding;
const isCancelled = proposal.stage === PROPOSAL_STAGE.CANCELED;
const isJudged = isAccepted || isCancelled;
const displayBountyFunding =
!isVersionTwo || (isVersionTwo && isAcceptedWithFunding);
content = (
<React.Fragment>
{isLive && (
@ -94,14 +97,15 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
{CATEGORY_UI[proposal.category].label}
</div>
</div>
{!isFundingOver && (
<div className="ProposalCampaignBlock-info">
<div className="ProposalCampaignBlock-info-label">Deadline</div>
<div className="ProposalCampaignBlock-info-value">
{moment(deadline).fromNow()}
{!isVersionTwo &&
!isFundingOver && (
<div className="ProposalCampaignBlock-info">
<div className="ProposalCampaignBlock-info-label">Deadline</div>
<div className="ProposalCampaignBlock-info-value">
{moment(deadline).fromNow()}
</div>
</div>
</div>
)}
)}
<div className="ProposalCampaignBlock-info">
<div className="ProposalCampaignBlock-info-label">Funding</div>
<div className="ProposalCampaignBlock-info-value">
@ -109,40 +113,46 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
</div>
</div>
{bounty && (
<div className="ProposalCampaignBlock-bounty">
Awarded with <UnitDisplay value={bounty} symbol="ZEC" /> bounty
</div>
{bounty &&
displayBountyFunding && (
<div className="ProposalCampaignBlock-bounty">
Awarded with <UnitDisplay value={bounty} symbol="ZEC" /> bounty
</div>
)}
{isAcceptedWithoutFunding && (
<div className="ProposalCampaignBlock-bounty">Accepted without funding</div>
)}
{proposal.contributionMatching > 0 && (
<div className="ProposalCampaignBlock-matching">
<span>Funds are being matched x{proposal.contributionMatching + 1}</span>
<Popover
overlayClassName="ProposalCampaignBlock-popover-overlay"
placement="left"
content={
<>
<b>Matching</b>
<br />
Increase your impact! Contributions to this proposal are being matched
by the Zcash Foundation, up to the target amount.
</>
}
>
<Icon type="question-circle" theme="filled" />
</Popover>
</div>
)}
{!isVersionTwo &&
proposal.contributionMatching > 0 && (
<div className="ProposalCampaignBlock-matching">
<span>Funds are being matched x{proposal.contributionMatching + 1}</span>
<Popover
overlayClassName="ProposalCampaignBlock-popover-overlay"
placement="left"
content={
<>
<b>Matching</b>
<br />
Increase your impact! Contributions to this proposal are being
matched by the Zcash Foundation, up to the target amount.
</>
}
>
<Icon type="question-circle" theme="filled" />
</Popover>
</div>
)}
{isFundingOver ? (
{!isVersionTwo && isFundingOver ? (
<div
className={classnames({
['ProposalCampaignBlock-fundingOver']: true,
['is-success']: isRaiseGoalReached,
})}
>
{proposal.stage === PROPOSAL_STAGE.CANCELED ? (
{isCancelled ? (
<>
<Icon type="close-circle-o" />
<span>Proposal was canceled</span>
@ -170,7 +180,9 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
/>
</div>
<Form layout="vertical" className="ProposalCampaignBlock-contribute">
{/* TODO: use this as a base for tipjar? */}
{/* <Form layout="vertical" className="ProposalCampaignBlock-contribute">
<Form.Item
validateStatus={amountError ? 'error' : undefined}
help={amountError}
@ -219,18 +231,41 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
>
Fund this project
</Button>
</Form>
</Form> */}
</>
)}
<ContributionModal
{isVersionTwo &&
isJudged && (
<div
className={classnames({
['ProposalCampaignBlock-fundingOver']: true,
['is-success']: isAccepted,
})}
>
{proposal.stage === PROPOSAL_STAGE.CANCELED ? (
<>
<Icon type="close-circle-o" />
<span>Proposal was canceled</span>
</>
) : (
<>
<Icon type="check-circle-o" />
<span>Proposal has been accepted</span>
</>
)}
</div>
)}
{/* TODO: adapt below for tipjar? */}
{/* <ContributionModal
isVisible={isContributing}
proposalId={proposal.proposalId}
amount={amountToRaise}
isAnonymous={!authUser}
isPublic={!isPrivate}
handleClose={this.closeContributionModal}
/>
/> */}
</React.Fragment>
);
} else {
@ -244,38 +279,6 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
</div>
);
}
private handleAmountChange = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { value } = event.currentTarget;
if (!value) {
this.setState({ amountToRaise: '', amountError: null });
return;
}
const { target, funded } = this.props.proposal;
const remainingTarget = target.sub(funded);
const amount = parseFloat(value);
let amountError = null;
if (Number.isNaN(amount)) {
// They're entering some garbage, theyll work it out
} else {
const remainingTargetNum = parseFloat(fromZat(remainingTarget));
amountError = getAmountError(amount, remainingTargetNum);
}
this.setState({ amountToRaise: value, amountError });
};
private handleChangePrivate = (ev: RadioChangeEvent) => {
const isPrivate = ev.target.value === 'isPrivate';
this.setState({ isPrivate });
};
private openContributionModal = () => this.setState({ isContributing: true });
private closeContributionModal = () => this.setState({ isContributing: false });
}
function mapStateToProps(state: AppState) {

View File

@ -72,7 +72,7 @@ class RFPDetail extends React.Component<Props> {
tags.push(
<Tag key="closed" color="#f5222d">
Closed
</Tag>
</Tag>,
);
}

View File

@ -248,6 +248,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta
arbiter: {
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
},
acceptedWithFunding: false,
isVersionTwo: true,
milestones: draft.milestones.map((m, idx) => ({
id: idx,

View File

@ -173,7 +173,8 @@ export function generateProposal({
socialMedias: [],
},
},
isVersionTwo: true,
acceptedWithFunding: null,
isVersionTwo: false,
team: [
{
userid: 123,

View File

@ -62,6 +62,7 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
datePublished: number | null;
dateApproved: number | null;
arbiter: ProposalProposalArbiter;
acceptedWithFunding: boolean | null;
isVersionTwo: boolean;
isTeamMember?: boolean; // FE derived
isArbiter?: boolean; // FE derived