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 { toZat, fromZat } from 'src/util/units';
import FeedbackModal from '../FeedbackModal';
import { formatUsd } from 'util/formatters';
import './index.less';
type Props = RouteComponentProps<any>;
@ -285,11 +286,23 @@ class ProposalDetailNaked extends React.Component<Props, State> {
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 (
<Alert
className="ProposalDetail-alert"
@ -306,7 +319,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
</p>
<p>
{' '}
Please make a payment of <b>{amount.toString()} ZEC</b> to:
Please make a payment of <b>{paymentMsg}</b> to:
</p>{' '}
<pre>{p.payoutAddress}</pre>
<Input.Search
@ -445,9 +458,12 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{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))}

View File

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

View File

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

View File

@ -47,6 +47,7 @@ export interface RFP {
matching: boolean;
bounty: string | null;
dateCloses: number | null;
isVersionTwo: boolean;
}
export interface RFPArgs {
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
# 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:
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"

View File

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

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": "$$$",
"milestones": test_milestones,
"category": Category.ACCESSIBILITY,
"target": "123.456",
"target": "12345",
"payoutAddress": "123",
}

View File

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

View File

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

View File

@ -169,7 +169,10 @@ class CreateFlowBasics extends React.Component<Props, State> {
<Form.Item
label="Target amount"
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
size="large"
@ -178,7 +181,7 @@ class CreateFlowBasics extends React.Component<Props, State> {
type="number"
value={target}
onChange={this.handleInputChange}
addonAfter="ZEC"
addonBefore="$"
maxLength={16}
/>
</Form.Item>

View File

@ -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<Props> {
},
{
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,
},
],

View File

@ -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<OwnProps> {
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 (
<div className="ProfileProposal">
<div className="ProfileProposal-block">
<Link to={`/proposals/${proposalId}`} className="ProfileProposal-title">
{title}
{title} {isVersionTwo && (<Tag color={tagColor} style={{verticalAlign: 'text-top'}}>{tagMessage}</Tag>)}
</Link>
<div className="ProfileProposal-brief">{brief}</div>
<div className="ProfileProposal-raised">
<UnitDisplay value={funded} symbol="ZEC" displayShortBalance={4} />{' '}
<small>raised</small> of{' '}
<UnitDisplay value={target} symbol="ZEC" displayShortBalance={4} /> goal
</div>
{!isVersionTwo && (
<div className="ProfileProposal-raised">
<UnitDisplay value={funded} symbol="ZEC" displayShortBalance={4} />{' '}
<small>raised</small> of{' '}
<UnitDisplay value={target} symbol="ZEC" displayShortBalance={4} /> goal
</div>
)}
{isVersionTwo && (
<div className="ProfileProposal-raised">
{formatUsd(target.toString())}
</div>
)}
</div>
<div className="ProfileProposal-block">
<h3>Team</h3>

View File

@ -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<Props, State> {
{isAcceptedWithFunding ? 'Funding' : 'Requested Funding'}
</div>
<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>
)}

View File

@ -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<Props, State> {
!!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<MilestoneProps> = p => {
const estimatedDate = p.dateEstimated
? moment(p.dateEstimated * 1000).format('MMMM YYYY')
: '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 = {
[MILESTONE_STAGE.IDLE]: () => null,
[MILESTONE_STAGE.REQUESTED]: () => ({
@ -370,7 +378,7 @@ const Milestone: React.SFC<MilestoneProps> = p => {
type: 'success',
message: (
<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)}.
</span>
),

View File

@ -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<Proposal> {
@ -41,7 +42,7 @@ export class ProposalCard extends React.Component<Proposal> {
{isVersionTwo && (
<div className="ProposalCard-funding">
<div className="ProposalCard-funding-raised">
<UnitDisplay value={target} symbol="ZEC" />
{formatUsd(target.toString(10))}
</div>
</div>
)}

View File

@ -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<Props> {
}
if (rfp.bounty) {
tags.push(
<Tag key="bounty" color="#CF8A00">
<UnitDisplay value={rfp.bounty} symbol="ZEC" /> bounty
</Tag>,
);
if (rfp.isVersionTwo) {
tags.push(
<Tag key="bounty" color="#CF8A00">
{formatUsd(rfp.bounty.toString(10))} bounty
</Tag>,
);
} else {
tags.push(
<Tag key="bounty" color="#CF8A00">
<UnitDisplay value={rfp.bounty} symbol="ZEC" /> bounty
</Tag>,
);
}
}
if (!isLive) {
@ -98,14 +107,22 @@ class RFPDetail extends React.Component<Props> {
<Markdown className="RFPDetail-content" source={rfp.content} />
<div className="RFPDetail-rules">
<ul>
{rfp.bounty && (
<li>
Accepted proposals will be funded up to{' '}
<strong>
<UnitDisplay value={rfp.bounty} symbol="ZEC" />
</strong>
</li>
)}
{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>
Accepted proposals will be funded up to{' '}
<strong>
<UnitDisplay value={rfp.bounty} symbol="ZEC" />
</strong>
</li>
)}
{rfp.matching && (
<li>
Contributions will have their <strong>funding matched</strong> by the

View File

@ -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<Props> {
dateClosed,
bounty,
matching,
isVersionTwo,
} = rfp;
const closeDate = dateCloses || dateClosed;
const tags = [];
if (!isSmall) {
if (bounty) {
tags.push(
<Tag key="bounty" color="#CF8A00">
<UnitDisplay value={bounty} symbol="ZEC" /> bounty
</Tag>,
);
if (isVersionTwo) {
tags.push(
<Tag key="bounty" color="#CF8A00">
{formatUsd(bounty.toString(10))} bounty
</Tag>,
);
} else {
tags.push(
<Tag key="bounty" color="#CF8A00">
<UnitDisplay value={bounty} symbol="ZEC" /> bounty
</Tag>,
);
}
}
if (matching) {
tags.push(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ProposalDraft, 'target' | 'invites'> {
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

View File

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