Flexible Matching/Bounties + KYC (#277)
* BE: proposal rfp opt in & proposal bounty * admin: proposal rfp opt in display & modify bounty * FE: proposal rfp opt in / proposal.contributionBounty * fix github merge (so close) * add status field to update_rfp * handle only showing canel and refund popover correctly * undo unneeded change * BE: make sure rfp.bounty is None when it is set to '0' or null during create/update
This commit is contained in:
parent
7bbefe8abe
commit
1d2228a394
|
@ -1,18 +1,31 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Modal, Input, Button } from 'antd';
|
||||
import { ModalFuncProps } from 'antd/lib/modal';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import TextArea, { TextAreaProps } from 'antd/lib/input/TextArea';
|
||||
import { InputProps } from 'antd/lib/input';
|
||||
import './index.less';
|
||||
|
||||
interface OpenProps extends ModalFuncProps {
|
||||
label: ReactNode;
|
||||
label?: ReactNode;
|
||||
inputProps?: InputProps;
|
||||
textAreaProps?: TextAreaProps;
|
||||
type?: 'textArea' | 'input';
|
||||
onOk: (feedback: string) => void;
|
||||
}
|
||||
|
||||
const open = (p: OpenProps) => {
|
||||
// NOTE: display=none antd buttons and using our own to control things more
|
||||
const ref = { text: '' };
|
||||
const { label, content, okText, cancelText, ...rest } = p;
|
||||
const {
|
||||
label,
|
||||
content,
|
||||
type,
|
||||
inputProps,
|
||||
textAreaProps,
|
||||
okText,
|
||||
cancelText,
|
||||
...rest
|
||||
} = p;
|
||||
const modal = Modal.confirm({
|
||||
maskClosable: true,
|
||||
icon: <></>,
|
||||
|
@ -21,6 +34,9 @@ const open = (p: OpenProps) => {
|
|||
<Feedback
|
||||
label={label}
|
||||
content={content}
|
||||
type={type || 'textArea'}
|
||||
inputProps={inputProps}
|
||||
textAreaProps={textAreaProps}
|
||||
okText={okText}
|
||||
cancelText={cancelText}
|
||||
onCancel={() => {
|
||||
|
@ -40,7 +56,10 @@ const open = (p: OpenProps) => {
|
|||
// Feedback content
|
||||
interface OwnProps {
|
||||
onChange: (t: string) => void;
|
||||
label: ReactNode;
|
||||
label?: ReactNode;
|
||||
type: 'textArea' | 'input';
|
||||
inputProps?: InputProps;
|
||||
textAreaProps?: TextAreaProps;
|
||||
onOk: ModalFuncProps['onOk'];
|
||||
onCancel: ModalFuncProps['onCancel'];
|
||||
okText?: ReactNode;
|
||||
|
@ -58,27 +77,51 @@ type State = typeof STATE;
|
|||
|
||||
class Feedback extends React.Component<Props, State> {
|
||||
state = STATE;
|
||||
input: null | TextArea = null;
|
||||
input: null | TextArea | Input = null;
|
||||
componentDidMount() {
|
||||
if (this.input) this.input.focus();
|
||||
}
|
||||
render() {
|
||||
const { text } = this.state;
|
||||
const { label, onOk, onCancel, content, okText, cancelText } = this.props;
|
||||
const {
|
||||
label,
|
||||
type,
|
||||
textAreaProps,
|
||||
inputProps,
|
||||
onOk,
|
||||
onCancel,
|
||||
content,
|
||||
okText,
|
||||
cancelText,
|
||||
} = this.props;
|
||||
return (
|
||||
<div>
|
||||
{content && <p>{content}</p>}
|
||||
<div className="FeedbackModal-label">{label}</div>
|
||||
<Input.TextArea
|
||||
ref={ta => (this.input = ta)}
|
||||
rows={4}
|
||||
required={true}
|
||||
value={text}
|
||||
onChange={e => {
|
||||
this.setState({ text: e.target.value });
|
||||
this.props.onChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{label && <div className="FeedbackModal-label">{label}</div>}
|
||||
{type === 'textArea' && (
|
||||
<Input.TextArea
|
||||
ref={ta => (this.input = ta)}
|
||||
rows={4}
|
||||
required={true}
|
||||
value={text}
|
||||
onChange={e => {
|
||||
this.setState({ text: e.target.value });
|
||||
this.props.onChange(e.target.value);
|
||||
}}
|
||||
{...textAreaProps}
|
||||
/>
|
||||
)}
|
||||
{type === 'input' && (
|
||||
<Input
|
||||
ref={ta => (this.input = ta)}
|
||||
value={text}
|
||||
onChange={e => {
|
||||
this.setState({ text: e.target.value });
|
||||
this.props.onChange(e.target.value);
|
||||
}}
|
||||
{...inputProps}
|
||||
/>
|
||||
)}
|
||||
<div className="FeedbackModal-controls">
|
||||
<Button onClick={onCancel}>{cancelText || 'Cancel'}</Button>
|
||||
<Button onClick={onOk} disabled={text.length === 0} type="primary">
|
||||
|
|
|
@ -36,6 +36,7 @@ type Props = RouteComponentProps<any>;
|
|||
|
||||
const STATE = {
|
||||
paidTxId: '',
|
||||
showCancelAndRefundPopover: false,
|
||||
};
|
||||
|
||||
type State = typeof STATE;
|
||||
|
@ -57,7 +58,8 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
const needsArbiter =
|
||||
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
|
||||
p.status === PROPOSAL_STATUS.LIVE &&
|
||||
!p.isFailed;
|
||||
!p.isFailed &&
|
||||
p.stage !== PROPOSAL_STAGE.COMPLETED;
|
||||
const refundablePct = p.milestones.reduce((prev, m) => {
|
||||
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
|
||||
}, 100);
|
||||
|
@ -76,10 +78,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
);
|
||||
|
||||
const renderCancelControl = () => {
|
||||
const disabled =
|
||||
p.status !== PROPOSAL_STATUS.LIVE ||
|
||||
p.stage === PROPOSAL_STAGE.FAILED ||
|
||||
p.stage === PROPOSAL_STAGE.CANCELED;
|
||||
const disabled = this.getCancelAndRefundDisabled();
|
||||
|
||||
return (
|
||||
<Popconfirm
|
||||
|
@ -93,16 +92,18 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
placement="left"
|
||||
cancelText="cancel"
|
||||
okText="confirm"
|
||||
visible={!disabled}
|
||||
visible={this.state.showCancelAndRefundPopover}
|
||||
okButtonProps={{
|
||||
loading: store.proposalDetailCanceling,
|
||||
}}
|
||||
onConfirm={this.handleCancel}
|
||||
onCancel={this.handleCancelCancel}
|
||||
onConfirm={this.handleConfirmCancel}
|
||||
>
|
||||
<Button
|
||||
icon="close-circle"
|
||||
className="ProposalDetail-controls-control"
|
||||
loading={store.proposalDetailCanceling}
|
||||
onClick={this.handleCancelAndRefundClick}
|
||||
disabled={disabled}
|
||||
block
|
||||
>
|
||||
|
@ -119,7 +120,10 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
type: 'default',
|
||||
className: 'ProposalDetail-controls-control',
|
||||
block: true,
|
||||
disabled: p.status !== PROPOSAL_STATUS.LIVE || p.isFailed,
|
||||
disabled:
|
||||
p.status !== PROPOSAL_STATUS.LIVE ||
|
||||
p.isFailed ||
|
||||
p.stage === PROPOSAL_STAGE.COMPLETED,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -147,7 +151,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
>
|
||||
<Switch
|
||||
checked={p.contributionMatching === 1}
|
||||
loading={false}
|
||||
loading={store.proposalDetailUpdating}
|
||||
disabled={
|
||||
p.isFailed ||
|
||||
[PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
|
||||
|
@ -170,6 +174,23 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
</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 && (
|
||||
<Alert
|
||||
|
@ -394,6 +415,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
{renderDeleteControl()}
|
||||
{renderCancelControl()}
|
||||
{renderArbiterControl()}
|
||||
{renderBountyControl()}
|
||||
{renderMatchingControl()}
|
||||
</Card>
|
||||
|
||||
|
@ -419,6 +441,8 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
{renderDeetItem('contributed', p.contributed)}
|
||||
{renderDeetItem('funded (inc. matching)', p.funded)}
|
||||
{renderDeetItem('matching', p.contributionMatching)}
|
||||
{renderDeetItem('bounty', p.contributionBounty)}
|
||||
{renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))}
|
||||
{renderDeetItem(
|
||||
'arbiter',
|
||||
<>
|
||||
|
@ -453,6 +477,28 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
private getCancelAndRefundDisabled = () => {
|
||||
const { proposalDetail: p } = store;
|
||||
if (!p) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
p.status !== PROPOSAL_STATUS.LIVE ||
|
||||
p.stage === PROPOSAL_STAGE.FAILED ||
|
||||
p.stage === PROPOSAL_STAGE.CANCELED ||
|
||||
p.isFailed
|
||||
);
|
||||
};
|
||||
|
||||
private handleCancelAndRefundClick = () => {
|
||||
const disabled = this.getCancelAndRefundDisabled();
|
||||
if (!disabled) {
|
||||
if (!this.state.showCancelAndRefundPopover) {
|
||||
this.setState({ showCancelAndRefundPopover: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private getIdFromQuery = () => {
|
||||
return Number(this.props.match.params.id);
|
||||
};
|
||||
|
@ -466,9 +512,14 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
store.deleteProposal(store.proposalDetail.proposalId);
|
||||
};
|
||||
|
||||
private handleCancel = () => {
|
||||
private handleCancelCancel = () => {
|
||||
this.setState({ showCancelAndRefundPopover: false });
|
||||
};
|
||||
|
||||
private handleConfirmCancel = () => {
|
||||
if (!store.proposalDetail) return;
|
||||
store.cancelProposal(store.proposalDetail.proposalId);
|
||||
this.setState({ showCancelAndRefundPopover: false });
|
||||
};
|
||||
|
||||
private handleApprove = () => {
|
||||
|
@ -485,7 +536,29 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
// 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;
|
||||
store.updateProposalDetail({ contributionMatching });
|
||||
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');
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -249,6 +249,8 @@ const app = store({
|
|||
proposalDetailApproving: false,
|
||||
proposalDetailMarkingMilestonePaid: false,
|
||||
proposalDetailCanceling: false,
|
||||
proposalDetailUpdating: false,
|
||||
proposalDetailUpdated: false,
|
||||
|
||||
comments: {
|
||||
page: createDefaultPageData<Comment>('CREATED:DESC'),
|
||||
|
@ -466,15 +468,19 @@ const app = store({
|
|||
if (!app.proposalDetail) {
|
||||
return;
|
||||
}
|
||||
app.proposalDetailUpdating = true;
|
||||
app.proposalDetailUpdated = false;
|
||||
try {
|
||||
const res = await updateProposal({
|
||||
...updates,
|
||||
proposalId: app.proposalDetail.proposalId,
|
||||
});
|
||||
app.updateProposalInStore(res);
|
||||
app.proposalDetailUpdated = true;
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
}
|
||||
app.proposalDetailUpdating = false;
|
||||
},
|
||||
|
||||
async deleteProposal(id: number) {
|
||||
|
|
|
@ -111,6 +111,8 @@ export interface Proposal {
|
|||
funded: string;
|
||||
rejectReason: string;
|
||||
contributionMatching: number;
|
||||
contributionBounty: string;
|
||||
rfpOptIn: null | boolean;
|
||||
rfp?: RFP;
|
||||
arbiter: ProposalArbiter;
|
||||
}
|
||||
|
|
|
@ -158,9 +158,9 @@ def stats():
|
|||
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
||||
.join(Proposal) \
|
||||
.filter(or_(
|
||||
Proposal.stage == ProposalStage.FAILED,
|
||||
Proposal.stage == ProposalStage.CANCELED,
|
||||
)) \
|
||||
Proposal.stage == ProposalStage.FAILED,
|
||||
Proposal.stage == ProposalStage.CANCELED,
|
||||
)) \
|
||||
.join(ProposalContribution.user) \
|
||||
.join(UserSettings) \
|
||||
.filter(UserSettings.refund_address != None) \
|
||||
|
@ -312,9 +312,9 @@ def set_arbiter(proposal_id, user_id):
|
|||
db.session.commit()
|
||||
|
||||
return {
|
||||
'proposal': proposal_schema.dump(proposal),
|
||||
'user': admin_user_schema.dump(user)
|
||||
}, 200
|
||||
'proposal': proposal_schema.dump(proposal),
|
||||
'user': admin_user_schema.dump(user)
|
||||
}, 200
|
||||
|
||||
|
||||
# PROPOSALS
|
||||
|
@ -353,10 +353,11 @@ def delete_proposal(id):
|
|||
|
||||
@blueprint.route('/proposals/<id>', methods=['PUT'])
|
||||
@body({
|
||||
"contributionMatching": fields.Int(required=False, missing=None)
|
||||
"contributionMatching": fields.Int(required=False, missing=None),
|
||||
"contributionBounty": fields.Str(required=False, missing=None)
|
||||
})
|
||||
@admin.admin_auth_required
|
||||
def update_proposal(id, contribution_matching):
|
||||
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
|
||||
|
@ -364,6 +365,9 @@ def update_proposal(id, contribution_matching):
|
|||
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()
|
||||
|
||||
|
@ -495,8 +499,9 @@ def get_rfp(rfp_id):
|
|||
"title": fields.Str(required=True),
|
||||
"brief": fields.Str(required=True),
|
||||
"content": fields.Str(required=True),
|
||||
"status": fields.Str(required=True),
|
||||
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list())),
|
||||
"bounty": fields.Str(required=False, missing=""),
|
||||
"bounty": fields.Str(required=False, allow_none=True, missing=None),
|
||||
"matching": fields.Bool(required=False, default=False, missing=False),
|
||||
"dateCloses": fields.Int(required=False, missing=None),
|
||||
"status": fields.Str(required=True, validate=validate.OneOf(choices=RFPStatus.list())),
|
||||
|
@ -513,7 +518,7 @@ def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_c
|
|||
rfp.content = content
|
||||
rfp.category = category
|
||||
rfp.matching = matching
|
||||
rfp.bounty = bounty if bounty and bounty != "" else None
|
||||
rfp.bounty = bounty
|
||||
rfp.date_closes = datetime.fromtimestamp(date_closes) if date_closes else None
|
||||
|
||||
# Update timestamps if status changed
|
||||
|
@ -607,7 +612,8 @@ def get_contribution(contribution_id):
|
|||
# TODO guard status
|
||||
"status": fields.Str(required=False, missing=None),
|
||||
"amount": fields.Str(required=False, missing=None),
|
||||
"txId": fields.Str(required=False, missing=None)
|
||||
"txId": fields.Str(required=False, missing=None),
|
||||
"refundTxId": fields.Str(required=False, allow_none=True, missing=None),
|
||||
})
|
||||
@admin.admin_auth_required
|
||||
def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_id, refund_tx_id):
|
||||
|
|
|
@ -227,6 +227,8 @@ class Proposal(db.Model):
|
|||
payout_address = db.Column(db.String(255), nullable=False)
|
||||
deadline_duration = db.Column(db.Integer(), nullable=False)
|
||||
contribution_matching = db.Column(db.Float(), nullable=False, default=0, server_default=db.text("0"))
|
||||
contribution_bounty = db.Column(db.String(255), nullable=False, default='0', server_default=db.text("'0'"))
|
||||
rfp_opt_in = db.Column(db.Boolean(), nullable=True)
|
||||
contributed = db.column_property()
|
||||
|
||||
# Relations
|
||||
|
@ -342,6 +344,16 @@ class Proposal(db.Model):
|
|||
self.deadline_duration = deadline_duration
|
||||
Proposal.validate(vars(self))
|
||||
|
||||
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, amount, user_id: int = None, staking: bool = False):
|
||||
contribution = ProposalContribution(
|
||||
proposal_id=self.id,
|
||||
|
@ -460,6 +472,16 @@ class Proposal(db.Model):
|
|||
# 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
|
||||
if self.is_funded:
|
||||
raise ValidationException("Cannot change contribution bounty on fully-funded proposal")
|
||||
# wrap in Decimal so it throws for non-decimal strings
|
||||
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:
|
||||
|
@ -474,7 +496,6 @@ class Proposal(db.Model):
|
|||
raise ValidationException("Bad value for contribution_matching, must be 1 or 0")
|
||||
|
||||
def cancel(self):
|
||||
print(self.status)
|
||||
if self.status != ProposalStatus.LIVE:
|
||||
raise ValidationException("Cannot cancel a proposal until it's live")
|
||||
|
||||
|
@ -508,9 +529,9 @@ class Proposal(db.Model):
|
|||
target = Decimal(self.target)
|
||||
# apply matching multiplier
|
||||
funded = Decimal(self.contributed) * Decimal(1 + self.contribution_matching)
|
||||
# apply bounty, if available
|
||||
if self.rfp and self.rfp.bounty and self.rfp.bounty != "":
|
||||
funded = funded + Decimal(self.rfp.bounty)
|
||||
# apply bounty
|
||||
if self.contribution_bounty:
|
||||
funded = funded + Decimal(self.contribution_bounty)
|
||||
# if funded > target, just set as target
|
||||
if funded > target:
|
||||
return str(target)
|
||||
|
@ -578,8 +599,10 @@ class ProposalSchema(ma.Schema):
|
|||
"payout_address",
|
||||
"deadline_duration",
|
||||
"contribution_matching",
|
||||
"contribution_bounty",
|
||||
"invites",
|
||||
"rfp",
|
||||
"rfp_opt_in",
|
||||
"arbiter"
|
||||
)
|
||||
|
||||
|
@ -737,7 +760,7 @@ class ProposalContributionSchema(ma.Schema):
|
|||
return {
|
||||
'transparent': addresses['transparent'],
|
||||
}
|
||||
|
||||
|
||||
def get_is_anonymous(self, obj):
|
||||
return not obj.user_id
|
||||
|
||||
|
|
|
@ -187,8 +187,6 @@ def make_proposal_draft(rfp_id):
|
|||
if not rfp:
|
||||
return {"message": "The request this proposal was made for doesn’t exist"}, 400
|
||||
proposal.category = rfp.category
|
||||
if rfp.matching:
|
||||
proposal.contribution_matching = 1.0
|
||||
rfp.proposals.append(proposal)
|
||||
db.session.add(rfp)
|
||||
|
||||
|
@ -226,14 +224,20 @@ def get_proposal_drafts():
|
|||
"payoutAddress": fields.Str(required=True),
|
||||
"deadlineDuration": fields.Int(required=True),
|
||||
"milestones": fields.List(fields.Dict(), required=True),
|
||||
"rfpOptIn": fields.Bool(required=False, missing=None)
|
||||
})
|
||||
def update_proposal(milestones, proposal_id, **kwargs):
|
||||
def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs):
|
||||
# Update the base proposal fields
|
||||
try:
|
||||
g.current_proposal.update(**kwargs)
|
||||
except ValidationException as e:
|
||||
return {"message": "{}".format(str(e))}, 400
|
||||
db.session.add(g.current_proposal)
|
||||
|
||||
# twiddle rfp opt-in (modifies proposal matching and/or bounty)
|
||||
if rfp_opt_in is not None:
|
||||
g.current_proposal.update_rfp_opt_in(rfp_opt_in)
|
||||
|
||||
# Delete & re-add milestones
|
||||
[db.session.delete(x) for x in g.current_proposal.milestones]
|
||||
if milestones:
|
||||
|
@ -258,6 +262,9 @@ def update_proposal(milestones, proposal_id, **kwargs):
|
|||
@requires_team_member_auth
|
||||
def unlink_proposal_from_rfp(proposal_id):
|
||||
g.current_proposal.rfp_id = None
|
||||
# this will zero matching and bounty
|
||||
g.current_proposal.update_rfp_opt_in(False)
|
||||
g.current_proposal.rfp_opt_in = None
|
||||
db.session.add(g.current_proposal)
|
||||
db.session.commit()
|
||||
return proposal_schema.dump(g.current_proposal), 200
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from grant.extensions import ma, db
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from grant.utils.enums import RFPStatus
|
||||
from grant.utils.misc import dt_to_unix, gen_random_id
|
||||
from grant.utils.enums import Category
|
||||
|
@ -17,7 +19,7 @@ class RFP(db.Model):
|
|||
category = db.Column(db.String(255), nullable=False)
|
||||
status = db.Column(db.String(255), nullable=False)
|
||||
matching = db.Column(db.Boolean, default=False, nullable=False)
|
||||
bounty = db.Column(db.String(255), nullable=True)
|
||||
_bounty = db.Column("bounty", db.String(255), nullable=True)
|
||||
date_closes = db.Column(db.DateTime, nullable=True)
|
||||
date_opened = db.Column(db.DateTime, nullable=True)
|
||||
date_closed = db.Column(db.DateTime, nullable=True)
|
||||
|
@ -36,6 +38,17 @@ class RFP(db.Model):
|
|||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
@hybrid_property
|
||||
def bounty(self):
|
||||
return self._bounty
|
||||
|
||||
@bounty.setter
|
||||
def bounty(self, bounty: str):
|
||||
if bounty and Decimal(bounty) > 0:
|
||||
self._bounty = bounty
|
||||
else:
|
||||
self._bounty = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
|
@ -102,6 +115,7 @@ class RFPSchema(ma.Schema):
|
|||
def get_date_closed(self, obj):
|
||||
return dt_to_unix(obj.date_closed) if obj.date_closed else None
|
||||
|
||||
|
||||
rfp_schema = RFPSchema()
|
||||
rfps_schema = RFPSchema(many=True)
|
||||
|
||||
|
@ -141,15 +155,16 @@ class AdminRFPSchema(ma.Schema):
|
|||
|
||||
def get_date_created(self, obj):
|
||||
return dt_to_unix(obj.date_created)
|
||||
|
||||
|
||||
def get_date_closes(self, obj):
|
||||
return dt_to_unix(obj.date_closes) if obj.date_closes else None
|
||||
|
||||
|
||||
def get_date_opened(self, obj):
|
||||
return dt_to_unix(obj.date_opened) if obj.date_opened else None
|
||||
|
||||
|
||||
def get_date_closed(self, obj):
|
||||
return dt_to_unix(obj.date_closes) if obj.date_closes else None
|
||||
|
||||
|
||||
admin_rfp_schema = AdminRFPSchema()
|
||||
admin_rfps_schema = AdminRFPSchema(many=True)
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
"""add proposal contribution_bounty & rfp_opt_in
|
||||
|
||||
Revision ID: 13365ffe910e
|
||||
Revises: 332a15eba9d8
|
||||
Create Date: 2019-02-28 19:41:42.215923
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '13365ffe910e'
|
||||
down_revision = '332a15eba9d8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('proposal', sa.Column('contribution_bounty', sa.String(
|
||||
length=255), server_default=sa.text("'0'"), nullable=False))
|
||||
op.add_column('proposal', sa.Column('rfp_opt_in', sa.Boolean(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('proposal', 'rfp_opt_in')
|
||||
op.drop_column('proposal', 'contribution_bounty')
|
||||
# ### end Alembic commands ###
|
|
@ -213,7 +213,11 @@ export function deleteProposalDraft(proposalId: number): Promise<any> {
|
|||
|
||||
export function putProposal(proposal: ProposalDraft): Promise<{ data: ProposalDraft }> {
|
||||
// Exclude some keys
|
||||
const { proposalId, stage, dateCreated, team, ...rest } = proposal;
|
||||
const { proposalId, stage, dateCreated, team, rfpOptIn, ...rest } = proposal;
|
||||
// add rfpOptIn if it is not null
|
||||
if (rfpOptIn !== null) {
|
||||
(rest as any).rfpOptIn = rfpOptIn;
|
||||
}
|
||||
return axios.put(`/api/v1/proposals/${proposal.proposalId}`, rest);
|
||||
}
|
||||
|
||||
|
|
|
@ -109,7 +109,7 @@ export default class PaymentInfo extends React.Component<Props, State> {
|
|||
text = `
|
||||
Thank you for contributing! Just send using whichever method works best for
|
||||
you, and your contribution will show up anonymously once it's been confirmed.
|
||||
`
|
||||
`;
|
||||
} else {
|
||||
text = `
|
||||
Thank you for contributing! Just send using whichever method works best for
|
||||
|
@ -120,9 +120,7 @@ export default class PaymentInfo extends React.Component<Props, State> {
|
|||
|
||||
return (
|
||||
<Form className="PaymentInfo" layout="vertical">
|
||||
<div className="PaymentInfo-text">
|
||||
{text}
|
||||
</div>
|
||||
<div className="PaymentInfo-text">{text}</div>
|
||||
<Radio.Group
|
||||
className="PaymentInfo-types"
|
||||
onChange={this.handleChangeSendType}
|
||||
|
|
|
@ -93,14 +93,14 @@ export default class ContributionModal extends React.Component<Props, State> {
|
|||
description={
|
||||
<>
|
||||
You are about to contribute anonymously. Your contribution will show up
|
||||
without attribution, and even if you're logged in, will not
|
||||
appear anywhere on your account after you close this modal.
|
||||
without attribution, and even if you're logged in, will not appear anywhere
|
||||
on your account after you close this modal.
|
||||
<br /> <br />
|
||||
In the case of a refund, your contribution will be treated as a donation to
|
||||
the Zcash Foundation instead.
|
||||
<br /> <br />
|
||||
If you would like to have your contribution attached to an account, you
|
||||
can close this modal, make sure you're logged in, and don't check the
|
||||
If you would like to have your contribution attached to an account, you can
|
||||
close this modal, make sure you're logged in, and don't check the
|
||||
"Contribute anonymously" checkbox.
|
||||
</>
|
||||
}
|
||||
|
@ -116,15 +116,14 @@ export default class ContributionModal extends React.Component<Props, State> {
|
|||
description={
|
||||
<>
|
||||
Your transaction should be confirmed in about 20 minutes.{' '}
|
||||
{isAnonymous
|
||||
? 'Once it’s confirmed, it’ll show up in the contributions tab.'
|
||||
: (
|
||||
<>
|
||||
You can keep an eye on it at the{' '}
|
||||
<Link to="/profile?tab=funded">funded tab on your profile</Link>.
|
||||
</>
|
||||
)
|
||||
}
|
||||
{isAnonymous ? (
|
||||
'Once it’s confirmed, it’ll show up in the contributions tab.'
|
||||
) : (
|
||||
<>
|
||||
You can keep an eye on it at the{' '}
|
||||
<Link to="/profile?tab=funded">funded tab on your profile</Link>.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
style={{ width: '90%' }}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Input, Form, Icon, Select, Alert, Popconfirm, message } from 'antd';
|
||||
import BN from 'bn.js';
|
||||
import { Input, Form, Icon, Select, Alert, Popconfirm, message, Radio } from 'antd';
|
||||
import { SelectValue } from 'antd/lib/select';
|
||||
import { RadioChangeEvent } from 'antd/lib/radio';
|
||||
import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants';
|
||||
import { ProposalDraft, RFP } from 'types';
|
||||
import { getCreateErrors } from 'modules/create/utils';
|
||||
|
@ -63,13 +65,17 @@ class CreateFlowBasics extends React.Component<Props, State> {
|
|||
|
||||
render() {
|
||||
const { isUnlinkingProposalRFP } = this.props;
|
||||
const { title, brief, category, target, rfp } = this.state;
|
||||
const { title, brief, category, target, rfp, rfpOptIn } = this.state;
|
||||
const errors = getCreateErrors(this.state, true);
|
||||
|
||||
const rfpOptInRequired =
|
||||
rfp && (rfp.matching || (rfp.bounty && new BN(rfp.bounty).gtn(0)));
|
||||
|
||||
return (
|
||||
<Form layout="vertical" style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||
{rfp && (
|
||||
<Alert
|
||||
className="CreateFlow-rfpAlert"
|
||||
type="info"
|
||||
message="This proposal is linked to a request"
|
||||
description={
|
||||
|
@ -89,11 +95,46 @@ class CreateFlowBasics extends React.Component<Props, State> {
|
|||
to do so.
|
||||
</>
|
||||
}
|
||||
style={{ marginBottom: '2rem' }}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
{rfpOptInRequired && (
|
||||
<Alert
|
||||
className="CreateFlow-rfpAlert"
|
||||
type="warning"
|
||||
message="KYC (know your customer)"
|
||||
description={
|
||||
<>
|
||||
<div>
|
||||
This RFP offers either a bounty or matching. This will require ZFGrants
|
||||
to fulfill{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://en.wikipedia.org/wiki/Know_your_customer"
|
||||
>
|
||||
KYC
|
||||
</a>{' '}
|
||||
due dilligence. In the event your proposal is successful, you will need
|
||||
to provide identifying information to ZFGrants.
|
||||
<Radio.Group onChange={this.handleRfpOptIn}>
|
||||
<Radio value={true} checked={rfpOptIn && rfpOptIn === true}>
|
||||
<b>Yes</b>, I am willing to provide KYC information
|
||||
</Radio>
|
||||
<Radio
|
||||
value={false}
|
||||
checked={rfpOptIn !== null && rfpOptIn === false}
|
||||
>
|
||||
<b>No</b>, I do not wish to provide KYC information and understand I
|
||||
will not receive any matching or bounty funds from ZFGrants
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="Title"
|
||||
validateStatus={errors.title ? 'error' : undefined}
|
||||
|
@ -176,6 +217,12 @@ class CreateFlowBasics extends React.Component<Props, State> {
|
|||
});
|
||||
};
|
||||
|
||||
private handleRfpOptIn = (e: RadioChangeEvent) => {
|
||||
this.setState({ rfpOptIn: e.target.value }, () => {
|
||||
this.props.updateForm(this.state);
|
||||
});
|
||||
};
|
||||
|
||||
private unlinkRfp = () => {
|
||||
this.props.unlinkProposalRFP(this.props.proposalId);
|
||||
};
|
||||
|
|
|
@ -29,7 +29,9 @@ export default class CreateFlowPayment extends React.Component<Props, State> {
|
|||
render() {
|
||||
const { payoutAddress, deadlineDuration } = this.state;
|
||||
const errors = getCreateErrors(this.state, true);
|
||||
const payoutHelp = errors.payoutAddress || `
|
||||
const payoutHelp =
|
||||
errors.payoutAddress ||
|
||||
`
|
||||
This must be a Sapling Z address
|
||||
`;
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ interface Field {
|
|||
key: KeyOfForm;
|
||||
content: React.ReactNode;
|
||||
error: string | Falsy;
|
||||
isHide?: boolean;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
|
@ -48,6 +49,12 @@ class CreateReview extends React.Component<Props> {
|
|||
content: <h2 style={{ fontSize: '1.6rem', margin: 0 }}>{form.title}</h2>,
|
||||
error: errors.title,
|
||||
},
|
||||
{
|
||||
key: 'rfpOptIn',
|
||||
content: <div>{form.rfpOptIn ? 'Accepted' : 'Declined'}</div>,
|
||||
error: errors.rfpOptIn,
|
||||
isHide: !form.rfp || (form.rfp && !form.rfp.matching && !form.rfp.bounty),
|
||||
},
|
||||
{
|
||||
key: 'brief',
|
||||
content: form.brief,
|
||||
|
@ -126,21 +133,26 @@ class CreateReview extends React.Component<Props> {
|
|||
<div className="CreateReview">
|
||||
{sections.map(s => (
|
||||
<div className="CreateReview-section" key={s.step}>
|
||||
{s.fields.map(f => (
|
||||
<div className="ReviewField" key={f.key}>
|
||||
<div className="ReviewField-label">
|
||||
{FIELD_NAME_MAP[f.key]}
|
||||
{f.error && <div className="ReviewField-label-error">{f.error}</div>}
|
||||
</div>
|
||||
<div className="ReviewField-content">
|
||||
{this.isEmpty(form[f.key]) ? (
|
||||
<div className="ReviewField-content-empty">N/A</div>
|
||||
) : (
|
||||
f.content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{s.fields.map(
|
||||
f =>
|
||||
!f.isHide && (
|
||||
<div className="ReviewField" key={f.key}>
|
||||
<div className="ReviewField-label">
|
||||
{FIELD_NAME_MAP[f.key]}
|
||||
{f.error && (
|
||||
<div className="ReviewField-label-error">{f.error}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ReviewField-content">
|
||||
{this.isEmpty(form[f.key]) ? (
|
||||
<div className="ReviewField-content-empty">N/A</div>
|
||||
) : (
|
||||
f.content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<div className="ReviewField">
|
||||
<div className="ReviewField-label" />
|
||||
<div className="ReviewField-content">
|
||||
|
@ -163,6 +175,9 @@ class CreateReview extends React.Component<Props> {
|
|||
};
|
||||
|
||||
private isEmpty(value: any) {
|
||||
if (typeof value === 'boolean') {
|
||||
return false; // defined booleans are never empty
|
||||
}
|
||||
return !value || value.length === 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
&-subtitle {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0;
|
||||
|
@ -79,7 +79,7 @@
|
|||
|
||||
&.is-primary {
|
||||
background: @primary-color;
|
||||
color: #FFF;
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
@ -111,6 +111,25 @@
|
|||
}
|
||||
}
|
||||
|
||||
&-rfpAlert {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.ant-radio-group {
|
||||
display: block;
|
||||
margin: 1.5rem 0 0 1.5rem;
|
||||
|
||||
.ant-radio-wrapper {
|
||||
display: flex;
|
||||
margin: 1rem 0;
|
||||
|
||||
span.ant-radio + * {
|
||||
white-space: normal;
|
||||
margin-top: -0.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-draftNotification {
|
||||
position: fixed;
|
||||
bottom: 8rem;
|
||||
|
@ -127,4 +146,4 @@
|
|||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -55,28 +55,26 @@ class HeaderDrawer extends React.Component<Props> {
|
|||
<div className="HeaderDrawer-title">Navigation</div>
|
||||
<Menu mode="inline" style={{ borderRight: 0 }} selectedKeys={[location.pathname]}>
|
||||
<Menu.ItemGroup className="HeaderDrawer-user" title={userTitle}>
|
||||
{user ? (
|
||||
[
|
||||
<Menu.Item key={`/profile/${user.userid}`}>
|
||||
<Link to={`/profile/${user.userid}`}>Profile</Link>
|
||||
</Menu.Item>,
|
||||
<Menu.Item key="/profile/settings">
|
||||
<Link to="/profile/settings">Settings</Link>
|
||||
</Menu.Item>,
|
||||
<Menu.Item key="/auth/sign-out">
|
||||
<Link to="/auth/sign-out">Sign out</Link>
|
||||
</Menu.Item>,
|
||||
]
|
||||
) : (
|
||||
[
|
||||
<Menu.Item key="/auth/sign-in">
|
||||
<Link to="/auth/sign-in">Sign in</Link>
|
||||
</Menu.Item>,
|
||||
<Menu.Item key="/auth/sign-up">
|
||||
<Link to="/auth/sign-up">Create account</Link>
|
||||
</Menu.Item>
|
||||
]
|
||||
)}
|
||||
{user
|
||||
? [
|
||||
<Menu.Item key={`/profile/${user.userid}`}>
|
||||
<Link to={`/profile/${user.userid}`}>Profile</Link>
|
||||
</Menu.Item>,
|
||||
<Menu.Item key="/profile/settings">
|
||||
<Link to="/profile/settings">Settings</Link>
|
||||
</Menu.Item>,
|
||||
<Menu.Item key="/auth/sign-out">
|
||||
<Link to="/auth/sign-out">Sign out</Link>
|
||||
</Menu.Item>,
|
||||
]
|
||||
: [
|
||||
<Menu.Item key="/auth/sign-in">
|
||||
<Link to="/auth/sign-in">Sign in</Link>
|
||||
</Menu.Item>,
|
||||
<Menu.Item key="/auth/sign-up">
|
||||
<Link to="/auth/sign-up">Create account</Link>
|
||||
</Menu.Item>,
|
||||
]}
|
||||
</Menu.ItemGroup>
|
||||
<Menu.ItemGroup title="Proposals">
|
||||
<Menu.Item key="/proposals">
|
||||
|
|
|
@ -11,7 +11,7 @@ const HomeActions: React.SFC<WithNamespaces> = ({ t }) => (
|
|||
{t('home.actions.proposals')}
|
||||
</Link>
|
||||
<Link className="HomeActions-buttons-button is-dark" to="/requests">
|
||||
{t('home.actions.requests')}
|
||||
{t('home.actions.requests')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -7,28 +7,31 @@ import CompleteIcon from 'static/images/guide-complete.svg';
|
|||
import './Guide.less';
|
||||
|
||||
const HomeGuide: React.SFC<WithNamespaces> = ({ t }) => {
|
||||
const items = [{
|
||||
text: t('home.guide.submit'),
|
||||
icon: <SubmitIcon />,
|
||||
}, {
|
||||
text: t('home.guide.review'),
|
||||
icon: <ReviewIcon />,
|
||||
}, {
|
||||
text: t('home.guide.community'),
|
||||
icon: <CommunityIcon />,
|
||||
}, {
|
||||
text: t('home.guide.complete'),
|
||||
icon: <CompleteIcon />,
|
||||
}];
|
||||
const items = [
|
||||
{
|
||||
text: t('home.guide.submit'),
|
||||
icon: <SubmitIcon />,
|
||||
},
|
||||
{
|
||||
text: t('home.guide.review'),
|
||||
icon: <ReviewIcon />,
|
||||
},
|
||||
{
|
||||
text: t('home.guide.community'),
|
||||
icon: <CommunityIcon />,
|
||||
},
|
||||
{
|
||||
text: t('home.guide.complete'),
|
||||
icon: <CompleteIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="HomeGuide" id="home-guide">
|
||||
<svg className="HomeGuide-cap" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<polygon points="100 0, 100 100, 0 100"/>
|
||||
<polygon points="100 0, 100 100, 0 100" />
|
||||
</svg>
|
||||
<h2 className="HomeGuide-title">
|
||||
{t('home.guide.title')}
|
||||
</h2>
|
||||
<h2 className="HomeGuide-title">{t('home.guide.title')}</h2>
|
||||
<div className="HomeGuide-items">
|
||||
{items.map((item, idx) => (
|
||||
<div className="HomeGuide-items-item" key={idx}>
|
||||
|
|
|
@ -38,6 +38,6 @@ const HomeIntro: React.SFC<Props> = ({ t, authUser }) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
export default connect<StateProps, {}, {}, AppState>(
|
||||
state => ({ authUser: state.auth.user }),
|
||||
)(withNamespaces()(HomeIntro));
|
||||
export default connect<StateProps, {}, {}, AppState>(state => ({
|
||||
authUser: state.auth.user,
|
||||
}))(withNamespaces()(HomeIntro));
|
||||
|
|
|
@ -67,25 +67,21 @@ class HomeRequests extends React.Component<Props> {
|
|||
<div className="HomeRequests">
|
||||
<div className="HomeRequests-divider" />
|
||||
<div className="HomeRequests-text">
|
||||
<h2 className="HomeRequests-text-title">
|
||||
{t('home.requests.title')}
|
||||
</h2>
|
||||
<h2 className="HomeRequests-text-title">{t('home.requests.title')}</h2>
|
||||
<div className="HomeRequests-text-description">
|
||||
{t('home.requests.description').split('\n').map((s: string, idx: number) =>
|
||||
<p key={idx}>{s}</p>
|
||||
)}
|
||||
{t('home.requests.description')
|
||||
.split('\n')
|
||||
.map((s: string, idx: number) => (
|
||||
<p key={idx}>{s}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="HomeRequests-content">
|
||||
{content}
|
||||
</div>
|
||||
<div className="HomeRequests-content">{content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default connect<StateProps, DispatchProps, {}, AppState>(
|
||||
state => ({
|
||||
rfps: state.rfps.rfps,
|
||||
|
|
|
@ -66,10 +66,10 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
|
||||
// Get bounty from RFP. If it exceeds proposal target, show bounty as full amount
|
||||
let bounty;
|
||||
if (proposal.rfp && proposal.rfp.bounty) {
|
||||
bounty = proposal.rfp.bounty.gt(proposal.target)
|
||||
if (proposal.contributionBounty && proposal.contributionBounty.gtn(0)) {
|
||||
bounty = proposal.contributionBounty.gt(proposal.target)
|
||||
? proposal.target
|
||||
: proposal.rfp.bounty;
|
||||
: proposal.contributionBounty;
|
||||
}
|
||||
|
||||
content = (
|
||||
|
|
|
@ -9,9 +9,11 @@ interface Props {
|
|||
extra?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Wrap = ({ user, children }: { user: User, children: React.ReactNode }) => {
|
||||
const Wrap = ({ user, children }: { user: User; children: React.ReactNode }) => {
|
||||
if (user.userid) {
|
||||
return <Link to={`/profile/${user.userid}`} className="UserRow" children={children} />;
|
||||
return (
|
||||
<Link to={`/profile/${user.userid}`} className="UserRow" children={children} />
|
||||
);
|
||||
} else {
|
||||
return <div className="UserRow" children={children} />;
|
||||
}
|
||||
|
@ -26,11 +28,7 @@ const UserRow = ({ user, extra }: Props) => (
|
|||
<div className="UserRow-info-main">{user.displayName}</div>
|
||||
<p className="UserRow-info-secondary">{user.title}</p>
|
||||
</div>
|
||||
{extra && (
|
||||
<div className="UserRow-extra">
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
{extra && <div className="UserRow-extra">{extra}</div>}
|
||||
</Wrap>
|
||||
);
|
||||
|
||||
|
|
|
@ -25,7 +25,6 @@ const i18nLanguage = window && (window as any).__PRELOADED_I18N__;
|
|||
i18n.changeLanguage(i18nLanguage.locale);
|
||||
i18n.addResourceBundle(i18nLanguage.locale, 'common', i18nLanguage.resources, true);
|
||||
|
||||
|
||||
const App = hot(module)(() => (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Provider store={store}>
|
||||
|
|
|
@ -6,7 +6,12 @@ import {
|
|||
PROPOSAL_ARBITER_STATUS,
|
||||
} from 'types';
|
||||
import { User } from 'types';
|
||||
import { getAmountError, isValidSaplingAddress, isValidTAddress, isValidSproutAddress } from 'utils/validators';
|
||||
import {
|
||||
getAmountError,
|
||||
isValidSaplingAddress,
|
||||
isValidTAddress,
|
||||
isValidSproutAddress,
|
||||
} from 'utils/validators';
|
||||
import { Zat, toZat } from 'utils/units';
|
||||
import { ONE_DAY } from 'utils/time';
|
||||
import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants';
|
||||
|
@ -18,6 +23,7 @@ import {
|
|||
export const TARGET_ZEC_LIMIT = 1000;
|
||||
|
||||
interface CreateFormErrors {
|
||||
rfpOptIn?: string;
|
||||
title?: string;
|
||||
brief?: string;
|
||||
category?: string;
|
||||
|
@ -31,6 +37,7 @@ interface CreateFormErrors {
|
|||
|
||||
export type KeyOfForm = keyof CreateFormErrors;
|
||||
export const FIELD_NAME_MAP: { [key in KeyOfForm]: string } = {
|
||||
rfpOptIn: 'RFP KYC',
|
||||
title: 'Title',
|
||||
brief: 'Brief',
|
||||
category: 'Category',
|
||||
|
@ -57,7 +64,7 @@ export function getCreateErrors(
|
|||
skipRequired?: boolean,
|
||||
): CreateFormErrors {
|
||||
const errors: CreateFormErrors = {};
|
||||
const { title, team, milestones, target, payoutAddress } = form;
|
||||
const { title, team, milestones, target, payoutAddress, rfp, rfpOptIn } = form;
|
||||
|
||||
// Required fields with no extra validation
|
||||
if (!skipRequired) {
|
||||
|
@ -75,6 +82,11 @@ export function getCreateErrors(
|
|||
}
|
||||
}
|
||||
|
||||
// RFP opt-in
|
||||
if (rfp && (rfp.bounty || rfp.matching) && rfpOptIn === null) {
|
||||
errors.rfpOptIn = 'Please accept or decline KYC';
|
||||
}
|
||||
|
||||
// Title
|
||||
if (title && title.length > 60) {
|
||||
errors.title = 'Title can only be 60 characters maximum';
|
||||
|
@ -212,6 +224,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta
|
|||
target: toZat(draft.target),
|
||||
funded: Zat('0'),
|
||||
contributionMatching: 0,
|
||||
contributionBounty: Zat('0'),
|
||||
percentFunded: 0,
|
||||
stage: PROPOSAL_STAGE.PREVIEW,
|
||||
category: draft.category || PROPOSAL_CATEGORY.CORE_DEV,
|
||||
|
|
|
@ -86,6 +86,7 @@ export function formatProposalFromGet(p: any): Proposal {
|
|||
proposal.proposalUrlId = generateSlugUrl(proposal.proposalId, proposal.title);
|
||||
proposal.target = toZat(p.target);
|
||||
proposal.funded = toZat(p.funded);
|
||||
proposal.contributionBounty = toZat(p.contributionBounty);
|
||||
proposal.percentFunded = proposal.target.isZero()
|
||||
? 0
|
||||
: proposal.funded.div(proposal.target.divn(100)).toNumber();
|
||||
|
@ -145,6 +146,8 @@ export function massageSerializedState(state: AppState) {
|
|||
(state.proposal.detail.funded as any) as string,
|
||||
16,
|
||||
);
|
||||
state.proposal.detail.contributionBounty = new BN((state.proposal.detail
|
||||
.contributionBounty as any) as string);
|
||||
if (state.proposal.detail.rfp && state.proposal.detail.rfp.bounty) {
|
||||
state.proposal.detail.rfp.bounty = new BN(
|
||||
(state.proposal.detail.rfp.bounty as any) as string,
|
||||
|
@ -157,6 +160,7 @@ export function massageSerializedState(state: AppState) {
|
|||
...p,
|
||||
target: new BN((p.target as any) as string, 16),
|
||||
funded: new BN((p.funded as any) as string, 16),
|
||||
contributionBounty: new BN((p.contributionMatching as any) as string, 16),
|
||||
milestones: p.milestones.map(m => ({
|
||||
...m,
|
||||
amount: new BN((m.amount as any) as string, 16),
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
export const DONATION = {
|
||||
ZCASH_TRANSPARENT: 't1aib2cbwPVrFfrjGGkhWD67imdBet1xDTr',
|
||||
ZCASH_SPROUT: 'zcWGwZU7FyUgpdrWGkeFqCEnvhLRDAVuf2ZbhW4vzNMTTR6VUgfiBGkiNbkC4e38QaPtS13RKZCriqN9VcyyKNRRQxbgnen',
|
||||
ZCASH_SAPLING: 'zs15el0hzs4w60ggfy6kq4p3zttjrl00mfq7yxfwsjqpz9d7hptdtkltzlcqar994jg2ju3j9k85zk',
|
||||
ZCASH_SPROUT:
|
||||
'zcWGwZU7FyUgpdrWGkeFqCEnvhLRDAVuf2ZbhW4vzNMTTR6VUgfiBGkiNbkC4e38QaPtS13RKZCriqN9VcyyKNRRQxbgnen',
|
||||
ZCASH_SAPLING:
|
||||
'zs15el0hzs4w60ggfy6kq4p3zttjrl00mfq7yxfwsjqpz9d7hptdtkltzlcqar994jg2ju3j9k85zk',
|
||||
};
|
||||
|
|
|
@ -17,7 +17,6 @@ export function isValidEmail(email: string): boolean {
|
|||
return /\S+@\S+\.\S+/.test(email);
|
||||
}
|
||||
|
||||
|
||||
// Uses simple regex to validate addresses, doesn't check checksum or network
|
||||
export function isValidTAddress(address: string): boolean {
|
||||
if (/^t[a-zA-Z0-9]{34}$/.test(address)) {
|
||||
|
|
|
@ -155,6 +155,7 @@ export function generateProposal({
|
|||
funded: fundedBn,
|
||||
percentFunded,
|
||||
contributionMatching: 0,
|
||||
contributionBounty: new BN(0),
|
||||
title: 'Crowdfund Title',
|
||||
brief: 'A cool test crowdfund',
|
||||
content: 'body',
|
||||
|
|
|
@ -46,6 +46,7 @@ export interface ProposalDraft {
|
|||
status: STATUS;
|
||||
isStaked: boolean;
|
||||
rfp?: RFP;
|
||||
rfpOptIn?: boolean;
|
||||
}
|
||||
|
||||
export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
||||
|
@ -55,6 +56,7 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
|||
funded: Zat;
|
||||
percentFunded: number;
|
||||
contributionMatching: number;
|
||||
contributionBounty: Zat;
|
||||
milestones: ProposalMilestone[];
|
||||
currentMilestone?: ProposalMilestone;
|
||||
datePublished: number | null;
|
||||
|
|
Loading…
Reference in New Issue