From 1d2228a394dfacd808eadfdf7bb3711148de5012 Mon Sep 17 00:00:00 2001 From: AMStrix Date: Wed, 6 Mar 2019 14:25:58 -0600 Subject: [PATCH] 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 --- admin/src/components/FeedbackModal/index.tsx | 77 +++++++++++---- admin/src/components/ProposalDetail/index.tsx | 95 ++++++++++++++++--- admin/src/store.ts | 6 ++ admin/src/types.ts | 2 + backend/grant/admin/views.py | 28 +++--- backend/grant/proposal/models.py | 33 ++++++- backend/grant/proposal/views.py | 13 ++- backend/grant/rfp/models.py | 23 ++++- backend/migrations/versions/13365ffe910e_.py | 31 ++++++ frontend/client/api/api.ts | 6 +- .../ContributionModal/PaymentInfo.tsx | 6 +- .../components/ContributionModal/index.tsx | 25 +++-- .../client/components/CreateFlow/Basics.tsx | 53 ++++++++++- .../client/components/CreateFlow/Payment.tsx | 4 +- .../client/components/CreateFlow/Review.tsx | 45 ++++++--- .../client/components/CreateFlow/index.less | 25 ++++- frontend/client/components/Header/Drawer.tsx | 42 ++++---- frontend/client/components/Home/Actions.tsx | 2 +- frontend/client/components/Home/Guide.tsx | 37 ++++---- frontend/client/components/Home/Intro.tsx | 6 +- frontend/client/components/Home/Requests.tsx | 18 ++-- .../Proposal/CampaignBlock/index.tsx | 6 +- frontend/client/components/UserRow/index.tsx | 12 +-- frontend/client/index.tsx | 1 - frontend/client/modules/create/utils.ts | 17 +++- frontend/client/utils/api.ts | 4 + frontend/client/utils/constants.ts | 6 +- frontend/client/utils/validators.ts | 1 - frontend/stories/props.tsx | 1 + frontend/types/proposal.ts | 2 + 30 files changed, 466 insertions(+), 161 deletions(-) create mode 100644 backend/migrations/versions/13365ffe910e_.py diff --git a/admin/src/components/FeedbackModal/index.tsx b/admin/src/components/FeedbackModal/index.tsx index 243cf803..b6715603 100644 --- a/admin/src/components/FeedbackModal/index.tsx +++ b/admin/src/components/FeedbackModal/index.tsx @@ -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) => { { @@ -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 { 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 (
{content &&

{content}

} -
{label}
- (this.input = ta)} - rows={4} - required={true} - value={text} - onChange={e => { - this.setState({ text: e.target.value }); - this.props.onChange(e.target.value); - }} - /> + {label &&
{label}
} + {type === 'textArea' && ( + (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' && ( + (this.input = ta)} + value={text} + onChange={e => { + this.setState({ text: e.target.value }); + this.props.onChange(e.target.value); + }} + {...inputProps} + /> + )}
); + const renderBountyControl = () => ( +
+ +
+ ); + const renderApproved = () => p.status === PROPOSAL_STATUS.APPROVED && ( { {renderDeleteControl()} {renderCancelControl()} {renderArbiterControl()} + {renderBountyControl()} {renderMatchingControl()} @@ -419,6 +441,8 @@ class ProposalDetailNaked extends React.Component { {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 { ); } + 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 { 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 { // 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'); + }, + }); } }; diff --git a/admin/src/store.ts b/admin/src/store.ts index 893e95a8..b562f7f9 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -249,6 +249,8 @@ const app = store({ proposalDetailApproving: false, proposalDetailMarkingMilestonePaid: false, proposalDetailCanceling: false, + proposalDetailUpdating: false, + proposalDetailUpdated: false, comments: { page: createDefaultPageData('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) { diff --git a/admin/src/types.ts b/admin/src/types.ts index 22184c00..d458034a 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -111,6 +111,8 @@ export interface Proposal { funded: string; rejectReason: string; contributionMatching: number; + contributionBounty: string; + rfpOptIn: null | boolean; rfp?: RFP; arbiter: ProposalArbiter; } diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 0a29ae68..91ae8aa9 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -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/', 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): diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 8636e55f..ffdb9160 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -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 diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 12164449..de00e6e3 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -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 diff --git a/backend/grant/rfp/models.py b/backend/grant/rfp/models.py index 02e0d2be..c9159d43 100644 --- a/backend/grant/rfp/models.py +++ b/backend/grant/rfp/models.py @@ -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) diff --git a/backend/migrations/versions/13365ffe910e_.py b/backend/migrations/versions/13365ffe910e_.py new file mode 100644 index 00000000..3a031b05 --- /dev/null +++ b/backend/migrations/versions/13365ffe910e_.py @@ -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 ### diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 37641f65..0a52a027 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -213,7 +213,11 @@ export function deleteProposalDraft(proposalId: number): Promise { 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); } diff --git a/frontend/client/components/ContributionModal/PaymentInfo.tsx b/frontend/client/components/ContributionModal/PaymentInfo.tsx index 654958ae..b9d6ed0c 100644 --- a/frontend/client/components/ContributionModal/PaymentInfo.tsx +++ b/frontend/client/components/ContributionModal/PaymentInfo.tsx @@ -109,7 +109,7 @@ export default class PaymentInfo extends React.Component { 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 { return (
-
- {text} -
+
{text}
{ 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.

In the case of a refund, your contribution will be treated as a donation to the Zcash Foundation instead.

- 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 { 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{' '} - funded tab on your profile. - - ) - } + {isAnonymous ? ( + 'Once it’s confirmed, it’ll show up in the contributions tab.' + ) : ( + <> + You can keep an eye on it at the{' '} + funded tab on your profile. + + )} } style={{ width: '90%' }} diff --git a/frontend/client/components/CreateFlow/Basics.tsx b/frontend/client/components/CreateFlow/Basics.tsx index a602c502..ab064d83 100644 --- a/frontend/client/components/CreateFlow/Basics.tsx +++ b/frontend/client/components/CreateFlow/Basics.tsx @@ -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 { 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 ( {rfp && ( { to do so. } - style={{ marginBottom: '2rem' }} showIcon /> )} + {rfpOptInRequired && ( + +
+ This RFP offers either a bounty or matching. This will require ZFGrants + to fulfill{' '} + + KYC + {' '} + due dilligence. In the event your proposal is successful, you will need + to provide identifying information to ZFGrants. + + + Yes, I am willing to provide KYC information + + + No, I do not wish to provide KYC information and understand I + will not receive any matching or bounty funds from ZFGrants + + +
+ + } + /> + )} + { }); }; + private handleRfpOptIn = (e: RadioChangeEvent) => { + this.setState({ rfpOptIn: e.target.value }, () => { + this.props.updateForm(this.state); + }); + }; + private unlinkRfp = () => { this.props.unlinkProposalRFP(this.props.proposalId); }; diff --git a/frontend/client/components/CreateFlow/Payment.tsx b/frontend/client/components/CreateFlow/Payment.tsx index 8c91eed2..18bdfc3c 100644 --- a/frontend/client/components/CreateFlow/Payment.tsx +++ b/frontend/client/components/CreateFlow/Payment.tsx @@ -29,7 +29,9 @@ export default class CreateFlowPayment extends React.Component { 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 `; diff --git a/frontend/client/components/CreateFlow/Review.tsx b/frontend/client/components/CreateFlow/Review.tsx index 3db4c4de..defe4417 100644 --- a/frontend/client/components/CreateFlow/Review.tsx +++ b/frontend/client/components/CreateFlow/Review.tsx @@ -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 { content:

{form.title}

, error: errors.title, }, + { + key: 'rfpOptIn', + content:
{form.rfpOptIn ? 'Accepted' : 'Declined'}
, + 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 {
{sections.map(s => (
- {s.fields.map(f => ( -
-
- {FIELD_NAME_MAP[f.key]} - {f.error &&
{f.error}
} -
-
- {this.isEmpty(form[f.key]) ? ( -
N/A
- ) : ( - f.content - )} -
-
- ))} + {s.fields.map( + f => + !f.isHide && ( +
+
+ {FIELD_NAME_MAP[f.key]} + {f.error && ( +
{f.error}
+ )} +
+
+ {this.isEmpty(form[f.key]) ? ( +
N/A
+ ) : ( + f.content + )} +
+
+ ), + )}
@@ -163,6 +175,9 @@ class CreateReview extends React.Component { }; private isEmpty(value: any) { + if (typeof value === 'boolean') { + return false; // defined booleans are never empty + } return !value || value.length === 0; } } diff --git a/frontend/client/components/CreateFlow/index.less b/frontend/client/components/CreateFlow/index.less index fb1476db..3ffec37e 100644 --- a/frontend/client/components/CreateFlow/index.less +++ b/frontend/client/components/CreateFlow/index.less @@ -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%); } -} \ No newline at end of file +} diff --git a/frontend/client/components/Header/Drawer.tsx b/frontend/client/components/Header/Drawer.tsx index f74e5e9f..31114393 100644 --- a/frontend/client/components/Header/Drawer.tsx +++ b/frontend/client/components/Header/Drawer.tsx @@ -55,28 +55,26 @@ class HeaderDrawer extends React.Component {
Navigation
- {user ? ( - [ - - Profile - , - - Settings - , - - Sign out - , - ] - ) : ( - [ - - Sign in - , - - Create account - - ] - )} + {user + ? [ + + Profile + , + + Settings + , + + Sign out + , + ] + : [ + + Sign in + , + + Create account + , + ]} diff --git a/frontend/client/components/Home/Actions.tsx b/frontend/client/components/Home/Actions.tsx index 54dab3ef..fab1df92 100644 --- a/frontend/client/components/Home/Actions.tsx +++ b/frontend/client/components/Home/Actions.tsx @@ -11,7 +11,7 @@ const HomeActions: React.SFC = ({ t }) => ( {t('home.actions.proposals')} - {t('home.actions.requests')} + {t('home.actions.requests')}
diff --git a/frontend/client/components/Home/Guide.tsx b/frontend/client/components/Home/Guide.tsx index fba212a9..b22d3425 100644 --- a/frontend/client/components/Home/Guide.tsx +++ b/frontend/client/components/Home/Guide.tsx @@ -7,28 +7,31 @@ import CompleteIcon from 'static/images/guide-complete.svg'; import './Guide.less'; const HomeGuide: React.SFC = ({ t }) => { - const items = [{ - text: t('home.guide.submit'), - icon: , - }, { - text: t('home.guide.review'), - icon: , - }, { - text: t('home.guide.community'), - icon: , - }, { - text: t('home.guide.complete'), - icon: , - }]; + const items = [ + { + text: t('home.guide.submit'), + icon: , + }, + { + text: t('home.guide.review'), + icon: , + }, + { + text: t('home.guide.community'), + icon: , + }, + { + text: t('home.guide.complete'), + icon: , + }, + ]; return (
- + -

- {t('home.guide.title')} -

+

{t('home.guide.title')}

{items.map((item, idx) => (
diff --git a/frontend/client/components/Home/Intro.tsx b/frontend/client/components/Home/Intro.tsx index b6cf675e..37d96bf4 100644 --- a/frontend/client/components/Home/Intro.tsx +++ b/frontend/client/components/Home/Intro.tsx @@ -38,6 +38,6 @@ const HomeIntro: React.SFC = ({ t, authUser }) => (
); -export default connect( - state => ({ authUser: state.auth.user }), -)(withNamespaces()(HomeIntro)); +export default connect(state => ({ + authUser: state.auth.user, +}))(withNamespaces()(HomeIntro)); diff --git a/frontend/client/components/Home/Requests.tsx b/frontend/client/components/Home/Requests.tsx index d99bca24..e3d2ea45 100644 --- a/frontend/client/components/Home/Requests.tsx +++ b/frontend/client/components/Home/Requests.tsx @@ -67,25 +67,21 @@ class HomeRequests extends React.Component {
-

- {t('home.requests.title')} -

+

{t('home.requests.title')}

- {t('home.requests.description').split('\n').map((s: string, idx: number) => -

{s}

- )} + {t('home.requests.description') + .split('\n') + .map((s: string, idx: number) => ( +

{s}

+ ))}
-
- {content} -
+
{content}
); } } - - export default connect( state => ({ rfps: state.rfps.rfps, diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index 9593b871..7b32ba81 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -66,10 +66,10 @@ export class ProposalCampaignBlock extends React.Component { // 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 = ( diff --git a/frontend/client/components/UserRow/index.tsx b/frontend/client/components/UserRow/index.tsx index b3bcb84e..26dac054 100644 --- a/frontend/client/components/UserRow/index.tsx +++ b/frontend/client/components/UserRow/index.tsx @@ -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 ; + return ( + + ); } else { return
; } @@ -26,11 +28,7 @@ const UserRow = ({ user, extra }: Props) => (
{user.displayName}

{user.title}

- {extra && ( -
- {extra} -
- )} + {extra &&
{extra}
} ); diff --git a/frontend/client/index.tsx b/frontend/client/index.tsx index 02522932..7462fa61 100644 --- a/frontend/client/index.tsx +++ b/frontend/client/index.tsx @@ -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)(() => ( diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 0c41fddc..c1061d59 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -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, diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index 7caa30fa..02da03fb 100644 --- a/frontend/client/utils/api.ts +++ b/frontend/client/utils/api.ts @@ -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), diff --git a/frontend/client/utils/constants.ts b/frontend/client/utils/constants.ts index e5271f27..cd260add 100644 --- a/frontend/client/utils/constants.ts +++ b/frontend/client/utils/constants.ts @@ -1,5 +1,7 @@ export const DONATION = { ZCASH_TRANSPARENT: 't1aib2cbwPVrFfrjGGkhWD67imdBet1xDTr', - ZCASH_SPROUT: 'zcWGwZU7FyUgpdrWGkeFqCEnvhLRDAVuf2ZbhW4vzNMTTR6VUgfiBGkiNbkC4e38QaPtS13RKZCriqN9VcyyKNRRQxbgnen', - ZCASH_SAPLING: 'zs15el0hzs4w60ggfy6kq4p3zttjrl00mfq7yxfwsjqpz9d7hptdtkltzlcqar994jg2ju3j9k85zk', + ZCASH_SPROUT: + 'zcWGwZU7FyUgpdrWGkeFqCEnvhLRDAVuf2ZbhW4vzNMTTR6VUgfiBGkiNbkC4e38QaPtS13RKZCriqN9VcyyKNRRQxbgnen', + ZCASH_SAPLING: + 'zs15el0hzs4w60ggfy6kq4p3zttjrl00mfq7yxfwsjqpz9d7hptdtkltzlcqar994jg2ju3j9k85zk', }; diff --git a/frontend/client/utils/validators.ts b/frontend/client/utils/validators.ts index b7cc434f..e7cd7957 100644 --- a/frontend/client/utils/validators.ts +++ b/frontend/client/utils/validators.ts @@ -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)) { diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 9c489c07..2f0fcf18 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -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', diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index 143d9033..33548aa9 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -46,6 +46,7 @@ export interface ProposalDraft { status: STATUS; isStaked: boolean; rfp?: RFP; + rfpOptIn?: boolean; } export interface Proposal extends Omit { @@ -55,6 +56,7 @@ export interface Proposal extends Omit { funded: Zat; percentFunded: number; contributionMatching: number; + contributionBounty: Zat; milestones: ProposalMilestone[]; currentMilestone?: ProposalMilestone; datePublished: number | null;