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:
AMStrix 2019-03-06 14:25:58 -06:00 committed by Daniel Ternyak
parent 7bbefe8abe
commit 1d2228a394
30 changed files with 466 additions and 161 deletions

View File

@ -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">

View File

@ -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');
},
});
}
};

View File

@ -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) {

View File

@ -111,6 +111,8 @@ export interface Proposal {
funded: string;
rejectReason: string;
contributionMatching: number;
contributionBounty: string;
rfpOptIn: null | boolean;
rfp?: RFP;
arbiter: ProposalArbiter;
}

View File

@ -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):

View File

@ -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

View File

@ -187,8 +187,6 @@ def make_proposal_draft(rfp_id):
if not rfp:
return {"message": "The request this proposal was made for doesnt 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

View File

@ -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)

View File

@ -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 ###

View File

@ -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);
}

View File

@ -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}

View File

@ -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 its confirmed, itll 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 its confirmed, itll 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%' }}

View File

@ -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);
};

View File

@ -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
`;

View File

@ -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;
}
}

View File

@ -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%);
}
}
}

View File

@ -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">

View File

@ -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>

View File

@ -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}>

View File

@ -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));

View File

@ -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,

View File

@ -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 = (

View File

@ -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>
);

View File

@ -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}>

View File

@ -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,

View File

@ -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),

View File

@ -1,5 +1,7 @@
export const DONATION = {
ZCASH_TRANSPARENT: 't1aib2cbwPVrFfrjGGkhWD67imdBet1xDTr',
ZCASH_SPROUT: 'zcWGwZU7FyUgpdrWGkeFqCEnvhLRDAVuf2ZbhW4vzNMTTR6VUgfiBGkiNbkC4e38QaPtS13RKZCriqN9VcyyKNRRQxbgnen',
ZCASH_SAPLING: 'zs15el0hzs4w60ggfy6kq4p3zttjrl00mfq7yxfwsjqpz9d7hptdtkltzlcqar994jg2ju3j9k85zk',
ZCASH_SPROUT:
'zcWGwZU7FyUgpdrWGkeFqCEnvhLRDAVuf2ZbhW4vzNMTTR6VUgfiBGkiNbkC4e38QaPtS13RKZCriqN9VcyyKNRRQxbgnen',
ZCASH_SAPLING:
'zs15el0hzs4w60ggfy6kq4p3zttjrl00mfq7yxfwsjqpz9d7hptdtkltzlcqar994jg2ju3j9k85zk',
};

View File

@ -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)) {

View File

@ -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',

View File

@ -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;