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:
parent
6f4e1b779b
commit
4a0e23e9c7
|
@ -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))}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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"
|
||||
/>,
|
||||
)}
|
||||
|
|
|
@ -47,6 +47,7 @@ export interface RFP {
|
|||
matching: boolean;
|
||||
bounty: string | null;
|
||||
dateCloses: number | null;
|
||||
isVersionTwo: boolean;
|
||||
}
|
||||
export interface RFPArgs {
|
||||
title: string;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 ###
|
|
@ -48,7 +48,7 @@ test_proposal = {
|
|||
"brief": "$$$",
|
||||
"milestones": test_milestones,
|
||||
"category": Category.ACCESSIBILITY,
|
||||
"target": "123.456",
|
||||
"target": "12345",
|
||||
"payoutAddress": "123",
|
||||
}
|
||||
|
||||
|
|
|
@ -44,7 +44,7 @@ test_proposal = {
|
|||
"brief": "$$$",
|
||||
"milestones": milestones,
|
||||
"category": Category.ACCESSIBILITY,
|
||||
"target": "123.456",
|
||||
"target": "12345",
|
||||
"payoutAddress": "123",
|
||||
"deadlineDuration": 100
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
<Icon type="info-circle" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
),
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue