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:
parent
701a2f95a9
commit
fb6b9b5af7
|
@ -30,10 +30,11 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { arbiter } = this.props;
|
const { arbiter, isVersionTwo, acceptedWithFunding } = this.props;
|
||||||
const { showSearch, searching } = this.state;
|
const { showSearch, searching } = this.state;
|
||||||
const { results, search, error } = store.arbitersSearch;
|
const { results, search, error } = store.arbitersSearch;
|
||||||
const showEmpty = !results.length && !searching;
|
const showEmpty = !results.length && !searching;
|
||||||
|
const buttonDisabled = isVersionTwo && acceptedWithFunding === false
|
||||||
|
|
||||||
const disp = {
|
const disp = {
|
||||||
[PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter',
|
[PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter',
|
||||||
|
@ -51,6 +52,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={this.handleShowSearch}
|
onClick={this.handleShowSearch}
|
||||||
{...this.props.buttonProps}
|
{...this.props.buttonProps}
|
||||||
|
disabled={buttonDisabled}
|
||||||
>
|
>
|
||||||
{disp[arbiter.status]}
|
{disp[arbiter.status]}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -27,8 +27,9 @@
|
||||||
.ant-collapse {
|
.ant-collapse {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|
||||||
button + button {
|
button {
|
||||||
margin-left: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,6 @@ import {
|
||||||
Collapse,
|
Collapse,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Input,
|
Input,
|
||||||
Switch,
|
|
||||||
Tag,
|
Tag,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
@ -26,7 +25,6 @@ import {
|
||||||
} from 'src/types';
|
} from 'src/types';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import Back from 'components/Back';
|
import Back from 'components/Back';
|
||||||
import Info from 'components/Info';
|
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
import ArbiterControl from 'components/ArbiterControl';
|
import ArbiterControl from 'components/ArbiterControl';
|
||||||
import { toZat, fromZat } from 'src/util/units';
|
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;
|
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
|
const { isVersionTwo } = p
|
||||||
|
const shouldShowArbiter =
|
||||||
|
!isVersionTwo ||
|
||||||
|
(isVersionTwo && p.acceptedWithFunding === true);
|
||||||
|
const cancelButtonText = isVersionTwo ? 'Cancel' : 'Cancel & refund'
|
||||||
|
|
||||||
const renderCancelControl = () => {
|
const renderCancelControl = () => {
|
||||||
const disabled = this.getCancelAndRefundDisabled();
|
const disabled = this.getCancelAndRefundDisabled();
|
||||||
|
|
||||||
|
@ -95,7 +99,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
block
|
block
|
||||||
>
|
>
|
||||||
Cancel & refund
|
{ cancelButtonText }
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</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 = () =>
|
const renderApproved = () =>
|
||||||
p.status === PROPOSAL_STATUS.APPROVED && (
|
p.status === PROPOSAL_STATUS.APPROVED && (
|
||||||
|
@ -205,9 +147,17 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
loading={store.proposalDetailApproving}
|
loading={store.proposalDetailApproving}
|
||||||
icon="check"
|
icon="check"
|
||||||
type="primary"
|
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>
|
||||||
<Button
|
<Button
|
||||||
loading={store.proposalDetailApproving}
|
loading={store.proposalDetailApproving}
|
||||||
|
@ -250,7 +200,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderNominateArbiter = () =>
|
const renderNominateArbiter = () =>
|
||||||
needsArbiter && (
|
needsArbiter && shouldShowArbiter && (
|
||||||
<Alert
|
<Alert
|
||||||
showIcon
|
showIcon
|
||||||
type="warning"
|
type="warning"
|
||||||
|
@ -381,7 +331,6 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
{renderMilestoneAccepted()}
|
{renderMilestoneAccepted()}
|
||||||
{renderFailed()}
|
{renderFailed()}
|
||||||
<Collapse defaultActiveKey={['brief', 'content', 'milestones']}>
|
<Collapse defaultActiveKey={['brief', 'content', 'milestones']}>
|
||||||
|
|
||||||
<Collapse.Panel key="brief" header="brief">
|
<Collapse.Panel key="brief" header="brief">
|
||||||
{p.brief}
|
{p.brief}
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
|
@ -391,24 +340,25 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
|
|
||||||
<Collapse.Panel key="milestones" header="milestones">
|
<Collapse.Panel key="milestones" header="milestones">
|
||||||
{
|
{p.milestones.map((milestone, i) => (
|
||||||
p.milestones.map((milestone, i) =>
|
<Card
|
||||||
|
title={
|
||||||
<Card title={
|
<>
|
||||||
<>
|
{milestone.title + ' '}
|
||||||
{milestone.title + ' '}
|
{milestone.immediatePayout && (
|
||||||
{milestone.immediatePayout && <Tag color="magenta">Immediate Payout</Tag>}
|
<Tag color="magenta">Immediate Payout</Tag>
|
||||||
</>
|
)}
|
||||||
}
|
</>
|
||||||
extra={`${milestone.payoutPercent}% Payout`}
|
}
|
||||||
key={i}
|
extra={`${milestone.payoutPercent}% Payout`}
|
||||||
>
|
key={i}
|
||||||
<p><b>Estimated Date:</b> {formatDateSeconds(milestone.dateEstimated )} </p>
|
>
|
||||||
<p>{milestone.content}</p>
|
<p>
|
||||||
</Card>
|
<b>Estimated Date:</b> {formatDateSeconds(milestone.dateEstimated)}{' '}
|
||||||
|
</p>
|
||||||
)
|
<p>{milestone.content}</p>
|
||||||
}
|
</Card>
|
||||||
|
))}
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
|
|
||||||
<Collapse.Panel key="json" header="json">
|
<Collapse.Panel key="json" header="json">
|
||||||
|
@ -423,8 +373,6 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
<Card size="small" className="ProposalDetail-controls">
|
<Card size="small" className="ProposalDetail-controls">
|
||||||
{renderCancelControl()}
|
{renderCancelControl()}
|
||||||
{renderArbiterControl()}
|
{renderArbiterControl()}
|
||||||
{renderBountyControl()}
|
|
||||||
{renderMatchingControl()}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* DETAILS */}
|
{/* DETAILS */}
|
||||||
|
@ -454,6 +402,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
{renderDeetItem('matching', p.contributionMatching)}
|
{renderDeetItem('matching', p.contributionMatching)}
|
||||||
{renderDeetItem('bounty', p.contributionBounty)}
|
{renderDeetItem('bounty', p.contributionBounty)}
|
||||||
{renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))}
|
{renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))}
|
||||||
|
{renderDeetItem('acceptedWithFunding', JSON.stringify(p.acceptedWithFunding))}
|
||||||
{renderDeetItem(
|
{renderDeetItem(
|
||||||
'arbiter',
|
'arbiter',
|
||||||
<>
|
<>
|
||||||
|
@ -526,46 +475,15 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
this.setState({ showCancelAndRefundPopover: false });
|
this.setState({ showCancelAndRefundPopover: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleApprove = () => {
|
private handleApprove = (withFunding: boolean) => {
|
||||||
store.approveProposal(true);
|
store.approveProposal(true, withFunding);
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleReject = async (reason: string) => {
|
private handleReject = async (reason: string) => {
|
||||||
await store.approveProposal(false, reason);
|
await store.approveProposal(false, false, reason);
|
||||||
message.info('Proposal rejected');
|
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 () => {
|
private handlePaidMilestone = async () => {
|
||||||
const pid = store.proposalDetail!.proposalId;
|
const pid = store.proposalDetail!.proposalId;
|
||||||
const mid = store.proposalDetail!.currentMilestone!.id;
|
const mid = store.proposalDetail!.currentMilestone!.id;
|
||||||
|
|
|
@ -11,7 +11,6 @@ import {
|
||||||
Button,
|
Button,
|
||||||
message,
|
message,
|
||||||
Spin,
|
Spin,
|
||||||
Checkbox,
|
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
|
@ -208,17 +207,6 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
size="large"
|
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>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col sm={12} xs={24}>
|
<Col sm={12} xs={24}>
|
||||||
|
|
|
@ -129,9 +129,15 @@ async function deleteProposal(id: number) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function approveProposal(id: number, isApprove: boolean, rejectReason?: string) {
|
async function approveProposal(
|
||||||
const { data } = await api.put(`/admin/proposals/${id}/approve`, {
|
id: number,
|
||||||
isApprove,
|
isAccepted: boolean,
|
||||||
|
withFunding: boolean,
|
||||||
|
rejectReason?: string,
|
||||||
|
) {
|
||||||
|
const { data } = await api.put(`/admin/proposals/${id}/accept`, {
|
||||||
|
isAccepted,
|
||||||
|
withFunding,
|
||||||
rejectReason,
|
rejectReason,
|
||||||
});
|
});
|
||||||
return data;
|
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) {
|
if (!app.proposalDetail) {
|
||||||
const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
|
const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
|
||||||
app.generalError.push(m);
|
app.generalError.push(m);
|
||||||
|
@ -546,7 +552,7 @@ const app = store({
|
||||||
app.proposalDetailApproving = true;
|
app.proposalDetailApproving = true;
|
||||||
try {
|
try {
|
||||||
const { proposalId } = app.proposalDetail;
|
const { proposalId } = app.proposalDetail;
|
||||||
const res = await approveProposal(proposalId, isApprove, rejectReason);
|
const res = await approveProposal(proposalId, isAccepted, withFunding, rejectReason);
|
||||||
app.updateProposalInStore(res);
|
app.updateProposalInStore(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleApiError(e);
|
handleApiError(e);
|
||||||
|
|
|
@ -116,6 +116,7 @@ export interface Proposal {
|
||||||
rfpOptIn: null | boolean;
|
rfpOptIn: null | boolean;
|
||||||
rfp?: RFP;
|
rfp?: RFP;
|
||||||
arbiter: ProposalArbiter;
|
arbiter: ProposalArbiter;
|
||||||
|
acceptedWithFunding: boolean | null;
|
||||||
isVersionTwo: boolean;
|
isVersionTwo: boolean;
|
||||||
}
|
}
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
|
|
|
@ -352,39 +352,17 @@ def delete_proposal(id):
|
||||||
return {"message": "Not implemented."}, 400
|
return {"message": "Not implemented."}, 400
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/proposals/<id>', methods=['PUT'])
|
@blueprint.route('/proposals/<id>/accept', methods=['PUT'])
|
||||||
@body({
|
@body({
|
||||||
"contributionMatching": fields.Int(required=False, missing=None),
|
"isAccepted": fields.Bool(required=True),
|
||||||
"contributionBounty": fields.Str(required=False, missing=None)
|
"withFunding": fields.Bool(required=True),
|
||||||
})
|
|
||||||
@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),
|
|
||||||
"rejectReason": fields.Str(required=False, missing=None)
|
"rejectReason": fields.Str(required=False, missing=None)
|
||||||
})
|
})
|
||||||
@admin.admin_auth_required
|
@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()
|
proposal = Proposal.query.filter_by(id=id).first()
|
||||||
if proposal:
|
if proposal:
|
||||||
proposal.approve_pending(is_approve, reject_reason)
|
proposal.approve_pending(is_accepted, with_funding, reject_reason)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return proposal_schema.dump(proposal)
|
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.add(contribution)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
|
#TODO: should this stay?
|
||||||
contribution.proposal.set_pending_when_ready()
|
contribution.proposal.set_pending_when_ready()
|
||||||
contribution.proposal.set_funded_when_ready()
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return admin_proposal_contribution_schema.dump(contribution), 200
|
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.add(contribution)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
|
# TODO: should this stay?
|
||||||
contribution.proposal.set_pending_when_ready()
|
contribution.proposal.set_pending_when_ready()
|
||||||
contribution.proposal.set_funded_when_ready()
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return admin_proposal_contribution_schema.dump(contribution), 200
|
return admin_proposal_contribution_schema.dump(contribution), 200
|
||||||
|
|
|
@ -35,7 +35,7 @@ def create_proposals(count):
|
||||||
user = User.query.filter_by().first()
|
user = User.query.filter_by().first()
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
if i < 5:
|
if i < 5:
|
||||||
stage = ProposalStageEnum.FUNDING_REQUIRED
|
stage = ProposalStageEnum.WIP
|
||||||
else:
|
else:
|
||||||
stage = ProposalStageEnum.COMPLETED
|
stage = ProposalStageEnum.COMPLETED
|
||||||
p = Proposal.create(
|
p = Proposal.create(
|
||||||
|
|
|
@ -230,6 +230,7 @@ class Proposal(db.Model):
|
||||||
date_approved = db.Column(db.DateTime)
|
date_approved = db.Column(db.DateTime)
|
||||||
date_published = db.Column(db.DateTime)
|
date_published = db.Column(db.DateTime)
|
||||||
reject_reason = db.Column(db.String())
|
reject_reason = db.Column(db.String())
|
||||||
|
accepted_with_funding = db.Column(db.Boolean(), nullable=True)
|
||||||
|
|
||||||
# Payment info
|
# Payment info
|
||||||
target = db.Column(db.String(255), nullable=False)
|
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):
|
def update_rfp_opt_in(self, opt_in: bool):
|
||||||
self.rfp_opt_in = opt_in
|
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(
|
def create_contribution(
|
||||||
self,
|
self,
|
||||||
|
@ -501,7 +495,7 @@ class Proposal(db.Model):
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
# state: status PENDING -> (APPROVED || REJECTED)
|
# 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()
|
self.validate_publishable()
|
||||||
# specific validation
|
# specific validation
|
||||||
if not self.status == ProposalStatus.PENDING:
|
if not self.status == ProposalStatus.PENDING:
|
||||||
|
@ -510,12 +504,17 @@ class Proposal(db.Model):
|
||||||
if is_approve:
|
if is_approve:
|
||||||
self.status = ProposalStatus.APPROVED
|
self.status = ProposalStatus.APPROVED
|
||||||
self.date_approved = datetime.datetime.now()
|
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:
|
for t in self.team:
|
||||||
send_email(t.email_address, 'proposal_approved', {
|
send_email(t.email_address, 'proposal_approved', {
|
||||||
'user': t,
|
'user': t,
|
||||||
'proposal': self,
|
'proposal': self,
|
||||||
'proposal_url': make_url(f'/proposals/{self.id}'),
|
'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:
|
else:
|
||||||
if not reject_reason:
|
if not reject_reason:
|
||||||
|
@ -538,28 +537,7 @@ class Proposal(db.Model):
|
||||||
raise ValidationException(f"Proposal status must be approved")
|
raise ValidationException(f"Proposal status must be approved")
|
||||||
self.date_published = datetime.datetime.now()
|
self.date_published = datetime.datetime.now()
|
||||||
self.status = ProposalStatus.LIVE
|
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
|
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):
|
def set_contribution_bounty(self, bounty: str):
|
||||||
# do not allow changes on funded/WIP proposals
|
# do not allow changes on funded/WIP proposals
|
||||||
|
@ -569,20 +547,9 @@ class Proposal(db.Model):
|
||||||
self.contribution_bounty = str(Decimal(bounty))
|
self.contribution_bounty = str(Decimal(bounty))
|
||||||
db.session.add(self)
|
db.session.add(self)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
self.set_funded_when_ready()
|
|
||||||
|
|
||||||
def set_contribution_matching(self, matching: float):
|
def fully_fund_contibution_bounty(self):
|
||||||
# do not allow on funded/WIP proposals
|
self.set_contribution_bounty(self.target)
|
||||||
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 cancel(self):
|
def cancel(self):
|
||||||
if self.status != ProposalStatus.LIVE:
|
if self.status != ProposalStatus.LIVE:
|
||||||
|
@ -706,6 +673,7 @@ class ProposalSchema(ma.Schema):
|
||||||
"rfp",
|
"rfp",
|
||||||
"rfp_opt_in",
|
"rfp_opt_in",
|
||||||
"arbiter",
|
"arbiter",
|
||||||
|
"accepted_with_funding",
|
||||||
"is_version_two"
|
"is_version_two"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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 '',
|
'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()
|
db.session.commit()
|
||||||
return {"message": "ok"}, 200
|
return {"message": "ok"}, 200
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,6 @@ ProposalSort = ProposalSortEnum()
|
||||||
|
|
||||||
class ProposalStageEnum(CustomEnum):
|
class ProposalStageEnum(CustomEnum):
|
||||||
PREVIEW = 'PREVIEW'
|
PREVIEW = 'PREVIEW'
|
||||||
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
|
|
||||||
WIP = 'WIP'
|
WIP = 'WIP'
|
||||||
COMPLETED = 'COMPLETED'
|
COMPLETED = 'COMPLETED'
|
||||||
FAILED = 'FAILED'
|
FAILED = 'FAILED'
|
||||||
|
|
|
@ -242,30 +242,8 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
||||||
# 2 proposals created by BaseProposalCreatorConfig
|
# 2 proposals created by BaseProposalCreatorConfig
|
||||||
self.assertEqual(len(resp.json['items']), 2)
|
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)
|
@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()
|
self.login_admin()
|
||||||
|
|
||||||
# proposal needs to be PENDING
|
# proposal needs to be PENDING
|
||||||
|
@ -273,11 +251,33 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
||||||
|
|
||||||
# approve
|
# approve
|
||||||
resp = self.app.put(
|
resp = self.app.put(
|
||||||
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
|
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||||
data=json.dumps({"isApprove": True})
|
data=json.dumps({"isAccepted": True, "withFunding": True})
|
||||||
)
|
)
|
||||||
|
print(resp.json)
|
||||||
self.assert200(resp)
|
self.assert200(resp)
|
||||||
self.assertEqual(resp.json["status"], ProposalStatus.APPROVED)
|
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)
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
def test_reject_proposal(self, mock_get):
|
def test_reject_proposal(self, mock_get):
|
||||||
|
@ -288,8 +288,8 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
||||||
|
|
||||||
# reject
|
# reject
|
||||||
resp = self.app.put(
|
resp = self.app.put(
|
||||||
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
|
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||||
data=json.dumps({"isApprove": False, "rejectReason": "Funnzies."})
|
data=json.dumps({"isAccepted": False, "withFunding": False, "rejectReason": "Funnzies."})
|
||||||
)
|
)
|
||||||
self.assert200(resp)
|
self.assert200(resp)
|
||||||
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)
|
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)
|
||||||
|
|
|
@ -84,8 +84,8 @@ const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = {
|
||||||
},
|
},
|
||||||
[CREATE_STEP.PAYMENT]: {
|
[CREATE_STEP.PAYMENT]: {
|
||||||
short: 'Payment',
|
short: 'Payment',
|
||||||
title: 'Choose how you get paid',
|
title: 'Set your payout address',
|
||||||
subtitle: 'You’ll only be paid if your funding target is reached',
|
subtitle: '',
|
||||||
help:
|
help:
|
||||||
'Double check your address, and make sure it’s secure. Once sent, payments are irreversible!',
|
'Double check your address, and make sure it’s secure. Once sent, payments are irreversible!',
|
||||||
component: Payment,
|
component: Payment,
|
||||||
|
|
|
@ -1,18 +1,14 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { Form, Input, Button, Icon, Popover, Tooltip, Radio } from 'antd';
|
import { Icon, Popover } from 'antd';
|
||||||
import { RadioChangeEvent } from 'antd/lib/radio';
|
|
||||||
import { Proposal, STATUS } from 'types';
|
import { Proposal, STATUS } from 'types';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { fromZat } from 'utils/units';
|
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { compose } from 'recompose';
|
import { compose } from 'recompose';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import { withRouter } from 'react-router';
|
import { withRouter } from 'react-router';
|
||||||
import UnitDisplay from 'components/UnitDisplay';
|
import UnitDisplay from 'components/UnitDisplay';
|
||||||
import ContributionModal from 'components/ContributionModal';
|
|
||||||
import Loader from 'components/Loader';
|
import Loader from 'components/Loader';
|
||||||
import { getAmountError } from 'utils/validators';
|
|
||||||
import { CATEGORY_UI, PROPOSAL_STAGE } from 'api/constants';
|
import { CATEGORY_UI, PROPOSAL_STAGE } from 'api/constants';
|
||||||
import './style.less';
|
import './style.less';
|
||||||
|
|
||||||
|
@ -46,12 +42,10 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { proposal, isPreview, authUser } = this.props;
|
const { proposal } = this.props;
|
||||||
const { amountToRaise, amountError, isPrivate, isContributing } = this.state;
|
|
||||||
const amountFloat = parseFloat(amountToRaise) || 0;
|
|
||||||
let content;
|
let content;
|
||||||
if (proposal) {
|
if (proposal) {
|
||||||
const { target, funded, percentFunded } = proposal;
|
const { target, funded, percentFunded, isVersionTwo } = proposal;
|
||||||
const datePublished = proposal.datePublished || Date.now() / 1000;
|
const datePublished = proposal.datePublished || Date.now() / 1000;
|
||||||
const isRaiseGoalReached = funded.gte(target);
|
const isRaiseGoalReached = funded.gte(target);
|
||||||
const deadline = proposal.deadlineDuration
|
const deadline = proposal.deadlineDuration
|
||||||
|
@ -62,9 +56,9 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
||||||
proposal.stage === PROPOSAL_STAGE.CANCELED;
|
proposal.stage === PROPOSAL_STAGE.CANCELED;
|
||||||
const isLive = proposal.status === STATUS.LIVE;
|
const isLive = proposal.status === STATUS.LIVE;
|
||||||
|
|
||||||
const isFundingOver = isRaiseGoalReached || deadline < Date.now() || isFrozen;
|
const isFundingOver = deadline
|
||||||
const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview;
|
? isRaiseGoalReached || deadline < Date.now() || isFrozen
|
||||||
const remainingTargetNum = parseFloat(fromZat(target.sub(funded)));
|
: null;
|
||||||
|
|
||||||
// Get bounty from RFP. If it exceeds proposal target, show bounty as full amount
|
// Get bounty from RFP. If it exceeds proposal target, show bounty as full amount
|
||||||
let bounty;
|
let bounty;
|
||||||
|
@ -74,6 +68,15 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
||||||
: proposal.contributionBounty;
|
: 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 = (
|
content = (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
{isLive && (
|
{isLive && (
|
||||||
|
@ -94,14 +97,15 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
||||||
{CATEGORY_UI[proposal.category].label}
|
{CATEGORY_UI[proposal.category].label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isFundingOver && (
|
{!isVersionTwo &&
|
||||||
<div className="ProposalCampaignBlock-info">
|
!isFundingOver && (
|
||||||
<div className="ProposalCampaignBlock-info-label">Deadline</div>
|
<div className="ProposalCampaignBlock-info">
|
||||||
<div className="ProposalCampaignBlock-info-value">
|
<div className="ProposalCampaignBlock-info-label">Deadline</div>
|
||||||
{moment(deadline).fromNow()}
|
<div className="ProposalCampaignBlock-info-value">
|
||||||
|
{moment(deadline).fromNow()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
<div className="ProposalCampaignBlock-info">
|
<div className="ProposalCampaignBlock-info">
|
||||||
<div className="ProposalCampaignBlock-info-label">Funding</div>
|
<div className="ProposalCampaignBlock-info-label">Funding</div>
|
||||||
<div className="ProposalCampaignBlock-info-value">
|
<div className="ProposalCampaignBlock-info-value">
|
||||||
|
@ -109,40 +113,46 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{bounty && (
|
{bounty &&
|
||||||
<div className="ProposalCampaignBlock-bounty">
|
displayBountyFunding && (
|
||||||
Awarded with <UnitDisplay value={bounty} symbol="ZEC" /> bounty
|
<div className="ProposalCampaignBlock-bounty">
|
||||||
</div>
|
Awarded with <UnitDisplay value={bounty} symbol="ZEC" /> bounty
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAcceptedWithoutFunding && (
|
||||||
|
<div className="ProposalCampaignBlock-bounty">Accepted without funding</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{proposal.contributionMatching > 0 && (
|
{!isVersionTwo &&
|
||||||
<div className="ProposalCampaignBlock-matching">
|
proposal.contributionMatching > 0 && (
|
||||||
<span>Funds are being matched x{proposal.contributionMatching + 1}</span>
|
<div className="ProposalCampaignBlock-matching">
|
||||||
<Popover
|
<span>Funds are being matched x{proposal.contributionMatching + 1}</span>
|
||||||
overlayClassName="ProposalCampaignBlock-popover-overlay"
|
<Popover
|
||||||
placement="left"
|
overlayClassName="ProposalCampaignBlock-popover-overlay"
|
||||||
content={
|
placement="left"
|
||||||
<>
|
content={
|
||||||
<b>Matching</b>
|
<>
|
||||||
<br />
|
<b>Matching</b>
|
||||||
Increase your impact! Contributions to this proposal are being matched
|
<br />
|
||||||
by the Zcash Foundation, up to the target amount.
|
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>
|
<Icon type="question-circle" theme="filled" />
|
||||||
</div>
|
</Popover>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isFundingOver ? (
|
{!isVersionTwo && isFundingOver ? (
|
||||||
<div
|
<div
|
||||||
className={classnames({
|
className={classnames({
|
||||||
['ProposalCampaignBlock-fundingOver']: true,
|
['ProposalCampaignBlock-fundingOver']: true,
|
||||||
['is-success']: isRaiseGoalReached,
|
['is-success']: isRaiseGoalReached,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{proposal.stage === PROPOSAL_STAGE.CANCELED ? (
|
{isCancelled ? (
|
||||||
<>
|
<>
|
||||||
<Icon type="close-circle-o" />
|
<Icon type="close-circle-o" />
|
||||||
<span>Proposal was canceled</span>
|
<span>Proposal was canceled</span>
|
||||||
|
@ -170,7 +180,9 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form layout="vertical" className="ProposalCampaignBlock-contribute">
|
{/* TODO: use this as a base for tipjar? */}
|
||||||
|
|
||||||
|
{/* <Form layout="vertical" className="ProposalCampaignBlock-contribute">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
validateStatus={amountError ? 'error' : undefined}
|
validateStatus={amountError ? 'error' : undefined}
|
||||||
help={amountError}
|
help={amountError}
|
||||||
|
@ -219,18 +231,41 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
||||||
>
|
>
|
||||||
Fund this project
|
Fund this project
|
||||||
</Button>
|
</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}
|
isVisible={isContributing}
|
||||||
proposalId={proposal.proposalId}
|
proposalId={proposal.proposalId}
|
||||||
amount={amountToRaise}
|
amount={amountToRaise}
|
||||||
isAnonymous={!authUser}
|
isAnonymous={!authUser}
|
||||||
isPublic={!isPrivate}
|
isPublic={!isPrivate}
|
||||||
handleClose={this.closeContributionModal}
|
handleClose={this.closeContributionModal}
|
||||||
/>
|
/> */}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
@ -244,38 +279,6 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
||||||
</div>
|
</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, they’ll 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) {
|
function mapStateToProps(state: AppState) {
|
||||||
|
|
|
@ -72,7 +72,7 @@ class RFPDetail extends React.Component<Props> {
|
||||||
tags.push(
|
tags.push(
|
||||||
<Tag key="closed" color="#f5222d">
|
<Tag key="closed" color="#f5222d">
|
||||||
Closed
|
Closed
|
||||||
</Tag>
|
</Tag>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -248,6 +248,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta
|
||||||
arbiter: {
|
arbiter: {
|
||||||
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
|
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
|
||||||
},
|
},
|
||||||
|
acceptedWithFunding: false,
|
||||||
isVersionTwo: true,
|
isVersionTwo: true,
|
||||||
milestones: draft.milestones.map((m, idx) => ({
|
milestones: draft.milestones.map((m, idx) => ({
|
||||||
id: idx,
|
id: idx,
|
||||||
|
|
|
@ -173,7 +173,8 @@ export function generateProposal({
|
||||||
socialMedias: [],
|
socialMedias: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
isVersionTwo: true,
|
acceptedWithFunding: null,
|
||||||
|
isVersionTwo: false,
|
||||||
team: [
|
team: [
|
||||||
{
|
{
|
||||||
userid: 123,
|
userid: 123,
|
||||||
|
|
|
@ -62,6 +62,7 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
||||||
datePublished: number | null;
|
datePublished: number | null;
|
||||||
dateApproved: number | null;
|
dateApproved: number | null;
|
||||||
arbiter: ProposalProposalArbiter;
|
arbiter: ProposalProposalArbiter;
|
||||||
|
acceptedWithFunding: boolean | null;
|
||||||
isVersionTwo: boolean;
|
isVersionTwo: boolean;
|
||||||
isTeamMember?: boolean; // FE derived
|
isTeamMember?: boolean; // FE derived
|
||||||
isArbiter?: boolean; // FE derived
|
isArbiter?: boolean; // FE derived
|
||||||
|
|
Loading…
Reference in New Issue