From 4a0e23e9c7effeba198e5e00cba070096e1e4d11 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Tue, 3 Dec 2019 19:02:39 -0500 Subject: [PATCH] Price in Usd (#91) * init profile tipjar backend * init profile tipjar frontend * fix lint * implement tip jar block * fix wrapping, hide tip block on self * init backend proposal tipjar * init frontend proposal tipjar * add hide title, fix bug * uncomment rate limit * rename vars, use null check * allow address and view key to be unset * add api tests * fix tsc errors * fix lint * fix CopyInput styling * fix migrations * hide tipping in proposal if address not set * add tip address to create flow * redesign campaign block * fix typo * init backend changes * init admin changes * init frontend changes * fix backend tests * update campaign block * be - init rfp usd changes * admin - init rfp usd changes * fe - fully adapt api util functions to usd * fe - init rfp usd changes * adapt profile created to usd * misc usd changes * add tip jar to dedicated card * fix tipjar bug * use zf light logo * switch to zf grants logo * hide profile tip jar if address not set * add comment, run prettier * conditionally add info icon and tooltip to funding line * admin - disallow decimals in RFPs * fe - cover usd string edge case * add Usd as rfp bounty type --- admin/src/components/ProposalDetail/index.tsx | 32 ++++-- admin/src/components/RFPDetail/index.tsx | 6 +- admin/src/components/RFPForm/index.tsx | 16 ++- admin/src/types.ts | 1 + admin/src/util/formatters.ts | 72 ++++++++++++ backend/.env.example | 2 +- backend/grant/proposal/models.py | 9 +- backend/grant/rfp/models.py | 14 ++- backend/migrations/versions/1e1460456ce4_.py | 28 +++++ .../tests/milestone/test_milestone_methods.py | 2 +- backend/tests/test_data.py | 2 +- frontend/.env.example | 2 +- .../client/components/CreateFlow/Basics.tsx | 7 +- .../client/components/CreateFlow/Review.tsx | 3 +- .../components/Profile/ProfileProposal.tsx | 44 ++++++-- .../Proposal/CampaignBlock/index.tsx | 15 ++- .../components/Proposal/Milestones/index.tsx | 12 +- .../Proposals/ProposalCard/index.tsx | 3 +- frontend/client/components/RFP/index.tsx | 43 +++++--- frontend/client/components/RFPs/RFPItem.tsx | 20 +++- frontend/client/modules/create/utils.ts | 6 +- frontend/client/utils/api.ts | 103 ++++++++++++------ frontend/client/utils/formatters.ts | 11 ++ frontend/client/utils/units.ts | 11 ++ frontend/client/utils/validators.ts | 32 +++++- frontend/types/milestone.ts | 2 +- frontend/types/proposal.ts | 12 +- frontend/types/rfp.ts | 5 +- 28 files changed, 417 insertions(+), 98 deletions(-) create mode 100644 admin/src/util/formatters.ts create mode 100644 backend/migrations/versions/1e1460456ce4_.py diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index 193b0810..ed83a145 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -29,6 +29,7 @@ import Markdown from 'components/Markdown'; import ArbiterControl from 'components/ArbiterControl'; import { toZat, fromZat } from 'src/util/units'; import FeedbackModal from '../FeedbackModal'; +import { formatUsd } from 'util/formatters'; import './index.less'; type Props = RouteComponentProps; @@ -285,11 +286,23 @@ class ProposalDetailNaked extends React.Component { return; } const ms = p.currentMilestone; - const amount = fromZat( - toZat(p.target) - .mul(new BN(ms.payoutPercent)) - .divn(100), - ); + + let paymentMsg; + if (p.isVersionTwo) { + const target = parseFloat(p.target.toString()); + const payoutPercent = parseFloat(ms.payoutPercent); + const amountNum = (target * payoutPercent) / 100; + const amount = formatUsd(amountNum, true, 2); + paymentMsg = `${amount} in ZEC`; + } else { + const amount = fromZat( + toZat(p.target) + .mul(new BN(ms.payoutPercent)) + .divn(100), + ); + paymentMsg = `${amount} ZEC`; + } + return ( {

{' '} - Please make a payment of {amount.toString()} ZEC to: + Please make a payment of {paymentMsg} to:

{' '}
{p.payoutAddress}
{ {renderDeetItem('isFailed', JSON.stringify(p.isFailed))} {renderDeetItem('status', p.status)} {renderDeetItem('stage', p.stage)} - {renderDeetItem('target', p.target)} + {renderDeetItem('target', p.isVersionTwo ? formatUsd(p.target) : p.target)} {renderDeetItem('contributed', p.contributed)} - {renderDeetItem('funded (inc. matching)', p.funded)} + {renderDeetItem( + 'funded (inc. matching)', + p.isVersionTwo ? formatUsd(p.funded) : p.funded, + )} {renderDeetItem('matching', p.contributionMatching)} {renderDeetItem('bounty', p.contributionBounty)} {renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))} diff --git a/admin/src/components/RFPDetail/index.tsx b/admin/src/components/RFPDetail/index.tsx index fc270c5c..e9e34982 100644 --- a/admin/src/components/RFPDetail/index.tsx +++ b/admin/src/components/RFPDetail/index.tsx @@ -9,6 +9,7 @@ import Markdown from 'components/Markdown'; import { formatDateSeconds } from 'util/time'; import store from 'src/store'; import { PROPOSAL_STATUS } from 'src/types'; +import { formatUsd } from 'src/util/formatters'; import './index.less'; type Props = RouteComponentProps<{ id?: string }>; @@ -93,7 +94,10 @@ class RFPDetail extends React.Component { {renderDeetItem('created', formatDateSeconds(rfp.dateCreated))} {renderDeetItem('status', rfp.status)} {renderDeetItem('matching', String(rfp.matching))} - {renderDeetItem('bounty', `${rfp.bounty} ZEC`)} + {renderDeetItem( + 'bounty', + rfp.isVersionTwo ? formatUsd(rfp.bounty) : `${rfp.bounty} ZEC`, + )} {renderDeetItem( 'dateCloses', rfp.dateCloses && formatDateSeconds(rfp.dateCloses), diff --git a/admin/src/components/RFPForm/index.tsx b/admin/src/components/RFPForm/index.tsx index a9ebe121..a82789f8 100644 --- a/admin/src/components/RFPForm/index.tsx +++ b/admin/src/components/RFPForm/index.tsx @@ -47,6 +47,8 @@ class RFPForm extends React.Component { dateCloses: undefined, }; const rfpId = this.getRFPId(); + let isVersionTwo = true; + if (rfpId) { if (!store.rfpsFetched) { return ; @@ -63,6 +65,7 @@ class RFPForm extends React.Component { bounty: rfp.bounty, dateCloses: rfp.dateCloses || undefined, }; + isVersionTwo = rfp.isVersionTwo; } else { return ; } @@ -73,6 +76,10 @@ class RFPForm extends React.Component { : defaults.dateCloses && moment(defaults.dateCloses * 1000); const forceClosed = dateCloses && dateCloses.isBefore(moment.now()); + const bountyMatchRule = isVersionTwo + ? { pattern: /^[^.]*$/, message: 'Cannot contain a decimal' } + : {}; + return (
@@ -162,12 +169,17 @@ class RFPForm extends React.Component { {getFieldDecorator('bounty', { initialValue: defaults.bounty, + rules: [ + { required: true, message: 'Bounty is required' }, + bountyMatchRule, + ], })( , )} diff --git a/admin/src/types.ts b/admin/src/types.ts index 1c3a84ab..c28a77e4 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -47,6 +47,7 @@ export interface RFP { matching: boolean; bounty: string | null; dateCloses: number | null; + isVersionTwo: boolean; } export interface RFPArgs { title: string; diff --git a/admin/src/util/formatters.ts b/admin/src/util/formatters.ts new file mode 100644 index 00000000..1e1b0848 --- /dev/null +++ b/admin/src/util/formatters.ts @@ -0,0 +1,72 @@ + +const toFixed = (num: string, digits: number = 3) => { + const [integerPart, fractionPart = ''] = num.split('.'); + if (fractionPart.length === digits) { + return num; + } + if (fractionPart.length < digits) { + return `${integerPart}.${fractionPart.padEnd(digits, '0')}`; + } + + let decimalPoint = integerPart.length; + + const formattedFraction = fractionPart.slice(0, digits); + + const integerArr = `${integerPart}${formattedFraction}`.split('').map(str => +str); + + let carryOver = Math.floor((+fractionPart[digits] + 5) / 10); + + // grade school addition / rounding + for (let i = integerArr.length - 1; i >= 0; i--) { + const currVal = integerArr[i] + carryOver; + const newVal = currVal % 10; + carryOver = Math.floor(currVal / 10); + integerArr[i] = newVal; + if (i === 0 && carryOver > 0) { + integerArr.unshift(0); + decimalPoint++; + i++; + } + } + + const strArr = integerArr.map(n => n.toString()); + + strArr.splice(decimalPoint, 0, '.'); + + if (strArr[strArr.length - 1] === '.') { + strArr.pop(); + } + + return strArr.join(''); +}; + +export function formatNumber(num: string, digits?: number): string { + const parts = toFixed(num, digits).split('.'); + + // Remove trailing zeroes on decimal (If there is a decimal) + if (parts[1]) { + parts[1] = parts[1].replace(/0+$/, ''); + + // If there's nothing left, remove decimal altogether + if (!parts[1]) { + parts.pop(); + } + } + + // Commafy the whole numbers + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); + + return parts.join('.'); +} + +export function formatUsd( + amount: number | string | undefined | null, + includeDollarSign: boolean = true, + digits: number = 0, +) { + if (!amount) return includeDollarSign ? '$0' : '0'; + const a = typeof amount === 'number' ? amount.toString() : amount; + const str = formatNumber(a, digits); + return includeDollarSign ? `$${str}` : str; +} + diff --git a/backend/.env.example b/backend/.env.example index 94801610..ef3a6010 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -39,4 +39,4 @@ EXPLORER_URL="https://chain.so/tx/ZECTEST/" PROPOSAL_STAKING_AMOUNT=0.025 # Maximum amount for a proposal target, keep in sync with frontend .env -PROPOSAL_TARGET_MAX=10000 +PROPOSAL_TARGET_MAX=500000 diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 64791f53..47d8482c 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -363,9 +363,11 @@ class Proposal(db.Model): if len(self.content) > 250000: raise ValidationException("Content cannot be longer than 250,000 characters") if Decimal(self.target) > PROPOSAL_TARGET_MAX: - raise ValidationException("Target cannot be more than {} ZEC".format(PROPOSAL_TARGET_MAX)) - if Decimal(self.target) < 0.0001: - raise ValidationException("Target cannot be less than 0.0001") + raise ValidationException("Target cannot be more than {} USD".format(PROPOSAL_TARGET_MAX)) + if Decimal(self.target) < 0: + raise ValidationException("Target cannot be less than 0") + if not self.target.isdigit(): + raise ValidationException("Target must be a whole number") if self.deadline_duration > 7776000: raise ValidationException("Deadline duration cannot be more than 90 days") @@ -863,6 +865,7 @@ user_fields = [ "date_published", "reject_reason", "team", + "accepted_with_funding", "is_version_two", "authed_follows", "authed_liked" diff --git a/backend/grant/rfp/models.py b/backend/grant/rfp/models.py index 1b8c3252..0db23d69 100644 --- a/backend/grant/rfp/models.py +++ b/backend/grant/rfp/models.py @@ -32,6 +32,7 @@ class RFP(db.Model): date_closes = db.Column(db.DateTime, nullable=True) date_opened = db.Column(db.DateTime, nullable=True) date_closed = db.Column(db.DateTime, nullable=True) + version = db.Column(db.String(255), nullable=True) # Relationships proposals = db.relationship( @@ -111,6 +112,7 @@ class RFP(db.Model): self.date_closes = date_closes self.matching = matching self.status = status + self.version = '2' class RFPSchema(ma.Schema): @@ -131,7 +133,8 @@ class RFPSchema(ma.Schema): "date_closed", "accepted_proposals", "authed_liked", - "likes_count" + "likes_count", + "is_version_two" ) status = ma.Method("get_status") @@ -139,6 +142,7 @@ class RFPSchema(ma.Schema): date_opened = ma.Method("get_date_opened") date_closed = ma.Method("get_date_closed") accepted_proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"]) + is_version_two = ma.Method("get_is_version_two") def get_status(self, obj): # Force it into closed state if date_closes is in the past @@ -155,6 +159,9 @@ class RFPSchema(ma.Schema): def get_date_closed(self, obj): return dt_to_unix(obj.date_closed) if obj.date_closed else None + def get_is_version_two(self, obj): + return True if obj.version == '2' else False + rfp_schema = RFPSchema() rfps_schema = RFPSchema(many=True) @@ -177,6 +184,7 @@ class AdminRFPSchema(ma.Schema): "date_opened", "date_closed", "proposals", + "is_version_two" ) status = ma.Method("get_status") @@ -185,6 +193,7 @@ class AdminRFPSchema(ma.Schema): date_opened = ma.Method("get_date_opened") date_closed = ma.Method("get_date_closed") proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"]) + is_version_two = ma.Method("get_is_version_two") def get_status(self, obj): # Force it into closed state if date_closes is in the past @@ -204,6 +213,9 @@ class AdminRFPSchema(ma.Schema): def get_date_closed(self, obj): return dt_to_unix(obj.date_closes) if obj.date_closes else None + def get_is_version_two(self, obj): + return True if obj.version == '2' else False + admin_rfp_schema = AdminRFPSchema() admin_rfps_schema = AdminRFPSchema(many=True) diff --git a/backend/migrations/versions/1e1460456ce4_.py b/backend/migrations/versions/1e1460456ce4_.py new file mode 100644 index 00000000..11ea69f1 --- /dev/null +++ b/backend/migrations/versions/1e1460456ce4_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 1e1460456ce4 +Revises: c55f96720196 +Create Date: 2019-11-21 20:36:37.504400 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1e1460456ce4' +down_revision = 'c55f96720196' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.add_column('rfp', sa.Column('version', sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.drop_column('rfp', 'version') + # ### end Alembic commands ### diff --git a/backend/tests/milestone/test_milestone_methods.py b/backend/tests/milestone/test_milestone_methods.py index 59804986..3e9315b8 100644 --- a/backend/tests/milestone/test_milestone_methods.py +++ b/backend/tests/milestone/test_milestone_methods.py @@ -48,7 +48,7 @@ test_proposal = { "brief": "$$$", "milestones": test_milestones, "category": Category.ACCESSIBILITY, - "target": "123.456", + "target": "12345", "payoutAddress": "123", } diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index d39d3434..80b77e90 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -44,7 +44,7 @@ test_proposal = { "brief": "$$$", "milestones": milestones, "category": Category.ACCESSIBILITY, - "target": "123.456", + "target": "12345", "payoutAddress": "123", "deadlineDuration": 100 } diff --git a/frontend/.env.example b/frontend/.env.example index 83333a15..f7532867 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -26,4 +26,4 @@ DISABLE_SSL=true # TESTNET=true # Maximum amount for a proposal target, keep in sync with backend .env -PROPOSAL_TARGET_MAX=10000 +PROPOSAL_TARGET_MAX=500000 diff --git a/frontend/client/components/CreateFlow/Basics.tsx b/frontend/client/components/CreateFlow/Basics.tsx index 18a9a11f..71262906 100644 --- a/frontend/client/components/CreateFlow/Basics.tsx +++ b/frontend/client/components/CreateFlow/Basics.tsx @@ -169,7 +169,10 @@ class CreateFlowBasics extends React.Component { { type="number" value={target} onChange={this.handleInputChange} - addonAfter="ZEC" + addonBefore="$" maxLength={16} /> diff --git a/frontend/client/components/CreateFlow/Review.tsx b/frontend/client/components/CreateFlow/Review.tsx index 60a95fcc..d719026f 100644 --- a/frontend/client/components/CreateFlow/Review.tsx +++ b/frontend/client/components/CreateFlow/Review.tsx @@ -7,6 +7,7 @@ import UserAvatar from 'components/UserAvatar'; import { AppState } from 'store/reducers'; import { CREATE_STEP } from './index'; import { ProposalDraft } from 'types'; +import { formatUsd } from 'utils/formatters'; import './Review.less'; interface OwnProps { @@ -59,7 +60,7 @@ class CreateReview extends React.Component { }, { key: 'target', - content:
{form.target} ZEC
, + content:
{formatUsd(form.target)}
, error: errors.target, }, ], diff --git a/frontend/client/components/Profile/ProfileProposal.tsx b/frontend/client/components/Profile/ProfileProposal.tsx index 68538d9f..b858e94d 100644 --- a/frontend/client/components/Profile/ProfileProposal.tsx +++ b/frontend/client/components/Profile/ProfileProposal.tsx @@ -4,6 +4,8 @@ import { UserProposal } from 'types'; import './ProfileProposal.less'; import UserRow from 'components/UserRow'; import UnitDisplay from 'components/UnitDisplay'; +import { Tag } from 'antd'; +import { formatUsd } from 'utils/formatters' interface OwnProps { proposal: UserProposal; @@ -11,19 +13,47 @@ interface OwnProps { export default class Profile extends React.Component { render() { - const { title, brief, team, proposalId, funded, target } = this.props.proposal; + const { + title, + brief, + team, + proposalId, + funded, + target, + isVersionTwo, + acceptedWithFunding, + } = this.props.proposal; + + // pulled from `variables.less` + const infoColor = '#1890ff' + const secondaryColor = '#2D2A26' + + const tagColor = acceptedWithFunding + ? secondaryColor + : infoColor + const tagMessage = acceptedWithFunding + ? 'Funded by ZF' + : 'Open for Contributions' + return (
- {title} + {title} {isVersionTwo && ({tagMessage})}
{brief}
-
- {' '} - raised of{' '} - goal -
+ {!isVersionTwo && ( +
+ {' '} + raised of{' '} + goal +
+ )} + {isVersionTwo && ( +
+ {formatUsd(target.toString())} +
+ )}

Team

diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index 4e4b384c..0924eb24 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import moment from 'moment'; -import { Icon, Popover } from 'antd'; +import { Icon, Popover, Tooltip } from 'antd'; import { Proposal, STATUS } from 'types'; import classnames from 'classnames'; import { connect } from 'react-redux'; @@ -10,7 +10,8 @@ import { withRouter } from 'react-router'; import UnitDisplay from 'components/UnitDisplay'; import Loader from 'components/Loader'; import { PROPOSAL_STAGE } from 'api/constants'; -import ZFGrantsLogo from 'static/images/logo-name-light.svg' +import { formatUsd } from 'utils/formatters'; +import ZFGrantsLogo from 'static/images/logo-name-light.svg'; import './style.less'; interface OwnProps { @@ -107,7 +108,15 @@ export class ProposalCampaignBlock extends React.Component { {isAcceptedWithFunding ? 'Funding' : 'Requested Funding'}
- + {formatUsd(target.toString(10))} + {isAcceptedWithFunding && ( + +   + + )}
)} diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx index 6984ffc4..32ef7e8e 100644 --- a/frontend/client/components/Proposal/Milestones/index.tsx +++ b/frontend/client/components/Proposal/Milestones/index.tsx @@ -22,6 +22,8 @@ import { proposalActions } from 'modules/proposals'; import { ProposalDetail } from 'modules/proposals/reducers'; import './index.less'; import { Link } from 'react-router-dom'; +import { formatUsd } from 'utils/formatters'; +import { Zat } from 'utils/units'; enum STEP_STATUS { WAIT = 'wait', @@ -254,6 +256,7 @@ class ProposalMilestones extends React.Component { !!proposal.arbiter.user && proposal.arbiter.status === PROPOSAL_ARBITER_STATUS.ACCEPTED } + isVersionTwo={proposal.isVersionTwo} /> ) : ( @@ -329,12 +332,17 @@ interface MilestoneProps extends MSProps { isCurrent: boolean; proposalId: number; isFunded: boolean; + isVersionTwo: boolean; } const Milestone: React.SFC = p => { const estimatedDate = p.dateEstimated ? moment(p.dateEstimated * 1000).format('MMMM YYYY') : 'N/A'; - const reward = ; + const reward = p.isVersionTwo ? ( + formatUsd(p.amount as string, true, 2) + ) : ( + + ); const getAlertProps = { [MILESTONE_STAGE.IDLE]: () => null, [MILESTONE_STAGE.REQUESTED]: () => ({ @@ -370,7 +378,7 @@ const Milestone: React.SFC = p => { type: 'success', message: ( - The team was awarded {reward}{' '} + The team was awarded {reward} {p.isVersionTwo && `in ZEC`} {p.immediatePayout && ` as an initial payout `} on {fmtDate(p.datePaid)}. ), diff --git a/frontend/client/components/Proposals/ProposalCard/index.tsx b/frontend/client/components/Proposals/ProposalCard/index.tsx index caef0040..58a0592d 100644 --- a/frontend/client/components/Proposals/ProposalCard/index.tsx +++ b/frontend/client/components/Proposals/ProposalCard/index.tsx @@ -6,6 +6,7 @@ import { Proposal } from 'types'; import Card from 'components/Card'; import UserAvatar from 'components/UserAvatar'; import UnitDisplay from 'components/UnitDisplay'; +import { formatUsd } from 'utils/formatters' import './style.less'; export class ProposalCard extends React.Component { @@ -41,7 +42,7 @@ export class ProposalCard extends React.Component { {isVersionTwo && (
- + {formatUsd(target.toString(10))}
)} diff --git a/frontend/client/components/RFP/index.tsx b/frontend/client/components/RFP/index.tsx index de77f111..c1c98033 100644 --- a/frontend/client/components/RFP/index.tsx +++ b/frontend/client/components/RFP/index.tsx @@ -15,6 +15,7 @@ import UnitDisplay from 'components/UnitDisplay'; import HeaderDetails from 'components/HeaderDetails'; import Like from 'components/Like'; import { RFP_STATUS } from 'api/constants'; +import { formatUsd } from 'utils/formatters'; import './index.less'; interface OwnProps { @@ -62,11 +63,19 @@ class RFPDetail extends React.Component { } if (rfp.bounty) { - tags.push( - - bounty - , - ); + if (rfp.isVersionTwo) { + tags.push( + + {formatUsd(rfp.bounty.toString(10))} bounty + , + ); + } else { + tags.push( + + bounty + , + ); + } } if (!isLive) { @@ -98,14 +107,22 @@ class RFPDetail extends React.Component {
    - {rfp.bounty && ( -
  • - Accepted proposals will be funded up to{' '} - - - -
  • - )} + {rfp.bounty && + rfp.isVersionTwo && ( +
  • + Accepted proposals will be funded up to{' '} + {formatUsd(rfp.bounty.toString(10))} in ZEC +
  • + )} + {rfp.bounty && + !rfp.isVersionTwo && ( +
  • + Accepted proposals will be funded up to{' '} + + + +
  • + )} {rfp.matching && (
  • Contributions will have their funding matched by the diff --git a/frontend/client/components/RFPs/RFPItem.tsx b/frontend/client/components/RFPs/RFPItem.tsx index 8863227a..02c305af 100644 --- a/frontend/client/components/RFPs/RFPItem.tsx +++ b/frontend/client/components/RFPs/RFPItem.tsx @@ -5,6 +5,7 @@ import { Tag } from 'antd'; import { Link } from 'react-router-dom'; import UnitDisplay from 'components/UnitDisplay'; import { RFP } from 'types'; +import { formatUsd } from 'utils/formatters'; import './RFPItem.less'; interface Props { @@ -25,17 +26,26 @@ export default class RFPItem extends React.Component { dateClosed, bounty, matching, + isVersionTwo, } = rfp; const closeDate = dateCloses || dateClosed; const tags = []; if (!isSmall) { if (bounty) { - tags.push( - - bounty - , - ); + if (isVersionTwo) { + tags.push( + + {formatUsd(bounty.toString(10))} bounty + , + ); + } else { + tags.push( + + bounty + , + ); + } } if (matching) { tags.push( diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index b128674b..c83c35ce 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -1,7 +1,8 @@ import { ProposalDraft, STATUS, MILESTONE_STAGE, PROPOSAL_ARBITER_STATUS } from 'types'; import { User } from 'types'; import { - getAmountError, + getAmountErrorUsd, + getAmountErrorUsdFromString, isValidSaplingAddress, isValidTAddress, isValidSproutAddress, @@ -98,7 +99,8 @@ export function getCreateErrors( const targetFloat = target ? parseFloat(target) : 0; if (target && !Number.isNaN(targetFloat)) { const limit = parseFloat(process.env.PROPOSAL_TARGET_MAX as string); - const targetErr = getAmountError(targetFloat, limit, 0.001); + const targetErr = + getAmountErrorUsd(targetFloat, limit) || getAmountErrorUsdFromString(target); if (targetErr) { errors.target = targetErr; } diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index 984d1ffa..5508986b 100644 --- a/frontend/client/utils/api.ts +++ b/frontend/client/utils/api.ts @@ -10,7 +10,7 @@ import { } from 'types'; import { UserState } from 'modules/users/reducers'; import { AppState } from 'store/reducers'; -import { toZat } from './units'; +import { toZat, toUsd } from './units'; export function formatUserForPost(user: User) { return { @@ -21,8 +21,14 @@ export function formatUserForPost(user: User) { export function formatUserFromGet(user: UserState) { const bnUserProp = (p: any) => { - p.funded = toZat(p.funded); - p.target = toZat(p.target); + if (p.isVersionTwo) { + p.funded = toUsd(p.funded); + p.target = toUsd(p.target); + } else { + p.funded = toZat(p.funded); + p.target = toZat(p.target); + } + return p; }; if (user.pendingProposals) { @@ -84,17 +90,40 @@ export function formatProposalPageFromGet(page: any): ProposalPage { export function formatProposalFromGet(p: any): Proposal { const proposal = { ...p } as 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(); + + if (proposal.isVersionTwo) { + proposal.target = toUsd(p.target); + proposal.funded = toUsd(p.funded); + + // not used in v2 proposals, but populated for completeness + proposal.contributionBounty = toUsd(p.contributionBounty); + proposal.percentFunded = 0; + } else { + 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(); + } + if (proposal.milestones) { - const msToFe = (m: any) => ({ - ...m, - amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100), - }); + const msToFe = (m: any) => { + let amount; + + if (proposal.isVersionTwo) { + const target = parseFloat(proposal.target.toString()); + const payoutPercent = parseFloat(m.payoutPercent); + amount = ((target * payoutPercent) / 100).toFixed(2); + } else { + amount = proposal.target.mul(new BN(m.payoutPercent)).divn(100); + } + + return { + ...m, + amount, + }; + }; proposal.milestones = proposal.milestones.map(msToFe); proposal.currentMilestone = msToFe(proposal.currentMilestone); } @@ -107,7 +136,7 @@ export function formatProposalFromGet(p: any): Proposal { export function formatRFPFromGet(rfp: RFP): RFP { rfp.urlId = generateSlugUrl(rfp.id, rfp.title); if (rfp.bounty) { - rfp.bounty = toZat(rfp.bounty as any); + rfp.bounty = rfp.isVersionTwo ? toUsd(rfp.bounty as any) : toZat(rfp.bounty as any); } if (rfp.acceptedProposals) { rfp.acceptedProposals = rfp.acceptedProposals.map(formatProposalFromGet); @@ -139,42 +168,53 @@ export function extractIdFromSlug(slug: string) { export function massageSerializedState(state: AppState) { // proposal detail if (state.proposal.detail) { + const { isVersionTwo } = state.proposal.detail + const base = isVersionTwo ? 10 : 16; + state.proposal.detail.target = new BN( (state.proposal.detail.target as any) as string, - 16, + base, ); state.proposal.detail.funded = new BN( (state.proposal.detail.funded as any) as string, - 16, + base, ); state.proposal.detail.contributionBounty = new BN((state.proposal.detail .contributionBounty as any) as string); state.proposal.detail.milestones = state.proposal.detail.milestones.map(m => ({ ...m, - amount: new BN((m.amount as any) as string, 16), + amount: isVersionTwo + ? m.amount + : new BN((m.amount as any) as string, 16), })); 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, - 16, + base, ); } } // proposals - state.proposal.page.items = state.proposal.page.items.map(p => ({ - ...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), - })), - })); + state.proposal.page.items = state.proposal.page.items.map(p => { + const base = p.isVersionTwo ? 10 : 16; + return { + ...p, + target: new BN((p.target as any) as string, base), + funded: new BN((p.funded as any) as string, base), + contributionBounty: new BN((p.contributionMatching as any) as string, base), + milestones: p.milestones.map(m => ({ + ...m, + amount: p.isVersionTwo + ? m.amount + : new BN((m.amount as any) as string, 16), + })), + }; + }); // users const bnUserProp = (p: UserProposal) => { - p.funded = new BN(p.funded, 16); - p.target = new BN(p.target, 16); + const base = p.isVersionTwo ? 10 : 16; + p.funded = new BN(p.funded, base); + p.target = new BN(p.target, base); return p; }; Object.values(state.users.map).forEach(user => { @@ -190,8 +230,9 @@ export function massageSerializedState(state: AppState) { }); // RFPs state.rfps.rfps = state.rfps.rfps.map(rfp => { + const base = rfp.isVersionTwo ? 10 : 16; if (rfp.bounty) { - rfp.bounty = new BN(rfp.bounty, 16); + rfp.bounty = new BN(rfp.bounty, base); } return rfp; }); diff --git a/frontend/client/utils/formatters.ts b/frontend/client/utils/formatters.ts index 87de277c..c825032b 100644 --- a/frontend/client/utils/formatters.ts +++ b/frontend/client/utils/formatters.ts @@ -92,3 +92,14 @@ export function formatTxExplorerUrl(txid: string) { } throw new Error('EXPLORER_URL env variable needs to be set!'); } + +export function formatUsd( + amount: number | string | undefined | null, + includeDollarSign: boolean = true, + digits: number = 0, +) { + if (!amount) return includeDollarSign ? '$0' : '0'; + const a = typeof amount === 'number' ? amount.toString() : amount; + const str = formatNumber(a, digits); + return includeDollarSign ? `$${str}` : str; +} diff --git a/frontend/client/utils/units.ts b/frontend/client/utils/units.ts index d0f384e0..0966df12 100644 --- a/frontend/client/utils/units.ts +++ b/frontend/client/utils/units.ts @@ -9,6 +9,7 @@ export const Units = { }; export type Zat = BN; +export type Usd = BN; export type UnitKey = keyof typeof Units; export const handleValues = (input: string | BN) => { @@ -61,4 +62,14 @@ export const toZat = (value: string | number): Zat => { return Zat(zat); }; +export const toUsd = (value: string | number): Usd => { + value = value.toString(); + const hasDecimal = value.indexOf('.') !== -1; + + // decimals aren't allowed for proposal targets, + // but remove decimal if it exists + value = hasDecimal ? value.split('.')[0] : value; + return new BN(value, 10); +}; + export const getDecimalFromUnitKey = (key: UnitKey) => Units[key].length - 1; diff --git a/frontend/client/utils/validators.ts b/frontend/client/utils/validators.ts index a572d590..348b32dd 100644 --- a/frontend/client/utils/validators.ts +++ b/frontend/client/utils/validators.ts @@ -1,3 +1,5 @@ +import { formatUsd } from './formatters'; + export function getAmountError(amount: number, max: number = Infinity, min?: number) { if (amount < 0) { return 'Amount must be a positive number'; @@ -15,16 +17,38 @@ export function getAmountError(amount: number, max: number = Infinity, min?: num return null; } +export function getAmountErrorUsd(amount: number, max: number = Infinity, min?: number) { + if (amount < 0) { + return 'Amount must be a positive number'; + } else if (!Number.isInteger(amount)) { + return 'Amount must be a whole number'; + } else if (amount > max) { + return `Cannot exceed maximum (${formatUsd(max)})`; + } else if (min && amount < min) { + return `Must be at least ${formatUsd(min)}`; + } + + return null; +} + + +// Covers the edge case where whole decimals (eg. 100.00) is valid in getAmountErrorUsd +export function getAmountErrorUsdFromString(amount: string) { + return amount.indexOf('.') !== -1 + ? 'Amount must be a whole number' + : null +} + export function getAmountErrorFromString(amount: string, max?: number, min?: number) { - const parsedAmount = parseFloat(amount) + const parsedAmount = parseFloat(amount); if (Number.isNaN(parsedAmount)) { - return 'Not a valid number' + return 'Not a valid number'; } // prevents "-0" from being valid... if (amount[0] === '-') { - return 'Amount must be a positive number' + return 'Amount must be a positive number'; } - return getAmountError(parsedAmount, max, min) + return getAmountError(parsedAmount, max, min); } export function isValidEmail(email: string): boolean { diff --git a/frontend/types/milestone.ts b/frontend/types/milestone.ts index 22c43d92..19ad27cb 100644 --- a/frontend/types/milestone.ts +++ b/frontend/types/milestone.ts @@ -19,7 +19,7 @@ export enum MILESTONE_STAGE { export interface Milestone { index: number; stage: MILESTONE_STAGE; - amount: Zat; + amount: Zat | string; immediatePayout: boolean; dateEstimated?: number; daysEstimated?: string; diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index e1bdd93a..42907545 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -1,4 +1,4 @@ -import { Zat } from 'utils/units'; +import { Zat, Usd } from 'utils/units'; import { PROPOSAL_STAGE } from 'api/constants'; import { CreateMilestone, Update, User, Comment, ContributionWithUser } from 'types'; import { ProposalMilestone } from './milestone'; @@ -52,8 +52,8 @@ export interface ProposalDraft { export interface Proposal extends Omit { proposalAddress: string; proposalUrlId: string; - target: Zat; - funded: Zat; + target: Zat | Usd; + funded: Zat | Usd; percentFunded: number; contributionMatching: number; contributionBounty: Zat; @@ -99,13 +99,15 @@ export interface UserProposal { status: STATUS; title: string; brief: string; - funded: Zat; - target: Zat; + funded: Zat | Usd; + target: Zat | Usd; dateCreated: number; dateApproved: number; datePublished: number; team: User[]; rejectReason: string; + acceptedWithFunding: boolean | null; + isVersionTwo: boolean; } // NOTE: sync with backend/grant/proposal/models.py STATUSES diff --git a/frontend/types/rfp.ts b/frontend/types/rfp.ts index 8e7efca8..d7ce6489 100644 --- a/frontend/types/rfp.ts +++ b/frontend/types/rfp.ts @@ -1,6 +1,6 @@ import { Proposal } from './proposal'; import { RFP_STATUS } from 'api/constants'; -import { Zat } from 'utils/units'; +import { Zat, Usd } from 'utils/units'; export interface RFP { id: number; @@ -10,11 +10,12 @@ export interface RFP { content: string; status: RFP_STATUS; acceptedProposals: Proposal[]; - bounty: Zat | null; + bounty: Zat | Usd | null; matching: boolean; dateOpened: number; dateClosed?: number; dateCloses?: number; authedLiked: boolean; likesCount: number; + isVersionTwo: boolean; }