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
This commit is contained in:
Danny Skubak 2019-12-03 19:02:39 -05:00 committed by Daniel Ternyak
parent 6f4e1b779b
commit 4a0e23e9c7
28 changed files with 417 additions and 98 deletions

View File

@ -29,6 +29,7 @@ import Markdown from 'components/Markdown';
import ArbiterControl from 'components/ArbiterControl'; import ArbiterControl from 'components/ArbiterControl';
import { toZat, fromZat } from 'src/util/units'; import { toZat, fromZat } from 'src/util/units';
import FeedbackModal from '../FeedbackModal'; import FeedbackModal from '../FeedbackModal';
import { formatUsd } from 'util/formatters';
import './index.less'; import './index.less';
type Props = RouteComponentProps<any>; type Props = RouteComponentProps<any>;
@ -285,11 +286,23 @@ class ProposalDetailNaked extends React.Component<Props, State> {
return; return;
} }
const ms = p.currentMilestone; const ms = p.currentMilestone;
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( const amount = fromZat(
toZat(p.target) toZat(p.target)
.mul(new BN(ms.payoutPercent)) .mul(new BN(ms.payoutPercent))
.divn(100), .divn(100),
); );
paymentMsg = `${amount} ZEC`;
}
return ( return (
<Alert <Alert
className="ProposalDetail-alert" className="ProposalDetail-alert"
@ -306,7 +319,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
</p> </p>
<p> <p>
{' '} {' '}
Please make a payment of <b>{amount.toString()} ZEC</b> to: Please make a payment of <b>{paymentMsg}</b> to:
</p>{' '} </p>{' '}
<pre>{p.payoutAddress}</pre> <pre>{p.payoutAddress}</pre>
<Input.Search <Input.Search
@ -445,9 +458,12 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderDeetItem('isFailed', JSON.stringify(p.isFailed))} {renderDeetItem('isFailed', JSON.stringify(p.isFailed))}
{renderDeetItem('status', p.status)} {renderDeetItem('status', p.status)}
{renderDeetItem('stage', p.stage)} {renderDeetItem('stage', p.stage)}
{renderDeetItem('target', p.target)} {renderDeetItem('target', p.isVersionTwo ? formatUsd(p.target) : p.target)}
{renderDeetItem('contributed', p.contributed)} {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('matching', p.contributionMatching)}
{renderDeetItem('bounty', p.contributionBounty)} {renderDeetItem('bounty', p.contributionBounty)}
{renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))} {renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))}

View File

@ -9,6 +9,7 @@ import Markdown from 'components/Markdown';
import { formatDateSeconds } from 'util/time'; import { formatDateSeconds } from 'util/time';
import store from 'src/store'; import store from 'src/store';
import { PROPOSAL_STATUS } from 'src/types'; import { PROPOSAL_STATUS } from 'src/types';
import { formatUsd } from 'src/util/formatters';
import './index.less'; import './index.less';
type Props = RouteComponentProps<{ id?: string }>; type Props = RouteComponentProps<{ id?: string }>;
@ -93,7 +94,10 @@ class RFPDetail extends React.Component<Props> {
{renderDeetItem('created', formatDateSeconds(rfp.dateCreated))} {renderDeetItem('created', formatDateSeconds(rfp.dateCreated))}
{renderDeetItem('status', rfp.status)} {renderDeetItem('status', rfp.status)}
{renderDeetItem('matching', String(rfp.matching))} {renderDeetItem('matching', String(rfp.matching))}
{renderDeetItem('bounty', `${rfp.bounty} ZEC`)} {renderDeetItem(
'bounty',
rfp.isVersionTwo ? formatUsd(rfp.bounty) : `${rfp.bounty} ZEC`,
)}
{renderDeetItem( {renderDeetItem(
'dateCloses', 'dateCloses',
rfp.dateCloses && formatDateSeconds(rfp.dateCloses), rfp.dateCloses && formatDateSeconds(rfp.dateCloses),

View File

@ -47,6 +47,8 @@ class RFPForm extends React.Component<Props, State> {
dateCloses: undefined, dateCloses: undefined,
}; };
const rfpId = this.getRFPId(); const rfpId = this.getRFPId();
let isVersionTwo = true;
if (rfpId) { if (rfpId) {
if (!store.rfpsFetched) { if (!store.rfpsFetched) {
return <Spin />; return <Spin />;
@ -63,6 +65,7 @@ class RFPForm extends React.Component<Props, State> {
bounty: rfp.bounty, bounty: rfp.bounty,
dateCloses: rfp.dateCloses || undefined, dateCloses: rfp.dateCloses || undefined,
}; };
isVersionTwo = rfp.isVersionTwo;
} else { } else {
return <Exception type="404" desc="This RFP does not exist" />; return <Exception type="404" desc="This RFP does not exist" />;
} }
@ -73,6 +76,10 @@ class RFPForm extends React.Component<Props, State> {
: defaults.dateCloses && moment(defaults.dateCloses * 1000); : defaults.dateCloses && moment(defaults.dateCloses * 1000);
const forceClosed = dateCloses && dateCloses.isBefore(moment.now()); const forceClosed = dateCloses && dateCloses.isBefore(moment.now());
const bountyMatchRule = isVersionTwo
? { pattern: /^[^.]*$/, message: 'Cannot contain a decimal' }
: {};
return ( return (
<Form className="RFPForm" layout="vertical" onSubmit={this.handleSubmit}> <Form className="RFPForm" layout="vertical" onSubmit={this.handleSubmit}>
<Back to="/rfps" text="RFPs" /> <Back to="/rfps" text="RFPs" />
@ -162,12 +169,17 @@ class RFPForm extends React.Component<Props, State> {
<Form.Item className="RFPForm-bounty" label="Bounty"> <Form.Item className="RFPForm-bounty" label="Bounty">
{getFieldDecorator('bounty', { {getFieldDecorator('bounty', {
initialValue: defaults.bounty, initialValue: defaults.bounty,
rules: [
{ required: true, message: 'Bounty is required' },
bountyMatchRule,
],
})( })(
<Input <Input
autoComplete="off" autoComplete="off"
name="bounty" name="bounty"
placeholder="100" placeholder="1000"
addonAfter="ZEC" addonBefore={isVersionTwo ? '$' : undefined}
addonAfter={isVersionTwo ? undefined : 'ZEC'}
size="large" size="large"
/>, />,
)} )}

View File

@ -47,6 +47,7 @@ export interface RFP {
matching: boolean; matching: boolean;
bounty: string | null; bounty: string | null;
dateCloses: number | null; dateCloses: number | null;
isVersionTwo: boolean;
} }
export interface RFPArgs { export interface RFPArgs {
title: string; title: string;

View File

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

View File

@ -39,4 +39,4 @@ EXPLORER_URL="https://chain.so/tx/ZECTEST/<txid>"
PROPOSAL_STAKING_AMOUNT=0.025 PROPOSAL_STAKING_AMOUNT=0.025
# Maximum amount for a proposal target, keep in sync with frontend .env # Maximum amount for a proposal target, keep in sync with frontend .env
PROPOSAL_TARGET_MAX=10000 PROPOSAL_TARGET_MAX=500000

View File

@ -363,9 +363,11 @@ class Proposal(db.Model):
if len(self.content) > 250000: if len(self.content) > 250000:
raise ValidationException("Content cannot be longer than 250,000 characters") raise ValidationException("Content cannot be longer than 250,000 characters")
if Decimal(self.target) > PROPOSAL_TARGET_MAX: if Decimal(self.target) > PROPOSAL_TARGET_MAX:
raise ValidationException("Target cannot be more than {} ZEC".format(PROPOSAL_TARGET_MAX)) raise ValidationException("Target cannot be more than {} USD".format(PROPOSAL_TARGET_MAX))
if Decimal(self.target) < 0.0001: if Decimal(self.target) < 0:
raise ValidationException("Target cannot be less than 0.0001") 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: if self.deadline_duration > 7776000:
raise ValidationException("Deadline duration cannot be more than 90 days") raise ValidationException("Deadline duration cannot be more than 90 days")
@ -863,6 +865,7 @@ user_fields = [
"date_published", "date_published",
"reject_reason", "reject_reason",
"team", "team",
"accepted_with_funding",
"is_version_two", "is_version_two",
"authed_follows", "authed_follows",
"authed_liked" "authed_liked"

View File

@ -32,6 +32,7 @@ class RFP(db.Model):
date_closes = db.Column(db.DateTime, nullable=True) date_closes = db.Column(db.DateTime, nullable=True)
date_opened = db.Column(db.DateTime, nullable=True) date_opened = db.Column(db.DateTime, nullable=True)
date_closed = db.Column(db.DateTime, nullable=True) date_closed = db.Column(db.DateTime, nullable=True)
version = db.Column(db.String(255), nullable=True)
# Relationships # Relationships
proposals = db.relationship( proposals = db.relationship(
@ -111,6 +112,7 @@ class RFP(db.Model):
self.date_closes = date_closes self.date_closes = date_closes
self.matching = matching self.matching = matching
self.status = status self.status = status
self.version = '2'
class RFPSchema(ma.Schema): class RFPSchema(ma.Schema):
@ -131,7 +133,8 @@ class RFPSchema(ma.Schema):
"date_closed", "date_closed",
"accepted_proposals", "accepted_proposals",
"authed_liked", "authed_liked",
"likes_count" "likes_count",
"is_version_two"
) )
status = ma.Method("get_status") status = ma.Method("get_status")
@ -139,6 +142,7 @@ class RFPSchema(ma.Schema):
date_opened = ma.Method("get_date_opened") date_opened = ma.Method("get_date_opened")
date_closed = ma.Method("get_date_closed") date_closed = ma.Method("get_date_closed")
accepted_proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"]) accepted_proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
is_version_two = ma.Method("get_is_version_two")
def get_status(self, obj): def get_status(self, obj):
# Force it into closed state if date_closes is in the past # 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): def get_date_closed(self, obj):
return dt_to_unix(obj.date_closed) if obj.date_closed else None 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() rfp_schema = RFPSchema()
rfps_schema = RFPSchema(many=True) rfps_schema = RFPSchema(many=True)
@ -177,6 +184,7 @@ class AdminRFPSchema(ma.Schema):
"date_opened", "date_opened",
"date_closed", "date_closed",
"proposals", "proposals",
"is_version_two"
) )
status = ma.Method("get_status") status = ma.Method("get_status")
@ -185,6 +193,7 @@ class AdminRFPSchema(ma.Schema):
date_opened = ma.Method("get_date_opened") date_opened = ma.Method("get_date_opened")
date_closed = ma.Method("get_date_closed") date_closed = ma.Method("get_date_closed")
proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"]) proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
is_version_two = ma.Method("get_is_version_two")
def get_status(self, obj): def get_status(self, obj):
# Force it into closed state if date_closes is in the past # 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): def get_date_closed(self, obj):
return dt_to_unix(obj.date_closes) if obj.date_closes else None 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_rfp_schema = AdminRFPSchema()
admin_rfps_schema = AdminRFPSchema(many=True) admin_rfps_schema = AdminRFPSchema(many=True)

View File

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

View File

@ -48,7 +48,7 @@ test_proposal = {
"brief": "$$$", "brief": "$$$",
"milestones": test_milestones, "milestones": test_milestones,
"category": Category.ACCESSIBILITY, "category": Category.ACCESSIBILITY,
"target": "123.456", "target": "12345",
"payoutAddress": "123", "payoutAddress": "123",
} }

View File

@ -44,7 +44,7 @@ test_proposal = {
"brief": "$$$", "brief": "$$$",
"milestones": milestones, "milestones": milestones,
"category": Category.ACCESSIBILITY, "category": Category.ACCESSIBILITY,
"target": "123.456", "target": "12345",
"payoutAddress": "123", "payoutAddress": "123",
"deadlineDuration": 100 "deadlineDuration": 100
} }

View File

@ -26,4 +26,4 @@ DISABLE_SSL=true
# TESTNET=true # TESTNET=true
# Maximum amount for a proposal target, keep in sync with backend .env # Maximum amount for a proposal target, keep in sync with backend .env
PROPOSAL_TARGET_MAX=10000 PROPOSAL_TARGET_MAX=500000

View File

@ -169,7 +169,10 @@ class CreateFlowBasics extends React.Component<Props, State> {
<Form.Item <Form.Item
label="Target amount" label="Target amount"
validateStatus={errors.target ? 'error' : undefined} validateStatus={errors.target ? 'error' : undefined}
help={errors.target || 'This cannot be changed once your proposal starts'} help={
errors.target ||
'You will be paid out in ZEC at market price at payout time. This cannot be changed once your proposal starts'
}
> >
<Input <Input
size="large" size="large"
@ -178,7 +181,7 @@ class CreateFlowBasics extends React.Component<Props, State> {
type="number" type="number"
value={target} value={target}
onChange={this.handleInputChange} onChange={this.handleInputChange}
addonAfter="ZEC" addonBefore="$"
maxLength={16} maxLength={16}
/> />
</Form.Item> </Form.Item>

View File

@ -7,6 +7,7 @@ import UserAvatar from 'components/UserAvatar';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { CREATE_STEP } from './index'; import { CREATE_STEP } from './index';
import { ProposalDraft } from 'types'; import { ProposalDraft } from 'types';
import { formatUsd } from 'utils/formatters';
import './Review.less'; import './Review.less';
interface OwnProps { interface OwnProps {
@ -59,7 +60,7 @@ class CreateReview extends React.Component<Props> {
}, },
{ {
key: 'target', key: 'target',
content: <div style={{ fontSize: '1.2rem' }}>{form.target} ZEC</div>, content: <div style={{ fontSize: '1.2rem' }}>{formatUsd(form.target)}</div>,
error: errors.target, error: errors.target,
}, },
], ],

View File

@ -4,6 +4,8 @@ import { UserProposal } from 'types';
import './ProfileProposal.less'; import './ProfileProposal.less';
import UserRow from 'components/UserRow'; import UserRow from 'components/UserRow';
import UnitDisplay from 'components/UnitDisplay'; import UnitDisplay from 'components/UnitDisplay';
import { Tag } from 'antd';
import { formatUsd } from 'utils/formatters'
interface OwnProps { interface OwnProps {
proposal: UserProposal; proposal: UserProposal;
@ -11,19 +13,47 @@ interface OwnProps {
export default class Profile extends React.Component<OwnProps> { export default class Profile extends React.Component<OwnProps> {
render() { 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 ( return (
<div className="ProfileProposal"> <div className="ProfileProposal">
<div className="ProfileProposal-block"> <div className="ProfileProposal-block">
<Link to={`/proposals/${proposalId}`} className="ProfileProposal-title"> <Link to={`/proposals/${proposalId}`} className="ProfileProposal-title">
{title} {title} {isVersionTwo && (<Tag color={tagColor} style={{verticalAlign: 'text-top'}}>{tagMessage}</Tag>)}
</Link> </Link>
<div className="ProfileProposal-brief">{brief}</div> <div className="ProfileProposal-brief">{brief}</div>
{!isVersionTwo && (
<div className="ProfileProposal-raised"> <div className="ProfileProposal-raised">
<UnitDisplay value={funded} symbol="ZEC" displayShortBalance={4} />{' '} <UnitDisplay value={funded} symbol="ZEC" displayShortBalance={4} />{' '}
<small>raised</small> of{' '} <small>raised</small> of{' '}
<UnitDisplay value={target} symbol="ZEC" displayShortBalance={4} /> goal <UnitDisplay value={target} symbol="ZEC" displayShortBalance={4} /> goal
</div> </div>
)}
{isVersionTwo && (
<div className="ProfileProposal-raised">
{formatUsd(target.toString())}
</div>
)}
</div> </div>
<div className="ProfileProposal-block"> <div className="ProfileProposal-block">
<h3>Team</h3> <h3>Team</h3>

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import moment from 'moment'; import moment from 'moment';
import { Icon, Popover } from 'antd'; import { Icon, Popover, Tooltip } from 'antd';
import { Proposal, STATUS } from 'types'; import { Proposal, STATUS } from 'types';
import classnames from 'classnames'; import classnames from 'classnames';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
@ -10,7 +10,8 @@ import { withRouter } from 'react-router';
import UnitDisplay from 'components/UnitDisplay'; import UnitDisplay from 'components/UnitDisplay';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import { PROPOSAL_STAGE } from 'api/constants'; 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'; import './style.less';
interface OwnProps { interface OwnProps {
@ -107,7 +108,15 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
{isAcceptedWithFunding ? 'Funding' : 'Requested Funding'} {isAcceptedWithFunding ? 'Funding' : 'Requested Funding'}
</div> </div>
<div className="ProposalCampaignBlock-info-value"> <div className="ProposalCampaignBlock-info-value">
<UnitDisplay value={target} symbol="ZEC" /> {formatUsd(target.toString(10))}
{isAcceptedWithFunding && (
<Tooltip
placement="left"
title="Proposal owners will be paid out in ZEC at market price at payout time"
>
&nbsp; <Icon type="info-circle" />
</Tooltip>
)}
</div> </div>
</div> </div>
)} )}

View File

@ -22,6 +22,8 @@ import { proposalActions } from 'modules/proposals';
import { ProposalDetail } from 'modules/proposals/reducers'; import { ProposalDetail } from 'modules/proposals/reducers';
import './index.less'; import './index.less';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { formatUsd } from 'utils/formatters';
import { Zat } from 'utils/units';
enum STEP_STATUS { enum STEP_STATUS {
WAIT = 'wait', WAIT = 'wait',
@ -254,6 +256,7 @@ class ProposalMilestones extends React.Component<Props, State> {
!!proposal.arbiter.user && !!proposal.arbiter.user &&
proposal.arbiter.status === PROPOSAL_ARBITER_STATUS.ACCEPTED proposal.arbiter.status === PROPOSAL_ARBITER_STATUS.ACCEPTED
} }
isVersionTwo={proposal.isVersionTwo}
/> />
</> </>
) : ( ) : (
@ -329,12 +332,17 @@ interface MilestoneProps extends MSProps {
isCurrent: boolean; isCurrent: boolean;
proposalId: number; proposalId: number;
isFunded: boolean; isFunded: boolean;
isVersionTwo: boolean;
} }
const Milestone: React.SFC<MilestoneProps> = p => { const Milestone: React.SFC<MilestoneProps> = p => {
const estimatedDate = p.dateEstimated const estimatedDate = p.dateEstimated
? moment(p.dateEstimated * 1000).format('MMMM YYYY') ? moment(p.dateEstimated * 1000).format('MMMM YYYY')
: 'N/A'; : 'N/A';
const reward = <UnitDisplay value={p.amount} symbol="ZEC" displayShortBalance={4} />; const reward = p.isVersionTwo ? (
formatUsd(p.amount as string, true, 2)
) : (
<UnitDisplay value={p.amount as Zat} symbol="ZEC" displayShortBalance={4} />
);
const getAlertProps = { const getAlertProps = {
[MILESTONE_STAGE.IDLE]: () => null, [MILESTONE_STAGE.IDLE]: () => null,
[MILESTONE_STAGE.REQUESTED]: () => ({ [MILESTONE_STAGE.REQUESTED]: () => ({
@ -370,7 +378,7 @@ const Milestone: React.SFC<MilestoneProps> = p => {
type: 'success', type: 'success',
message: ( message: (
<span> <span>
The team was awarded <strong>{reward}</strong>{' '} The team was awarded <strong>{reward}</strong> {p.isVersionTwo && `in ZEC`}
{p.immediatePayout && ` as an initial payout `} on {fmtDate(p.datePaid)}. {p.immediatePayout && ` as an initial payout `} on {fmtDate(p.datePaid)}.
</span> </span>
), ),

View File

@ -6,6 +6,7 @@ import { Proposal } from 'types';
import Card from 'components/Card'; import Card from 'components/Card';
import UserAvatar from 'components/UserAvatar'; import UserAvatar from 'components/UserAvatar';
import UnitDisplay from 'components/UnitDisplay'; import UnitDisplay from 'components/UnitDisplay';
import { formatUsd } from 'utils/formatters'
import './style.less'; import './style.less';
export class ProposalCard extends React.Component<Proposal> { export class ProposalCard extends React.Component<Proposal> {
@ -41,7 +42,7 @@ export class ProposalCard extends React.Component<Proposal> {
{isVersionTwo && ( {isVersionTwo && (
<div className="ProposalCard-funding"> <div className="ProposalCard-funding">
<div className="ProposalCard-funding-raised"> <div className="ProposalCard-funding-raised">
<UnitDisplay value={target} symbol="ZEC" /> {formatUsd(target.toString(10))}
</div> </div>
</div> </div>
)} )}

View File

@ -15,6 +15,7 @@ import UnitDisplay from 'components/UnitDisplay';
import HeaderDetails from 'components/HeaderDetails'; import HeaderDetails from 'components/HeaderDetails';
import Like from 'components/Like'; import Like from 'components/Like';
import { RFP_STATUS } from 'api/constants'; import { RFP_STATUS } from 'api/constants';
import { formatUsd } from 'utils/formatters';
import './index.less'; import './index.less';
interface OwnProps { interface OwnProps {
@ -62,12 +63,20 @@ class RFPDetail extends React.Component<Props> {
} }
if (rfp.bounty) { if (rfp.bounty) {
if (rfp.isVersionTwo) {
tags.push(
<Tag key="bounty" color="#CF8A00">
{formatUsd(rfp.bounty.toString(10))} bounty
</Tag>,
);
} else {
tags.push( tags.push(
<Tag key="bounty" color="#CF8A00"> <Tag key="bounty" color="#CF8A00">
<UnitDisplay value={rfp.bounty} symbol="ZEC" /> bounty <UnitDisplay value={rfp.bounty} symbol="ZEC" /> bounty
</Tag>, </Tag>,
); );
} }
}
if (!isLive) { if (!isLive) {
tags.push( tags.push(
@ -98,7 +107,15 @@ class RFPDetail extends React.Component<Props> {
<Markdown className="RFPDetail-content" source={rfp.content} /> <Markdown className="RFPDetail-content" source={rfp.content} />
<div className="RFPDetail-rules"> <div className="RFPDetail-rules">
<ul> <ul>
{rfp.bounty && ( {rfp.bounty &&
rfp.isVersionTwo && (
<li>
Accepted proposals will be funded up to{' '}
<strong>{formatUsd(rfp.bounty.toString(10))}</strong> in ZEC
</li>
)}
{rfp.bounty &&
!rfp.isVersionTwo && (
<li> <li>
Accepted proposals will be funded up to{' '} Accepted proposals will be funded up to{' '}
<strong> <strong>

View File

@ -5,6 +5,7 @@ import { Tag } from 'antd';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import UnitDisplay from 'components/UnitDisplay'; import UnitDisplay from 'components/UnitDisplay';
import { RFP } from 'types'; import { RFP } from 'types';
import { formatUsd } from 'utils/formatters';
import './RFPItem.less'; import './RFPItem.less';
interface Props { interface Props {
@ -25,18 +26,27 @@ export default class RFPItem extends React.Component<Props> {
dateClosed, dateClosed,
bounty, bounty,
matching, matching,
isVersionTwo,
} = rfp; } = rfp;
const closeDate = dateCloses || dateClosed; const closeDate = dateCloses || dateClosed;
const tags = []; const tags = [];
if (!isSmall) { if (!isSmall) {
if (bounty) { if (bounty) {
if (isVersionTwo) {
tags.push(
<Tag key="bounty" color="#CF8A00">
{formatUsd(bounty.toString(10))} bounty
</Tag>,
);
} else {
tags.push( tags.push(
<Tag key="bounty" color="#CF8A00"> <Tag key="bounty" color="#CF8A00">
<UnitDisplay value={bounty} symbol="ZEC" /> bounty <UnitDisplay value={bounty} symbol="ZEC" /> bounty
</Tag>, </Tag>,
); );
} }
}
if (matching) { if (matching) {
tags.push( tags.push(
<Tag key="matching" color="#1890ff"> <Tag key="matching" color="#1890ff">

View File

@ -1,7 +1,8 @@
import { ProposalDraft, STATUS, MILESTONE_STAGE, PROPOSAL_ARBITER_STATUS } from 'types'; import { ProposalDraft, STATUS, MILESTONE_STAGE, PROPOSAL_ARBITER_STATUS } from 'types';
import { User } from 'types'; import { User } from 'types';
import { import {
getAmountError, getAmountErrorUsd,
getAmountErrorUsdFromString,
isValidSaplingAddress, isValidSaplingAddress,
isValidTAddress, isValidTAddress,
isValidSproutAddress, isValidSproutAddress,
@ -98,7 +99,8 @@ export function getCreateErrors(
const targetFloat = target ? parseFloat(target) : 0; const targetFloat = target ? parseFloat(target) : 0;
if (target && !Number.isNaN(targetFloat)) { if (target && !Number.isNaN(targetFloat)) {
const limit = parseFloat(process.env.PROPOSAL_TARGET_MAX as string); 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) { if (targetErr) {
errors.target = targetErr; errors.target = targetErr;
} }

View File

@ -10,7 +10,7 @@ import {
} from 'types'; } from 'types';
import { UserState } from 'modules/users/reducers'; import { UserState } from 'modules/users/reducers';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { toZat } from './units'; import { toZat, toUsd } from './units';
export function formatUserForPost(user: User) { export function formatUserForPost(user: User) {
return { return {
@ -21,8 +21,14 @@ export function formatUserForPost(user: User) {
export function formatUserFromGet(user: UserState) { export function formatUserFromGet(user: UserState) {
const bnUserProp = (p: any) => { const bnUserProp = (p: any) => {
if (p.isVersionTwo) {
p.funded = toUsd(p.funded);
p.target = toUsd(p.target);
} else {
p.funded = toZat(p.funded); p.funded = toZat(p.funded);
p.target = toZat(p.target); p.target = toZat(p.target);
}
return p; return p;
}; };
if (user.pendingProposals) { if (user.pendingProposals) {
@ -84,17 +90,40 @@ export function formatProposalPageFromGet(page: any): ProposalPage {
export function formatProposalFromGet(p: any): Proposal { export function formatProposalFromGet(p: any): Proposal {
const proposal = { ...p } as Proposal; const proposal = { ...p } as Proposal;
proposal.proposalUrlId = generateSlugUrl(proposal.proposalId, proposal.title); proposal.proposalUrlId = generateSlugUrl(proposal.proposalId, proposal.title);
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.target = toZat(p.target);
proposal.funded = toZat(p.funded); proposal.funded = toZat(p.funded);
proposal.contributionBounty = toZat(p.contributionBounty); proposal.contributionBounty = toZat(p.contributionBounty);
proposal.percentFunded = proposal.target.isZero() proposal.percentFunded = proposal.target.isZero()
? 0 ? 0
: proposal.funded.div(proposal.target.divn(100)).toNumber(); : proposal.funded.div(proposal.target.divn(100)).toNumber();
}
if (proposal.milestones) { if (proposal.milestones) {
const msToFe = (m: any) => ({ 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, ...m,
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100), amount,
}); };
};
proposal.milestones = proposal.milestones.map(msToFe); proposal.milestones = proposal.milestones.map(msToFe);
proposal.currentMilestone = msToFe(proposal.currentMilestone); proposal.currentMilestone = msToFe(proposal.currentMilestone);
} }
@ -107,7 +136,7 @@ export function formatProposalFromGet(p: any): Proposal {
export function formatRFPFromGet(rfp: RFP): RFP { export function formatRFPFromGet(rfp: RFP): RFP {
rfp.urlId = generateSlugUrl(rfp.id, rfp.title); rfp.urlId = generateSlugUrl(rfp.id, rfp.title);
if (rfp.bounty) { 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) { if (rfp.acceptedProposals) {
rfp.acceptedProposals = rfp.acceptedProposals.map(formatProposalFromGet); rfp.acceptedProposals = rfp.acceptedProposals.map(formatProposalFromGet);
@ -139,42 +168,53 @@ export function extractIdFromSlug(slug: string) {
export function massageSerializedState(state: AppState) { export function massageSerializedState(state: AppState) {
// proposal detail // proposal detail
if (state.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 = new BN(
(state.proposal.detail.target as any) as string, (state.proposal.detail.target as any) as string,
16, base,
); );
state.proposal.detail.funded = new BN( state.proposal.detail.funded = new BN(
(state.proposal.detail.funded as any) as string, (state.proposal.detail.funded as any) as string,
16, base,
); );
state.proposal.detail.contributionBounty = new BN((state.proposal.detail state.proposal.detail.contributionBounty = new BN((state.proposal.detail
.contributionBounty as any) as string); .contributionBounty as any) as string);
state.proposal.detail.milestones = state.proposal.detail.milestones.map(m => ({ state.proposal.detail.milestones = state.proposal.detail.milestones.map(m => ({
...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) { if (state.proposal.detail.rfp && state.proposal.detail.rfp.bounty) {
state.proposal.detail.rfp.bounty = new BN( state.proposal.detail.rfp.bounty = new BN(
(state.proposal.detail.rfp.bounty as any) as string, (state.proposal.detail.rfp.bounty as any) as string,
16, base,
); );
} }
} }
// proposals // proposals
state.proposal.page.items = state.proposal.page.items.map(p => ({ state.proposal.page.items = state.proposal.page.items.map(p => {
const base = p.isVersionTwo ? 10 : 16;
return {
...p, ...p,
target: new BN((p.target as any) as string, 16), target: new BN((p.target as any) as string, base),
funded: new BN((p.funded as any) as string, 16), funded: new BN((p.funded as any) as string, base),
contributionBounty: new BN((p.contributionMatching as any) as string, 16), contributionBounty: new BN((p.contributionMatching as any) as string, base),
milestones: p.milestones.map(m => ({ milestones: p.milestones.map(m => ({
...m, ...m,
amount: new BN((m.amount as any) as string, 16), amount: p.isVersionTwo
? m.amount
: new BN((m.amount as any) as string, 16),
})), })),
})); };
});
// users // users
const bnUserProp = (p: UserProposal) => { const bnUserProp = (p: UserProposal) => {
p.funded = new BN(p.funded, 16); const base = p.isVersionTwo ? 10 : 16;
p.target = new BN(p.target, 16); p.funded = new BN(p.funded, base);
p.target = new BN(p.target, base);
return p; return p;
}; };
Object.values(state.users.map).forEach(user => { Object.values(state.users.map).forEach(user => {
@ -190,8 +230,9 @@ export function massageSerializedState(state: AppState) {
}); });
// RFPs // RFPs
state.rfps.rfps = state.rfps.rfps.map(rfp => { state.rfps.rfps = state.rfps.rfps.map(rfp => {
const base = rfp.isVersionTwo ? 10 : 16;
if (rfp.bounty) { if (rfp.bounty) {
rfp.bounty = new BN(rfp.bounty, 16); rfp.bounty = new BN(rfp.bounty, base);
} }
return rfp; return rfp;
}); });

View File

@ -92,3 +92,14 @@ export function formatTxExplorerUrl(txid: string) {
} }
throw new Error('EXPLORER_URL env variable needs to be set!'); 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;
}

View File

@ -9,6 +9,7 @@ export const Units = {
}; };
export type Zat = BN; export type Zat = BN;
export type Usd = BN;
export type UnitKey = keyof typeof Units; export type UnitKey = keyof typeof Units;
export const handleValues = (input: string | BN) => { export const handleValues = (input: string | BN) => {
@ -61,4 +62,14 @@ export const toZat = (value: string | number): Zat => {
return Zat(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; export const getDecimalFromUnitKey = (key: UnitKey) => Units[key].length - 1;

View File

@ -1,3 +1,5 @@
import { formatUsd } from './formatters';
export function getAmountError(amount: number, max: number = Infinity, min?: number) { export function getAmountError(amount: number, max: number = Infinity, min?: number) {
if (amount < 0) { if (amount < 0) {
return 'Amount must be a positive number'; return 'Amount must be a positive number';
@ -15,16 +17,38 @@ export function getAmountError(amount: number, max: number = Infinity, min?: num
return null; 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) { export function getAmountErrorFromString(amount: string, max?: number, min?: number) {
const parsedAmount = parseFloat(amount) const parsedAmount = parseFloat(amount);
if (Number.isNaN(parsedAmount)) { if (Number.isNaN(parsedAmount)) {
return 'Not a valid number' return 'Not a valid number';
} }
// prevents "-0" from being valid... // prevents "-0" from being valid...
if (amount[0] === '-') { 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 { export function isValidEmail(email: string): boolean {

View File

@ -19,7 +19,7 @@ export enum MILESTONE_STAGE {
export interface Milestone { export interface Milestone {
index: number; index: number;
stage: MILESTONE_STAGE; stage: MILESTONE_STAGE;
amount: Zat; amount: Zat | string;
immediatePayout: boolean; immediatePayout: boolean;
dateEstimated?: number; dateEstimated?: number;
daysEstimated?: string; daysEstimated?: string;

View File

@ -1,4 +1,4 @@
import { Zat } from 'utils/units'; import { Zat, Usd } from 'utils/units';
import { PROPOSAL_STAGE } from 'api/constants'; import { PROPOSAL_STAGE } from 'api/constants';
import { CreateMilestone, Update, User, Comment, ContributionWithUser } from 'types'; import { CreateMilestone, Update, User, Comment, ContributionWithUser } from 'types';
import { ProposalMilestone } from './milestone'; import { ProposalMilestone } from './milestone';
@ -52,8 +52,8 @@ export interface ProposalDraft {
export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> { export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
proposalAddress: string; proposalAddress: string;
proposalUrlId: string; proposalUrlId: string;
target: Zat; target: Zat | Usd;
funded: Zat; funded: Zat | Usd;
percentFunded: number; percentFunded: number;
contributionMatching: number; contributionMatching: number;
contributionBounty: Zat; contributionBounty: Zat;
@ -99,13 +99,15 @@ export interface UserProposal {
status: STATUS; status: STATUS;
title: string; title: string;
brief: string; brief: string;
funded: Zat; funded: Zat | Usd;
target: Zat; target: Zat | Usd;
dateCreated: number; dateCreated: number;
dateApproved: number; dateApproved: number;
datePublished: number; datePublished: number;
team: User[]; team: User[];
rejectReason: string; rejectReason: string;
acceptedWithFunding: boolean | null;
isVersionTwo: boolean;
} }
// NOTE: sync with backend/grant/proposal/models.py STATUSES // NOTE: sync with backend/grant/proposal/models.py STATUSES

View File

@ -1,6 +1,6 @@
import { Proposal } from './proposal'; import { Proposal } from './proposal';
import { RFP_STATUS } from 'api/constants'; import { RFP_STATUS } from 'api/constants';
import { Zat } from 'utils/units'; import { Zat, Usd } from 'utils/units';
export interface RFP { export interface RFP {
id: number; id: number;
@ -10,11 +10,12 @@ export interface RFP {
content: string; content: string;
status: RFP_STATUS; status: RFP_STATUS;
acceptedProposals: Proposal[]; acceptedProposals: Proposal[];
bounty: Zat | null; bounty: Zat | Usd | null;
matching: boolean; matching: boolean;
dateOpened: number; dateOpened: number;
dateClosed?: number; dateClosed?: number;
dateCloses?: number; dateCloses?: number;
authedLiked: boolean; authedLiked: boolean;
likesCount: number; likesCount: number;
isVersionTwo: boolean;
} }