diff --git a/admin/src/Routes.tsx b/admin/src/Routes.tsx
index 24a2cbcd..01f84bd2 100644
--- a/admin/src/Routes.tsx
+++ b/admin/src/Routes.tsx
@@ -13,6 +13,8 @@ import UserDetail from 'components/UserDetail';
import Emails from 'components/Emails';
import Proposals from 'components/Proposals';
import ProposalDetail from 'components/ProposalDetail';
+import CCRs from 'components/CCRs';
+import CCRDetail from 'components/CCRDetail';
import RFPs from 'components/RFPs';
import RFPForm from 'components/RFPForm';
import RFPDetail from 'components/RFPDetail';
@@ -47,6 +49,8 @@ class Routes extends React.Component {
+
+
diff --git a/admin/src/components/ArbiterControl/index.tsx b/admin/src/components/ArbiterControl/index.tsx
index 4c85d617..fd71ae5f 100644
--- a/admin/src/components/ArbiterControl/index.tsx
+++ b/admin/src/components/ArbiterControl/index.tsx
@@ -30,10 +30,11 @@ class ArbiterControlNaked extends React.Component {
}, 1000);
render() {
- const { arbiter } = this.props;
+ const { arbiter, isVersionTwo, acceptedWithFunding } = this.props;
const { showSearch, searching } = this.state;
const { results, search, error } = store.arbitersSearch;
const showEmpty = !results.length && !searching;
+ const buttonDisabled = isVersionTwo && acceptedWithFunding === false
const disp = {
[PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter',
@@ -51,6 +52,7 @@ class ArbiterControlNaked extends React.Component {
type="primary"
onClick={this.handleShowSearch}
{...this.props.buttonProps}
+ disabled={buttonDisabled}
>
{disp[arbiter.status]}
diff --git a/admin/src/components/CCRDetail/index.less b/admin/src/components/CCRDetail/index.less
new file mode 100644
index 00000000..5789dcfc
--- /dev/null
+++ b/admin/src/components/CCRDetail/index.less
@@ -0,0 +1,50 @@
+.CCRDetail {
+ h1 {
+ font-size: 1.5rem;
+ }
+
+ &-controls {
+ &-control + &-control {
+ margin-left: 0 !important;
+ margin-top: 0.8rem;
+ }
+ }
+
+ &-deet {
+ position: relative;
+ margin-bottom: 1rem;
+
+ & > span {
+ font-size: 0.7rem;
+ position: absolute;
+ opacity: 0.8;
+ bottom: -0.7rem;
+ }
+ }
+
+ & .ant-card,
+ .ant-alert,
+ .ant-collapse {
+ margin-bottom: 16px;
+ }
+
+ &-popover {
+ &-overlay {
+ max-width: 400px;
+ }
+ }
+
+ &-alert {
+ & pre {
+ margin: 1rem 0;
+ overflow: hidden;
+ word-break: break-all;
+ white-space: inherit;
+ }
+ }
+
+ &-review {
+ margin-right: 0.5rem;
+ margin-bottom: 0.25rem;
+ }
+}
diff --git a/admin/src/components/CCRDetail/index.tsx b/admin/src/components/CCRDetail/index.tsx
new file mode 100644
index 00000000..860d4d87
--- /dev/null
+++ b/admin/src/components/CCRDetail/index.tsx
@@ -0,0 +1,221 @@
+import React from 'react';
+import { view } from 'react-easy-state';
+import { RouteComponentProps, withRouter } from 'react-router';
+import { Alert, Button, Card, Col, Collapse, message, Row } from 'antd';
+import TextArea from 'antd/lib/input/TextArea';
+import store from 'src/store';
+import { formatDateSeconds } from 'util/time';
+import { CCR_STATUS } from 'src/types';
+import Back from 'components/Back';
+import Markdown from 'components/Markdown';
+import FeedbackModal from '../FeedbackModal';
+import './index.less';
+import { Link } from 'react-router-dom';
+
+type Props = RouteComponentProps;
+
+const STATE = {
+ paidTxId: '',
+ showCancelAndRefundPopover: false,
+ showChangeToAcceptedWithFundingPopover: false,
+};
+
+type State = typeof STATE;
+
+class CCRDetailNaked extends React.Component {
+ state = STATE;
+ rejectInput: null | TextArea = null;
+
+ componentDidMount() {
+ this.loadDetail();
+ }
+
+ render() {
+ const id = this.getIdFromQuery();
+ const { ccrDetail: c, ccrDetailFetching } = store;
+
+ if (!c || (c && c.ccrId !== id) || ccrDetailFetching) {
+ return 'loading ccr...';
+ }
+
+ const renderApproved = () =>
+ c.status === CCR_STATUS.APPROVED && (
+
+ );
+
+ const renderReview = () =>
+ c.status === CCR_STATUS.PENDING && (
+
+
+ Please review this Community Created Request and render your judgment.
+
+ this.handleApprove()}
+ >
+ Generate RFP from CCR
+
+ {
+ FeedbackModal.open({
+ title: 'Request changes for this Request?',
+ label: 'Please provide a reason:',
+ okText: 'Request changes',
+ onOk: this.handleReject,
+ });
+ }}
+ >
+ Request changes
+
+
+ }
+ />
+ );
+
+ const renderRejected = () =>
+ c.status === CCR_STATUS.REJECTED && (
+
+
+ This CCR has changes requested. The team will be able to re-submit it for
+ approval should they desire to do so.
+
+ Reason:
+
+ {c.rejectReason}
+
+ }
+ />
+ );
+
+ const renderDeetItem = (name: string, val: any) => (
+
+ {name}
+ {val}
+
+ );
+
+ return (
+
+
+
{c.title}
+
+ {/* MAIN */}
+
+ {renderApproved()}
+ {renderReview()}
+ {renderRejected()}
+
+
+
+ {c.brief}
+
+
+
+
+
+
+
+
+
+
+
+ {JSON.stringify(c, null, 4)}
+
+
+
+
+ {/* RIGHT SIDE */}
+
+ {c.rfp && (
+
+ This CCR has been accepted and is instantiated as an RFP{' '}
+ here.
+
+ }
+ type="info"
+ showIcon
+ />
+ )}
+
+ {/* DETAILS */}
+
+ {renderDeetItem('id', c.ccrId)}
+ {renderDeetItem('created', formatDateSeconds(c.dateCreated))}
+ {renderDeetItem(
+ 'published',
+ c.datePublished ? formatDateSeconds(c.datePublished) : 'n/a',
+ )}
+
+ {renderDeetItem(
+ 'status',
+ c.status === CCR_STATUS.LIVE ? 'Accepted/Generated RFP' : c.status,
+ )}
+ {renderDeetItem('target', c.target)}
+
+
+
+
+ {c.author.displayName}
+
+
+
+
+
+
+ );
+ }
+
+ private getIdFromQuery = () => {
+ return Number(this.props.match.params.id);
+ };
+
+ private loadDetail = () => {
+ store.fetchCCRDetail(this.getIdFromQuery());
+ };
+
+ private handleApprove = async () => {
+ await store.approveCCR(true);
+ if (store.ccrCreatedRFPId) {
+ message.success('Successfully created RFP from CCR!', 1);
+ setTimeout(
+ () => this.props.history.replace(`/rfps/${store.ccrCreatedRFPId}/edit`),
+ 1500,
+ );
+ }
+ };
+
+ private handleReject = async (reason: string) => {
+ await store.approveCCR(false, reason);
+ message.info('CCR changes requested');
+ };
+}
+
+const CCRDetail = withRouter(view(CCRDetailNaked));
+export default CCRDetail;
diff --git a/admin/src/components/CCRs/CCRItem.less b/admin/src/components/CCRs/CCRItem.less
new file mode 100644
index 00000000..c9e18451
--- /dev/null
+++ b/admin/src/components/CCRs/CCRItem.less
@@ -0,0 +1,16 @@
+.CCRItem {
+ & h2 {
+ font-size: 1.4rem;
+ margin-bottom: 0;
+
+ & .ant-tag {
+ vertical-align: text-top;
+ margin: 0.2rem 0 0 0.5rem;
+ }
+ }
+
+ & p {
+ color: rgba(#000, 0.5);
+ margin: 0;
+ }
+}
diff --git a/admin/src/components/CCRs/CCRItem.tsx b/admin/src/components/CCRs/CCRItem.tsx
new file mode 100644
index 00000000..67775e59
--- /dev/null
+++ b/admin/src/components/CCRs/CCRItem.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import { view } from 'react-easy-state';
+import { Tag, Tooltip, List } from 'antd';
+import { Link } from 'react-router-dom';
+import { CCR } from 'src/types';
+import { CCR_STATUSES, getStatusById } from 'util/statuses';
+import { formatDateSeconds } from 'util/time';
+import './CCRItem.less';
+
+class CCRItemNaked extends React.Component {
+ render() {
+ const props = this.props;
+ const status = getStatusById(CCR_STATUSES, props.status);
+
+ return (
+
+
+
+ {props.title || '(no title)'}
+
+
+ {status.tagDisplay === 'Live'
+ ? 'Accepted/Generated RFP'
+ : status.tagDisplay}
+
+
+
+ Created: {formatDateSeconds(props.dateCreated)}
+ {props.brief}
+
+
+ );
+ }
+}
+
+const CCRItem = view(CCRItemNaked);
+export default CCRItem;
diff --git a/admin/src/components/CCRs/index.tsx b/admin/src/components/CCRs/index.tsx
new file mode 100644
index 00000000..9eb5c059
--- /dev/null
+++ b/admin/src/components/CCRs/index.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { view } from 'react-easy-state';
+import store from 'src/store';
+import CCRItem from './CCRItem';
+import Pageable from 'components/Pageable';
+import { CCR } from 'src/types';
+import { ccrFilters } from 'util/filters';
+
+class CCRs extends React.Component<{}> {
+ render() {
+ const { page } = store.ccrs;
+ // NOTE: sync with /backend ... pagination.py CCRPagination.SORT_MAP
+ const sorts = ['CREATED:DESC', 'CREATED:ASC'];
+ return (
+ }
+ handleSearch={store.fetchCCRs}
+ handleChangeQuery={store.setCCRPageQuery}
+ handleResetQuery={store.resetCCRPageQuery}
+ />
+ );
+ }
+}
+
+export default view(CCRs);
diff --git a/admin/src/components/Emails/emails.ts b/admin/src/components/Emails/emails.ts
index 7312117e..1b7e888f 100644
--- a/admin/src/components/Emails/emails.ts
+++ b/admin/src/components/Emails/emails.ts
@@ -43,8 +43,8 @@ export default [
},
{
id: 'proposal_rejected',
- title: 'Proposal rejected',
- description: 'Sent when an admin rejects your submitted proposal',
+ title: 'Proposal changes requested',
+ description: 'Sent when an admin requests changes for your submitted proposal',
},
{
id: 'proposal_contribution',
@@ -130,6 +130,11 @@ export default [
title: 'Milestone paid',
description: 'Sent when milestone is paid',
},
+ {
+ id: 'milestone_deadline',
+ title: 'Milestone deadline',
+ description: 'Sent when the estimated deadline for milestone has been reached',
+ },
{
id: 'admin_approval',
title: 'Admin Approval',
@@ -145,4 +150,15 @@ export default [
title: 'Admin Payout',
description: 'Sent when milestone payout has been approved',
},
+ {
+ id: 'followed_proposal_milestone',
+ title: 'Followed Proposal Milestone',
+ description:
+ 'Sent to followers of a proposal when one of its milestones has been approved',
+ },
+ {
+ id: 'followed_proposal_update',
+ title: 'Followed Proposal Update',
+ description: 'Sent to followers of a proposal when it has a new update',
+ },
] as Email[];
diff --git a/admin/src/components/Home/index.tsx b/admin/src/components/Home/index.tsx
index 66ebb62a..fd6eeffa 100644
--- a/admin/src/components/Home/index.tsx
+++ b/admin/src/components/Home/index.tsx
@@ -14,6 +14,7 @@ class Home extends React.Component {
const {
userCount,
proposalCount,
+ ccrPendingCount,
proposalPendingCount,
proposalNoArbiterCount,
proposalMilestonePayoutsCount,
@@ -21,6 +22,13 @@ class Home extends React.Component {
} = store.stats;
const actionItems = [
+ !!ccrPendingCount && (
+
+ There are {ccrPendingCount} community
+ created requests waiting for review .{' '}
+ Click here to view them.
+
+ ),
!!proposalPendingCount && (
There are
{proposalPendingCount} {' '}
@@ -32,7 +40,7 @@ class Home extends React.Component {
There are
{proposalNoArbiterCount} {' '}
live proposals
without an arbiter .{' '}
-
+
Click here
{' '}
to view them.
diff --git a/admin/src/components/ProposalDetail/index.less b/admin/src/components/ProposalDetail/index.less
index b36ac488..63ec023e 100644
--- a/admin/src/components/ProposalDetail/index.less
+++ b/admin/src/components/ProposalDetail/index.less
@@ -26,10 +26,6 @@
.ant-alert,
.ant-collapse {
margin-bottom: 16px;
-
- button + button {
- margin-left: 0.5rem;
- }
}
&-popover {
@@ -46,4 +42,9 @@
white-space: inherit;
}
}
+
+ &-review {
+ margin-right: 0.5rem;
+ margin-bottom: 0.25rem;
+ }
}
diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx
index 977ad24b..3d921d34 100644
--- a/admin/src/components/ProposalDetail/index.tsx
+++ b/admin/src/components/ProposalDetail/index.tsx
@@ -11,7 +11,6 @@ import {
Collapse,
Popconfirm,
Input,
- Switch,
Tag,
message,
} from 'antd';
@@ -26,11 +25,11 @@ import {
} from 'src/types';
import { Link } from 'react-router-dom';
import Back from 'components/Back';
-import Info from 'components/Info';
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
;
@@ -38,6 +37,7 @@ type Props = RouteComponentProps;
const STATE = {
paidTxId: '',
showCancelAndRefundPopover: false,
+ showChangeToAcceptedWithFundingPopover: false,
};
type State = typeof STATE;
@@ -65,17 +65,32 @@ class ProposalDetailNaked extends React.Component {
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
}, 100);
+ const { isVersionTwo } = p;
+ const shouldShowArbiter =
+ !isVersionTwo || (isVersionTwo && p.acceptedWithFunding === true);
+ const cancelButtonText = isVersionTwo ? 'Cancel' : 'Cancel & refund';
+ const shouldShowChangeToAcceptedWithFunding =
+ isVersionTwo && p.acceptedWithFunding === false;
+
const renderCancelControl = () => {
const disabled = this.getCancelAndRefundDisabled();
return (
- Are you sure you want to cancel proposal and begin
-
- the refund process? This cannot be undone.
-
+ isVersionTwo ? (
+
+ Are you sure you want to cancel proposal?
+
+ This cannot be undone.
+
+ ) : (
+
+ Are you sure you want to cancel proposal and begin
+
+ the refund process? This cannot be undone.
+
+ )
}
placement="left"
cancelText="cancel"
@@ -95,7 +110,40 @@ class ProposalDetailNaked extends React.Component {
disabled={disabled}
block
>
- Cancel & refund
+ {cancelButtonText}
+
+
+ );
+ };
+
+ const renderChangeToAcceptedWithFundingControl = () => {
+ return (
+
+ Are you sure you want to accept the proposal
+
+ with funding? This cannot be undone.
+
+ }
+ placement="left"
+ cancelText="cancel"
+ okText="confirm"
+ visible={this.state.showChangeToAcceptedWithFundingPopover}
+ okButtonProps={{
+ loading: store.proposalDetailCanceling,
+ }}
+ onCancel={this.handleChangeToAcceptWithFundingCancel}
+ onConfirm={this.handleChangeToAcceptWithFundingConfirm}
+ >
+
+ Accept With Funding
);
@@ -116,69 +164,6 @@ class ProposalDetailNaked extends React.Component {
/>
);
- const renderMatchingControl = () => (
-
-
-
- Turn {p.contributionMatching ? 'off' : 'on'} contribution matching?
-
- {p.status === PROPOSAL_STATUS.LIVE && (
-
- This is a LIVE proposal, this will alter the funding state of the
- proposal!
-
- )}
- >
- }
- okText="ok"
- cancelText="cancel"
- >
- {' '}
-
-
- matching{' '}
-
- Contribution matching
- Funded amount will be multiplied by 2.
- Disabled after proposal is fully-funded.
-
- }
- />
-
-
- );
-
- const renderBountyControl = () => (
-
-
- Set bounty
-
-
- );
-
const renderApproved = () =>
p.status === PROPOSAL_STATUS.APPROVED && (
{
const renderReview = () =>
p.status === PROPOSAL_STATUS.PENDING && (
-
- Please review this proposal and render your judgment.
-
- Approve
-
- {
- FeedbackModal.open({
- title: 'Reject this proposal?',
- label: 'Please provide a reason:',
- okText: 'Reject',
- onOk: this.handleReject,
- });
- }}
- >
- Reject
-
-
- }
- />
+ <>
+
+
+
+ Please review this proposal and render your judgment.
+ this.handleApprove(true)}
+ >
+ Approve With Funding
+
+ this.handleApprove(false)}
+ >
+ Approve Without Funding
+
+ {
+ FeedbackModal.open({
+ title: 'Request changes to this proposal?',
+ label: 'Please provide a reason:',
+ okText: 'Request changes',
+ onOk: this.handleReject,
+ });
+ }}
+ >
+ Request changes
+
+
+ }
+ />
+
+ {p.isVersionTwo && (
+
+
+ {p.rfpOptIn ? (
+ KYC has been accepted by the proposer.
+ ) : (
+
+ KYC has been rejected. Recommend against approving with funding.
+
+ )}
+
+ }
+ />
+
+ )}
+
+ >
);
const renderRejected = () =>
@@ -234,12 +256,12 @@ class ProposalDetailNaked extends React.Component {
- This proposal has been rejected. The team will be able to re-submit it for
- approval should they desire to do so.
+ This proposal has changes requested. The team will be able to re-submit it
+ for approval should they desire to do so.
Reason:
@@ -250,7 +272,8 @@ class ProposalDetailNaked extends React.Component {
);
const renderNominateArbiter = () =>
- needsArbiter && (
+ needsArbiter &&
+ shouldShowArbiter && (
{
return;
}
const ms = p.currentMilestone;
- const amount = fromZat(
- toZat(p.target)
- .mul(new BN(ms.payoutPercent))
- .divn(100),
- );
+
+ let paymentMsg;
+ if (p.isVersionTwo) {
+ const target = parseFloat(p.target.toString());
+ const payoutPercent = parseFloat(ms.payoutPercent);
+ const amountNum = (target * payoutPercent) / 100;
+ const amount = formatUsd(amountNum, true, 2);
+ paymentMsg = `${amount} in ZEC`;
+ } else {
+ const amount = fromZat(
+ toZat(p.target)
+ .mul(new BN(ms.payoutPercent))
+ .divn(100),
+ );
+ paymentMsg = `${amount} ZEC`;
+ }
+
return (
{
{' '}
- Please make a payment of {amount.toString()} ZEC to:
+ Please make a payment of {paymentMsg} to:
{' '}
{p.payoutAddress}
{
{renderMilestoneAccepted()}
{renderFailed()}
-
{p.brief}
@@ -391,24 +425,35 @@ class ProposalDetailNaked extends React.Component {
- {
- p.milestones.map((milestone, i) =>
+ {p.milestones.map((milestone, i) => (
+
+ {milestone.title + ' '}
+ {milestone.immediatePayout && (
+ Immediate Payout
+ )}
+ >
+ }
+ extra={`${milestone.payoutPercent}% Payout`}
+ key={i}
+ >
+ {p.isVersionTwo && (
+
+ Estimated Days to Complete: {' '}
+ {milestone.immediatePayout ? 'N/A' : milestone.daysEstimated}{' '}
+
+ )}
+
+ Estimated Date: {' '}
+ {milestone.dateEstimated
+ ? formatDateSeconds(milestone.dateEstimated)
+ : 'N/A'}{' '}
+
-
- {milestone.title + ' '}
- {milestone.immediatePayout && Immediate Payout }
- >
- }
- extra={`${milestone.payoutPercent}% Payout`}
- key={i}
- >
- Estimated Date: {formatDateSeconds(milestone.dateEstimated )}
- {milestone.content}
-
-
- )
- }
+ {milestone.content}
+
+ ))}
@@ -419,12 +464,23 @@ class ProposalDetailNaked extends React.Component {
{/* RIGHT SIDE */}
+ {p.isVersionTwo &&
+ !p.acceptedWithFunding &&
+ p.stage === PROPOSAL_STAGE.WIP && (
+
+ )}
+
{/* ACTIONS */}
{renderCancelControl()}
{renderArbiterControl()}
- {renderBountyControl()}
- {renderMatchingControl()}
+ {shouldShowChangeToAcceptedWithFunding &&
+ renderChangeToAcceptedWithFundingControl()}
{/* DETAILS */}
@@ -447,13 +503,19 @@ class ProposalDetailNaked extends React.Component {
{renderDeetItem('isFailed', JSON.stringify(p.isFailed))}
{renderDeetItem('status', p.status)}
{renderDeetItem('stage', p.stage)}
- {renderDeetItem('category', p.category)}
- {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))}
+ {renderDeetItem(
+ 'acceptedWithFunding',
+ JSON.stringify(p.acceptedWithFunding),
+ )}
{renderDeetItem(
'arbiter',
<>
@@ -508,6 +570,20 @@ class ProposalDetailNaked extends React.Component {
}
};
+ private handleChangeToAcceptedWithFunding = () => {
+ this.setState({ showChangeToAcceptedWithFundingPopover: true });
+ };
+
+ private handleChangeToAcceptWithFundingCancel = () => {
+ this.setState({ showChangeToAcceptedWithFundingPopover: false });
+ };
+
+ private handleChangeToAcceptWithFundingConfirm = () => {
+ if (!store.proposalDetail) return;
+ store.changeProposalToAcceptedWithFunding(store.proposalDetail.proposalId);
+ this.setState({ showChangeToAcceptedWithFundingPopover: false });
+ };
+
private getIdFromQuery = () => {
return Number(this.props.match.params.id);
};
@@ -526,44 +602,13 @@ class ProposalDetailNaked extends React.Component {
this.setState({ showCancelAndRefundPopover: false });
};
- private handleApprove = () => {
- store.approveProposal(true);
+ private handleApprove = (withFunding: boolean) => {
+ store.approveProposal(true, withFunding);
};
private handleReject = async (reason: string) => {
- await store.approveProposal(false, reason);
- message.info('Proposal rejected');
- };
-
- private handleToggleMatching = async () => {
- if (store.proposalDetail) {
- // we lock this to be 1 or 0 for now, we may support more values later on
- const contributionMatching =
- store.proposalDetail.contributionMatching === 0 ? 1 : 0;
- await store.updateProposalDetail({ contributionMatching });
- message.success('Updated matching');
- }
- };
-
- private handleSetBounty = async () => {
- if (store.proposalDetail) {
- FeedbackModal.open({
- title: 'Set bounty?',
- content:
- 'Set the bounty for this proposal. The bounty will count towards the funding goal.',
- type: 'input',
- inputProps: {
- addonBefore: 'Amount',
- addonAfter: 'ZEC',
- placeholder: '1.5',
- },
- okText: 'Set bounty',
- onOk: async contributionBounty => {
- await store.updateProposalDetail({ contributionBounty });
- message.success('Updated bounty');
- },
- });
- }
+ await store.approveProposal(false, false, reason);
+ message.info('Proposal changes requested');
};
private handlePaidMilestone = async () => {
diff --git a/admin/src/components/RFPDetail/index.tsx b/admin/src/components/RFPDetail/index.tsx
index d6c5c8df..9f1e7e16 100644
--- a/admin/src/components/RFPDetail/index.tsx
+++ b/admin/src/components/RFPDetail/index.tsx
@@ -2,13 +2,14 @@ import React from 'react';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Link } from 'react-router-dom';
-import { Row, Col, Collapse, Card, Button, Popconfirm, Spin } from 'antd';
+import { Row, Col, Collapse, Card, Button, Popconfirm, Spin, Alert } from 'antd';
import Exception from 'ant-design-pro/lib/Exception';
import Back from 'components/Back';
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 }>;
@@ -37,9 +38,11 @@ class RFPDetail extends React.Component {
);
- const pendingProposals = rfp.proposals.filter(p => p.status === PROPOSAL_STATUS.PENDING);
- const acceptedProposals = rfp.proposals.filter(p =>
- p.status === PROPOSAL_STATUS.LIVE || p.status === PROPOSAL_STATUS.APPROVED
+ const pendingProposals = rfp.proposals.filter(
+ p => p.status === PROPOSAL_STATUS.PENDING,
+ );
+ const acceptedProposals = rfp.proposals.filter(
+ p => p.status === PROPOSAL_STATUS.LIVE || p.status === PROPOSAL_STATUS.APPROVED,
);
return (
@@ -66,6 +69,20 @@ class RFPDetail extends React.Component {
{/* RIGHT SIDE */}
+ {rfp.ccr && (
+
+ This RFP has been generated from a CCR{' '}
+ here.
+
+ }
+ type="info"
+ showIcon
+ />
+ )}
+
{/* ACTIONS */}
@@ -90,10 +107,15 @@ class RFPDetail extends React.Component {
{renderDeetItem('id', rfp.id)}
{renderDeetItem('created', formatDateSeconds(rfp.dateCreated))}
{renderDeetItem('status', rfp.status)}
- {renderDeetItem('category', rfp.category)}
{renderDeetItem('matching', String(rfp.matching))}
- {renderDeetItem('bounty', `${rfp.bounty} ZEC`)}
- {renderDeetItem('dateCloses', rfp.dateCloses && formatDateSeconds(rfp.dateCloses))}
+ {renderDeetItem(
+ 'bounty',
+ rfp.isVersionTwo ? formatUsd(rfp.bounty) : `${rfp.bounty} ZEC`,
+ )}
+ {renderDeetItem(
+ 'dateCloses',
+ rfp.dateCloses && formatDateSeconds(rfp.dateCloses),
+ )}
{/* PROPOSALS */}
diff --git a/admin/src/components/RFPForm/index.tsx b/admin/src/components/RFPForm/index.tsx
index 0726a4a4..a82789f8 100644
--- a/admin/src/components/RFPForm/index.tsx
+++ b/admin/src/components/RFPForm/index.tsx
@@ -3,23 +3,10 @@ import moment from 'moment';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Link } from 'react-router-dom';
-import {
- Form,
- Input,
- Select,
- Icon,
- Button,
- message,
- Spin,
- Checkbox,
- Row,
- Col,
- DatePicker,
-} from 'antd';
+import { Form, Input, Select, Button, message, Spin, Row, Col, DatePicker } from 'antd';
import Exception from 'ant-design-pro/lib/Exception';
import { FormComponentProps } from 'antd/lib/form';
-import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types';
-import { CATEGORY_UI } from 'util/ui';
+import { RFP_STATUS, RFPArgs } from 'src/types';
import { typedKeys } from 'util/ts';
import { RFP_STATUSES, getStatusById } from 'util/statuses';
import Markdown from 'components/Markdown';
@@ -54,13 +41,14 @@ class RFPForm extends React.Component {
title: '',
brief: '',
content: '',
- category: '',
status: '',
matching: false,
bounty: undefined,
dateCloses: undefined,
};
const rfpId = this.getRFPId();
+ let isVersionTwo = true;
+
if (rfpId) {
if (!store.rfpsFetched) {
return ;
@@ -72,12 +60,12 @@ class RFPForm extends React.Component {
title: rfp.title,
brief: rfp.brief,
content: rfp.content,
- category: rfp.category,
status: rfp.status,
matching: rfp.matching,
bounty: rfp.bounty,
dateCloses: rfp.dateCloses || undefined,
};
+ isVersionTwo = rfp.isVersionTwo;
} else {
return ;
}
@@ -88,6 +76,10 @@ class RFPForm extends React.Component {
: defaults.dateCloses && moment(defaults.dateCloses * 1000);
const forceClosed = dateCloses && dateCloses.isBefore(moment.now());
+ const bountyMatchRule = isVersionTwo
+ ? { pattern: /^[^.]*$/, message: 'Cannot contain a decimal' }
+ : {};
+
return (
+ );
+ }
+
+ private handleInputChange = (
+ event: React.ChangeEvent,
+ ) => {
+ const { value, name } = event.currentTarget;
+ this.setState({ [name]: value } as any, () => {
+ this.props.updateForm(this.state);
+ });
+ };
+}
+
+export default CCRFlowBasics;
diff --git a/frontend/client/components/CCRFlow/CCRExplainer.less b/frontend/client/components/CCRFlow/CCRExplainer.less
new file mode 100644
index 00000000..8126dafc
--- /dev/null
+++ b/frontend/client/components/CCRFlow/CCRExplainer.less
@@ -0,0 +1,86 @@
+@import '~styles/variables.less';
+
+@small-query: ~'(max-width: 640px)';
+
+.CCRExplainer {
+ display: flex;
+ flex-direction: column;
+
+ &-header {
+ margin: 3rem auto 5rem;
+
+ &-title {
+ font-size: 2rem;
+ text-align: center;
+
+ }
+
+ &-subtitle {
+ font-size: 1.4rem;
+ margin-bottom: 0;
+ opacity: 0.7;
+ text-align: center;
+
+ @media @small-query {
+ font-size: 1.8rem;
+ }
+ }
+ }
+
+ &-create {
+ display: block;
+ width: 280px;
+ margin-top: 0.5rem;
+ font-size: 1.5rem;
+ height: 4.2rem;
+ }
+
+ &-actions {
+ margin: 6rem auto;
+ justify-content: center;
+ display: flex;
+ flex-direction: column;
+ }
+
+ &-items {
+ max-width: 1200px;
+ padding: 0 2rem;
+ margin: 0 auto;
+ display: flex;
+
+ @media @small-query {
+ flex-direction: column;
+ }
+
+ &-item {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin: 0 2rem;
+ flex-direction: column;
+
+ @media @small-query {
+ margin-bottom: 5rem;
+ }
+
+ &-text {
+ font-size: 1.1rem;
+ text-align: center;
+ margin-top: 1rem;
+
+ @media @small-query {
+ font-size: 1.5rem;
+ }
+ }
+
+ &-icon {
+ flex-shrink: 0;
+ width: 8rem;
+
+ @media @small-query {
+ width: 12rem;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/client/components/CCRFlow/CCRExplainer.tsx b/frontend/client/components/CCRFlow/CCRExplainer.tsx
new file mode 100644
index 00000000..338ec02e
--- /dev/null
+++ b/frontend/client/components/CCRFlow/CCRExplainer.tsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { withNamespaces, WithNamespaces } from 'react-i18next';
+import SubmitIcon from 'static/images/guide-submit.svg';
+import ReviewIcon from 'static/images/guide-review.svg';
+import './CCRExplainer.less';
+import * as ls from 'local-storage';
+import { Button, Checkbox, Icon } from 'antd';
+
+interface CreateProps {
+ startSteps: () => void;
+}
+
+type Props = WithNamespaces & CreateProps;
+
+const CCRExplainer: React.SFC = ({ startSteps }) => {
+ const items = [
+ {
+ text:
+ 'Anyone can create a request for improvements to the Zcash ecosystem. Approved requests are posted publicly to garner interest for proposals.',
+ icon: ,
+ },
+ {
+ text:
+ "The request is reviewed by the Zcash Foundation. \nYou'll be notified should the Zcash Foundation choose to make your request public.",
+ icon: ,
+ },
+ ];
+
+ return (
+
+
+
Creating a Request
+
+ We can't wait to get your request! Before starting, here's what you should
+ know...
+
+
+
+ {items.map((item, idx) => (
+
+
{item.icon}
+
{item.text}
+
+ ))}
+
+
+ ls.set('noExplainCCR', ev.target.checked)}>
+ Don't show this again
+
+ startSteps()}
+ >
+ Let's do this
+
+
+
+ );
+};
+
+export default withNamespaces()(CCRExplainer);
diff --git a/frontend/client/components/CCRFlow/CCRFinal.less b/frontend/client/components/CCRFlow/CCRFinal.less
new file mode 100644
index 00000000..281aa156
--- /dev/null
+++ b/frontend/client/components/CCRFlow/CCRFinal.less
@@ -0,0 +1,41 @@
+@import '~styles/variables.less';
+
+.CCRFinal {
+ max-width: 550px;
+ padding: 1rem;
+ margin: 3rem auto;
+
+ &-message {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-bottom: 2rem;
+
+ .anticon {
+ margin-right: 1rem;
+ font-size: 3.2rem;
+ }
+ &.is-error .anticon {
+ color: @error-color;
+ }
+ &.is-success .anticon {
+ color: @success-color;
+ }
+
+ &-text {
+ font-size: 1rem;
+ text-align: left;
+ }
+ }
+
+ &-contribute {
+ border: 1px solid rgba(0, 0, 0, 0.05);
+ padding: 1.5rem;
+ }
+
+ &-staked {
+ margin-top: 1rem;
+ font-size: 1.1rem;
+ text-align: center;
+ }
+}
diff --git a/frontend/client/components/CCRFlow/CCRFinal.tsx b/frontend/client/components/CCRFlow/CCRFinal.tsx
new file mode 100644
index 00000000..816d8bea
--- /dev/null
+++ b/frontend/client/components/CCRFlow/CCRFinal.tsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { Icon } from 'antd';
+import { Link } from 'react-router-dom';
+import Loader from 'components/Loader';
+import { ccrActions } from 'modules/ccr';
+import { AppState } from 'store/reducers';
+import './CCRFinal.less';
+
+interface OwnProps {
+ goBack(): void;
+}
+
+interface StateProps {
+ form: AppState['ccr']['form'];
+ submittedCCR: AppState['ccr']['submittedCCR'];
+ submitError: AppState['ccr']['submitError'];
+}
+
+interface DispatchProps {
+ submitCCR: typeof ccrActions['submitCCR'];
+}
+
+type Props = OwnProps & StateProps & DispatchProps;
+
+class CCRFinal extends React.Component {
+ componentDidMount() {
+ this.submit();
+ }
+
+ render() {
+ const { submittedCCR, submitError, goBack } = this.props;
+ const ready = submittedCCR;
+
+ let content;
+ if (submitError) {
+ content = (
+
+
+
+
+ Something went wrong during creation
+
+
{submitError}
+
Click here to go back to the form and try again.
+
+
+ );
+ } else if (ready) {
+ content = (
+ <>
+
+
+
+
+ Your request has been submitted! Check your{' '}
+ profile's pending tab to check its
+ status.
+
+
+ >
+ );
+ } else {
+ content = ;
+ }
+
+ return {content}
;
+ }
+
+ private submit = () => {
+ if (this.props.form) {
+ this.props.submitCCR(this.props.form);
+ }
+ };
+}
+
+export default connect(
+ (state: AppState) => ({
+ form: state.ccr.form,
+ submittedCCR: state.ccr.submittedCCR,
+ submitError: state.ccr.submitError,
+ }),
+ {
+ submitCCR: ccrActions.submitCCR,
+ },
+)(CCRFinal);
diff --git a/frontend/client/components/CCRFlow/CCRPreview.less b/frontend/client/components/CCRFlow/CCRPreview.less
new file mode 100644
index 00000000..cd6c719d
--- /dev/null
+++ b/frontend/client/components/CCRFlow/CCRPreview.less
@@ -0,0 +1,28 @@
+@import '~styles/variables.less';
+
+.CCRPreview {
+ &-preview {
+ // simulate non-fullscreen template margins
+ margin: @template-space-top @template-space-sides;
+ padding-bottom: 8rem;
+ }
+
+ &-banner {
+ width: 100vw;
+ position: relative;
+ left: 50%;
+ right: 50%;
+ margin: 0 -50vw 1rem;
+ text-align: center;
+
+ .ant-alert {
+ padding: 1rem;
+ }
+ }
+
+ &-loader {
+ flex: 1;
+ position: relative;
+ height: 14rem;
+ }
+}
diff --git a/frontend/client/components/CCRFlow/CCRPreview.tsx b/frontend/client/components/CCRFlow/CCRPreview.tsx
new file mode 100644
index 00000000..bd92ec85
--- /dev/null
+++ b/frontend/client/components/CCRFlow/CCRPreview.tsx
@@ -0,0 +1,139 @@
+import React from 'react';
+import { Alert } from 'antd';
+import { connect } from 'react-redux';
+import { Link } from 'react-router-dom';
+import Loader from 'components/Loader';
+import { RFPDetail } from 'components/RFP';
+import { AppState } from 'store/reducers';
+import { makeRfpPreviewFromCcrDraft } from 'modules/create/utils';
+import { CCRDraft, CCR, CCRSTATUS } from 'types';
+import { getCCR } from 'api/api';
+import './CCRPreview.less';
+
+interface StateProps {
+ form: CCRDraft;
+}
+
+interface OwnProps {
+ id?: number;
+}
+
+type Props = StateProps & OwnProps;
+
+interface State {
+ loading: boolean;
+ ccr?: CCR;
+ error?: string;
+}
+
+class CCRFlowPreview extends React.Component {
+ state: State = {
+ loading: false,
+ };
+
+ async componentWillMount() {
+ const { id } = this.props;
+
+ if (id) {
+ this.setState({ loading: true });
+ try {
+ const { data } = await getCCR(id);
+ this.setState({ ccr: data });
+ } catch (e) {
+ this.setState({ error: e.message || e.toString()})
+ }
+ this.setState({ loading: false });
+ }
+ }
+
+ render() {
+ const { ccr, loading, error } = this.state;
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ const { form } = this.props;
+ const previewData = ccr ? ccr : form;
+ const rfp = makeRfpPreviewFromCcrDraft(previewData);
+
+ // BANNER
+ const statusBanner = {
+ [CCRSTATUS.DRAFT]: {
+ blurb: <>This is a preview of your request. It has not yet been published.>,
+ type: 'warning',
+ },
+ [CCRSTATUS.PENDING]: {
+ blurb: (
+ <>Your request is being reviewed. You will get an email when it is complete.>
+ ),
+ type: 'warning',
+ },
+ [CCRSTATUS.APPROVED]: {
+ blurb: (
+ <>
+ Your request has been approved! It will be made live to the community sometime
+ soon.
+ >
+ ),
+ type: 'success',
+ },
+ [CCRSTATUS.REJECTED]: {
+ blurb: (
+ <>
+ Your request has changes requested. Visit your profile's pending tab for more
+ information.
+ >
+ ),
+ type: 'error',
+ },
+ [CCRSTATUS.LIVE]: {
+ blurb: (
+ <>
+ Your request has been approved and is live! You can find it on the{' '}
+ requests page.
+ >
+ ),
+ type: 'success',
+ },
+ } as any;
+
+ const banner = statusBanner[previewData.status];
+
+ return (
+
+ {banner && (
+
+ )}
+
+
+ null) as any}
+ />
+
+
+ );
+ }
+}
+
+export default connect(state => ({
+ form: state.ccr.form as CCRDraft,
+}))(CCRFlowPreview);
diff --git a/frontend/client/components/CCRFlow/CCRReview.less b/frontend/client/components/CCRFlow/CCRReview.less
new file mode 100644
index 00000000..60de2eb1
--- /dev/null
+++ b/frontend/client/components/CCRFlow/CCRReview.less
@@ -0,0 +1,60 @@
+@import '~styles/variables.less';
+
+.CCRReview {
+ &-section {
+ max-width: 980px;
+ margin: 0 auto;
+ }
+}
+
+.CCRReviewField {
+ display: flex;
+ flex-direction: row;
+
+ &-label {
+ width: 220px;
+ padding: 0 1.5rem 1rem 0;
+ font-size: 1.3rem;
+ opacity: 0.7;
+ text-align: right;
+
+ &-error {
+ color: @error-color;
+ opacity: 0.8;
+ font-size: 0.8rem;
+ }
+ }
+
+ &-content {
+ flex: 1;
+ font-size: 1.3rem;
+ padding: 0 0 1rem 1.5rem;
+ border-left: 1px solid #ddd;
+ word-break: break-word;
+
+ code {
+ font-size: 1rem;
+ }
+
+ &-empty {
+ font-size: 1.3rem;
+ opacity: 0.3;
+ letter-spacing: 0.1rem;
+ }
+
+ &-edit {
+ margin-bottom: 5rem;
+ padding: 0.5rem 1rem;
+ font-size: 1rem;
+ border: 1px solid @primary-color;
+ color: @primary-color;
+ opacity: 0.8;
+ border-radius: 2px;
+ cursor: pointer;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
+}
diff --git a/frontend/client/components/CCRFlow/CCRReview.tsx b/frontend/client/components/CCRFlow/CCRReview.tsx
new file mode 100644
index 00000000..959cadb3
--- /dev/null
+++ b/frontend/client/components/CCRFlow/CCRReview.tsx
@@ -0,0 +1,129 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { FIELD_NAME_MAP, getCCRErrors, KeyOfForm } from 'modules/ccr/utils';
+import Markdown from 'components/Markdown';
+import { AppState } from 'store/reducers';
+import { CCR_STEP } from './index';
+import { CCRDraft } from 'types';
+import { formatUsd } from 'utils/formatters'
+import './CCRReview.less';
+
+interface OwnProps {
+ setStep(step: CCR_STEP): void;
+}
+
+interface StateProps {
+ form: CCRDraft;
+}
+
+type Props = OwnProps & StateProps;
+
+interface Field {
+ key: KeyOfForm;
+ content: React.ReactNode;
+ error: string | Falsy;
+ isHide?: boolean;
+}
+
+interface Section {
+ step: CCR_STEP;
+ name: string;
+ fields: Field[];
+}
+
+class CCRReview extends React.Component {
+ render() {
+ const { form } = this.props;
+ const errors = getCCRErrors(this.props.form);
+ const sections: Section[] = [
+ {
+ step: CCR_STEP.BASICS,
+ name: 'Basics',
+ fields: [
+ {
+ key: 'title',
+ content: {form.title} ,
+ error: errors.title,
+ },
+ {
+ key: 'brief',
+ content: form.brief,
+ error: errors.brief,
+ },
+ {
+ key: 'target',
+ content: {formatUsd(form.target)}
,
+ error: errors.target,
+ },
+ ],
+ },
+
+ {
+ step: CCR_STEP.DETAILS,
+ name: 'Details',
+ fields: [
+ {
+ key: 'content',
+ content: ,
+ error: errors.content,
+ },
+ ],
+ },
+ ];
+
+ return (
+
+ {sections.map(s => (
+
+ {s.fields.map(
+ f =>
+ !f.isHide && (
+
+
+ {FIELD_NAME_MAP[f.key]}
+ {f.error && (
+
{f.error}
+ )}
+
+
+ {this.isEmpty(form[f.key]) ? (
+
N/A
+ ) : (
+ f.content
+ )}
+
+
+ ),
+ )}
+
+
+
+ this.setStep(s.step)}
+ >
+ Edit {s.name}
+
+
+
+
+ ))}
+
+ );
+ }
+
+ private setStep = (step: CCR_STEP) => {
+ this.props.setStep(step);
+ };
+
+ private isEmpty(value: any) {
+ if (typeof value === 'boolean') {
+ return false; // defined booleans are never empty
+ }
+ return !value || value.length === 0;
+ }
+}
+
+export default connect(state => ({
+ form: state.ccr.form as CCRDraft,
+}))(CCRReview);
diff --git a/frontend/client/components/CCRFlow/CCRSubmitWarningModal.less b/frontend/client/components/CCRFlow/CCRSubmitWarningModal.less
new file mode 100644
index 00000000..0663eb01
--- /dev/null
+++ b/frontend/client/components/CCRFlow/CCRSubmitWarningModal.less
@@ -0,0 +1,13 @@
+.CCRSubmitWarningModal {
+ .ant-alert {
+ margin-bottom: 1rem;
+
+ ul {
+ padding-top: 0.25rem;
+ }
+
+ p:last-child {
+ margin-bottom: 0;
+ }
+ }
+}
diff --git a/frontend/client/components/CCRFlow/CCRSubmitWarningModal.tsx b/frontend/client/components/CCRFlow/CCRSubmitWarningModal.tsx
new file mode 100644
index 00000000..aac20b0a
--- /dev/null
+++ b/frontend/client/components/CCRFlow/CCRSubmitWarningModal.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { Modal } from 'antd';
+import './CCRSubmitWarningModal.less';
+
+interface Props {
+ isVisible: boolean;
+ handleClose(): void;
+ handleSubmit(): void;
+}
+
+export default class CCRSubmitWarningModal extends React.Component {
+ render() {
+ const { isVisible, handleClose, handleSubmit } = this.props;
+
+ return (
+ Confirm submission>}
+ visible={isVisible}
+ okText={'Submit'}
+ cancelText="Never mind"
+ onOk={handleSubmit}
+ onCancel={handleClose}
+ >
+
+
+ Are you sure you're ready to submit your request for approval? Once you’ve
+ done so, you won't be able to edit it.
+
+
+
+ );
+ }
+}
diff --git a/frontend/client/components/CCRFlow/Details.tsx b/frontend/client/components/CCRFlow/Details.tsx
new file mode 100644
index 00000000..2234753a
--- /dev/null
+++ b/frontend/client/components/CCRFlow/Details.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { Form, Alert } from 'antd';
+import MarkdownEditor from 'components/MarkdownEditor';
+import { CCRDraft } from 'types';
+import { getCCRErrors } from 'modules/ccr/utils';
+
+interface State {
+ content: string;
+}
+
+interface Props {
+ initialState?: Partial;
+ updateForm(form: Partial): void;
+}
+
+export default class CreateFlowTeam extends React.Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ content: '',
+ ...(props.initialState || {}),
+ };
+ }
+
+ render() {
+ const errors = getCCRErrors(this.state, true);
+
+ return (
+
+
+ {errors.content && }
+
+ );
+ }
+
+ private handleChange = (markdown: string) => {
+ if (markdown !== this.state.content) {
+ this.setState({ content: markdown }, () => {
+ this.props.updateForm(this.state);
+ });
+ }
+ };
+}
diff --git a/frontend/client/components/CCRFlow/index.less b/frontend/client/components/CCRFlow/index.less
new file mode 100644
index 00000000..0b772d50
--- /dev/null
+++ b/frontend/client/components/CCRFlow/index.less
@@ -0,0 +1,133 @@
+@import '~styles/variables.less';
+
+@keyframes draft-notification-popup {
+ from {
+ opacity: 0;
+ transform: translateY(0.5rem);
+ }
+ to {
+ opacity: 0.3;
+ transform: translateY(0);
+ }
+}
+
+.CCRFlow {
+ padding: 2.5rem 2rem 8rem;
+
+ &-header {
+ max-width: 860px;
+ padding: 0 1rem;
+ margin: 1rem auto 3rem;
+
+ &-title {
+ font-size: 2rem;
+ margin: 3rem auto 0.5rem;
+ text-align: center;
+ }
+
+ &-subtitle {
+ font-size: 1.4rem;
+ margin-bottom: 0;
+ opacity: 0.7;
+ text-align: center;
+ }
+ }
+
+ &-content {
+ padding-bottom: 2rem;
+ }
+
+ &-footer {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 7rem;
+ padding: 0 1rem;
+ background: #fff;
+ border-top: 1px solid #eee;
+ z-index: 1000;
+
+ &-help {
+ font-size: 1rem;
+ margin-right: 1rem;
+ max-width: 380px;
+ text-align: right;
+ }
+
+ &-button {
+ display: block;
+ height: 4rem;
+ line-height: 4rem;
+ width: 100%;
+ max-width: 12rem;
+ padding: 0;
+ margin: 0 0.5rem;
+ font-size: 1.4rem;
+ border: 1px solid #999;
+ color: #777;
+ background: transparent;
+ border-radius: 4px;
+ cursor: pointer;
+ transition-property: background, color, border-color, opacity;
+ transition-duration: 100ms;
+ transition-timing-function: ease;
+
+ &.is-primary {
+ background: @primary-color;
+ color: #fff;
+ border: none;
+ }
+
+ &:hover {
+ opacity: 0.8;
+ }
+
+ &:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+ }
+
+ .anticon {
+ font-size: 1.2rem;
+ margin-left: 0.2rem;
+ }
+ }
+
+ &-example {
+ position: absolute;
+ bottom: 10px;
+ right: 10px;
+ opacity: 0.08;
+ font-size: 1rem;
+
+ &:hover {
+ opacity: 0.5;
+ }
+ }
+ }
+
+ &-draftNotification {
+ position: fixed;
+ bottom: 8rem;
+ right: 1rem;
+ text-align: right;
+ font-size: 0.8rem;
+ opacity: 0.3;
+ animation: draft-notification-popup 120ms ease 1;
+
+ &.is-error {
+ color: @error-color;
+ }
+ }
+
+ &-loading {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ }
+}
diff --git a/frontend/client/components/CCRFlow/index.tsx b/frontend/client/components/CCRFlow/index.tsx
new file mode 100644
index 00000000..3e4fe112
--- /dev/null
+++ b/frontend/client/components/CCRFlow/index.tsx
@@ -0,0 +1,320 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { compose } from 'recompose';
+import { Steps, Icon } from 'antd';
+import qs from 'query-string';
+import { withRouter, RouteComponentProps } from 'react-router';
+import { History } from 'history';
+import { debounce } from 'underscore';
+import Basics from './Basics';
+import Details from './Details';
+import Review from './CCRReview';
+import Preview from './CCRPreview';
+import Final from './CCRFinal';
+import CCRSubmitWarningModal from './CCRSubmitWarningModal';
+import { ccrActions } from 'modules/ccr';
+import { CCRDraft } from 'types';
+import { getCCRErrors } from 'modules/ccr/utils';
+
+import { AppState } from 'store/reducers';
+
+import './index.less';
+import ls from 'local-storage';
+import Explainer from './CCRExplainer';
+
+export enum CCR_STEP {
+ BASICS = 'BASICS',
+ DETAILS = 'DETAILS',
+ REVIEW = 'REVIEW',
+}
+
+const STEP_ORDER = [CCR_STEP.BASICS, CCR_STEP.DETAILS, CCR_STEP.REVIEW];
+
+interface StepInfo {
+ short: string;
+ title: React.ReactNode;
+ subtitle: React.ReactNode;
+ help: React.ReactNode;
+ component: any;
+}
+
+interface LSExplainer {
+ noExplainCCR: boolean;
+}
+
+const STEP_INFO: { [key in CCR_STEP]: StepInfo } = {
+ [CCR_STEP.BASICS]: {
+ short: 'Basics',
+ title: 'Let’s start with the basics',
+ subtitle: 'Don’t worry, you can come back and change things before publishing',
+ help:
+ 'You don’t have to fill out everything at once right now, you can come back later.',
+ component: Basics,
+ },
+ [CCR_STEP.DETAILS]: {
+ short: 'Details',
+ title: 'Dive into the details',
+ subtitle: 'Here’s your chance to lay out the full request, in all its glory',
+ help: `Make sure people know what you’re requesting, why it's needed, how they can accomplish it`,
+ component: Details,
+ },
+ [CCR_STEP.REVIEW]: {
+ short: 'Review',
+ title: 'Review your request',
+ subtitle: 'Feel free to edit any field that doesn’t look right',
+ help: 'You’ll get a chance to preview your request next before you publish it',
+ component: Review,
+ },
+};
+
+interface StateProps {
+ form: AppState['ccr']['form'];
+ isSavingDraft: AppState['ccr']['isSavingDraft'];
+ hasSavedDraft: AppState['ccr']['hasSavedDraft'];
+ saveDraftError: AppState['ccr']['saveDraftError'];
+}
+
+interface DispatchProps {
+ updateCCRForm: typeof ccrActions['updateCCRForm'];
+}
+
+type Props = StateProps & DispatchProps & RouteComponentProps;
+
+interface State {
+ step: CCR_STEP;
+ isPreviewing: boolean;
+ isShowingSubmitWarning: boolean;
+ isSubmitting: boolean;
+ isExample: boolean;
+ isExplaining: boolean;
+}
+
+class CCRFlow extends React.Component {
+ private historyUnlisten: () => void;
+ private debouncedUpdateForm: (form: Partial) => void;
+
+ constructor(props: Props) {
+ super(props);
+ const searchValues = qs.parse(props.location.search);
+ const queryStep = searchValues.step ? searchValues.step.toUpperCase() : null;
+ const step =
+ queryStep && CCR_STEP[queryStep]
+ ? (CCR_STEP[queryStep] as CCR_STEP)
+ : CCR_STEP.BASICS;
+ const noExplain = !!ls('noExplainCCR');
+
+ this.state = {
+ step,
+ isPreviewing: false,
+ isSubmitting: false,
+ isExample: false,
+ isShowingSubmitWarning: false,
+ isExplaining: !noExplain,
+ };
+ this.debouncedUpdateForm = debounce(this.updateForm, 800);
+ this.historyUnlisten = this.props.history.listen(this.handlePop);
+ }
+
+ componentWillUnmount() {
+ if (this.historyUnlisten) {
+ this.historyUnlisten();
+ }
+ }
+
+ render() {
+ const { isSavingDraft, saveDraftError } = this.props;
+ const {
+ step,
+ isPreviewing,
+ isSubmitting,
+ isShowingSubmitWarning,
+ isExplaining,
+ } = this.state;
+
+ const info = STEP_INFO[step];
+ const currentIndex = STEP_ORDER.indexOf(step);
+ const isLastStep = STEP_ORDER.indexOf(step) === STEP_ORDER.length - 1;
+ const StepComponent = info.component;
+
+ let content;
+ let showFooter = true;
+ if (isSubmitting) {
+ content = ;
+ showFooter = false;
+ } else if (isPreviewing) {
+ content = ;
+ } else if (isExplaining) {
+ content = ;
+ showFooter = false;
+ } else {
+ // Antd definitions are missing `onClick` for step, even though it works.
+ const Step = Steps.Step as any;
+ content = (
+
+
+
+ {STEP_ORDER.slice(0, 3).map(s => (
+ this.setStep(s)}
+ style={{ cursor: 'pointer' }}
+ />
+ ))}
+
+
{info.title}
+
{info.subtitle}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {content}
+ {showFooter && (
+
+ {isLastStep ? (
+ <>
+
+ {isPreviewing ? 'Back to Edit' : 'Preview'}
+
+
+ Submit
+
+ >
+ ) : (
+ <>
+
{info.help}
+
+ Continue
+
+ >
+ )}
+
+ )}
+ {isSavingDraft ? (
+
Saving draft...
+ ) : (
+ saveDraftError && (
+
+ Failed to save draft!
+
+ {saveDraftError}
+
+ )
+ )}
+
+
+ );
+ }
+
+ private updateForm = (form: Partial) => {
+ this.props.updateCCRForm(form);
+ };
+
+ private setStep = (step: CCR_STEP, skipHistory?: boolean) => {
+ this.setState({ step });
+ if (!skipHistory) {
+ const { history, location } = this.props;
+ history.push(`${location.pathname}?step=${step.toLowerCase()}`);
+ }
+ };
+
+ private nextStep = () => {
+ const idx = STEP_ORDER.indexOf(this.state.step);
+ if (idx !== STEP_ORDER.length - 1) {
+ this.setStep(STEP_ORDER[idx + 1]);
+ }
+ };
+
+ private togglePreview = () => {
+ this.setState({ isPreviewing: !this.state.isPreviewing });
+ };
+
+ private startSubmit = () => {
+ this.setState({
+ isSubmitting: true,
+ isShowingSubmitWarning: false,
+ });
+ };
+
+ private checkFormErrors = () => {
+ if (!this.props.form) {
+ return true;
+ }
+ const errors = getCCRErrors(this.props.form);
+ return !!Object.keys(errors).length;
+ };
+
+ private handlePop: History.LocationListener = (location, action) => {
+ if (action === 'POP') {
+ this.setState({ isPreviewing: false });
+ const searchValues = qs.parse(location.search);
+ const urlStep = searchValues.step && searchValues.step.toUpperCase();
+ if (urlStep && CCR_STEP[urlStep]) {
+ this.setStep(urlStep as CCR_STEP, true);
+ } else {
+ this.setStep(CCR_STEP.BASICS, true);
+ }
+ }
+ };
+
+ private openPublishWarning = () => {
+ this.setState({ isShowingSubmitWarning: true });
+ };
+
+ private closePublishWarning = () => {
+ this.setState({ isShowingSubmitWarning: false });
+ };
+
+ private cancelSubmit = () => {
+ this.setState({ isSubmitting: false });
+ };
+
+ private startSteps = () => {
+ this.setState({ step: CCR_STEP.BASICS, isExplaining: false });
+ };
+}
+
+const withConnect = connect(
+ (state: AppState) => ({
+ form: state.ccr.form,
+ isSavingDraft: state.ccr.isSavingDraft,
+ hasSavedDraft: state.ccr.hasSavedDraft,
+ saveDraftError: state.ccr.saveDraftError,
+ }),
+ {
+ updateCCRForm: ccrActions.updateCCRForm,
+ },
+);
+
+export default compose(
+ withRouter,
+ withConnect,
+)(CCRFlow);
diff --git a/frontend/client/components/Card/index.tsx b/frontend/client/components/Card/index.tsx
index 193578e1..087f6678 100644
--- a/frontend/client/components/Card/index.tsx
+++ b/frontend/client/components/Card/index.tsx
@@ -1,27 +1,22 @@
import React from 'react';
import moment from 'moment';
import classnames from 'classnames';
-import { Icon } from 'antd';
-import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants';
import './index.less';
import { Link } from 'react-router-dom';
+import { Proposal } from 'types';
+import Like from 'components/Like';
interface CardInfoProps {
- category: PROPOSAL_CATEGORY;
+ proposal: Proposal;
time: number;
}
-export const CardInfo: React.SFC = ({ category, time }) => (
+export const CardInfo: React.SFC = ({ proposal, time }) => (
-
- {CATEGORY_UI[category].label}
-
-
- {moment(time).fromNow()}
+
+
+
{moment(time).fromNow()}
);
@@ -44,7 +39,7 @@ export class Card extends React.Component
{
{children}
- )
+ );
}
}
diff --git a/frontend/client/components/Comment/index.tsx b/frontend/client/components/Comment/index.tsx
index a2ada52f..43e97e3c 100644
--- a/frontend/client/components/Comment/index.tsx
+++ b/frontend/client/components/Comment/index.tsx
@@ -10,6 +10,7 @@ import { postProposalComment, reportProposalComment } from 'modules/proposals/ac
import { getIsSignedIn } from 'modules/auth/selectors';
import { Comment as IComment } from 'types';
import { AppState } from 'store/reducers';
+import Like from 'components/Like';
import './style.less';
interface OwnProps {
@@ -20,6 +21,7 @@ interface StateProps {
isPostCommentPending: AppState['proposal']['isPostCommentPending'];
postCommentError: AppState['proposal']['postCommentError'];
isSignedIn: ReturnType;
+ detail: AppState['proposal']['detail'];
}
interface DispatchProps {
@@ -71,19 +73,23 @@ class Comment extends React.Component {
- {isSignedIn && (
-
+
- )}
+ )}
+ {isSignedIn &&
+ !comment.hidden &&
+ !comment.reported && (
+
+ Report
+
+ )}
+
{(comment.replies.length || isReplying) && (
@@ -143,6 +149,7 @@ const ConnectedComment = connect
(
isPostCommentPending: state.proposal.isPostCommentPending,
postCommentError: state.proposal.postCommentError,
isSignedIn: getIsSignedIn(state),
+ detail: state.proposal.detail,
}),
{
postProposalComment,
diff --git a/frontend/client/components/Comment/style.less b/frontend/client/components/Comment/style.less
index 09928863..d055ebb7 100644
--- a/frontend/client/components/Comment/style.less
+++ b/frontend/client/components/Comment/style.less
@@ -48,6 +48,7 @@
&-controls {
display: flex;
margin-left: -0.5rem;
+ align-items: center;
&-button {
font-size: 0.65rem;
diff --git a/frontend/client/components/CopyInput.tsx b/frontend/client/components/CopyInput.tsx
index 50a1cd50..3611b06d 100644
--- a/frontend/client/components/CopyInput.tsx
+++ b/frontend/client/components/CopyInput.tsx
@@ -3,6 +3,8 @@ import { Button, Form, Input, message } from 'antd';
import classnames from 'classnames';
import CopyToClipboard from 'react-copy-to-clipboard';
+import './ContributionModal/PaymentInfo.less';
+
interface CopyInputProps {
label: string;
value: string | undefined;
diff --git a/frontend/client/components/CreateFlow/Basics.tsx b/frontend/client/components/CreateFlow/Basics.tsx
index ed2b4230..e6b2280b 100644
--- a/frontend/client/components/CreateFlow/Basics.tsx
+++ b/frontend/client/components/CreateFlow/Basics.tsx
@@ -1,12 +1,9 @@
import React from 'react';
import { connect } from 'react-redux';
-import { Input, Form, Icon, Select, Alert, Popconfirm, message, Radio } from 'antd';
-import { SelectValue } from 'antd/lib/select';
+import { Input, Form, Alert, Popconfirm, message, Radio } from 'antd';
import { RadioChangeEvent } from 'antd/lib/radio';
-import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants';
import { ProposalDraft, RFP } from 'types';
import { getCreateErrors } from 'modules/create/utils';
-import { typedKeys } from 'utils/ts';
import { Link } from 'react-router-dom';
import { unlinkProposalRFP } from 'modules/create/actions';
import { AppState } from 'store/reducers';
@@ -31,7 +28,6 @@ type Props = OwnProps & StateProps & DispatchProps;
interface State extends Partial {
title: string;
brief: string;
- category?: PROPOSAL_CATEGORY;
target: string;
rfp?: RFP;
}
@@ -42,7 +38,6 @@ class CreateFlowBasics extends React.Component {
this.state = {
title: '',
brief: '',
- category: undefined,
target: '',
...(props.initialState || {}),
};
@@ -64,7 +59,10 @@ class CreateFlowBasics extends React.Component {
render() {
const { isUnlinkingProposalRFP } = this.props;
- const { title, brief, category, target, rfp, rfpOptIn } = this.state;
+ const { title, brief, target, rfp, rfpOptIn } = this.state;
+ if (rfp && rfp.bounty && (target === null || target === '0')) {
+ this.setState({ target: rfp.bounty.toString() });
+ }
const errors = getCreateErrors(this.state, true);
// Don't show target error at zero since it defaults to that
@@ -73,9 +71,6 @@ class CreateFlowBasics extends React.Component {
errors.target = undefined;
}
- const rfpOptInRequired =
- rfp && (rfp.matching || (rfp.bounty && parseFloat(rfp.bounty.toString()) > 0));
-
return (
{rfp && (
@@ -104,41 +99,29 @@ class CreateFlowBasics extends React.Component {
/>
)}
- {rfpOptInRequired && (
-
-
- This RFP offers either a bounty or matching. This will require ZFGrants
- to fulfill{' '}
-
- KYC
- {' '}
- due dilligence. In the event your proposal is successful, you will need
- to provide identifying information to ZFGrants.
-
-
- Yes , I am willing to provide KYC information
-
-
- No , I do not wish to provide KYC information and understand I
- will not receive any matching or bounty funds from ZFGrants
-
-
-
- >
- }
- />
- )}
+
+
+ In the event your proposal is accepted with funding, you will need to
+ provide identifying information to the Zcash Foundation.
+
+
+ Yes , I am willing to provide KYC information
+
+
+ No , I do not wish to provide KYC information and understand my
+ proposal may still be posted on ZF Grants, but I will not be eligible
+ to funding from the Zcash Foundation.
+
+
+
+ >
+ }
+ />
{
/>
-
-
- {typedKeys(PROPOSAL_CATEGORY).map(c => (
-
- {' '}
- {CATEGORY_UI[c].label}
-
- ))}
-
-
-
{
type="number"
value={target}
onChange={this.handleInputChange}
- addonAfter="ZEC"
+ addonBefore="$"
maxLength={16}
/>
@@ -219,12 +186,6 @@ class CreateFlowBasics extends React.Component {
});
};
- private handleCategoryChange = (value: SelectValue) => {
- this.setState({ category: value as PROPOSAL_CATEGORY }, () => {
- this.props.updateForm(this.state);
- });
- };
-
private handleRfpOptIn = (e: RadioChangeEvent) => {
this.setState({ rfpOptIn: e.target.value }, () => {
this.props.updateForm(this.state);
diff --git a/frontend/client/components/CreateFlow/Details.tsx b/frontend/client/components/CreateFlow/Details.tsx
index 070e504b..097f876a 100644
--- a/frontend/client/components/CreateFlow/Details.tsx
+++ b/frontend/client/components/CreateFlow/Details.tsx
@@ -30,7 +30,7 @@ export default class CreateFlowTeam extends React.Component {
{errors.content && }
diff --git a/frontend/client/components/CreateFlow/Explainer.less b/frontend/client/components/CreateFlow/Explainer.less
new file mode 100644
index 00000000..31dbef27
--- /dev/null
+++ b/frontend/client/components/CreateFlow/Explainer.less
@@ -0,0 +1,86 @@
+@import '~styles/variables.less';
+
+@small-query: ~'(max-width: 640px)';
+
+.Explainer {
+ display: flex;
+ flex-direction: column;
+
+ &-header {
+ margin: 3rem auto 5rem;
+
+ &-title {
+ font-size: 2rem;
+ text-align: center;
+
+ }
+
+ &-subtitle {
+ font-size: 1.4rem;
+ margin-bottom: 0;
+ opacity: 0.7;
+ text-align: center;
+
+ @media @small-query {
+ font-size: 1.8rem;
+ }
+ }
+ }
+
+ &-create {
+ display: block;
+ width: 280px;
+ margin-top: 0.5rem;
+ font-size: 1.5rem;
+ height: 4.2rem;
+ }
+
+ &-actions {
+ margin: 6rem auto;
+ justify-content: center;
+ display: flex;
+ flex-direction: column;
+ }
+
+ &-items {
+ max-width: 1200px;
+ padding: 0 2rem;
+ margin: 0 auto;
+ display: flex;
+
+ @media @small-query {
+ flex-direction: column;
+ }
+
+ &-item {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin: 0 2rem;
+ flex-direction: column;
+
+ @media @small-query {
+ margin-bottom: 5rem;
+ }
+
+ &-text {
+ font-size: 1.1rem;
+ text-align: center;
+ margin-top: 1rem;
+
+ @media @small-query {
+ font-size: 1.5rem;
+ }
+ }
+
+ &-icon {
+ flex-shrink: 0;
+ width: 8rem;
+
+ @media @small-query {
+ width: 12rem;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/client/components/CreateFlow/Explainer.tsx b/frontend/client/components/CreateFlow/Explainer.tsx
new file mode 100644
index 00000000..b793c714
--- /dev/null
+++ b/frontend/client/components/CreateFlow/Explainer.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import { withNamespaces, WithNamespaces } from 'react-i18next';
+import SubmitIcon from 'static/images/guide-submit.svg';
+import ReviewIcon from 'static/images/guide-review.svg';
+import CommunityIcon from 'static/images/guide-community.svg';
+import './Explainer.less';
+import * as ls from 'local-storage';
+import { Button, Checkbox, Icon } from 'antd';
+
+interface CreateProps {
+ startSteps: () => void;
+}
+
+type Props = WithNamespaces & CreateProps;
+
+const Explainer: React.SFC = ({ t, startSteps }) => {
+ const items = [
+ {
+ text: t('home.guide.submit'),
+ icon: ,
+ },
+ {
+ text: t('home.guide.review'),
+ icon: ,
+ },
+ {
+ text: t('home.guide.community'),
+ icon: ,
+ },
+ ];
+
+ return (
+
+
+
Creating a Proposal
+
+ We can't wait to get your request! Before starting, here's what you should
+ know...
+
+
+
+ {items.map((item, idx) => (
+
+
{item.icon}
+
{item.text}
+
+ ))}
+
+
+ ls.set('noExplain', ev.target.checked)}>
+ Don't show this again
+
+ startSteps()}
+ >
+ Let's do this
+
+
+
+ );
+};
+
+export default withNamespaces()(Explainer);
diff --git a/frontend/client/components/CreateFlow/Final.tsx b/frontend/client/components/CreateFlow/Final.tsx
index 9d5ad601..fb09182c 100644
--- a/frontend/client/components/CreateFlow/Final.tsx
+++ b/frontend/client/components/CreateFlow/Final.tsx
@@ -2,14 +2,10 @@ import React from 'react';
import { connect } from 'react-redux';
import { Icon } from 'antd';
import { Link } from 'react-router-dom';
-import Result from 'ant-design-pro/lib/Result';
import Loader from 'components/Loader';
import { createActions } from 'modules/create';
import { AppState } from 'store/reducers';
-import { getProposalStakingContribution } from 'api/api';
import './Final.less';
-import PaymentInfo from 'components/ContributionModal/PaymentInfo';
-import { ContributionWithAddresses } from 'types';
interface OwnProps {
goBack(): void;
@@ -27,34 +23,15 @@ interface DispatchProps {
type Props = OwnProps & StateProps & DispatchProps;
-const STATE = {
- contribution: null as null | ContributionWithAddresses,
- contributionError: null as null | Error,
-};
-
-type State = typeof STATE;
-
-class CreateFinal extends React.Component {
- state = STATE;
+class CreateFinal extends React.Component {
componentDidMount() {
this.submit();
}
- componentDidUpdate(prev: Props) {
- const { submittedProposal } = this.props;
- if (!prev.submittedProposal && submittedProposal) {
- if (!submittedProposal.isStaked) {
- this.getStakingContribution();
- }
- }
- }
-
render() {
const { submittedProposal, submitError, goBack } = this.props;
- const { contribution, contributionError } = this.state;
- const ready = submittedProposal && (submittedProposal.isStaked || contribution);
- const staked = submittedProposal && submittedProposal.isStaked;
+ const ready = submittedProposal;
let content;
if (submitError) {
@@ -75,67 +52,14 @@ class CreateFinal extends React.Component {
<>
- {staked && (
-
- Your proposal has been staked and submitted! Check your{' '}
- profile's pending proposals tab{' '}
- to check its status.
-
- )}
- {!staked && (
-
- Your proposal has been submitted! Please send the staking contribution of{' '}
- {contribution && contribution.amount} ZEC using the instructions
- below.
-
- )}
+
+ Your proposal has been submitted! Check your{' '}
+ profile's pending tab to check its
+ status.
+
- {!staked && (
- <>
-
-
-
- If you cannot send the payment now, you may bring up these
- instructions again by visiting your{' '}
- profile's funded tab.
-
-
- Once your payment has been sent and processed with 6
- confirmations, you will receive an email. Visit your{' '}
-
- profile's pending proposals tab
- {' '}
- at any time to check its status.
-
- >
- }
- contribution={contribution}
- />
-
-
- I'm finished, take me to{' '}
- my pending proposals!
-
- >
- )}
>
);
- } else if (contributionError) {
- content = (
-
- We were unable to get your staking contribution started. You can finish
- staking from your profile, please try
- again from there soon.
- >
- }
- />
- );
} else {
content = ;
}
@@ -148,18 +72,6 @@ class CreateFinal extends React.Component {
this.props.submitProposal(this.props.form);
}
};
-
- private getStakingContribution = async () => {
- const { submittedProposal } = this.props;
- if (submittedProposal) {
- try {
- const res = await getProposalStakingContribution(submittedProposal.proposalId);
- this.setState({ contribution: res.data });
- } catch (err) {
- this.setState({ contributionError: err });
- }
- }
- };
}
export default connect(
diff --git a/frontend/client/components/CreateFlow/Milestones.tsx b/frontend/client/components/CreateFlow/Milestones.tsx
index 2e060621..0bb0ac76 100644
--- a/frontend/client/components/CreateFlow/Milestones.tsx
+++ b/frontend/client/components/CreateFlow/Milestones.tsx
@@ -1,6 +1,5 @@
import React from 'react';
-import { Form, Input, DatePicker, Card, Icon, Alert, Checkbox, Button } from 'antd';
-import moment from 'moment';
+import { Form, Input, Card, Icon, Alert, Checkbox, Button, Tooltip } from 'antd';
import { ProposalDraft, CreateMilestone } from 'types';
import { getCreateErrors } from 'modules/create/utils';
@@ -18,7 +17,7 @@ const DEFAULT_STATE: State = {
{
title: '',
content: '',
- dateEstimated: moment().unix(),
+ daysEstimated: '',
payoutPercent: '',
immediatePayout: false,
},
@@ -78,11 +77,6 @@ export default class CreateFlowMilestones extends React.Component
milestone={milestone}
index={idx}
error={errors.milestones && errors.milestones[idx]}
- previousMilestoneDateEstimate={
- milestones[idx - 1] && milestones[idx - 1].dateEstimated
- ? moment(milestones[idx - 1].dateEstimated * 1000)
- : undefined
- }
onChange={this.handleMilestoneChange}
onRemove={this.removeMilestone}
/>
@@ -101,7 +95,7 @@ export default class CreateFlowMilestones extends React.Component
interface MilestoneFieldsProps {
index: number;
milestone: CreateMilestone;
- previousMilestoneDateEstimate: moment.Moment | undefined;
+ // previousMilestoneDateEstimate: moment.Moment | undefined;
error: Falsy | string;
onChange(index: number, milestone: CreateMilestone): void;
onRemove(index: number): void;
@@ -113,7 +107,6 @@ const MilestoneFields = ({
error,
onChange,
onRemove,
- previousMilestoneDateEstimate,
}: MilestoneFieldsProps) => (
@@ -151,37 +144,27 @@ const MilestoneFields = ({
maxLength={255}
/>
+ {index > 0 && (
+
+ (Note: This number represents the number of days past the previous milestone day
+ estimate)
+
+ )}
-
- onChange(index, { ...milestone, dateEstimated: time.startOf('month').unix() })
- }
+ {
- if (!previousMilestoneDateEstimate) {
- return current
- ? current <
- moment()
- .subtract(1, 'month')
- .endOf('month')
- : false;
- } else {
- return current
- ? current <
- moment()
- .subtract(1, 'month')
- .endOf('month') || current < previousMilestoneDateEstimate
- : false;
- }
+ placeholder="Estimated days to complete"
+ onChange={ev => {
+ return onChange(index, {
+ ...milestone,
+ daysEstimated: ev.currentTarget.value,
+ });
}}
+ addonAfter="days"
+ style={{ flex: 1, marginRight: '0.5rem' }}
+ maxLength={6}
/>
Payout Immediately
+
+
+
)}
diff --git a/frontend/client/components/CreateFlow/Payment.tsx b/frontend/client/components/CreateFlow/Payment.tsx
index 18bdfc3c..ff28de40 100644
--- a/frontend/client/components/CreateFlow/Payment.tsx
+++ b/frontend/client/components/CreateFlow/Payment.tsx
@@ -1,14 +1,12 @@
import React from 'react';
-import { Input, Form, Radio } from 'antd';
-import { RadioChangeEvent } from 'antd/lib/radio';
+import { Input, Form } from 'antd';
import { ProposalDraft } from 'types';
import { getCreateErrors } from 'modules/create/utils';
-import { ONE_DAY } from 'utils/time';
import { DONATION } from 'utils/constants';
interface State {
payoutAddress: string;
- deadlineDuration: number;
+ tipJarAddress: string;
}
interface Props {
@@ -21,19 +19,24 @@ export default class CreateFlowPayment extends React.Component {
super(props);
this.state = {
payoutAddress: '',
- deadlineDuration: ONE_DAY * 60,
+ tipJarAddress: '',
...(props.initialState || {}),
};
}
render() {
- const { payoutAddress, deadlineDuration } = this.state;
+ const { payoutAddress, tipJarAddress } = this.state;
const errors = getCreateErrors(this.state, true);
const payoutHelp =
errors.payoutAddress ||
`
This must be a Sapling Z address
`;
+ const tipJarHelp =
+ errors.tipJarAddress ||
+ `
+ Allows your proposal to receive tips. Must be a Sapling Z address
+ `;
return (
@@ -49,39 +52,30 @@ export default class CreateFlowPayment extends React.Component {
placeholder={DONATION.ZCASH_SAPLING}
type="text"
value={payoutAddress}
- onChange={this.handleInputChange}
+ onChange={this.handlePaymentInputChange}
/>
-
-
+
- {deadlineDuration === 300 && (
-
- 5 minutes
-
- )}
-
- 30 Days
-
-
- 60 Days
-
-
- 90 Days
-
-
+ name="tipJarAddress"
+ placeholder={DONATION.ZCASH_SAPLING}
+ type="text"
+ value={tipJarAddress}
+ onChange={this.handleTippingInputChange}
+ />
);
}
- private handleInputChange = (
+ private handlePaymentInputChange = (
event: React.ChangeEvent,
) => {
const { value, name } = event.currentTarget;
@@ -90,9 +84,11 @@ export default class CreateFlowPayment extends React.Component {
});
};
- private handleRadioChange = (event: RadioChangeEvent) => {
- const { value, name } = event.target;
- this.setState({ [name as string]: value } as any, () => {
+ private handleTippingInputChange = (
+ event: React.ChangeEvent,
+ ) => {
+ const { value, name } = event.currentTarget;
+ this.setState({ [name]: value } as any, () => {
this.props.updateForm(this.state);
});
};
diff --git a/frontend/client/components/CreateFlow/Review.tsx b/frontend/client/components/CreateFlow/Review.tsx
index defe4417..b0e36a73 100644
--- a/frontend/client/components/CreateFlow/Review.tsx
+++ b/frontend/client/components/CreateFlow/Review.tsx
@@ -1,14 +1,13 @@
import React from 'react';
import { connect } from 'react-redux';
-import { Icon, Timeline } from 'antd';
-import moment from 'moment';
+import { Timeline } from 'antd';
import { getCreateErrors, KeyOfForm, FIELD_NAME_MAP } from 'modules/create/utils';
import Markdown from 'components/Markdown';
import UserAvatar from 'components/UserAvatar';
import { AppState } from 'store/reducers';
import { CREATE_STEP } from './index';
-import { CATEGORY_UI, PROPOSAL_CATEGORY } from 'api/constants';
import { ProposalDraft } from 'types';
+import { formatUsd } from 'utils/formatters';
import './Review.less';
interface OwnProps {
@@ -38,7 +37,6 @@ class CreateReview extends React.Component {
render() {
const { form } = this.props;
const errors = getCreateErrors(this.props.form);
- const catUI = CATEGORY_UI[form.category as PROPOSAL_CATEGORY] || ({} as any);
const sections: Section[] = [
{
step: CREATE_STEP.BASICS,
@@ -53,25 +51,15 @@ class CreateReview extends React.Component {
key: 'rfpOptIn',
content: {form.rfpOptIn ? 'Accepted' : 'Declined'}
,
error: errors.rfpOptIn,
- isHide: !form.rfp || (form.rfp && !form.rfp.matching && !form.rfp.bounty),
},
{
key: 'brief',
content: form.brief,
error: errors.brief,
},
- {
- key: 'category',
- content: (
-
- {catUI.label}
-
- ),
- error: errors.category,
- },
{
key: 'target',
- content: {form.target} ZEC
,
+ content: {formatUsd(form.target)}
,
error: errors.target,
},
],
@@ -118,12 +106,16 @@ class CreateReview extends React.Component {
content: {form.payoutAddress}
,
error: errors.payoutAddress,
},
+ ],
+ },
+ {
+ step: CREATE_STEP.PAYMENT,
+ name: 'Tipping',
+ fields: [
{
- key: 'deadlineDuration',
- content: `${Math.floor(
- moment.duration((form.deadlineDuration || 0) * 1000).asDays(),
- )} days`,
- error: errors.deadlineDuration,
+ key: 'tipJarAddress',
+ content: {form.tipJarAddress}
,
+ error: errors.tipJarAddress,
},
],
},
@@ -131,8 +123,8 @@ class CreateReview extends React.Component {
return (
- {sections.map(s => (
-
+ {sections.map((s, i) => (
+
{s.fields.map(
f =>
!f.isHide && (
@@ -197,9 +189,9 @@ const ReviewMilestones = ({
{m.title || No title }
- {moment(m.dateEstimated * 1000).format('MMMM YYYY')}
+ {m.immediatePayout || !m.daysEstimated ? '0' : m.daysEstimated} days
{' – '}
- {m.payoutPercent}% of funds
+ {m.payoutPercent || '0'}% of funds
{m.content ||
No description }
diff --git a/frontend/client/components/CreateFlow/SubmitWarningModal.tsx b/frontend/client/components/CreateFlow/SubmitWarningModal.tsx
index f2f077fa..84eb4f46 100644
--- a/frontend/client/components/CreateFlow/SubmitWarningModal.tsx
+++ b/frontend/client/components/CreateFlow/SubmitWarningModal.tsx
@@ -16,13 +16,11 @@ export default class SubmitWarningModal extends React.Component
{
const { proposal, isVisible, handleClose, handleSubmit } = this.props;
const warnings = proposal ? getCreateWarnings(proposal) : [];
- const staked = proposal && proposal.isStaked;
-
return (
Confirm submission>}
visible={isVisible}
- okText={staked ? 'Submit' : `I'm ready to stake`}
+ okText={'Submit'}
cancelText="Never mind"
onOk={handleSubmit}
onCancel={handleClose}
@@ -45,20 +43,10 @@ export default class SubmitWarningModal extends React.Component {
}
/>
)}
- {staked && (
-
- Are you sure you're ready to submit your proposal for approval? Once you’ve
- done so, you won't be able to edit it.
-
- )}
- {!staked && (
-
- Are you sure you're ready to submit your proposal? You will be asked to send
- a staking contribution of {process.env.PROPOSAL_STAKING_AMOUNT} ZEC .
- Once confirmed, the proposal will be submitted for approval by site
- administrators.
-
- )}
+
+ Are you sure you're ready to submit your proposal for approval? Once you’ve
+ done so, you won't be able to edit it.
+
);
diff --git a/frontend/client/components/CreateFlow/Team.tsx b/frontend/client/components/CreateFlow/Team.tsx
index 99d70c61..103496d0 100644
--- a/frontend/client/components/CreateFlow/Team.tsx
+++ b/frontend/client/components/CreateFlow/Team.tsx
@@ -81,7 +81,7 @@ class CreateFlowTeam extends React.Component
{
)}
-
Add a team member
+
Add an optional team member
=> {
- const cats = Object.keys(PROPOSAL_CATEGORY);
- const category = cats[Math.floor(Math.random() * cats.length)] as PROPOSAL_CATEGORY;
return {
title: 'Grant.io T-Shirts',
brief: "The most stylish wear, sporting your favorite brand's logo",
- category,
content:
'![](https://i.imgur.com/aQagS0D.png)\n\nWe all know it, Grant.io is the bee\'s knees. But wouldn\'t it be great if you could show all your friends and family how much you love it? Well that\'s what we\'re here to offer today.\n\n# What We\'re Building\n\nWhy, T-Shirts of course! These beautiful shirts made out of 100% cotton and laser printed for long lasting goodness come from American Apparel. We\'ll be offering them in 4 styles:\n\n* Crew neck (wrinkled)\n* Crew neck (straight)\n* Scoop neck (fitted)\n* V neck (fitted)\n\nShirt sizings will be as follows:\n\n| Size | S | M | L | XL |\n|--------|-----|-----|-----|------|\n| **Width** | 18" | 20" | 22" | 24" |\n| **Length** | 28" | 29" | 30" | 31" |\n\n# Who We Are\n\nWe are the team behind grant.io. In addition to our software engineering experience, we have over 78 years of T-Shirt printing expertise combined. Sometimes I wake up at night and realize I was printing shirts in my dreams. Weird, man.\n\n# Expense Breakdown\n\n* $1,000 - A professional designer will hand-craft each letter on the shirt.\n* $500 - We\'ll get the shirt printed from 5 different factories and choose the best quality one.\n* $3,000 - The full run of prints, with 20 smalls, 20 mediums, and 20 larges.\n* $500 - Pizza. Lots of pizza.\n\n**Total**: $5,000',
target: '5',
@@ -20,9 +15,7 @@ const createExampleProposal = (): Partial => {
title: 'Initial Funding',
content:
'This will be used to pay for a professional designer to hand-craft each letter on the shirt.',
- dateEstimated: moment()
- .add(1, 'month')
- .unix(),
+ daysEstimated: '40',
payoutPercent: '30',
immediatePayout: true,
},
@@ -30,9 +23,7 @@ const createExampleProposal = (): Partial => {
title: 'Test Prints',
content:
"We'll get test prints from 5 different factories and choose the highest quality shirts. Once we've decided, we'll order a full batch of prints.",
- dateEstimated: moment()
- .add(2, 'month')
- .unix(),
+ daysEstimated: '30',
payoutPercent: '20',
immediatePayout: false,
},
@@ -40,14 +31,11 @@ const createExampleProposal = (): Partial => {
title: 'All Shirts Printed',
content:
"All of the shirts have been printed, hooray! They'll be given out at conferences and meetups.",
- dateEstimated: moment()
- .add(3, 'month')
- .unix(),
+ daysEstimated: '30',
payoutPercent: '50',
immediatePayout: false,
},
],
- deadlineDuration: 300,
};
};
diff --git a/frontend/client/components/CreateFlow/index.less b/frontend/client/components/CreateFlow/index.less
index 9d28fa23..dab3d79a 100644
--- a/frontend/client/components/CreateFlow/index.less
+++ b/frontend/client/components/CreateFlow/index.less
@@ -15,7 +15,7 @@
padding: 2.5rem 2rem 8rem;
&-header {
- max-width: 860px;
+ max-width: 1200px;
padding: 0 1rem;
margin: 1rem auto 3rem;
diff --git a/frontend/client/components/CreateFlow/index.tsx b/frontend/client/components/CreateFlow/index.tsx
index 2c4142ea..5d9e61ea 100644
--- a/frontend/client/components/CreateFlow/index.tsx
+++ b/frontend/client/components/CreateFlow/index.tsx
@@ -14,11 +14,13 @@ import Payment from './Payment';
import Review from './Review';
import Preview from './Preview';
import Final from './Final';
+import Explainer from './Explainer';
import SubmitWarningModal from './SubmitWarningModal';
import createExampleProposal from './example';
import { createActions } from 'modules/create';
import { ProposalDraft } from 'types';
import { getCreateErrors } from 'modules/create/utils';
+import ls from 'local-storage';
import { AppState } from 'store/reducers';
@@ -49,6 +51,11 @@ interface StepInfo {
help: React.ReactNode;
component: any;
}
+
+interface LSExplainer {
+ noExplain: boolean;
+}
+
const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = {
[CREATE_STEP.BASICS]: {
short: 'Basics',
@@ -84,10 +91,10 @@ const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = {
},
[CREATE_STEP.PAYMENT]: {
short: 'Payment',
- title: 'Choose how you get paid',
- subtitle: 'You’ll only be paid if your funding target is reached',
+ title: 'Set your payout and tip addresses',
+ subtitle: '',
help:
- 'Double check your address, and make sure it’s secure. Once sent, payments are irreversible!',
+ 'Double check your addresses, and make sure they’re secure. Once sent, transactions are irreversible!',
component: Payment,
},
[CREATE_STEP.REVIEW]: {
@@ -117,6 +124,7 @@ interface State {
isPreviewing: boolean;
isShowingSubmitWarning: boolean;
isSubmitting: boolean;
+ isExplaining: boolean;
isExample: boolean;
}
@@ -132,12 +140,15 @@ class CreateFlow extends React.Component {
queryStep && CREATE_STEP[queryStep]
? (CREATE_STEP[queryStep] as CREATE_STEP)
: CREATE_STEP.BASICS;
+ const noExplain = !!ls('noExplain');
+
this.state = {
step,
isPreviewing: false,
isSubmitting: false,
isExample: false,
isShowingSubmitWarning: false,
+ isExplaining: !noExplain,
};
this.debouncedUpdateForm = debounce(this.updateForm, 800);
this.historyUnlisten = this.props.history.listen(this.handlePop);
@@ -151,11 +162,18 @@ class CreateFlow extends React.Component {
render() {
const { isSavingDraft, saveDraftError } = this.props;
- const { step, isPreviewing, isSubmitting, isShowingSubmitWarning } = this.state;
+ const {
+ step,
+ isPreviewing,
+ isSubmitting,
+ isShowingSubmitWarning,
+ isExplaining,
+ } = this.state;
const info = STEP_INFO[step];
const currentIndex = STEP_ORDER.indexOf(step);
- const isLastStep = STEP_ORDER.indexOf(step) === STEP_ORDER.length - 1;
+ const isLastStep = currentIndex === STEP_ORDER.length - 1;
+ const isSecondToLastStep = currentIndex === STEP_ORDER.length - 2;
const StepComponent = info.component;
let content;
@@ -165,6 +183,9 @@ class CreateFlow extends React.Component {
showFooter = false;
} else if (isPreviewing) {
content = ;
+ } else if (isExplaining) {
+ content = ;
+ showFooter = false;
} else {
// Antd definitions are missing `onClick` for step, even though it works.
const Step = Steps.Step as any;
@@ -172,7 +193,7 @@ class CreateFlow extends React.Component {
- {STEP_ORDER.slice(0, 5).map(s => (
+ {STEP_ORDER.map(s => (
{
key="next"
onClick={this.nextStep}
>
- Continue
+ {isSecondToLastStep ? 'Review' : 'Continue'}{' '}
+
>
)}
@@ -264,6 +286,10 @@ class CreateFlow extends React.Component {
this.props.updateForm(form);
};
+ private startSteps = () => {
+ this.setState({ step: CREATE_STEP.BASICS, isExplaining: false });
+ };
+
private setStep = (step: CREATE_STEP, skipHistory?: boolean) => {
this.setState({ step });
if (!skipHistory) {
diff --git a/frontend/client/components/DraftList/index.tsx b/frontend/client/components/DraftList/index.tsx
index e95aa6b1..e5cc38e6 100644
--- a/frontend/client/components/DraftList/index.tsx
+++ b/frontend/client/components/DraftList/index.tsx
@@ -44,13 +44,17 @@ interface State {
deletingId: number | null;
}
+const EMAIL_VERIFIED_RELOAD_TIMEOUT = 10000;
+
class DraftList extends React.Component {
state: State = {
deletingId: null,
};
+ private reloadTimeout: number | null = null;
+
componentDidMount() {
- const { createIfNone, createWithRfpId } = this.props;
+ const { createIfNone, createWithRfpId, isVerified } = this.props;
if (createIfNone || createWithRfpId) {
this.props.fetchAndCreateDrafts({
rfpId: createWithRfpId,
@@ -59,6 +63,18 @@ class DraftList extends React.Component {
} else {
this.props.fetchDrafts();
}
+
+ if (!isVerified) {
+ this.reloadTimeout = window.setTimeout(() => {
+ window.location.reload();
+ }, EMAIL_VERIFIED_RELOAD_TIMEOUT);
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.reloadTimeout !== null) {
+ window.clearTimeout(this.reloadTimeout);
+ }
}
componentDidUpdate(prevProps: Props) {
@@ -119,8 +135,8 @@ class DraftList extends React.Component {
- {d.title || Untitled proposal }
- {d.status === STATUS.REJECTED && (rejected) }
+ {d.title || Untitled Proposal }
+ {d.status === STATUS.REJECTED && (changes requested) }
>
}
description={d.brief || No description }
@@ -142,7 +158,7 @@ class DraftList extends React.Component {
return (
-
Your drafts
+
Your Proposal Drafts
{draftsEl}
or
{
+ state: State = { ...STATE };
+ render() {
+ const { style, className } = this.props;
+ const { authedFollows, followersCount } = this.props.proposal;
+ const { loading } = this.state;
+ return (
+
+
+
+ {authedFollows ? ' Unfollow' : ' Follow'}
+
+
+ {followersCount}
+
+
+ );
+ }
+
+ private handleFollow = async () => {
+ const { proposalId, authedFollows } = this.props.proposal;
+ this.setState({ loading: true });
+ try {
+ await followProposal(proposalId, !authedFollows);
+ await this.props.fetchProposal(proposalId);
+ } catch (error) {
+ // tslint:disable:no-console
+ console.error('Follow.handleFollow - unable to change follow state', error);
+ message.error('Unable to follow proposal');
+ }
+ this.setState({ loading: false });
+ };
+}
+
+const withConnect = connect(
+ state => ({
+ authUser: state.auth.user,
+ }),
+ {
+ fetchProposal: proposalActions.fetchProposal,
+ },
+);
+
+export default withConnect(Follow);
diff --git a/frontend/client/components/Header/Auth.less b/frontend/client/components/Header/Auth.less
index c0d473ef..61f05f96 100644
--- a/frontend/client/components/Header/Auth.less
+++ b/frontend/client/components/Header/Auth.less
@@ -1,5 +1,6 @@
.AuthButton {
transition: opacity 200ms ease;
+ padding-left: 0.7rem;
&.is-loading {
opacity: 0;
diff --git a/frontend/client/components/Header/Auth.tsx b/frontend/client/components/Header/Auth.tsx
index 47c6e9c0..83fcb3b4 100644
--- a/frontend/client/components/Header/Auth.tsx
+++ b/frontend/client/components/Header/Auth.tsx
@@ -11,6 +11,7 @@ interface StateProps {
user: AppState['auth']['user'];
isAuthingUser: AppState['auth']['isAuthingUser'];
isCheckingUser: AppState['auth']['isCheckingUser'];
+ hasCheckedUser: AppState['auth']['hasCheckedUser'];
}
type Props = StateProps;
@@ -25,7 +26,7 @@ class HeaderAuth extends React.Component {
};
render() {
- const { user, isAuthingUser, isCheckingUser } = this.props;
+ const { user, isAuthingUser, isCheckingUser, hasCheckedUser } = this.props;
const { isMenuOpen } = this.state;
const isAuthed = !!user;
@@ -33,7 +34,7 @@ class HeaderAuth extends React.Component {
let isLoading;
if (user) {
avatar = ;
- } else if (isAuthingUser || isCheckingUser) {
+ } else if (isAuthingUser || isCheckingUser || !hasCheckedUser) {
isLoading = true;
}
@@ -83,16 +84,13 @@ class HeaderAuth extends React.Component {
>
{link}
- )
- }
- else {
+ );
+ } else {
content = link;
}
return (
-
- {content}
-
+ {content}
);
}
@@ -120,4 +118,5 @@ export default connect(state => ({
user: state.auth.user,
isAuthingUser: state.auth.isAuthingUser,
isCheckingUser: state.auth.isCheckingUser,
+ hasCheckedUser: state.auth.hasCheckedUser,
}))(HeaderAuth);
diff --git a/frontend/client/components/Header/Drawer.tsx b/frontend/client/components/Header/Drawer.tsx
index 31114393..836b5764 100644
--- a/frontend/client/components/Header/Drawer.tsx
+++ b/frontend/client/components/Header/Drawer.tsx
@@ -84,6 +84,14 @@ class HeaderDrawer extends React.Component {
Start a proposal
+
+
+ Browse requests
+
+
+ Create a Request
+
+
);
diff --git a/frontend/client/components/Header/index.tsx b/frontend/client/components/Header/index.tsx
index 8a8adafc..122575c0 100644
--- a/frontend/client/components/Header/index.tsx
+++ b/frontend/client/components/Header/index.tsx
@@ -6,8 +6,24 @@ import HeaderDrawer from './Drawer';
import MenuIcon from 'static/images/menu.svg';
import Logo from 'static/images/logo-name.svg';
import './style.less';
+import { Button } from 'antd';
+import { connect } from 'react-redux';
+import { AppState } from 'store/reducers';
+import { ccrActions } from 'modules/ccr';
+import { createActions } from 'modules/create';
-interface Props {
+import { compose } from 'recompose';
+import { withRouter } from 'react-router';
+import { fetchCCRDrafts } from 'modules/ccr/actions';
+import { fetchDrafts } from 'modules/create/actions';
+
+interface StateProps {
+ hasCheckedUser: AppState['auth']['hasCheckedUser'];
+ ccrDrafts: AppState['ccr']['drafts'];
+ proposalDrafts: AppState['create']['drafts'];
+}
+
+interface OwnProps {
isTransparent?: boolean;
}
@@ -15,13 +31,25 @@ interface State {
isDrawerOpen: boolean;
}
-export default class Header extends React.Component {
+interface DispatchProps {
+ fetchCCRDrafts: typeof fetchCCRDrafts;
+ fetchDrafts: typeof fetchDrafts;
+}
+
+type Props = StateProps & OwnProps & DispatchProps;
+
+class Header extends React.Component {
state: State = {
isDrawerOpen: false,
};
+ componentDidMount = () => {
+ this.props.fetchCCRDrafts();
+ this.props.fetchDrafts();
+ };
+
render() {
- const { isTransparent } = this.props;
+ const { isTransparent, ccrDrafts, proposalDrafts, hasCheckedUser } = this.props;
const { isDrawerOpen } = this.state;
return (
@@ -39,8 +67,8 @@ export default class Header extends React.Component {
Requests
-
- Start a Proposal
+
+ Guide
@@ -54,9 +82,30 @@ export default class Header extends React.Component {
-
-
-
+ {!hasCheckedUser && (ccrDrafts === null || proposalDrafts === null) ? null : (
+
+
+
+ {Array.isArray(proposalDrafts) && proposalDrafts.length > 0 ? (
+ My Proposals
+ ) : (
+ Start a Proposal
+ )}
+
+
+
+
+ {Array.isArray(ccrDrafts) && ccrDrafts.length > 0 ? (
+ My Requests
+ ) : (
+ Create a Request
+ )}
+
+
+
+
+
+ )}
@@ -73,3 +122,20 @@ export default class Header extends React.Component {
private openDrawer = () => this.setState({ isDrawerOpen: true });
private closeDrawer = () => this.setState({ isDrawerOpen: false });
}
+
+const withConnect = connect(
+ (state: AppState) => ({
+ hasCheckedUser: state.auth.hasCheckedUser,
+ ccrDrafts: state.ccr.drafts,
+ proposalDrafts: state.create.drafts,
+ }),
+ {
+ fetchCCRDrafts: ccrActions.fetchCCRDrafts,
+ fetchDrafts: createActions.fetchDrafts,
+ },
+);
+
+export default compose(
+ withRouter,
+ withConnect,
+)(Header);
diff --git a/frontend/client/components/Header/style.less b/frontend/client/components/Header/style.less
index 9b803586..68cd3351 100644
--- a/frontend/client/components/Header/style.less
+++ b/frontend/client/components/Header/style.less
@@ -4,6 +4,13 @@
@link-padding: 0.7rem;
@small-query: ~'(max-width: 820px)';
@big-query: ~'(min-width: 821px)';
+@big: ~'(max-width: 1040px)';
+
+.is-desktop {
+ @media @big {
+ display: none;
+ }
+}
.Header {
top: 0;
@@ -67,6 +74,8 @@
&-links {
display: flex;
+ align-items: center;
+ justify-content: center;
transition: transform @header-transition ease;
.is-transparent & {
@@ -95,6 +104,11 @@
}
}
+ &-button {
+ padding: 0 @link-padding / 2;
+ }
+
+
&-link {
display: block;
background: none;
diff --git a/frontend/client/components/Home/Intro.less b/frontend/client/components/Home/Intro.less
index e6bcea83..51661f0a 100644
--- a/frontend/client/components/Home/Intro.less
+++ b/frontend/client/components/Home/Intro.less
@@ -1,13 +1,14 @@
@import '~styles/variables.less';
+@min-tablet-query: ~'(min-width: 920px)';
.HomeIntro {
position: relative;
display: flex;
- justify-content: space-between;
+ justify-content: space-around;
align-items: center;
max-width: 1440px;
padding: 0 4rem;
- margin: 0 auto 6rem;
+ margin: 4rem auto;
overflow: hidden;
@media @thin-query {
@@ -19,10 +20,6 @@
}
&-content {
- width: 50%;
- min-width: 600px;
- padding-right: 2rem;
- margin: 0 auto;
&-title {
margin-bottom: 2rem;
@@ -38,27 +35,78 @@
&-buttons {
display: flex;
align-items: center;
+ justify-content: start;
- &-main {
+ @media @tablet-query {
+ margin-left: 0;
+ }
+
+ @media @mobile-query {
+ flex-direction: column;
+ width: 100%;
+ }
+
+ &-button {
display: flex;
align-items: center;
justify-content: center;
- height: 3.6rem;
- padding: 0 3rem;
- margin-right: 0.75rem;
- font-size: 1.2rem;
- background: @primary-color;
- color: #FFF;
+ height: 4.2rem;
+ width: 16rem;
+ padding: 0;
+ margin: 0 10px;
+ border: 2px solid rgba(@text-color, 0.7);
+ color: rgba(@text-color, 0.7);
+ text-align: center;
+ font-size: 1.4rem;
border-radius: 4px;
+ background: #fff;
+ transition: transform 200ms ease, box-shadow 200ms ease;
- &:hover {
- color: #FFF;
- opacity: 0.9;
+ &:hover,
+ &:focus {
+ transform: translateY(-2px);
+ border-color: rgba(@text-color, 0.9);
+ color: rgba(@text-color, 0.9);
}
- }
- &-learn {
- font-size: 1rem;
+ &:active {
+ transform: translateY(0px);
+ border-color: rgba(@text-color, 1);
+ color: rgba(@text-color, 1);
+ }
+
+ @media @tablet-query {
+ width: 100%;
+ height: 5rem;
+ font-size: 1.8rem;
+ max-width: 320px;
+ margin-bottom: 1rem;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ &.is-primary {
+ border-color: rgba(@primary-color, 0.7);
+ color: rgba(@primary-color, 0.7);
+
+ @media @min-tablet-query {
+ margin-left: 0;
+ }
+
+ &hover,
+ &:focus {
+ color: @primary-color;
+ border-color: rgba(@primary-color, 0.9);
+ color: rgba(@primary-color, 0.9);
+ }
+
+ &:active {
+ border-color: rgba(@primary-color, 1);
+ color: rgba(@primary-color, 1);
+ }
+ }
}
}
@@ -98,7 +146,7 @@
&-illustration {
position: relative;
width: 100%;
- max-width: 640px;
+ max-width: 480px;
background-size: contain;
&:after {
diff --git a/frontend/client/components/Home/Intro.tsx b/frontend/client/components/Home/Intro.tsx
index e804acf1..4aa48186 100644
--- a/frontend/client/components/Home/Intro.tsx
+++ b/frontend/client/components/Home/Intro.tsx
@@ -19,17 +19,20 @@ const HomeIntro: React.SFC = ({ t, authUser }) => (
{t('home.intro.subtitle')}
{authUser ? (
-
+
{t('home.intro.browse')}
) : (
-
+
{t('home.intro.signup')}
)}
-
- {t('home.intro.learn')}
-
+
+ {t('home.intro.ccr')}
+
{
+ state: State = {
+ latestProposals: [],
+ latestRfps: [],
+ isLoading: true,
+ error: null,
+ };
+
+ async componentDidMount() {
+ try {
+ const res = await getHomeLatest();
+ this.setState({
+ ...res.data,
+ error: null,
+ isLoading: false,
+ });
+ } catch (err) {
+ // tslint:disable-next-line
+ console.error('Failed to load homepage content:', err);
+ this.setState({
+ error: err.message,
+ isLoading: false,
+ });
+ }
+ }
+
+ render() {
+ const { t } = this.props;
+ const { latestProposals, latestRfps, isLoading } = this.state;
+ const numItems = latestProposals.length + latestRfps.length;
+
+ let content;
+ if (isLoading) {
+ content = (
+
+
+
+ );
+ } else if (numItems) {
+ const columns: ContentColumnProps[] = [
+ {
+ title: t('home.latest.proposalsTitle'),
+ placeholder: t('home.latest.proposalsPlaceholder'),
+ path: 'proposals',
+ items: latestProposals,
+ },
+ {
+ title: t('home.latest.requestsTitle'),
+ placeholder: t('home.latest.requestsPlaceholder'),
+ path: 'requests',
+ items: latestRfps,
+ },
+ ];
+ content = columns.filter(c => !!c.items.length).map((col, idx) => (
+
+
+
+ ));
+ } else {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+interface ContentColumnProps {
+ title: string;
+ placeholder: string;
+ path: string;
+ items: Array
;
+}
+
+const ContentColumn: React.SFC = p => {
+ let content: React.ReactNode;
+ if (p.items.length) {
+ content = (
+ <>
+ {p.items.map(item => {
+ const isProposal = (x: Proposal | RFP): x is Proposal =>
+ (x as Proposal).proposalUrlId !== undefined;
+ const id = isProposal(item) ? item.proposalId : item.id;
+ const urlId = isProposal(item) ? item.proposalUrlId : item.urlId;
+
+ return (
+
+
+
{item.title}
+
{item.brief}
+
+
+ );
+ })}
+
+ See more →
+
+ >
+ );
+ } else {
+ content = ;
+ }
+ return (
+
+
{p.title}
+ {content}
+
+ );
+};
+
+export default withNamespaces()(HomeLatest);
diff --git a/frontend/client/components/Home/index.tsx b/frontend/client/components/Home/index.tsx
index a383088b..b1119080 100644
--- a/frontend/client/components/Home/index.tsx
+++ b/frontend/client/components/Home/index.tsx
@@ -5,6 +5,7 @@ import Intro from './Intro';
import Requests from './Requests';
import Guide from './Guide';
import Actions from './Actions';
+import Latest from './Latest';
import './style.less';
class Home extends React.Component {
@@ -14,6 +15,7 @@ class Home extends React.Component {
+
diff --git a/frontend/client/components/Like/index.less b/frontend/client/components/Like/index.less
new file mode 100644
index 00000000..c59d7832
--- /dev/null
+++ b/frontend/client/components/Like/index.less
@@ -0,0 +1,24 @@
+@import '~styles/variables.less';
+
+@collapse-width: 800px;
+
+.Like {
+ white-space: nowrap;
+
+ .ant-btn:focus,
+ .ant-btn:active {
+ border-color: inherit;
+ outline-color: inherit;
+ color: inherit;
+ }
+
+ &-label {
+ @media (max-width: @collapse-width) {
+ display: none !important;
+ }
+ }
+
+ &-count {
+ color: @text-color !important;
+ }
+}
diff --git a/frontend/client/components/Like/index.tsx b/frontend/client/components/Like/index.tsx
new file mode 100644
index 00000000..5a12fa6c
--- /dev/null
+++ b/frontend/client/components/Like/index.tsx
@@ -0,0 +1,192 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { Icon, Button, Input, message } from 'antd';
+import { AppState } from 'store/reducers';
+import { proposalActions } from 'modules/proposals';
+import { rfpActions } from 'modules/rfps';
+import { Proposal } from 'types';
+import { Comment, RFP } from 'types';
+import { likeProposal, likeComment, likeRfp } from 'api/api';
+import AuthButton from 'components/AuthButton';
+import classnames from 'classnames';
+import './index.less';
+
+interface OwnProps {
+ proposal?: Proposal;
+ proposal_card?: boolean;
+ comment?: Comment;
+ rfp?: RFP;
+ style?: React.CSSProperties;
+ className?: string;
+}
+
+interface StateProps {
+ authUser: AppState['auth']['user'];
+}
+
+interface DispatchProps {
+ fetchProposal: typeof proposalActions['fetchProposal'];
+ updateComment: typeof proposalActions['updateProposalComment'];
+ fetchRfp: typeof rfpActions['fetchRfp'];
+}
+
+type Props = OwnProps & StateProps & DispatchProps;
+
+const STATE = {
+ loading: false,
+};
+type State = typeof STATE;
+
+class Like extends React.Component
{
+ state: State = { ...STATE };
+
+ render() {
+ const { likesCount, authedLiked } = this.deriveInfo();
+ const { proposal, rfp, comment, style, proposal_card, className } = this.props;
+ const { loading } = this.state;
+ const zoom = comment || proposal_card ? 0.8 : 1;
+ const shouldShowLikeText = (!!proposal && !proposal_card) || !!rfp;
+
+ // if like button is on a proposal card...
+ // 1) use regular button to prevent login redirect
+ const IconButton = proposal_card ? Button : AuthButton;
+ // 2) prevent mouseover effects
+ const pointerEvents = proposal_card ? 'none' : undefined;
+ // 3) make button click a noop
+ const handleIconButtonClick = proposal_card ? undefined : this.handleLike;
+
+ return (
+
+
+
+ {shouldShowLikeText && (
+ {authedLiked ? ' Unlike' : ' Like'}
+ )}
+
+
+ {likesCount}
+
+
+ );
+ }
+
+ private deriveInfo = () => {
+ let authedLiked = false;
+ let likesCount = 0;
+
+ const { proposal, comment, rfp } = this.props;
+
+ if (comment) {
+ authedLiked = comment.authedLiked;
+ likesCount = comment.likesCount;
+ } else if (proposal) {
+ authedLiked = proposal.authedLiked;
+ likesCount = proposal.likesCount;
+ } else if (rfp) {
+ authedLiked = rfp.authedLiked;
+ likesCount = rfp.likesCount;
+ }
+
+ return {
+ authedLiked,
+ likesCount,
+ };
+ };
+
+ private handleLike = () => {
+ if (this.state.loading) return;
+ const { proposal, rfp, comment } = this.props;
+
+ if (proposal) {
+ return this.handleProposalLike();
+ }
+ if (comment) {
+ return this.handleCommentLike();
+ }
+ if (rfp) {
+ return this.handleRfpLike();
+ }
+ };
+
+ private handleProposalLike = async () => {
+ if (!this.props.proposal) return;
+
+ const {
+ proposal: { proposalId, authedLiked },
+ fetchProposal,
+ } = this.props;
+
+ this.setState({ loading: true });
+ try {
+ await likeProposal(proposalId, !authedLiked);
+ await fetchProposal(proposalId);
+ } catch (error) {
+ // tslint:disable:no-console
+ console.error('Like.handleProposalLike - unable to change like state', error);
+ message.error('Unable to like proposal');
+ }
+ this.setState({ loading: false });
+ };
+
+ private handleCommentLike = async () => {
+ if (!this.props.comment) return;
+
+ const {
+ comment: { id, authedLiked },
+ updateComment,
+ } = this.props;
+
+ this.setState({ loading: true });
+ try {
+ const updatedComment = await likeComment(id, !authedLiked);
+ updateComment(id, updatedComment);
+ message.success(<>Comment {authedLiked ? 'unliked' : 'liked'}>);
+ } catch (error) {
+ // tslint:disable:no-console
+ console.error('Like.handleCommentLike - unable to change like state', error);
+ message.error('Unable to like comment');
+ }
+ this.setState({ loading: false });
+ };
+
+ private handleRfpLike = async () => {
+ if (!this.props.rfp) return;
+
+ const {
+ rfp: { id, authedLiked },
+ fetchRfp,
+ } = this.props;
+
+ this.setState({ loading: true });
+ try {
+ await likeRfp(id, !authedLiked);
+ await fetchRfp(id);
+ message.success(<>Request for proposal {authedLiked ? 'unliked' : 'liked'}>);
+ } catch (error) {
+ // tslint:disable:no-console
+ console.error('Like.handleRfpLike - unable to change like state', error);
+ message.error('Unable to like rfp');
+ }
+ this.setState({ loading: false });
+ };
+}
+
+const withConnect = connect(
+ state => ({
+ authUser: state.auth.user,
+ }),
+ {
+ fetchProposal: proposalActions.fetchProposal,
+ updateComment: proposalActions.updateProposalComment,
+ fetchRfp: rfpActions.fetchRfp,
+ },
+);
+
+export default withConnect(Like);
diff --git a/frontend/client/components/Profile/ProfileCCR.less b/frontend/client/components/Profile/ProfileCCR.less
new file mode 100644
index 00000000..6ef99610
--- /dev/null
+++ b/frontend/client/components/Profile/ProfileCCR.less
@@ -0,0 +1,60 @@
+@small-query: ~'(max-width: 640px)';
+
+.ProfileCCR {
+ display: flex;
+ padding-bottom: 1.2rem;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.05);
+ margin-bottom: 1rem;
+
+ &:last-child {
+ border-bottom: none;
+ padding-bottom: none;
+ }
+
+ @media @small-query {
+ flex-direction: column;
+ padding-bottom: 0.6rem;
+ }
+
+ &-title {
+ font-size: 1.2rem;
+ font-weight: 600;
+ color: inherit;
+ display: block;
+ margin-bottom: 0.5rem;
+ }
+
+ &-block {
+ flex: 1 0 0%;
+
+ &:last-child {
+ margin-left: 1.2rem;
+ flex: 0 0 0%;
+ min-width: 15rem;
+
+ @media @small-query {
+ margin-left: 0;
+ margin-top: 0.6rem;
+ }
+ }
+
+ &-team {
+ @media @small-query {
+ display: flex;
+ flex-flow: wrap;
+ }
+
+ & .UserRow {
+ margin-right: 1rem;
+ }
+ }
+ }
+
+ &-raised {
+ margin-top: 0.6rem;
+
+ & small {
+ opacity: 0.6;
+ }
+ }
+}
diff --git a/frontend/client/components/Profile/ProfileCCR.tsx b/frontend/client/components/Profile/ProfileCCR.tsx
new file mode 100644
index 00000000..535e3917
--- /dev/null
+++ b/frontend/client/components/Profile/ProfileCCR.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { UserCCR } from 'types';
+import UserRow from 'components/UserRow';
+import './ProfileCCR.less';
+
+interface OwnProps {
+ ccr: UserCCR;
+}
+
+export default class ProfileCCR extends React.Component {
+ render() {
+ const { title, brief, ccrId, author } = this.props.ccr;
+ return (
+
+
+
+ {title}
+
+
{brief}
+
+
+
+ );
+ }
+}
diff --git a/frontend/client/components/Profile/ProfilePending.tsx b/frontend/client/components/Profile/ProfilePending.tsx
index 8d4e4d24..f453d4ae 100644
--- a/frontend/client/components/Profile/ProfilePending.tsx
+++ b/frontend/client/components/Profile/ProfilePending.tsx
@@ -1,17 +1,14 @@
import React, { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Button, Popconfirm, message, Tag } from 'antd';
-import { UserProposal, STATUS, ContributionWithAddressesAndUser } from 'types';
-import ContributionModal from 'components/ContributionModal';
-import { getProposalStakingContribution } from 'api/api';
-import { deletePendingProposal, publishPendingProposal } from 'modules/users/actions';
+import { UserProposal, STATUS } from 'types';
+import { deletePendingProposal } from 'modules/users/actions';
import { connect } from 'react-redux';
import { AppState } from 'store/reducers';
import './ProfilePending.less';
interface OwnProps {
proposal: UserProposal;
- onPublish(id: UserProposal['proposalId']): void;
}
interface StateProps {
@@ -20,7 +17,6 @@ interface StateProps {
interface DispatchProps {
deletePendingProposal: typeof deletePendingProposal;
- publishPendingProposal: typeof publishPendingProposal;
}
type Props = OwnProps & StateProps & DispatchProps;
@@ -28,21 +24,17 @@ type Props = OwnProps & StateProps & DispatchProps;
interface State {
isDeleting: boolean;
isPublishing: boolean;
- isLoadingStake: boolean;
- stakeContribution: ContributionWithAddressesAndUser | null;
}
class ProfilePending extends React.Component {
state: State = {
isDeleting: false,
isPublishing: false,
- isLoadingStake: false,
- stakeContribution: null,
};
render() {
const { status, title, proposalId, rejectReason } = this.props.proposal;
- const { isDeleting, isPublishing, isLoadingStake, stakeContribution } = this.state;
+ const { isDeleting, isPublishing } = this.state;
const isDisableActions = isDeleting || isPublishing;
@@ -54,10 +46,10 @@ class ProfilePending extends React.Component {
},
[STATUS.REJECTED]: {
color: 'red',
- tag: 'Rejected',
+ tag: 'Changes requested',
blurb: (
<>
- This proposal was rejected for the following reason:
+ This proposal has changes requested:
{rejectReason}
You may edit this proposal and re-submit it for approval.
>
@@ -68,7 +60,7 @@ class ProfilePending extends React.Component {
tag: 'Staking',
blurb: (
- Awaiting staking contribution, you will recieve an email when staking has been
+ Awaiting staking contribution, you will receive an email when staking has been
confirmed. If you staked this proposal you may check its status under the
"funded" tab.
@@ -89,23 +81,13 @@ class ProfilePending extends React.Component {
- {title}
{st[status].tag}
+ {title}
{st[status].tag} Proposal
{st[status].blurb}
- {STATUS.APPROVED === status && (
-
- Publish
-
- )}
{STATUS.REJECTED === status && (
@@ -113,15 +95,6 @@ class ProfilePending extends React.Component {
)}
- {STATUS.STAKING === status && (
-
- Stake
-
- )}
{
-
- {STATUS.STAKING && (
-
- For your proposal to be considered, please send a staking contribution of{' '}
- {stakeContribution && stakeContribution.amount} ZEC using the
- instructions below. Once your payment has been sent and received 6
- confirmations, you will receive an email.
-
- }
- />
- )}
);
}
- private handlePublish = async () => {
- const {
- user,
- proposal: { proposalId },
- onPublish,
- } = this.props;
- if (!user) return;
- this.setState({ isPublishing: true });
- try {
- await this.props.publishPendingProposal(user.userid, proposalId);
- onPublish(proposalId);
- } catch (e) {
- message.error(e.message || e.toString());
- this.setState({ isPublishing: false });
- }
- };
-
private handleDelete = async () => {
const {
user,
@@ -185,26 +125,6 @@ class ProfilePending extends React.Component {
this.setState({ isDeleting: false });
}
};
-
- private openStakingModal = async () => {
- try {
- this.setState({ isLoadingStake: true });
- const res = await getProposalStakingContribution(this.props.proposal.proposalId);
- this.setState({ stakeContribution: res.data }, () => {
- this.setState({ isLoadingStake: false });
- });
- } catch (err) {
- console.error(err);
- message.error('Failed to get staking contribution, try again later', 3);
- this.setState({ isLoadingStake: false });
- }
- };
-
- private closeStakingModal = () =>
- this.setState({
- isLoadingStake: false,
- stakeContribution: null,
- });
}
export default connect(
@@ -213,6 +133,5 @@ export default connect(
}),
{
deletePendingProposal,
- publishPendingProposal,
},
)(ProfilePending);
diff --git a/frontend/client/components/Profile/ProfilePendingCCR.tsx b/frontend/client/components/Profile/ProfilePendingCCR.tsx
new file mode 100644
index 00000000..bb8d8446
--- /dev/null
+++ b/frontend/client/components/Profile/ProfilePendingCCR.tsx
@@ -0,0 +1,119 @@
+import React, { ReactNode } from 'react';
+import { Link } from 'react-router-dom';
+import { Button, Popconfirm, message, Tag } from 'antd';
+import { CCRSTATUS, STATUS, UserCCR } from 'types';
+import { deletePendingRequest } from 'modules/users/actions';
+import { connect } from 'react-redux';
+import { AppState } from 'store/reducers';
+import './ProfilePending.less';
+
+interface OwnProps {
+ ccr: UserCCR;
+}
+
+interface StateProps {
+ user: AppState['auth']['user'];
+}
+
+interface DispatchProps {
+ deletePendingRequest: typeof deletePendingRequest;
+}
+
+type Props = OwnProps & StateProps & DispatchProps;
+
+interface State {
+ isDeleting: boolean;
+}
+
+class ProfilePendingCCR extends React.Component {
+ state: State = {
+ isDeleting: false,
+ };
+
+ render() {
+ const { status, title, ccrId, rejectReason } = this.props.ccr;
+ const { isDeleting } = this.state;
+
+ const isDisableActions = isDeleting;
+
+ const st = {
+ [STATUS.REJECTED]: {
+ color: 'red',
+ tag: 'Changes Requested',
+ blurb: (
+ <>
+ This request has changes requested for the following reason:
+ {rejectReason}
+ You may edit this request and re-submit it for approval.
+ >
+ ),
+ },
+ [STATUS.PENDING]: {
+ color: 'purple',
+ tag: 'Pending Request',
+ blurb: (
+
+ You will receive an email when this request has completed the review process.
+
+ ),
+ },
+ } as { [key in STATUS]: { color: string; tag: string; blurb: ReactNode } };
+
+ return (
+
+
+
+ {title}
{st[status].tag}
+
+
+ {st[status].blurb}
+
+
+
+ {CCRSTATUS.REJECTED === status && (
+
+
+ Edit
+
+
+ )}
+
+
this.handleDelete()}
+ >
+
+ Delete
+
+
+
+
+ );
+ }
+
+ private handleDelete = async () => {
+ const {
+ user,
+ ccr: { ccrId },
+ } = this.props;
+ if (!user) return;
+ this.setState({ isDeleting: true });
+ try {
+ await this.props.deletePendingRequest(user.userid, ccrId);
+ message.success('Request deleted.');
+ } catch (e) {
+ message.error(e.message || e.toString());
+ }
+ this.setState({ isDeleting: false });
+ };
+}
+
+export default connect(
+ state => ({
+ user: state.auth.user,
+ }),
+ {
+ deletePendingRequest,
+ },
+)(ProfilePendingCCR);
diff --git a/frontend/client/components/Profile/ProfilePendingList.tsx b/frontend/client/components/Profile/ProfilePendingList.tsx
index ca26ab47..31d0f114 100644
--- a/frontend/client/components/Profile/ProfilePendingList.tsx
+++ b/frontend/client/components/Profile/ProfilePendingList.tsx
@@ -1,54 +1,29 @@
import React from 'react';
-import { Link } from 'react-router-dom';
-import { Modal } from 'antd';
-import { UserProposal } from 'types';
+import { UserProposal, UserCCR } from 'types';
import ProfilePending from './ProfilePending';
+import ProfilePendingCCR from './ProfilePendingCCR';
interface OwnProps {
proposals: UserProposal[];
+ requests: UserCCR[];
}
type Props = OwnProps;
-const STATE = {
- publishedId: null as null | UserProposal['proposalId'],
-};
-
-type State = typeof STATE;
-
-class ProfilePendingList extends React.Component {
- state = STATE;
+class ProfilePendingList extends React.Component {
render() {
- const { proposals } = this.props;
- const { publishedId } = this.state;
+ const { proposals, requests } = this.props;
return (
<>
{proposals.map(p => (
-
+
+ ))}
+ {requests.map(r => (
+
))}
-
- this.setState({ publishedId: null })}
- >
-
- Your proposal is live!{' '}
- Click here to check it out.
-
-
>
);
}
-
- private handlePublish = (publishedId: UserProposal['proposalId']) => {
- this.setState({ publishedId });
- };
}
export default ProfilePendingList;
diff --git a/frontend/client/components/Profile/ProfileProposal.tsx b/frontend/client/components/Profile/ProfileProposal.tsx
index 68538d9f..b858e94d 100644
--- a/frontend/client/components/Profile/ProfileProposal.tsx
+++ b/frontend/client/components/Profile/ProfileProposal.tsx
@@ -4,6 +4,8 @@ import { UserProposal } from 'types';
import './ProfileProposal.less';
import UserRow from 'components/UserRow';
import UnitDisplay from 'components/UnitDisplay';
+import { Tag } from 'antd';
+import { formatUsd } from 'utils/formatters'
interface OwnProps {
proposal: UserProposal;
@@ -11,19 +13,47 @@ interface OwnProps {
export default class Profile extends React.Component {
render() {
- const { title, brief, team, proposalId, funded, target } = this.props.proposal;
+ const {
+ title,
+ brief,
+ team,
+ proposalId,
+ funded,
+ target,
+ isVersionTwo,
+ acceptedWithFunding,
+ } = this.props.proposal;
+
+ // pulled from `variables.less`
+ const infoColor = '#1890ff'
+ const secondaryColor = '#2D2A26'
+
+ const tagColor = acceptedWithFunding
+ ? secondaryColor
+ : infoColor
+ const tagMessage = acceptedWithFunding
+ ? 'Funded by ZF'
+ : 'Open for Contributions'
+
return (
- {title}
+ {title} {isVersionTwo && (
{tagMessage} )}
{brief}
-
- {' '}
- raised of{' '}
- goal
-
+ {!isVersionTwo && (
+
+ {' '}
+ raised of{' '}
+ goal
+
+ )}
+ {isVersionTwo && (
+
+ {formatUsd(target.toString())}
+
+ )}
Team
diff --git a/frontend/client/components/Profile/ProfileUser.less b/frontend/client/components/Profile/ProfileUser.less
index 7e126fa3..5cc73eb5 100644
--- a/frontend/client/components/Profile/ProfileUser.less
+++ b/frontend/client/components/Profile/ProfileUser.less
@@ -2,8 +2,10 @@
display: flex;
align-items: center;
margin-bottom: 1.5rem;
+ flex-wrap: wrap;
&-avatar {
+ margin-bottom: 2rem;
position: relative;
flex: 0 0 auto;
height: 10.5rem;
@@ -20,6 +22,8 @@
&-info {
// no overflow of flexbox
min-width: 0;
+ margin-bottom: 2rem;
+ flex-grow: 1;
&-name {
font-size: 1.6rem;
@@ -30,6 +34,8 @@
font-size: 1rem;
opacity: 0.7;
margin-bottom: 0.3rem;
+ max-width: fit-content;
+ margin-right: 1rem;
}
&-address {
diff --git a/frontend/client/components/Profile/ProfileUser.tsx b/frontend/client/components/Profile/ProfileUser.tsx
index e257b145..0564ec36 100644
--- a/frontend/client/components/Profile/ProfileUser.tsx
+++ b/frontend/client/components/Profile/ProfileUser.tsx
@@ -5,6 +5,7 @@ import { Button } from 'antd';
import { SocialMedia } from 'types';
import { UserState } from 'modules/users/reducers';
import UserAvatar from 'components/UserAvatar';
+import { TipJarBlock } from 'components/TipJar';
import { SOCIAL_INFO } from 'utils/social';
import { AppState } from 'store/reducers';
import './ProfileUser.less';
@@ -19,7 +20,15 @@ interface StateProps {
type Props = OwnProps & StateProps;
-class ProfileUser extends React.Component
{
+const STATE = {
+ tipJarModalOpen: false,
+};
+
+type State = typeof STATE;
+
+class ProfileUser extends React.Component {
+ state = STATE;
+
render() {
const {
authUser,
@@ -52,6 +61,8 @@ class ProfileUser extends React.Component {
)}
+ {!isSelf &&
+ user.tipJarAddress && }
);
}
diff --git a/frontend/client/components/Profile/index.tsx b/frontend/client/components/Profile/index.tsx
index 43256ef6..7050d7ec 100644
--- a/frontend/client/components/Profile/index.tsx
+++ b/frontend/client/components/Profile/index.tsx
@@ -19,6 +19,7 @@ import ProfileProposal from './ProfileProposal';
import ProfileContribution from './ProfileContribution';
import ProfileComment from './ProfileComment';
import ProfileInvite from './ProfileInvite';
+import ProfileCCR from './ProfileCCR';
import Placeholder from 'components/Placeholder';
import Loader from 'components/Loader';
import ExceptionPage from 'components/ExceptionPage';
@@ -31,6 +32,7 @@ import './style.less';
interface StateProps {
usersMap: AppState['users']['map'];
authUser: AppState['auth']['user'];
+ hasCheckedUser: AppState['auth']['hasCheckedUser'];
}
interface DispatchProps {
@@ -63,7 +65,7 @@ class Profile extends React.Component {
}
render() {
- const { authUser, match, location } = this.props;
+ const { authUser, match, location, hasCheckedUser } = this.props;
const { activeContribution } = this.state;
const userLookupParam = match.params.id;
@@ -76,7 +78,7 @@ class Profile extends React.Component {
}
const user = this.props.usersMap[userLookupParam];
- const waiting = !user || !user.hasFetched;
+ const waiting = !user || !user.hasFetched || !hasCheckedUser;
const isAuthedUser = user && authUser && user.userid === authUser.userid;
if (waiting) {
@@ -90,6 +92,8 @@ class Profile extends React.Component {
const {
proposals,
pendingProposals,
+ pendingRequests,
+ requests,
contributions,
comments,
invites,
@@ -97,8 +101,10 @@ class Profile extends React.Component {
} = user;
const isLoading = user.isFetching;
- const nonePending = pendingProposals.length === 0;
- const noneCreated = proposals.length === 0;
+ const noProposalsPending = pendingProposals.length === 0;
+ const noProposalsCreated = proposals.length === 0;
+ const noRequestsPending = pendingRequests.length === 0;
+ const noRequestsCreated = requests.length === 0;
const noneFunded = contributions.length === 0;
const noneCommented = comments.length === 0;
const noneArbitrated = arbitrated.length === 0;
@@ -107,8 +113,8 @@ class Profile extends React.Component {
return (
@@ -127,33 +133,47 @@ class Profile extends React.Component {
{isAuthedUser && (
- {nonePending && (
-
- )}
-
+ {noProposalsPending &&
+ noRequestsPending && (
+
+ )}
+
)}
-
+
- {noneCreated && (
-
- )}
+ {noProposalsCreated &&
+ noRequestsCreated && (
+
+ )}
{proposals.map(p => (
))}
+ {requests.map(c => (
+
+ ))}
@@ -271,6 +291,7 @@ const withConnect = connect(
state => ({
usersMap: state.users.map,
authUser: state.auth.user,
+ hasCheckedUser: state.auth.hasCheckedUser,
}),
{
fetchUser: usersActions.fetchUser,
diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx
index 12123d22..fd446ce0 100644
--- a/frontend/client/components/Proposal/CampaignBlock/index.tsx
+++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx
@@ -1,19 +1,17 @@
import React from 'react';
import moment from 'moment';
-import { Form, Input, Button, Icon, Popover, Tooltip, Radio } from 'antd';
-import { RadioChangeEvent } from 'antd/lib/radio';
+import { Icon, Popover, Tooltip, Alert } from 'antd';
import { Proposal, STATUS } from 'types';
import classnames from 'classnames';
-import { fromZat } from 'utils/units';
import { connect } from 'react-redux';
import { compose } from 'recompose';
import { AppState } from 'store/reducers';
import { withRouter } from 'react-router';
import UnitDisplay from 'components/UnitDisplay';
-import ContributionModal from 'components/ContributionModal';
import Loader from 'components/Loader';
-import { getAmountError } from 'utils/validators';
-import { CATEGORY_UI, PROPOSAL_STAGE } from 'api/constants';
+import { PROPOSAL_STAGE } from 'api/constants';
+import { formatUsd } from 'utils/formatters';
+import ZFGrantsLogo from 'static/images/logo-name-light.svg';
import './style.less';
interface OwnProps {
@@ -46,23 +44,23 @@ export class ProposalCampaignBlock extends React.Component {
}
render() {
- const { proposal, isPreview, authUser } = this.props;
- const { amountToRaise, amountError, isPrivate, isContributing } = this.state;
- const amountFloat = parseFloat(amountToRaise) || 0;
+ const { proposal } = this.props;
let content;
if (proposal) {
- const { target, funded, percentFunded } = proposal;
+ const { target, funded, percentFunded, isVersionTwo } = proposal;
const datePublished = proposal.datePublished || Date.now() / 1000;
const isRaiseGoalReached = funded.gte(target);
- const deadline = (datePublished + proposal.deadlineDuration) * 1000;
+ const deadline = proposal.deadlineDuration
+ ? (datePublished + proposal.deadlineDuration) * 1000
+ : 0;
const isFrozen =
proposal.stage === PROPOSAL_STAGE.FAILED ||
proposal.stage === PROPOSAL_STAGE.CANCELED;
const isLive = proposal.status === STATUS.LIVE;
- const isFundingOver = isRaiseGoalReached || deadline < Date.now() || isFrozen;
- const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview;
- const remainingTargetNum = parseFloat(fromZat(target.sub(funded)));
+ const isFundingOver = deadline
+ ? isRaiseGoalReached || deadline < Date.now() || isFrozen
+ : null;
// Get bounty from RFP. If it exceeds proposal target, show bounty as full amount
let bounty;
@@ -72,6 +70,9 @@ export class ProposalCampaignBlock extends React.Component {
: proposal.contributionBounty;
}
+ const isAcceptedWithFunding = proposal.acceptedWithFunding === true;
+ const isCanceled = proposal.stage === PROPOSAL_STAGE.CANCELED;
+
content = (
{isLive && (
@@ -82,153 +83,132 @@ export class ProposalCampaignBlock extends React.Component {
)}
-
-
Category
-
- {' '}
- {CATEGORY_UI[proposal.category].label}
-
-
- {!isFundingOver && (
+ {!isVersionTwo &&
+ !isFundingOver && (
+
+
Deadline
+
+ {moment(deadline).fromNow()}
+
+
+ )}
+ {!isVersionTwo && (
-
Deadline
+
Funding
- {moment(deadline).fromNow()}
+ /{' '}
+
)}
-
- {bounty && (
-
- Awarded with
bounty
+ {isVersionTwo && (
+
+
+ {isAcceptedWithFunding ? 'Funding' : 'Requested Funding'}
+
+
+ {formatUsd(target.toString(10))}
+ {isAcceptedWithFunding && (
+
+
+
+ )}
+
)}
- {proposal.contributionMatching > 0 && (
-
-
Funds are being matched x{proposal.contributionMatching + 1}
-
- Matching
-
- Increase your impact! Contributions to this proposal are being matched
- by the Zcash Foundation, up to the target amount.
- >
- }
+ {bounty &&
+ !isVersionTwo && (
+
+ Awarded with bounty
+
+ )}
+
+ {isVersionTwo &&
+ isAcceptedWithFunding && (
+
+ Funded through
+
+
+ )}
+
+ {isVersionTwo &&
+ !isAcceptedWithFunding && (
+
+ Open for Community Donations
+
+ )}
+
+ {!isVersionTwo &&
+ proposal.contributionMatching > 0 && (
+
+
Funds are being matched x{proposal.contributionMatching + 1}
+
+ Matching
+
+ Increase your impact! Contributions to this proposal are being
+ matched by the Zcash Foundation, up to the target amount.
+ >
+ }
+ >
+
+
+
+ )}
+
+ {!isVersionTwo &&
+ (isFundingOver ? (
+
-
-
-
- )}
-
- {isFundingOver ? (
-
- {proposal.stage === PROPOSAL_STAGE.CANCELED ? (
- <>
-
- Proposal was canceled
- >
- ) : isRaiseGoalReached ? (
- <>
-
- Proposal has been funded
- >
- ) : (
- <>
-
- Proposal didn’t get funded
- >
- )}
-
- ) : (
- <>
-
-
+ {isCanceled ? (
+ <>
+
+
Proposal was canceled
+ >
+ ) : isRaiseGoalReached ? (
+ <>
+
+
Proposal has been funded
+ >
+ ) : (
+ <>
+
+
Proposal didn’t get funded
+ >
+ )}
-
-
-
-
+
+
-
- {amountToRaise &&
- !!authUser && (
-
-
- Contribute without attribution
-
-
-
-
-
- Attribute contribution publicly
-
-
-
-
-
- )}
-
- Fund this project
-
-
- >
- )}
+
+ >
+ ))}
-
+ {isVersionTwo &&
+ isCanceled && (
+
+
+ Proposal was canceled
+
+ )}
);
} else {
@@ -237,43 +217,24 @@ export class ProposalCampaignBlock extends React.Component {
return (
+ {proposal &&
+ proposal.isVersionTwo &&
+ !proposal.acceptedWithFunding &&
+ proposal.stage === PROPOSAL_STAGE.WIP && (
+
+ )}
Campaign
{content}
);
}
-
- private handleAmountChange = (
- event: React.ChangeEvent,
- ) => {
- const { value } = event.currentTarget;
- if (!value) {
- this.setState({ amountToRaise: '', amountError: null });
- return;
- }
-
- const { target, funded } = this.props.proposal;
- const remainingTarget = target.sub(funded);
- const amount = parseFloat(value);
- let amountError = null;
-
- if (Number.isNaN(amount)) {
- // They're entering some garbage, they’ll work it out
- } else {
- const remainingTargetNum = parseFloat(fromZat(remainingTarget));
- amountError = getAmountError(amount, remainingTargetNum);
- }
-
- this.setState({ amountToRaise: value, amountError });
- };
-
- private handleChangePrivate = (ev: RadioChangeEvent) => {
- const isPrivate = ev.target.value === 'isPrivate';
- this.setState({ isPrivate });
- };
-
- private openContributionModal = () => this.setState({ isContributing: true });
- private closeContributionModal = () => this.setState({ isContributing: false });
}
function mapStateToProps(state: AppState) {
diff --git a/frontend/client/components/Proposal/CampaignBlock/style.less b/frontend/client/components/Proposal/CampaignBlock/style.less
index 50dad9fc..1bb36c06 100644
--- a/frontend/client/components/Proposal/CampaignBlock/style.less
+++ b/frontend/client/components/Proposal/CampaignBlock/style.less
@@ -57,6 +57,25 @@
margin-top: 0;
}
+ &-with-funding,
+ &-without-funding {
+ margin: 0.5rem -1.5rem;
+ padding: 0.75rem 1rem;
+ font-size: 1rem;
+ color: #fff;
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ }
+
+ &-with-funding {
+ background: @secondary-color;
+ }
+
+ &-without-funding {
+ background: @info-color;
+ }
+
&-popover {
&-overlay {
max-width: 400px;
@@ -123,4 +142,8 @@
color: @success-color;
}
}
+
+ &-tipJarWrapper {
+ margin-top: 1rem;
+ }
}
diff --git a/frontend/client/components/Proposal/CancelModal.tsx b/frontend/client/components/Proposal/CancelModal.tsx
index c0bad93f..e0375fb1 100644
--- a/frontend/client/components/Proposal/CancelModal.tsx
+++ b/frontend/client/components/Proposal/CancelModal.tsx
@@ -11,7 +11,11 @@ interface Props {
export default class CancelModal extends React.Component {
render() {
- const { isVisible, handleClose } = this.props;
+ const {
+ isVisible,
+ handleClose,
+ proposal: { isVersionTwo },
+ } = this.props;
return (
{
onCancel={handleClose}
>
- Are you sure you would like to cancel this proposal, and refund any
- contributors? This cannot be undone .
+ Are you sure you would like to cancel this proposal
+ {isVersionTwo ? '' : ', and refund any contributors'}?{' '}
+ This cannot be undone .
- Canceled proposals cannot be deleted and will still be viewable by contributors
- or anyone with a direct link. However, they will be de-listed everywhere else on
- ZF Grants.
+ Canceled proposals cannot be deleted and will still be viewable by{' '}
+ {isVersionTwo ? '' : 'contributors or '}
+ anyone with a direct link. However, they will be de-listed everywhere else on ZF
+ Grants.
If you're sure you'd like to cancel, please{' '}
diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx
index eceba29c..32ef7e8e 100644
--- a/frontend/client/components/Proposal/Milestones/index.tsx
+++ b/frontend/client/components/Proposal/Milestones/index.tsx
@@ -22,6 +22,8 @@ import { proposalActions } from 'modules/proposals';
import { ProposalDetail } from 'modules/proposals/reducers';
import './index.less';
import { Link } from 'react-router-dom';
+import { formatUsd } from 'utils/formatters';
+import { Zat } from 'utils/units';
enum STEP_STATUS {
WAIT = 'wait',
@@ -156,8 +158,15 @@ class ProposalMilestones extends React.Component {
if (!proposal) {
return ;
}
- const { milestones, currentMilestone, isRejectingPayout } = proposal;
+ const {
+ milestones,
+ currentMilestone,
+ isRejectingPayout,
+ isVersionTwo,
+ acceptedWithFunding,
+ } = proposal;
const milestoneCount = milestones.length;
+ const milestonesDisabled = isVersionTwo ? !acceptedWithFunding : false;
// arbiter reject modal
const rejectModal = (
@@ -220,7 +229,12 @@ class ProposalMilestones extends React.Component {
['do-titles-overflow']: this.state.doTitlesOverflow,
})}
>
- {!!milestoneSteps.length ? (
+ {milestonesDisabled ? (
+
+ ) : !!milestoneSteps.length ? (
<>
{milestoneSteps.map(mss => (
@@ -242,6 +256,7 @@ class ProposalMilestones extends React.Component {
!!proposal.arbiter.user &&
proposal.arbiter.status === PROPOSAL_ARBITER_STATUS.ACCEPTED
}
+ isVersionTwo={proposal.isVersionTwo}
/>
>
) : (
@@ -317,10 +332,17 @@ interface MilestoneProps extends MSProps {
isCurrent: boolean;
proposalId: number;
isFunded: boolean;
+ isVersionTwo: boolean;
}
const Milestone: React.SFC = p => {
- const estimatedDate = moment(p.dateEstimated * 1000).format('MMMM YYYY');
- const reward = ;
+ const estimatedDate = p.dateEstimated
+ ? moment(p.dateEstimated * 1000).format('MMMM YYYY')
+ : 'N/A';
+ const reward = p.isVersionTwo ? (
+ formatUsd(p.amount as string, true, 2)
+ ) : (
+
+ );
const getAlertProps = {
[MILESTONE_STAGE.IDLE]: () => null,
[MILESTONE_STAGE.REQUESTED]: () => ({
@@ -356,7 +378,7 @@ const Milestone: React.SFC = p => {
type: 'success',
message: (
- The team was awarded {reward} {' '}
+ The team was awarded {reward} {p.isVersionTwo && `in ZEC`}
{p.immediatePayout && ` as an initial payout `} on {fmtDate(p.datePaid)}.
),
diff --git a/frontend/client/components/Proposal/TippingBlock/index.tsx b/frontend/client/components/Proposal/TippingBlock/index.tsx
new file mode 100644
index 00000000..01fc526c
--- /dev/null
+++ b/frontend/client/components/Proposal/TippingBlock/index.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import { Tooltip, Icon } from 'antd';
+import { Proposal } from 'types';
+import Loader from 'components/Loader';
+import { TipJarBlock } from 'components/TipJar';
+import { PROPOSAL_STAGE } from 'api/constants';
+import './style.less';
+
+interface Props {
+ proposal: Proposal;
+}
+
+const TippingBlock: React.SFC = ({ proposal }) => {
+ let content;
+ if (proposal) {
+ if (!proposal.tipJarAddress || proposal.stage === PROPOSAL_STAGE.CANCELED) {
+ return null;
+ }
+ content = (
+ <>
+
+
Tips Received
+
+ ???
+
+
+
+
+
+
+
+
+ >
+ );
+ } else {
+ content = ;
+ }
+
+ return (
+
+ );
+};
+
+export default TippingBlock;
diff --git a/frontend/client/components/Proposal/TippingBlock/style.less b/frontend/client/components/Proposal/TippingBlock/style.less
new file mode 100644
index 00000000..7bed56b6
--- /dev/null
+++ b/frontend/client/components/Proposal/TippingBlock/style.less
@@ -0,0 +1,32 @@
+@import '~styles/variables.less';
+
+.TippingBlock {
+ &-info {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 0.5rem;
+ overflow: hidden;
+ line-height: 1.7rem;
+
+ &-label {
+ font-size: 0.95rem;
+ font-weight: 300;
+ opacity: 0.8;
+ letter-spacing: 0.05rem;
+ flex: 0 0 auto;
+ margin-right: 1.5rem;
+ }
+
+ &-value {
+ font-size: 1.1rem;
+ flex-basis: 0;
+ flex-grow: 1;
+ overflow: hidden;
+ text-align: right;
+ }
+ }
+
+ &-tipJarWrapper {
+ margin-top: 1rem;
+ }
+}
diff --git a/frontend/client/components/Proposal/index.less b/frontend/client/components/Proposal/index.less
index c533ec65..9a68cd75 100644
--- a/frontend/client/components/Proposal/index.less
+++ b/frontend/client/components/Proposal/index.less
@@ -4,7 +4,6 @@
@single-col-width: 600px;
@block-title-space: 3.75rem;
-
.Proposal {
max-width: @max-width;
margin: 0 auto;
@@ -65,23 +64,47 @@
}
&-title {
- font-size: 2rem;
- line-height: 3rem;
- margin-bottom: 0.75rem;
- margin-left: 0.5rem;
-
- @media (min-width: @collapse-width) {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
-
- @media (max-width: @collapse-width) {
- font-size: 1.8rem;
- }
+ display: flex;
@media (max-width: @single-col-width) {
- font-size: 1.6rem;
+ flex-direction: column-reverse;
+ margin-top: -1rem;
+ }
+
+ h1 {
+ font-size: 2rem;
+ line-height: 3rem;
+ margin-bottom: 0.75rem;
+ margin-left: 0.5rem;
+ margin-right: 1rem;
+ flex-grow: 1;
+
+ @media (min-width: @collapse-width) {
+ overflow: hidden;
+ text-overflow: unset;
+ }
+
+ @media (max-width: @collapse-width) {
+ font-size: 1.8rem;
+ }
+
+ @media (max-width: @single-col-width) {
+ font-size: 1.6rem;
+ }
+ }
+
+ &-menu {
+ display: flex;
+ align-items: center;
+ height: 3rem;
+
+ & .ant-input-group {
+ width: inherit;
+ }
+
+ &-item {
+ margin-left: 0.5rem;
+ }
}
}
@@ -181,9 +204,16 @@
margin-left: 0;
}
- &:last-child {
+ &:nth-child(2) {
+ width: calc(50% - 1rem);
+ }
+
+ &:nth-child(3) {
+ width: calc(50% - 1rem);
+ }
+
+ &:nth-child(4) {
width: calc(50% - 1rem);
- margin-left: 1rem;
}
}
}
@@ -194,7 +224,9 @@
> * {
&,
&:first-child,
- &:last-child {
+ &:nth-child(2),
+ &:nth-child(3),
+ &:nth-child(4) {
width: auto;
margin-left: 0;
}
diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx
index 9c3e9927..bd34e65c 100644
--- a/frontend/client/components/Proposal/index.tsx
+++ b/frontend/client/components/Proposal/index.tsx
@@ -14,6 +14,7 @@ import { AlertProps } from 'antd/lib/alert';
import ExceptionPage from 'components/ExceptionPage';
import HeaderDetails from 'components/HeaderDetails';
import CampaignBlock from './CampaignBlock';
+import TippingBlock from './TippingBlock';
import TeamBlock from './TeamBlock';
import RFPBlock from './RFPBlock';
import Milestones from './Milestones';
@@ -25,6 +26,9 @@ import CancelModal from './CancelModal';
import classnames from 'classnames';
import { withRouter } from 'react-router';
import SocialShare from 'components/SocialShare';
+import Follow from 'components/Follow';
+import Like from 'components/Like';
+import { TipJarProposalSettingsModal } from 'components/TipJar';
import './index.less';
interface OwnProps {
@@ -50,6 +54,7 @@ interface State {
isBodyOverflowing: boolean;
isUpdateOpen: boolean;
isCancelOpen: boolean;
+ isTipJarOpen: boolean;
}
export class ProposalDetail extends React.Component {
@@ -58,6 +63,7 @@ export class ProposalDetail extends React.Component {
isBodyOverflowing: false,
isUpdateOpen: false,
isCancelOpen: false,
+ isTipJarOpen: false,
};
bodyEl: HTMLElement | null = null;
@@ -88,7 +94,13 @@ export class ProposalDetail extends React.Component {
render() {
const { user, detail: proposal, isPreview, detailError } = this.props;
- const { isBodyExpanded, isBodyOverflowing, isCancelOpen, isUpdateOpen } = this.state;
+ const {
+ isBodyExpanded,
+ isBodyOverflowing,
+ isCancelOpen,
+ isUpdateOpen,
+ isTipJarOpen,
+ } = this.state;
const showExpand = !isBodyExpanded && isBodyOverflowing;
const wrongProposal = proposal && proposal.proposalId !== this.props.proposalId;
@@ -101,9 +113,16 @@ export class ProposalDetail extends React.Component {
const isTrustee = !!proposal.team.find(tm => tm.userid === (user && user.userid));
const isLive = proposal.status === STATUS.LIVE;
+ const milestonesDisabled = proposal.isVersionTwo
+ ? !proposal.acceptedWithFunding
+ : false;
+ const defaultTab = !milestonesDisabled || !isLive ? 'milestones' : 'discussions';
const adminMenu = (
+
+ Manage Tipping
+
Post an Update
@@ -137,8 +156,8 @@ export class ProposalDetail extends React.Component {
[STATUS.REJECTED]: {
blurb: (
<>
- Your proposal was rejected and is only visible to the team. Visit your{' '}
- profile's pending tab for more
+ Your proposal has changes requested and is only visible to the team. Visit
+ your profile's pending tab for more
information.
>
),
@@ -184,9 +203,34 @@ export class ProposalDetail extends React.Component {
)}
-
- {proposal ? proposal.title : }
-
+
+
{proposal ? proposal.title : }
+ {isLive && (
+
+ {isTrustee && (
+
+
+ Actions
+
+
+
+ )}
+
+
+
+ )}
+
+
(this.bodyEl = el)}
@@ -206,23 +250,9 @@ export class ProposalDetail extends React.Component
{
)}
- {isLive &&
- isTrustee && (
-
-
-
- Actions
-
-
-
-
- )}
+
{proposal.rfp &&
}
@@ -230,7 +260,7 @@ export class ProposalDetail extends React.Component
{
-
+
@@ -242,9 +272,11 @@ export class ProposalDetail extends React.Component
{
-
-
-
+ {!proposal.isVersionTwo && (
+
+
+
+ )}
@@ -260,6 +292,11 @@ export class ProposalDetail extends React.Component {
isVisible={isCancelOpen}
handleClose={this.closeCancelModal}
/>
+
>
)}
@@ -286,6 +323,9 @@ export class ProposalDetail extends React.Component
{
}
};
+ private openTipJarModal = () => this.setState({ isTipJarOpen: true });
+ private closeTipJarModal = () => this.setState({ isTipJarOpen: false });
+
private openUpdateModal = () => this.setState({ isUpdateOpen: true });
private closeUpdateModal = () => this.setState({ isUpdateOpen: false });
diff --git a/frontend/client/components/Proposals/Filters/index.tsx b/frontend/client/components/Proposals/Filters/index.tsx
index 26933ef5..2e127fee 100644
--- a/frontend/client/components/Proposals/Filters/index.tsx
+++ b/frontend/client/components/Proposals/Filters/index.tsx
@@ -1,15 +1,8 @@
import React from 'react';
-import { Select, Checkbox, Radio, Card, Divider } from 'antd';
+import { Select, Radio, Card } from 'antd';
import { RadioChangeEvent } from 'antd/lib/radio';
import { SelectValue } from 'antd/lib/select';
-import {
- PROPOSAL_SORT,
- SORT_LABELS,
- PROPOSAL_CATEGORY,
- CATEGORY_UI,
- PROPOSAL_STAGE,
- STAGE_UI,
-} from 'api/constants';
+import { PROPOSAL_SORT, SORT_LABELS, PROPOSAL_STAGE, STAGE_UI } from 'api/constants';
import { typedKeys } from 'utils/ts';
import { ProposalPage } from 'types';
@@ -39,21 +32,6 @@ export default class ProposalFilters extends React.Component {
Reset}>
- Category
- {typedKeys(PROPOSAL_CATEGORY).map(c => (
-
-
- {CATEGORY_UI[c].label}
-
-
- ))}
-
-
-
Proposal stage
{
PROPOSAL_STAGE.PREVIEW,
PROPOSAL_STAGE.FAILED,
PROPOSAL_STAGE.CANCELED,
+ PROPOSAL_STAGE.FUNDING_REQUIRED,
].includes(s as PROPOSAL_STAGE),
) // skip a few
.map(s => (
@@ -91,19 +70,6 @@ export default class ProposalFilters extends React.Component {
);
}
- private handleCategoryChange = (ev: RadioChangeEvent) => {
- const { filters } = this.props;
- const cat = ev.target.value as PROPOSAL_CATEGORY;
- const category = ev.target.checked
- ? [...filters.category, cat]
- : filters.category.filter(c => c !== cat);
-
- this.props.handleChangeFilters({
- ...filters,
- category,
- });
- };
-
private handleStageChange = (ev: RadioChangeEvent) => {
let stage = [] as PROPOSAL_STAGE[];
if (ev.target.value !== 'ALL') {
diff --git a/frontend/client/components/Proposals/ProposalCard/index.tsx b/frontend/client/components/Proposals/ProposalCard/index.tsx
index 2df2aa7b..edd9d879 100644
--- a/frontend/client/components/Proposals/ProposalCard/index.tsx
+++ b/frontend/client/components/Proposals/ProposalCard/index.tsx
@@ -1,11 +1,12 @@
import React from 'react';
+import { Redirect } from 'react-router-dom';
import classnames from 'classnames';
import { Progress } from 'antd';
-import { Redirect } from 'react-router-dom';
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 {
@@ -18,13 +19,13 @@ export class ProposalCard extends React.Component {
title,
proposalAddress,
proposalUrlId,
- category,
datePublished,
dateCreated,
team,
target,
- funded,
contributionMatching,
+ isVersionTwo,
+ funded,
percentFunded,
} = this.props;
@@ -38,28 +39,40 @@ export class ProposalCard extends React.Component {
)}
-
-
-
raised of{' '}
-
goal
+ {isVersionTwo && (
+
+
+ {formatUsd(target.toString(10))}
+
-
= 100,
- })}
- >
- {percentFunded}%
-
-
-
= 100 ? 'success' : 'active'}
- showInfo={false}
- />
+ )}
+
+ {!isVersionTwo && (
+ <>
+
+
+ raised of{' '}
+ goal
+
+
= 100,
+ })}
+ >
+ {percentFunded}%
+
+
+ = 100 ? 'success' : 'active'}
+ showInfo={false}
+ />
+ >
+ )}
-
+
{team[0].displayName}{' '}
{team.length > 1 && +{team.length - 1} other }
@@ -67,14 +80,14 @@ export class ProposalCard extends React.Component
{
{[...team].reverse().map((u, idx) => (
))}
{proposalAddress}
-
+
);
}
diff --git a/frontend/client/components/Proposals/ProposalCard/style.less b/frontend/client/components/Proposals/ProposalCard/style.less
index cc4c5fc3..56b12fbd 100644
--- a/frontend/client/components/Proposals/ProposalCard/style.less
+++ b/frontend/client/components/Proposals/ProposalCard/style.less
@@ -40,6 +40,20 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ font-size: 1rem;
+
+ small {
+ opacity: 0.6;
+ font-size: 0.6rem;
+ font-weight: 500;
+ }
+ }
+
+ &-name-v1 {
+ opacity: 0.8;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
small {
opacity: 0.6;
@@ -54,6 +68,14 @@
margin-left: 1.25rem;
&-avatar {
+ width: 2.5rem;
+ height: 2.5rem;
+ margin-left: -0.75rem;
+ border-radius: 100%;
+ border: 2px solid #fff;
+ }
+
+ &-avatar-v1 {
width: 1.8rem;
height: 1.8rem;
margin-left: -0.75rem;
@@ -64,9 +86,32 @@
}
&-funding {
+ line-height: 2.5rem;
+
+ &-raised {
+ font-size: 2.2rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ small {
+ opacity: 0.6;
+ }
+ }
+
+ &-percent {
+ font-size: 0.7rem;
+ padding-left: 0.25rem;
+ &.is-funded {
+ color: @success-color;
+ }
+ }
+ }
+
+ &-funding-v1 {
display: flex;
justify-content: space-between;
- line-height: 1.2rem;
+ line-height: 1.9rem;
&-raised {
font-size: 0.9rem;
diff --git a/frontend/client/components/Proposals/index.tsx b/frontend/client/components/Proposals/index.tsx
index e8e66938..2d4963c9 100644
--- a/frontend/client/components/Proposals/index.tsx
+++ b/frontend/client/components/Proposals/index.tsx
@@ -8,6 +8,7 @@ import { Input, Divider, Drawer, Icon, Button } from 'antd';
import ProposalResults from './Results';
import ProposalFilters from './Filters';
import { PROPOSAL_SORT } from 'api/constants';
+import ZCFLogo from 'static/images/zcf.svg';
import './style.less';
interface StateProps {
@@ -60,47 +61,69 @@ class Proposals extends React.Component {
);
return (
- {isFiltersDrawered ? (
-
- {filtersComponent}
-
+ {isFiltersDrawered ? (
+
- Done
-
-
- ) : (
-
{filtersComponent}
- )}
+ {filtersComponent}
+
+ Done
+
+
+ ) : (
+
+
+
+
+ Filters
+
+
+ {filtersComponent}
+
+ )}
-
-
-
+
+
+
+
+
+
Zcash Foundation Proposals
+
+ The Zcash Foundation accepts proposals from community members to improve
+ the Zcash ecosystem. Proposals are either funded by the Zcash Foundation
+ directly, or are opened for community donations should they be approved
+ by the Zcash Foundation.
+
+
+
+
+
+
-
- Filters
-
-
-
);
diff --git a/frontend/client/components/Proposals/style.less b/frontend/client/components/Proposals/style.less
index fb17174d..ec56b67a 100644
--- a/frontend/client/components/Proposals/style.less
+++ b/frontend/client/components/Proposals/style.less
@@ -2,7 +2,47 @@
.Proposals {
display: flex;
- flex-direction: row;
+ flex-direction: column;
+
+ &-content {
+ display: flex;
+ flex-direction: row;
+ }
+
+ &-about {
+ display: flex;
+ align-content: center;
+
+ &-logo {
+ flex-shrink: 0;
+ width: 120px;
+ padding-right: 1.5rem;
+ border-right: 1px solid rgba(0, 0, 0, 0.05);
+ margin-right: 1.5rem;
+
+ svg {
+ width: 100%;
+ height: auto;
+ }
+ }
+
+ &-text {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+
+ &-title {
+ font-size: 1.4rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ }
+
+ &-desc {
+ font-size: 1rem;
+ }
+ }
+ }
&-filters {
width: 220px;
@@ -21,6 +61,7 @@
&-search {
display: flex;
+ margin-bottom: 1rem;
&-filterButton.ant-btn {
display: none;
diff --git a/frontend/client/components/RFP/index.less b/frontend/client/components/RFP/index.less
index 74513ef7..95181234 100644
--- a/frontend/client/components/RFP/index.less
+++ b/frontend/client/components/RFP/index.less
@@ -21,6 +21,10 @@
&-date {
opacity: 0.7;
}
+
+ & .ant-input-group {
+ width: inherit;
+ }
}
&-brief {
@@ -97,8 +101,8 @@
// Only has this class while affixed
.ant-affix {
- box-shadow: 0 0 10px 10px #FFF;
- background: #FFF;
+ box-shadow: 0 0 10px 10px #fff;
+ background: #fff;
}
}
-}
\ No newline at end of file
+}
diff --git a/frontend/client/components/RFP/index.tsx b/frontend/client/components/RFP/index.tsx
index 1278f825..4207c11f 100644
--- a/frontend/client/components/RFP/index.tsx
+++ b/frontend/client/components/RFP/index.tsx
@@ -13,6 +13,9 @@ import Markdown from 'components/Markdown';
import ProposalCard from 'components/Proposals/ProposalCard';
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 {
@@ -31,7 +34,7 @@ interface DispatchProps {
type Props = OwnProps & StateProps & DispatchProps;
-class RFPDetail extends React.Component {
+export class RFPDetail extends React.Component {
componentDidMount() {
this.props.fetchRfp(this.props.rfpId);
}
@@ -48,6 +51,7 @@ class RFPDetail extends React.Component {
}
}
+ const isLive = rfp.status === RFP_STATUS.LIVE;
const tags = [];
if (rfp.matching) {
@@ -59,9 +63,25 @@ class RFPDetail extends React.Component {
}
if (rfp.bounty) {
+ if (rfp.isVersionTwo) {
+ tags.push(
+
+ {formatUsd(rfp.bounty.toString(10))} bounty
+ ,
+ );
+ } else {
+ tags.push(
+
+ bounty
+ ,
+ );
+ }
+ }
+
+ if (!isLive) {
tags.push(
-
- bounty
+
+ Closed
,
);
}
@@ -77,6 +97,7 @@ class RFPDetail extends React.Component {
Opened {moment(rfp.dateOpened * 1000).format('LL')}
+
{rfp.title}
@@ -86,14 +107,22 @@ class RFPDetail extends React.Component {
- {rfp.bounty && (
-
- Accepted proposals will be funded up to{' '}
-
-
-
-
- )}
+ {rfp.bounty &&
+ rfp.isVersionTwo && (
+
+ Accepted proposals will be funded up to{' '}
+ {formatUsd(rfp.bounty.toString(10))} in ZEC
+
+ )}
+ {rfp.bounty &&
+ !rfp.isVersionTwo && (
+
+ Accepted proposals will be funded up to{' '}
+
+
+
+
+ )}
{rfp.matching && (
Contributions will have their funding matched by the
@@ -105,6 +134,14 @@ class RFPDetail extends React.Component {
Proposal submissions end {moment(rfp.dateCloses * 1000).format('LL')}
)}
+ {rfp.ccr && (
+
+ Submitted by{' '}
+
+ {rfp.ccr.author.displayName}
+
+
+ )}
@@ -117,23 +154,25 @@ class RFPDetail extends React.Component {
)}
-
-
-
- Ready to take on this request? {' '}
-
-
- Start a Proposal
-
-
-
-
-
-
+ {isLive && (
+
+
+
+ Ready to take on this request? {' '}
+
+
+ Start a Proposal
+
+
+
+
+
+
+ )}
);
}
diff --git a/frontend/client/components/RFPs/RFPItem.tsx b/frontend/client/components/RFPs/RFPItem.tsx
index 8863227a..a32ef19c 100644
--- a/frontend/client/components/RFPs/RFPItem.tsx
+++ b/frontend/client/components/RFPs/RFPItem.tsx
@@ -5,6 +5,7 @@ import { Tag } from 'antd';
import { Link } from 'react-router-dom';
import UnitDisplay from 'components/UnitDisplay';
import { RFP } from 'types';
+import { formatUsd } from 'utils/formatters';
import './RFPItem.less';
interface Props {
@@ -25,17 +26,27 @@ export default class RFPItem extends React.Component
{
dateClosed,
bounty,
matching,
+ isVersionTwo,
+ ccr,
} = rfp;
const closeDate = dateCloses || dateClosed;
const tags = [];
if (!isSmall) {
if (bounty) {
- tags.push(
-
- bounty
- ,
- );
+ if (isVersionTwo) {
+ tags.push(
+
+ {formatUsd(bounty.toString(10))} bounty
+ ,
+ );
+ } else {
+ tags.push(
+
+ bounty
+ ,
+ );
+ }
}
if (matching) {
tags.push(
@@ -44,6 +55,13 @@ export default class RFPItem extends React.Component {
,
);
}
+ if (ccr) {
+ tags.push(
+
+ Community Created Request
+ ,
+ );
+ }
}
return (
@@ -65,6 +83,11 @@ export default class RFPItem extends React.Component {
{acceptedProposals.length} proposals approved
+ {ccr && (
+
+ Submitted by {ccr.author.displayName}
+
+ )}
);
diff --git a/frontend/client/components/RFPs/index.tsx b/frontend/client/components/RFPs/index.tsx
index d8cf3fb2..4bb7c65b 100644
--- a/frontend/client/components/RFPs/index.tsx
+++ b/frontend/client/components/RFPs/index.tsx
@@ -75,9 +75,9 @@ class RFPs extends React.Component {
Zcash Foundation Requests
The Zcash Foundation periodically makes requests for proposals that solve
- high-priority needs in the Zcash ecosystem. These proposals will typically
- receive large or matched contributions, should they be approved by the
- Foundation.
+ high-priority needs in the Zcash ecosystem. In addition to funding from the
+ Zcash Foundation, accepted proposals may receive supplemental donations from
+ the community when they have set a "tip address" for their proposal.
diff --git a/frontend/client/components/Settings/Account/RefundAddress.tsx b/frontend/client/components/Settings/Account/RefundAddress.tsx
index 77b9d62c..54c0ab77 100644
--- a/frontend/client/components/Settings/Account/RefundAddress.tsx
+++ b/frontend/client/components/Settings/Account/RefundAddress.tsx
@@ -1,33 +1,55 @@
import React from 'react';
-import { connect } from 'react-redux';
import { Form, Input, Button, message } from 'antd';
-import { AppState } from 'store/reducers';
-import { updateUserSettings, getUserSettings } from 'api/api';
+import { updateUserSettings } from 'api/api';
import { isValidAddress } from 'utils/validators';
+import { UserSettings } from 'types';
-interface StateProps {
+interface Props {
+ userSettings?: UserSettings;
+ isFetching: boolean;
+ errorFetching: boolean;
userid: number;
+ onAddressSet: (refundAddress: UserSettings['refundAddress']) => void;
}
-type Props = StateProps;
+interface State {
+ isSaving: boolean;
+ refundAddress: string | null;
+ refundAddressSet: string | null;
+}
-const STATE = {
- refundAddress: '',
- isFetching: false,
- isSaving: false,
-};
+export default class RefundAddress extends React.Component
{
+ static getDerivedStateFromProps(nextProps: Props, prevState: State) {
+ const { userSettings } = nextProps;
+ const { refundAddress, refundAddressSet } = prevState;
-type State = typeof STATE;
+ const ret: Partial = {};
-class RefundAddress extends React.Component {
- state: State = { ...STATE };
+ if (!userSettings || !userSettings.refundAddress) {
+ return ret;
+ }
- componentDidMount() {
- this.fetchRefundAddress();
+ if (userSettings.refundAddress !== refundAddressSet) {
+ ret.refundAddressSet = userSettings.refundAddress;
+
+ if (refundAddress === null) {
+ ret.refundAddress = userSettings.refundAddress;
+ }
+ }
+
+ return ret;
}
+ state: State = {
+ isSaving: false,
+ refundAddress: null,
+ refundAddressSet: null,
+ };
+
render() {
- const { refundAddress, isFetching, isSaving } = this.state;
+ const { isSaving, refundAddress, refundAddressSet } = this.state;
+ const { isFetching, errorFetching } = this.props;
+ const addressChanged = refundAddress !== refundAddressSet;
let status: 'validating' | 'error' | undefined;
let help;
@@ -42,10 +64,10 @@ class RefundAddress extends React.Component {
@@ -53,7 +75,9 @@ class RefundAddress extends React.Component {
type="primary"
htmlType="submit"
size="large"
- disabled={!refundAddress || isSaving || !!status}
+ disabled={
+ !refundAddress || isSaving || !!status || errorFetching || !addressChanged
+ }
loading={isSaving}
block
>
@@ -63,19 +87,6 @@ class RefundAddress extends React.Component {
);
}
- private async fetchRefundAddress() {
- const { userid } = this.props;
- this.setState({ isFetching: true });
- try {
- const res = await getUserSettings(userid);
- this.setState({ refundAddress: res.data.refundAddress || '' });
- } catch (err) {
- console.error(err);
- message.error('Failed to get refund address');
- }
- this.setState({ isFetching: false });
- }
-
private handleChange = (ev: React.ChangeEvent) => {
this.setState({ refundAddress: ev.currentTarget.value });
};
@@ -92,7 +103,9 @@ class RefundAddress extends React.Component {
try {
const res = await updateUserSettings(userid, { refundAddress });
message.success('Settings saved');
- this.setState({ refundAddress: res.data.refundAddress || '' });
+ const refundAddressNew = res.data.refundAddress || '';
+ this.setState({ refundAddress: refundAddressNew });
+ this.props.onAddressSet(refundAddressNew);
} catch (err) {
console.error(err);
message.error(err.message || err.toString(), 5);
@@ -100,9 +113,3 @@ class RefundAddress extends React.Component {
this.setState({ isSaving: false });
};
}
-
-const withConnect = connect(state => ({
- userid: state.auth.user ? state.auth.user.userid : 0,
-}));
-
-export default withConnect(RefundAddress);
diff --git a/frontend/client/components/Settings/Account/TipJarAddress.tsx b/frontend/client/components/Settings/Account/TipJarAddress.tsx
new file mode 100644
index 00000000..68e494d4
--- /dev/null
+++ b/frontend/client/components/Settings/Account/TipJarAddress.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+import { Form, Input, Button, message } from 'antd';
+import { updateUserSettings } from 'api/api';
+import { isValidAddress } from 'utils/validators';
+import { UserSettings } from 'types';
+
+interface Props {
+ userSettings?: UserSettings;
+ isFetching: boolean;
+ errorFetching: boolean;
+ userid: number;
+ onAddressSet: (refundAddress: UserSettings['tipJarAddress']) => void;
+}
+
+interface State {
+ isSaving: boolean;
+ tipJarAddress: string | null;
+ tipJarAddressSet: string | null;
+}
+
+export default class TipJarAddress extends React.Component {
+ static getDerivedStateFromProps(nextProps: Props, prevState: State) {
+ const { userSettings } = nextProps;
+ const { tipJarAddress, tipJarAddressSet } = prevState;
+
+ const ret: Partial = {};
+
+ if (!userSettings || userSettings.tipJarAddress === undefined) {
+ return ret;
+ }
+
+ if (userSettings.tipJarAddress !== tipJarAddressSet) {
+ ret.tipJarAddressSet = userSettings.tipJarAddress;
+
+ if (tipJarAddress === null) {
+ ret.tipJarAddress = userSettings.tipJarAddress;
+ }
+ }
+
+ return ret;
+ }
+
+ state: State = {
+ isSaving: false,
+ tipJarAddress: null,
+ tipJarAddressSet: null,
+ };
+
+ render() {
+ const { isSaving, tipJarAddress, tipJarAddressSet } = this.state;
+ const { isFetching, errorFetching, userSettings } = this.props;
+ const addressChanged = tipJarAddress !== tipJarAddressSet;
+ const hasViewKeySet = userSettings && userSettings.tipJarViewKey;
+
+ let addressIsValid;
+ let status: 'validating' | 'error' | undefined;
+ let help;
+
+ if (tipJarAddress !== null) {
+ addressIsValid = tipJarAddress === '' || isValidAddress(tipJarAddress);
+ }
+
+ if (isFetching) {
+ status = 'validating';
+ } else if (tipJarAddress !== null && !addressIsValid) {
+ status = 'error';
+ help = 'That doesn’t look like a valid address';
+ }
+
+ if (tipJarAddress === '' && hasViewKeySet) {
+ status = 'error';
+ help = 'You must unset your view key before unsetting your address';
+ }
+
+ return (
+
+
+
+
+
+
+ Change tip address
+
+
+ );
+ }
+
+ private handleChange = (ev: React.ChangeEvent) => {
+ this.setState({ tipJarAddress: ev.currentTarget.value });
+ };
+
+ private handleSubmit = async (ev: React.FormEvent) => {
+ ev.preventDefault();
+ const { userid } = this.props;
+ const { tipJarAddress } = this.state;
+
+ if (tipJarAddress === null) return;
+
+ this.setState({ isSaving: true });
+ try {
+ const res = await updateUserSettings(userid, { tipJarAddress });
+ message.success('Settings saved');
+ const tipJarAddressNew =
+ res.data.tipJarAddress === undefined ? null : res.data.tipJarAddress;
+ this.setState({ tipJarAddress: tipJarAddressNew });
+ this.props.onAddressSet(tipJarAddressNew);
+ } catch (err) {
+ console.error(err);
+ message.error(err.message || err.toString(), 5);
+ }
+ this.setState({ isSaving: false });
+ };
+}
diff --git a/frontend/client/components/Settings/Account/TipJarViewKey.tsx b/frontend/client/components/Settings/Account/TipJarViewKey.tsx
new file mode 100644
index 00000000..1b44cf9c
--- /dev/null
+++ b/frontend/client/components/Settings/Account/TipJarViewKey.tsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import { Form, Input, Button, message } from 'antd';
+import { updateUserSettings } from 'api/api';
+import { UserSettings } from 'types';
+
+interface Props {
+ userSettings?: UserSettings;
+ isFetching: boolean;
+ errorFetching: boolean;
+ userid: number;
+ onViewKeySet: (viewKey: UserSettings['tipJarViewKey']) => void;
+}
+
+interface State {
+ isSaving: boolean;
+ tipJarViewKey: string | null;
+ tipJarViewKeySet: string | null;
+}
+
+export default class TipJarViewKey extends React.Component {
+ static getDerivedStateFromProps(nextProps: Props, prevState: State) {
+ const { userSettings } = nextProps;
+ const { tipJarViewKey, tipJarViewKeySet } = prevState;
+
+ const ret: Partial = {};
+
+ if (!userSettings || userSettings.tipJarViewKey === undefined) {
+ return ret;
+ }
+
+ if (userSettings.tipJarViewKey !== tipJarViewKeySet) {
+ ret.tipJarViewKeySet = userSettings.tipJarViewKey;
+
+ if (tipJarViewKey === null) {
+ ret.tipJarViewKey = userSettings.tipJarViewKey;
+ }
+ }
+
+ return ret;
+ }
+
+ state: State = {
+ isSaving: false,
+ tipJarViewKey: null,
+ tipJarViewKeySet: null,
+ };
+
+ render() {
+ const { isSaving, tipJarViewKey, tipJarViewKeySet } = this.state;
+ const { isFetching, errorFetching, userSettings } = this.props;
+ const viewKeyChanged = tipJarViewKey !== tipJarViewKeySet;
+ const viewKeyDisabled = !(userSettings && userSettings.tipJarAddress);
+
+ // TODO: add view key validation
+
+ // let status: 'validating' | 'error' | undefined;
+ // let help;
+ // if (isFetching) {
+ // status = 'validating';
+ // } else if (tipJarAddress && !isValidAddress(tipJarAddress)) {
+ // status = 'error';
+ // help = 'That doesn’t look like a valid address';
+ // }
+
+ return (
+
+
+
+
+
+
+ Change tip view key
+
+
+ );
+ }
+
+ private handleChange = (ev: React.ChangeEvent) => {
+ this.setState({ tipJarViewKey: ev.currentTarget.value });
+ };
+
+ private handleSubmit = async (ev: React.FormEvent) => {
+ ev.preventDefault();
+ const { userid } = this.props;
+ const { tipJarViewKey } = this.state;
+
+ if (tipJarViewKey === null) return;
+
+ this.setState({ isSaving: true });
+ try {
+ const res = await updateUserSettings(userid, { tipJarViewKey });
+ message.success('Settings saved');
+ const tipJarViewKeyNew = res.data.tipJarViewKey || '';
+ this.setState({ tipJarViewKey: tipJarViewKeyNew });
+ this.props.onViewKeySet(tipJarViewKeyNew);
+ } catch (err) {
+ console.error(err);
+ message.error(err.message || err.toString(), 5);
+ }
+ this.setState({ isSaving: false });
+ };
+}
diff --git a/frontend/client/components/Settings/Account/index.tsx b/frontend/client/components/Settings/Account/index.tsx
index 58d9b197..2ba54322 100644
--- a/frontend/client/components/Settings/Account/index.tsx
+++ b/frontend/client/components/Settings/Account/index.tsx
@@ -1,16 +1,126 @@
import React from 'react';
-import { Divider } from 'antd';
+import { Divider, message } from 'antd';
+import { connect } from 'react-redux';
+import { AppState } from 'store/reducers';
+import { getUserSettings } from 'api/api';
import ChangeEmail from './ChangeEmail';
import RefundAddress from './RefundAddress';
+import TipJarAddress from './TipJarAddress';
+import ViewingKey from './TipJarViewKey';
+import { UserSettings } from 'types';
+
+interface StateProps {
+ userid: number;
+}
+
+type Props = StateProps;
+interface State {
+ userSettings: UserSettings | undefined;
+ isFetching: boolean;
+ errorFetching: boolean;
+}
+
+const STATE: State = {
+ userSettings: undefined,
+ isFetching: false,
+ errorFetching: false,
+};
+
+class AccountSettings extends React.Component {
+ state: State = { ...STATE };
+
+ componentDidMount() {
+ this.fetchUserSettings();
+ }
-export default class AccountSettings extends React.Component<{}> {
render() {
+ const { userid } = this.props;
+ const { userSettings, isFetching, errorFetching } = this.state;
+
return (
);
}
+
+ private async fetchUserSettings() {
+ const { userid } = this.props;
+ this.setState({ isFetching: true });
+ try {
+ const res = await getUserSettings(userid);
+ this.setState({ userSettings: res.data || undefined });
+ } catch (err) {
+ console.error(err);
+ message.error('Failed to get user settings');
+ this.setState({ errorFetching: true });
+ }
+ this.setState({ isFetching: false });
+ }
+
+ private handleRefundAddressSet = (refundAddress: UserSettings['refundAddress']) => {
+ const { userSettings } = this.state;
+ if (!userSettings) return;
+
+ this.setState({
+ userSettings: {
+ ...userSettings,
+ refundAddress,
+ },
+ });
+ };
+
+ private handleTipJarAddressSet = (tipJarAddress: UserSettings['tipJarAddress']) => {
+ const { userSettings } = this.state;
+ if (!userSettings) return;
+
+ this.setState({
+ userSettings: {
+ ...userSettings,
+ tipJarAddress,
+ },
+ });
+ };
+
+ private handleTipJarViewKeySet = (tipJarViewKey: UserSettings['tipJarViewKey']) => {
+ const { userSettings } = this.state;
+ if (!userSettings) return;
+
+ this.setState({
+ userSettings: {
+ ...userSettings,
+ tipJarViewKey,
+ },
+ });
+ };
}
+
+const withConnect = connect(state => ({
+ userid: state.auth.user ? state.auth.user.userid : 0,
+}));
+
+export default withConnect(AccountSettings);
diff --git a/frontend/client/components/Template/index.tsx b/frontend/client/components/Template/index.tsx
index fb2a7493..defb614a 100644
--- a/frontend/client/components/Template/index.tsx
+++ b/frontend/client/components/Template/index.tsx
@@ -36,6 +36,8 @@ export default class Template extends React.PureComponent {
)}
+ {/*
+ // @ts-ignore */}
{content}
diff --git a/frontend/client/components/TipJar/TipJarBlock.less b/frontend/client/components/TipJar/TipJarBlock.less
new file mode 100644
index 00000000..ee963259
--- /dev/null
+++ b/frontend/client/components/TipJar/TipJarBlock.less
@@ -0,0 +1,36 @@
+@import '~styles/variables.less';
+
+.TipJarBlock {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+
+ .ant-form-item {
+ width: 100%;
+ }
+
+ .ant-radio-wrapper {
+ margin-bottom: 0.5rem;
+ opacity: 0.7;
+ font-size: 0.8rem;
+
+ &:hover {
+ opacity: 1;
+ }
+
+ .anticon {
+ margin-left: 0.2rem;
+ color: @primary-color;
+ }
+ }
+
+ &-title {
+ font-size: 1.4rem;
+ line-height: 2rem;
+ margin-bottom: 1rem;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+}
diff --git a/frontend/client/components/TipJar/TipJarBlock.tsx b/frontend/client/components/TipJar/TipJarBlock.tsx
new file mode 100644
index 00000000..e3c2533d
--- /dev/null
+++ b/frontend/client/components/TipJar/TipJarBlock.tsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import { Button, Form, Input, Tooltip } from 'antd';
+import { TipJarModal } from './TipJarModal';
+import { getAmountErrorFromString } from 'utils/validators';
+import './TipJarBlock.less';
+import '../Proposal/index.less';
+
+interface Props {
+ address?: string | null;
+ type: 'user' | 'proposal';
+}
+
+const STATE = {
+ tipAmount: '',
+ modalOpen: false,
+};
+
+type State = typeof STATE;
+
+export class TipJarBlock extends React.Component {
+ state = STATE;
+
+ render() {
+ const { address, type } = this.props;
+ const { tipAmount, modalOpen } = this.state;
+ const amountError = tipAmount ? getAmountErrorFromString(tipAmount) : '';
+
+ const addressNotSet = !address;
+ const buttonTooltip = addressNotSet
+ ? `Tipping address has not been set for ${type}`
+ : '';
+ const isDisabled = addressNotSet || !tipAmount || !!amountError;
+
+ return (
+
+
+
+
+
+
+
+ 🎉 Tip
+
+
+
+
+ {address &&
+ tipAmount && (
+
+ )}
+
+ );
+ }
+
+ private handleAmountChange = (e: React.ChangeEvent) =>
+ this.setState({
+ tipAmount: e.currentTarget.value,
+ });
+
+ private handleTipJarModalOpen = () =>
+ this.setState({
+ modalOpen: true,
+ });
+
+ private handleTipJarModalClose = () =>
+ this.setState({
+ modalOpen: false,
+ });
+}
diff --git a/frontend/client/components/TipJar/TipJarModal.less b/frontend/client/components/TipJar/TipJarModal.less
new file mode 100644
index 00000000..1096be42
--- /dev/null
+++ b/frontend/client/components/TipJar/TipJarModal.less
@@ -0,0 +1,64 @@
+@import '~styles/variables.less';
+
+.TipJarModal {
+
+ &-uri {
+ display: flex;
+ padding-bottom: 1.5rem;
+ margin-bottom: 0.75rem;
+ border-bottom: 1px solid rgba(#000, 0.06);
+
+ &-qr {
+ position: relative;
+ padding: 0.5rem;
+ margin-right: 1rem;
+ border-radius: 4px;
+ box-shadow: 0 1px 2px rgba(#000, 0.1), 0 1px 4px rgba(#000, 0.2);
+
+ canvas {
+ display: flex;
+ flex-shrink: 1;
+ }
+ &.is-loading canvas {
+ opacity: 0;
+ }
+
+ .ant-spin {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ }
+ }
+
+ &-info {
+ flex: 1;
+ }
+ }
+
+ &-fields {
+ &-row {
+ display: flex;
+
+ > * {
+ flex: 1;
+ margin-right: 0.75rem;
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+
+ &-address {
+ min-width: 300px;
+ }
+ }
+ }
+
+ // Ant overrides
+ input[readonly],
+ textarea[readonly] {
+ background: rgba(#000, 0.05);
+ }
+}
+
diff --git a/frontend/client/components/TipJar/TipJarModal.tsx b/frontend/client/components/TipJar/TipJarModal.tsx
new file mode 100644
index 00000000..2a2f30cc
--- /dev/null
+++ b/frontend/client/components/TipJar/TipJarModal.tsx
@@ -0,0 +1,124 @@
+import React from 'react';
+import { Modal, Icon, Button, Form, Input } from 'antd';
+import classnames from 'classnames';
+import QRCode from 'qrcode.react';
+import { formatZcashCLI, formatZcashURI } from 'utils/formatters';
+import { getAmountErrorFromString } from 'utils/validators';
+import Loader from 'components/Loader';
+import './TipJarModal.less';
+import CopyInput from 'components/CopyInput';
+
+interface Props {
+ isOpen: boolean;
+ onClose: () => void;
+ type: 'user' | 'proposal';
+ address: string;
+ amount: string;
+}
+
+interface State {
+ amount: string;
+}
+
+export class TipJarModal extends React.Component {
+ static getDerivedStateFromProps = (nextProps: Props) => {
+ // while modal is closed, set amount state via props
+ return !nextProps.isOpen ? { amount: nextProps.amount } : {};
+ };
+
+ state: State = {
+ amount: '',
+ };
+
+ render() {
+ const { isOpen, onClose, type, address } = this.props;
+ const { amount } = this.state;
+
+ const amountError = getAmountErrorFromString(amount);
+ const amountIsValid = !amountError;
+
+ const cli = amountIsValid ? formatZcashCLI(address, amount) : '';
+ const uri = amountIsValid ? formatZcashURI(address, amount) : '';
+
+ const content = (
+
+
+
+
+
+
+
+
+
+ Open in Wallet
+
+
+
+
+
+
+ );
+ return (
+
+ Done
+
+ }
+ afterClose={this.handleAfterClose}
+ >
+ {content}
+
+ );
+ }
+
+ private handleAmountChange = (e: React.ChangeEvent) =>
+ this.setState({
+ amount: e.currentTarget.value,
+ });
+
+ private handleAfterClose = () => this.setState({ amount: '' });
+}
diff --git a/frontend/client/components/TipJar/TipJarProposalSettingsModal.tsx b/frontend/client/components/TipJar/TipJarProposalSettingsModal.tsx
new file mode 100644
index 00000000..eb3e331f
--- /dev/null
+++ b/frontend/client/components/TipJar/TipJarProposalSettingsModal.tsx
@@ -0,0 +1,224 @@
+import React from 'react';
+import { Modal, Button, Form, Input } from 'antd';
+import { Divider, message } from 'antd';
+import { bindActionCreators, Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { AppState } from 'store/reducers';
+import { updateProposal, TUpdateProposal } from 'modules/proposals/actions';
+import { Proposal } from 'types';
+import { updateProposalTipJarSettings } from 'api/api';
+import { isValidAddress } from 'utils/validators';
+
+interface OwnProps {
+ proposal: Proposal;
+ handleClose: () => void;
+ isVisible: boolean;
+}
+
+interface DispatchProps {
+ updateProposal: TUpdateProposal;
+}
+
+type Props = OwnProps & DispatchProps;
+
+interface State {
+ setViewKey: string | null;
+ setAddress: string | null;
+ address: string | null;
+ viewKey: string | null;
+ isSavingAddress: boolean;
+ isSavingViewKey: boolean;
+}
+
+class TipJarProposalSettingsModalBase extends React.Component {
+ static getDerivedStateFromProps(nextProps: Props, prevState: State) {
+ const { proposal } = nextProps;
+ const { setAddress, setViewKey, address, viewKey } = prevState;
+
+ const ret: Partial = {};
+
+ if (proposal.tipJarAddress !== setAddress) {
+ ret.setAddress = proposal.tipJarAddress;
+
+ if (address === null) {
+ ret.address = proposal.tipJarAddress;
+ }
+ }
+
+ if (proposal.tipJarViewKey !== setViewKey) {
+ ret.setViewKey = proposal.tipJarViewKey;
+
+ if (viewKey === null) {
+ ret.viewKey = proposal.tipJarViewKey;
+ }
+ }
+
+ return ret;
+ }
+
+ state: State = {
+ setViewKey: null,
+ setAddress: null,
+ address: null,
+ viewKey: null,
+ isSavingAddress: false,
+ isSavingViewKey: false,
+ };
+
+ render() {
+ const {
+ address,
+ viewKey,
+ setAddress,
+ setViewKey,
+ isSavingAddress,
+ isSavingViewKey,
+ } = this.state;
+
+ let addressIsValid;
+ let addressStatus: 'validating' | 'error' | undefined;
+ let addressHelp;
+ if (address !== null) {
+ addressIsValid = address === '' || isValidAddress(address);
+ }
+ if (address !== null && !addressIsValid) {
+ addressStatus = 'error';
+ addressHelp = 'That doesn’t look like a valid address';
+ }
+
+ const addressHasChanged = address !== setAddress;
+ const viewKeyHasChanged = viewKey !== setViewKey;
+
+ const addressInputDisabled = isSavingAddress;
+ const viewKeyInputDisabled = isSavingViewKey || !setAddress;
+
+ const addressButtonDisabled =
+ !addressHasChanged || isSavingAddress || !addressIsValid;
+ const viewKeyButtonDisabled = !viewKeyHasChanged || isSavingViewKey || !setAddress;
+
+ const content = (
+ <>
+
+
+
+
+
+ Change tip address
+
+
+
+
+
+
+
+
+
+
+ Change tip view key
+
+
+ >
+ );
+
+ return (
+
+ Done
+
+ }
+ >
+ {content}
+
+ );
+ }
+
+ handleTipJarViewKeyChange = (e: React.ChangeEvent) =>
+ this.setState({ viewKey: e.currentTarget.value });
+
+ handleTipJarAddressChange = (e: React.ChangeEvent) =>
+ this.setState({ address: e.currentTarget.value });
+
+ handleTipJarAddressSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const {
+ proposal: { proposalId },
+ } = this.props;
+ const { address } = this.state;
+
+ if (address === null) return;
+
+ this.setState({ isSavingAddress: true });
+ try {
+ const res = await updateProposalTipJarSettings(proposalId, { address });
+ message.success('Address saved');
+ this.props.updateProposal(res.data);
+ } catch (err) {
+ console.error(err);
+ message.error(err.message || err.toString(), 5);
+ }
+ this.setState({ isSavingAddress: false });
+ };
+
+ handleTipJarViewKeySubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const {
+ proposal: { proposalId },
+ } = this.props;
+ const { viewKey } = this.state;
+
+ if (viewKey === null) return;
+
+ this.setState({ isSavingViewKey: true });
+ try {
+ const res = await updateProposalTipJarSettings(proposalId, { viewKey });
+ message.success('View key saved');
+ this.props.updateProposal(res.data);
+ } catch (err) {
+ console.error(err);
+ message.error(err.message || err.toString(), 5);
+ }
+ this.setState({ isSavingViewKey: false });
+ };
+}
+
+function mapDispatchToProps(dispatch: Dispatch) {
+ return bindActionCreators({ updateProposal }, dispatch);
+}
+
+export const TipJarProposalSettingsModal = connect<{}, DispatchProps, OwnProps, AppState>(
+ null,
+ mapDispatchToProps,
+)(TipJarProposalSettingsModalBase);
diff --git a/frontend/client/components/TipJar/index.tsx b/frontend/client/components/TipJar/index.tsx
new file mode 100644
index 00000000..8980f31f
--- /dev/null
+++ b/frontend/client/components/TipJar/index.tsx
@@ -0,0 +1,3 @@
+export * from './TipJarBlock';
+export * from './TipJarModal';
+export * from './TipJarProposalSettingsModal';
diff --git a/frontend/client/modules/ccr/actions.ts b/frontend/client/modules/ccr/actions.ts
new file mode 100644
index 00000000..cf5c7435
--- /dev/null
+++ b/frontend/client/modules/ccr/actions.ts
@@ -0,0 +1,68 @@
+import { Dispatch } from 'redux';
+import types from './types';
+import { CCRDraft } from 'types/ccr';
+import { putCCR, putCCRSubmitForApproval } from 'api/api';
+
+export function initializeForm(ccrId: number) {
+ return {
+ type: types.INITIALIZE_CCR_FORM_PENDING,
+ payload: ccrId,
+ };
+}
+
+export function updateCCRForm(form: Partial) {
+ return (dispatch: Dispatch) => {
+ dispatch({
+ type: types.UPDATE_CCR_FORM,
+ payload: form,
+ });
+ dispatch(saveCCRDraft());
+ };
+}
+
+export function saveCCRDraft() {
+ return { type: types.SAVE_CCR_DRAFT_PENDING };
+}
+
+export function fetchCCRDrafts() {
+ return { type: types.FETCH_CCR_DRAFTS_PENDING };
+}
+
+export function createCCRDraft() {
+ return {
+ type: types.CREATE_CCR_DRAFT_PENDING,
+ };
+}
+
+export function fetchAndCreateCCRDrafts() {
+ return {
+ type: types.FETCH_AND_CREATE_CCR_DRAFTS,
+ };
+}
+
+export function deleteCCRDraft(ccrId: number) {
+ return {
+ type: types.DELETE_CCR_DRAFT_PENDING,
+ payload: ccrId,
+ };
+}
+
+export function submitCCR(form: CCRDraft) {
+ return async (dispatch: Dispatch) => {
+ dispatch({ type: types.SUBMIT_CCR_PENDING });
+ try {
+ await putCCR(form);
+ const res = await putCCRSubmitForApproval(form);
+ dispatch({
+ type: types.SUBMIT_CCR_FULFILLED,
+ payload: res.data,
+ });
+ } catch (err) {
+ dispatch({
+ type: types.SUBMIT_CCR_REJECTED,
+ payload: err.message || err.toString(),
+ error: true,
+ });
+ }
+ };
+}
diff --git a/frontend/client/modules/ccr/index.ts b/frontend/client/modules/ccr/index.ts
new file mode 100644
index 00000000..2446dd72
--- /dev/null
+++ b/frontend/client/modules/ccr/index.ts
@@ -0,0 +1,8 @@
+import reducers, { CCRState, INITIAL_STATE } from './reducers';
+import * as ccrActions from './actions';
+import * as ccrTypes from './types';
+import ccrSagas from './sagas';
+
+export { ccrActions, ccrTypes, ccrSagas, CCRState, INITIAL_STATE };
+
+export default reducers;
diff --git a/frontend/client/modules/ccr/reducers.ts b/frontend/client/modules/ccr/reducers.ts
new file mode 100644
index 00000000..82b298dd
--- /dev/null
+++ b/frontend/client/modules/ccr/reducers.ts
@@ -0,0 +1,195 @@
+import types from './types';
+import { CCRDraft, CCR } from 'types';
+
+export interface CCRState {
+ drafts: CCRDraft[] | null;
+ form: CCRDraft | null;
+
+ isInitializingForm: boolean;
+ initializeFormError: string | null;
+
+ isSavingDraft: boolean;
+ hasSavedDraft: boolean;
+ saveDraftError: string | null;
+
+ isFetchingDrafts: boolean;
+ fetchDraftsError: string | null;
+
+ isCreatingDraft: boolean;
+ createDraftError: string | null;
+
+ isDeletingDraft: boolean;
+ deleteDraftError: string | null;
+
+ submittedCCR: CCR | null;
+ isSubmitting: boolean;
+ submitError: string | null;
+
+ publishedCCR: CCR | null;
+ isPublishing: boolean;
+ publishError: string | null;
+}
+
+export const INITIAL_STATE: CCRState = {
+ drafts: null,
+ form: null,
+
+ isInitializingForm: false,
+ initializeFormError: null,
+
+ isSavingDraft: false,
+ hasSavedDraft: true,
+ saveDraftError: null,
+
+ isFetchingDrafts: false,
+ fetchDraftsError: null,
+
+ isCreatingDraft: false,
+ createDraftError: null,
+
+ isDeletingDraft: false,
+ deleteDraftError: null,
+
+ submittedCCR: null,
+ isSubmitting: false,
+ submitError: null,
+
+ publishedCCR: null,
+ isPublishing: false,
+ publishError: null,
+};
+
+export default function createReducer(
+ state: CCRState = INITIAL_STATE,
+ action: any,
+): CCRState {
+ switch (action.type) {
+ case types.UPDATE_CCR_FORM:
+ return {
+ ...state,
+ form: {
+ ...state.form,
+ ...action.payload,
+ },
+ hasSavedDraft: false,
+ };
+
+ case types.INITIALIZE_CCR_FORM_PENDING:
+ return {
+ ...state,
+ form: null,
+ isInitializingForm: true,
+ initializeFormError: null,
+ };
+ case types.INITIALIZE_CCR_FORM_FULFILLED:
+ return {
+ ...state,
+ form: { ...action.payload },
+ isInitializingForm: false,
+ };
+ case types.INITIALIZE_CCR_FORM_REJECTED:
+ return {
+ ...state,
+ isInitializingForm: false,
+ initializeFormError: action.payload,
+ };
+
+ case types.SAVE_CCR_DRAFT_PENDING:
+ return {
+ ...state,
+ isSavingDraft: true,
+ };
+ case types.SAVE_CCR_DRAFT_FULFILLED:
+ return {
+ ...state,
+ isSavingDraft: false,
+ hasSavedDraft: true,
+ // Only clear error once save was a success
+ saveDraftError: null,
+ };
+ case types.SAVE_CCR_DRAFT_REJECTED:
+ return {
+ ...state,
+ isSavingDraft: false,
+ hasSavedDraft: false,
+ saveDraftError: action.payload,
+ };
+
+ case types.FETCH_CCR_DRAFTS_PENDING:
+ return {
+ ...state,
+ isFetchingDrafts: true,
+ fetchDraftsError: null,
+ };
+ case types.FETCH_CCR_DRAFTS_FULFILLED:
+ return {
+ ...state,
+ isFetchingDrafts: false,
+ drafts: action.payload,
+ };
+ case types.FETCH_CCR_DRAFTS_REJECTED:
+ return {
+ ...state,
+ isFetchingDrafts: false,
+ fetchDraftsError: action.payload,
+ };
+
+ case types.CREATE_CCR_DRAFT_PENDING:
+ return {
+ ...state,
+ isCreatingDraft: true,
+ createDraftError: null,
+ };
+ case types.CREATE_CCR_DRAFT_FULFILLED:
+ return {
+ ...state,
+ drafts: [...(state.drafts || []), action.payload],
+ isCreatingDraft: false,
+ };
+ case types.CREATE_CCR_DRAFT_REJECTED:
+ return {
+ ...state,
+ createDraftError: action.payload,
+ isCreatingDraft: false,
+ };
+
+ case types.DELETE_CCR_DRAFT_PENDING:
+ return {
+ ...state,
+ isDeletingDraft: true,
+ deleteDraftError: null,
+ };
+ case types.DELETE_CCR_DRAFT_FULFILLED:
+ return {
+ ...state,
+ isDeletingDraft: false,
+ };
+ case types.DELETE_CCR_DRAFT_REJECTED:
+ return {
+ ...state,
+ isDeletingDraft: false,
+ deleteDraftError: action.payload,
+ };
+
+ case types.SUBMIT_CCR_PENDING:
+ return {
+ ...state,
+ submittedCCR: null,
+ isSubmitting: true,
+ submitError: null,
+ };
+ case types.SUBMIT_CCR_FULFILLED:
+ return {
+ ...state,
+ submittedCCR: action.payload,
+ isSubmitting: false,
+ };
+ case types.SUBMIT_CCR_REJECTED:
+ return {
+ ...state,
+ submitError: action.payload,
+ isSubmitting: false,
+ };
+ }
+ return state;
+}
diff --git a/frontend/client/modules/ccr/sagas.ts b/frontend/client/modules/ccr/sagas.ts
new file mode 100644
index 00000000..05ffe7a4
--- /dev/null
+++ b/frontend/client/modules/ccr/sagas.ts
@@ -0,0 +1,144 @@
+import { SagaIterator } from 'redux-saga';
+import { takeEvery, takeLatest, put, take, call, select } from 'redux-saga/effects';
+import { replace } from 'connected-react-router';
+import {
+ postCCRDraft,
+ getCCRDrafts,
+ putCCR,
+ deleteCCR as RDeleteCCRDraft,
+ getCCR,
+} from 'api/api';
+import { getDrafts, getDraftById, getFormState } from './selectors';
+import {
+ createCCRDraft,
+ fetchCCRDrafts,
+ initializeForm,
+ deleteCCRDraft,
+} from './actions';
+import types from './types';
+
+export function* handleCreateDraft(): SagaIterator {
+ try {
+ const res: Yielded = yield call(postCCRDraft);
+ yield put({
+ type: types.CREATE_CCR_DRAFT_FULFILLED,
+ payload: res.data,
+ });
+ yield put(replace(`/ccrs/${res.data.ccrId}/edit`));
+ } catch (err) {
+ yield put({
+ type: types.CREATE_CCR_DRAFT_REJECTED,
+ payload: err.message || err.toString(),
+ error: true,
+ });
+ }
+}
+
+export function* handleFetchDrafts(): SagaIterator {
+ try {
+ const res: Yielded = yield call(getCCRDrafts);
+ yield put({
+ type: types.FETCH_CCR_DRAFTS_FULFILLED,
+ payload: res.data,
+ });
+ } catch (err) {
+ yield put({
+ type: types.FETCH_CCR_DRAFTS_REJECTED,
+ payload: err.message || err.toString(),
+ error: true,
+ });
+ }
+}
+
+export function* handleSaveDraft(): SagaIterator {
+ try {
+ const draft: Yielded = yield select(getFormState);
+ if (!draft) {
+ throw new Error('No form state to save draft');
+ }
+ yield call(putCCR, draft);
+ yield put({ type: types.SAVE_CCR_DRAFT_FULFILLED });
+ } catch (err) {
+ yield put({
+ type: types.SAVE_CCR_DRAFT_REJECTED,
+ payload: err.message || err.toString(),
+ error: true,
+ });
+ }
+}
+
+export function* handleFetchAndCreateDrafts(): SagaIterator {
+ yield put(fetchCCRDrafts());
+ yield take([types.FETCH_CCR_DRAFTS_FULFILLED, types.FETCH_CCR_DRAFTS_PENDING]);
+ const drafts: Yielded = yield select(getDrafts);
+
+ // Back out if draft fetch failed and we don't have drafts
+ if (!drafts) {
+ console.warn('Fetch of drafts failed, not creating new draft');
+ return;
+ }
+
+ if (drafts.length === 0) {
+ yield put(createCCRDraft());
+ }
+}
+
+export function* handleDeleteDraft(
+ action: ReturnType,
+): SagaIterator {
+ try {
+ yield call(RDeleteCCRDraft, action.payload);
+ put({ type: types.DELETE_CCR_DRAFT_FULFILLED });
+ } catch (err) {
+ yield put({
+ type: types.DELETE_CCR_DRAFT_REJECTED,
+ payload: err.message || err.toString(),
+ error: true,
+ });
+ return;
+ }
+ yield call(handleFetchDrafts);
+}
+
+export function* handleInitializeForm(
+ action: ReturnType,
+): SagaIterator {
+ try {
+ const ccrId = action.payload;
+ let draft: Yielded = yield select(getDraftById, ccrId);
+ if (!draft) {
+ yield call(handleFetchDrafts);
+ draft = yield select(getDraftById, ccrId);
+ if (!draft) {
+ // If it's a real ccr, just not in draft form, redirect to it
+ try {
+ yield call(getCCR, ccrId);
+ yield put({ type: types.INITIALIZE_CCR_FORM_REJECTED });
+ yield put(replace(`/ccrs/${action.payload}`));
+ return;
+ } catch (err) {
+ throw new Error('CCR not found');
+ }
+ }
+ }
+ yield put({
+ type: types.INITIALIZE_CCR_FORM_FULFILLED,
+ payload: draft,
+ });
+ } catch (err) {
+ yield put({
+ type: types.INITIALIZE_CCR_FORM_REJECTED,
+ payload: err.message || err.toString(),
+ error: true,
+ });
+ }
+}
+
+export default function* ccrSagas(): SagaIterator {
+ yield takeEvery(types.CREATE_CCR_DRAFT_PENDING, handleCreateDraft);
+ yield takeLatest(types.FETCH_CCR_DRAFTS_PENDING, handleFetchDrafts);
+ yield takeLatest(types.SAVE_CCR_DRAFT_PENDING, handleSaveDraft);
+ yield takeEvery(types.DELETE_CCR_DRAFT_PENDING, handleDeleteDraft);
+ yield takeEvery(types.INITIALIZE_CCR_FORM_PENDING, handleInitializeForm);
+ yield takeEvery(types.FETCH_AND_CREATE_CCR_DRAFTS, handleFetchAndCreateDrafts);
+}
diff --git a/frontend/client/modules/ccr/selectors.ts b/frontend/client/modules/ccr/selectors.ts
new file mode 100644
index 00000000..7de00239
--- /dev/null
+++ b/frontend/client/modules/ccr/selectors.ts
@@ -0,0 +1,11 @@
+import { AppState as S } from 'store/reducers';
+
+export const getDrafts = (s: S) => s.ccr.drafts;
+export const getDraftsFetchError = (s: S) => s.ccr.fetchDraftsError;
+
+export const getDraftById = (s: S, id: number) => {
+ const drafts = getDrafts(s) || [];
+ return drafts.find(d => d.ccrId === id);
+};
+
+export const getFormState = (s: S) => s.ccr.form;
diff --git a/frontend/client/modules/ccr/types.ts b/frontend/client/modules/ccr/types.ts
new file mode 100644
index 00000000..65fa2f55
--- /dev/null
+++ b/frontend/client/modules/ccr/types.ts
@@ -0,0 +1,36 @@
+enum CCRTypes {
+ UPDATE_CCR_FORM = 'UPDATE_CCR_FORM',
+
+ INITIALIZE_CCR_FORM = 'INITIALIZE_CCR_FORM',
+ INITIALIZE_CCR_FORM_PENDING = 'INITIALIZE_CCR_FORM_PENDING',
+ INITIALIZE_CCR_FORM_FULFILLED = 'INITIALIZE_CCR_FORM_FULFILLED',
+ INITIALIZE_CCR_FORM_REJECTED = 'INITIALIZE_CCR_FORM_REJECTED',
+
+ SAVE_CCR_DRAFT = 'SAVE_CCR_DRAFT',
+ SAVE_CCR_DRAFT_PENDING = 'SAVE_CCR_DRAFT_PENDING',
+ SAVE_CCR_DRAFT_FULFILLED = 'SAVE_CCR_DRAFT_FULFILLED',
+ SAVE_CCR_DRAFT_REJECTED = 'SAVE_CCR_DRAFT_REJECTED',
+
+ FETCH_CCR_DRAFTS = 'FETCH_CCR_DRAFTS',
+ FETCH_CCR_DRAFTS_PENDING = 'FETCH_CCR_DRAFTS_PENDING',
+ FETCH_CCR_DRAFTS_FULFILLED = 'FETCH_CCR_DRAFTS_FULFILLED',
+ FETCH_CCR_DRAFTS_REJECTED = 'FETCH_CCR_DRAFTS_REJECTED',
+
+ CREATE_CCR_DRAFT = 'CREATE_CCR_DRAFT',
+ CREATE_CCR_DRAFT_PENDING = 'CREATE_CCR_DRAFT_PENDING',
+ CREATE_CCR_DRAFT_FULFILLED = 'CREATE_CCR_DRAFT_FULFILLED',
+ CREATE_CCR_DRAFT_REJECTED = 'CREATE_CCR_DRAFT_REJECTED',
+
+ FETCH_AND_CREATE_CCR_DRAFTS = 'FETCH_AND_CREATE_CCR_DRAFTS',
+
+ DELETE_CCR_DRAFT = 'DELETE_CCR_DRAFT',
+ DELETE_CCR_DRAFT_PENDING = 'DELETE_CCR_DRAFT_PENDING',
+ DELETE_CCR_DRAFT_FULFILLED = 'DELETE_CCR_DRAFT_FULFILLED',
+ DELETE_CCR_DRAFT_REJECTED = 'DELETE_CCR_DRAFT_REJECTED',
+
+ SUBMIT_CCR = 'SUBMIT_CCR',
+ SUBMIT_CCR_PENDING = 'SUBMIT_CCR_PENDING',
+ SUBMIT_CCR_FULFILLED = 'SUBMIT_CCR_FULFILLED',
+ SUBMIT_CCR_REJECTED = 'SUBMIT_CCR_REJECTED',
+}
+export default CCRTypes;
diff --git a/frontend/client/modules/ccr/utils.ts b/frontend/client/modules/ccr/utils.ts
new file mode 100644
index 00000000..9d50a92d
--- /dev/null
+++ b/frontend/client/modules/ccr/utils.ts
@@ -0,0 +1,64 @@
+import { CCRDraft } from 'types';
+import { getAmountErrorUsd, getAmountErrorUsdFromString } from 'utils/validators';
+
+interface CCRFormErrors {
+ title?: string;
+ brief?: string;
+ target?: string;
+ content?: string;
+}
+
+export type KeyOfForm = keyof CCRFormErrors;
+export const FIELD_NAME_MAP: { [key in KeyOfForm]: string } = {
+ title: 'Title',
+ brief: 'Brief',
+ target: 'Target amount',
+ content: 'Details',
+};
+
+const requiredFields = ['title', 'brief', 'target', 'content'];
+
+export function getCCRErrors(
+ form: Partial,
+ skipRequired?: boolean,
+): CCRFormErrors {
+ const errors: CCRFormErrors = {};
+ const { title, content, brief, target } = form;
+
+ // Required fields with no extra validation
+ if (!skipRequired) {
+ for (const key of requiredFields) {
+ if (!form[key as KeyOfForm]) {
+ errors[key as KeyOfForm] = `${FIELD_NAME_MAP[key as KeyOfForm]} is required`;
+ }
+ }
+ }
+
+ // Title
+ if (title && title.length > 60) {
+ errors.title = 'Title can only be 60 characters maximum';
+ }
+
+ // Brief
+ if (brief && brief.length > 140) {
+ errors.brief = 'Brief can only be 140 characters maximum';
+ }
+
+ // Content limit for our database's sake
+ if (content && content.length > 250000) {
+ errors.content = 'Details can only be 250,000 characters maximum';
+ }
+
+ // Amount to raise
+ const targetFloat = target ? parseFloat(target) : 0;
+ if (target && !Number.isNaN(targetFloat)) {
+ const limit = parseFloat(process.env.PROPOSAL_TARGET_MAX as string);
+ const targetErr =
+ getAmountErrorUsd(targetFloat, limit) || getAmountErrorUsdFromString(target);
+ if (targetErr) {
+ errors.target = targetErr;
+ }
+ }
+
+ return errors;
+}
diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts
index f10ee577..5995982b 100644
--- a/frontend/client/modules/create/utils.ts
+++ b/frontend/client/modules/create/utils.ts
@@ -3,18 +3,19 @@ import {
STATUS,
MILESTONE_STAGE,
PROPOSAL_ARBITER_STATUS,
- CreateMilestone,
+ CCRDraft,
+ RFP,
} from 'types';
-import moment from 'moment';
-import { User } from 'types';
+import { User, CCR } from 'types';
import {
- getAmountError,
+ getAmountErrorUsd,
+ getAmountErrorUsdFromString,
isValidSaplingAddress,
isValidTAddress,
isValidSproutAddress,
} from 'utils/validators';
-import { Zat, toZat } from 'utils/units';
-import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants';
+import { toUsd } from 'utils/units';
+import { PROPOSAL_STAGE, RFP_STATUS } from 'api/constants';
import {
ProposalDetail,
PROPOSAL_DETAIL_INITIAL_STATE,
@@ -24,38 +25,28 @@ interface CreateFormErrors {
rfpOptIn?: string;
title?: string;
brief?: string;
- category?: string;
target?: string;
team?: string[];
content?: string;
payoutAddress?: string;
+ tipJarAddress?: string;
milestones?: string[];
- deadlineDuration?: string;
}
export type KeyOfForm = keyof CreateFormErrors;
export const FIELD_NAME_MAP: { [key in KeyOfForm]: string } = {
- rfpOptIn: 'RFP KYC',
+ rfpOptIn: 'KYC',
title: 'Title',
brief: 'Brief',
- category: 'Category',
target: 'Target amount',
team: 'Team',
content: 'Details',
payoutAddress: 'Payout address',
+ tipJarAddress: 'Tip address',
milestones: 'Milestones',
- deadlineDuration: 'Funding deadline',
};
-const requiredFields = [
- 'title',
- 'brief',
- 'category',
- 'target',
- 'content',
- 'payoutAddress',
- 'deadlineDuration',
-];
+const requiredFields = ['title', 'brief', 'target', 'content', 'payoutAddress'];
export function getCreateErrors(
form: Partial,
@@ -69,7 +60,7 @@ export function getCreateErrors(
milestones,
target,
payoutAddress,
- rfp,
+ tipJarAddress,
rfpOptIn,
brief,
} = form;
@@ -91,7 +82,7 @@ export function getCreateErrors(
}
// RFP opt-in
- if (rfp && (rfp.bounty || rfp.matching) && rfpOptIn === null) {
+ if (rfpOptIn === null) {
errors.rfpOptIn = 'Please accept or decline KYC';
}
@@ -114,7 +105,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;
}
@@ -131,10 +123,20 @@ export function getCreateErrors(
}
}
+ // Tip Jar Address
+ if (tipJarAddress && !isValidSaplingAddress(tipJarAddress)) {
+ if (isValidSproutAddress(tipJarAddress)) {
+ errors.tipJarAddress = 'Must be a Sapling address, not a Sprout address';
+ } else if (isValidTAddress(tipJarAddress)) {
+ errors.tipJarAddress = 'Must be a Sapling Z address, not a T address';
+ } else {
+ errors.tipJarAddress = 'That doesn’t look like a valid Sapling address';
+ }
+ }
+
// Milestones
if (milestones) {
let cumulativeMilestonePct = 0;
- let lastMsEst: CreateMilestone['dateEstimated'] = 0;
const milestoneErrors = milestones.map((ms, idx) => {
// check payout first so we collect the cumulativePayout even if other fields are invalid
if (!ms.payoutPercent) {
@@ -164,22 +166,18 @@ export function getCreateErrors(
return 'Description can only be 200 characters maximum';
}
- if (!ms.dateEstimated) {
- return 'Estimate date is required';
- } else {
- // FE validation on milestone estimation
- if (
- ms.dateEstimated <
- moment(Date.now())
- .startOf('month')
- .unix()
- ) {
- return 'Estimate date should be in the future';
+ if (!ms.immediatePayout) {
+ if (!ms.daysEstimated) {
+ return 'Estimate in days is required';
+ } else if (Number.isNaN(parseInt(ms.daysEstimated, 10))) {
+ return 'Days estimated must be a valid number';
+ } else if (parseInt(ms.daysEstimated, 10) !== parseFloat(ms.daysEstimated)) {
+ return 'Days estimated must be a whole number, no decimals';
+ } else if (parseInt(ms.daysEstimated, 10) <= 0) {
+ return 'Days estimated must be greater than 0';
+ } else if (parseInt(ms.daysEstimated, 10) > 365) {
+ return 'Days estimated must be less than or equal to 365';
}
- if (ms.dateEstimated <= lastMsEst) {
- return 'Estimate date should be later than previous estimate date';
- }
- lastMsEst = ms.dateEstimated;
}
if (
@@ -240,25 +238,31 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta
dateCreated: Date.now() / 1000,
datePublished: Date.now() / 1000,
dateApproved: Date.now() / 1000,
- deadlineDuration: 86400 * 60,
- target: toZat(draft.target),
- funded: Zat('0'),
+ target: toUsd(draft.target),
+ funded: toUsd('0'),
contributionMatching: 0,
- contributionBounty: Zat('0'),
+ contributionBounty: toUsd('0'),
percentFunded: 0,
stage: PROPOSAL_STAGE.PREVIEW,
- category: draft.category || PROPOSAL_CATEGORY.CORE_DEV,
isStaked: true,
arbiter: {
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
},
+ tipJarAddress: null,
+ tipJarViewKey: null,
+ acceptedWithFunding: false,
+ authedFollows: false,
+ followersCount: 0,
+ authedLiked: false,
+ likesCount: 0,
+ isVersionTwo: true,
milestones: draft.milestones.map((m, idx) => ({
id: idx,
index: idx,
title: m.title,
content: m.content,
- amount: toZat(target * (parseInt(m.payoutPercent, 10) / 100)),
- dateEstimated: m.dateEstimated,
+ amount: (target * (parseInt(m.payoutPercent, 10) / 100)).toFixed(2),
+ daysEstimated: m.daysEstimated,
immediatePayout: m.immediatePayout,
payoutPercent: m.payoutPercent.toString(),
stage: MILESTONE_STAGE.IDLE,
@@ -266,3 +270,28 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta
...PROPOSAL_DETAIL_INITIAL_STATE,
};
}
+
+export function makeRfpPreviewFromCcrDraft(draft: CCRDraft): RFP {
+ const ccr: CCR = {
+ ...draft,
+ };
+ const now = new Date().getTime();
+ const { brief, content, title } = draft;
+
+ return {
+ id: 0,
+ urlId: '',
+ status: RFP_STATUS.LIVE,
+ acceptedProposals: [],
+ bounty: draft.target ? toUsd(draft.target) : null,
+ matching: false,
+ dateOpened: now / 1000,
+ authedLiked: false,
+ likesCount: 0,
+ isVersionTwo: true,
+ ccr,
+ brief,
+ content,
+ title,
+ };
+}
diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts
index 47cdd719..d73682d0 100644
--- a/frontend/client/modules/proposals/actions.ts
+++ b/frontend/client/modules/proposals/actions.ts
@@ -119,6 +119,16 @@ export function fetchProposal(proposalId: Proposal['proposalId']) {
};
}
+export type TUpdateProposal = typeof updateProposal;
+export function updateProposal(proposal: Proposal) {
+ return (dispatch: Dispatch, getState: GetState) => {
+ dispatch({
+ type: types.PROPOSAL_DATA_FULFILLED,
+ payload: addProposalUserRoles(proposal, getState()),
+ });
+ };
+}
+
export function fetchProposalComments(id?: number) {
return async (dispatch: Dispatch, getState: GetState) => {
const state = getState();
@@ -230,3 +240,17 @@ export function reportProposalComment(
}
};
}
+
+export function updateProposalComment(
+ commentId: Comment['id'],
+ commentUpdate: Partial,
+) {
+ return (dispatch: Dispatch) =>
+ dispatch({
+ type: types.UPDATE_PROPOSAL_COMMENT,
+ payload: {
+ commentId,
+ commentUpdate,
+ },
+ });
+}
diff --git a/frontend/client/modules/proposals/reducers.ts b/frontend/client/modules/proposals/reducers.ts
index 15af0181..d3316241 100644
--- a/frontend/client/modules/proposals/reducers.ts
+++ b/frontend/client/modules/proposals/reducers.ts
@@ -375,6 +375,9 @@ export default (state = INITIAL_STATE, action: any) => {
case types.REPORT_PROPOSAL_COMMENT_FULFILLED:
return updateCommentInStore(state, payload.commentId, { reported: true });
+ case types.UPDATE_PROPOSAL_COMMENT:
+ return updateCommentInStore(state, payload.commentId, payload.commentUpdate);
+
case types.PROPOSAL_UPDATES_PENDING:
return {
...state,
diff --git a/frontend/client/modules/proposals/types.ts b/frontend/client/modules/proposals/types.ts
index 25e31b04..b2cc1f90 100644
--- a/frontend/client/modules/proposals/types.ts
+++ b/frontend/client/modules/proposals/types.ts
@@ -52,6 +52,8 @@ enum proposalTypes {
REPORT_PROPOSAL_COMMENT_PENDING = 'REPORT_PROPOSAL_COMMENT_PENDING',
REPORT_PROPOSAL_COMMENT_FULFILLED = 'REPORT_PROPOSAL_COMMENT_FULFILLED',
REPORT_PROPOSAL_COMMENT_REJECTED = 'REPORT_PROPOSAL_COMMENT_REJECTED',
+
+ UPDATE_PROPOSAL_COMMENT = 'UPDATE_PROPOSAL_COMMENT',
}
export default proposalTypes;
diff --git a/frontend/client/modules/users/actions.ts b/frontend/client/modules/users/actions.ts
index 6a8c0133..1c762ca4 100644
--- a/frontend/client/modules/users/actions.ts
+++ b/frontend/client/modules/users/actions.ts
@@ -8,6 +8,7 @@ import {
deleteProposalContribution,
deleteProposalDraft,
putProposalPublish,
+ deleteCCR,
} from 'api/api';
import { Dispatch } from 'redux';
import { cleanClone } from 'utils/helpers';
@@ -93,7 +94,10 @@ export function respondToInvite(
};
}
-export function deleteContribution(userId: string | number, contributionId: string | number) {
+export function deleteContribution(
+ userId: string | number,
+ contributionId: string | number,
+) {
// Fire and forget
deleteProposalContribution(contributionId);
return {
@@ -126,3 +130,12 @@ export function publishPendingProposal(userId: number, proposalId: number) {
});
};
}
+
+export function deletePendingRequest(userId: number, requestId: number) {
+ return async (dispatch: Dispatch) => {
+ await dispatch({
+ type: types.USER_DELETE_REQUEST,
+ payload: deleteCCR(requestId).then(_ => ({ userId, requestId })),
+ });
+ };
+}
diff --git a/frontend/client/modules/users/reducers.ts b/frontend/client/modules/users/reducers.ts
index 3dd319f4..8fe5b0eb 100644
--- a/frontend/client/modules/users/reducers.ts
+++ b/frontend/client/modules/users/reducers.ts
@@ -6,6 +6,7 @@ import {
UserContribution,
TeamInviteWithProposal,
UserProposalArbiter,
+ UserCCR,
} from 'types';
import types from './types';
@@ -21,8 +22,10 @@ export interface UserState extends User {
isUpdating: boolean;
updateError: string | null;
pendingProposals: UserProposal[];
+ pendingRequests: UserCCR[];
arbitrated: UserProposalArbiter[];
proposals: UserProposal[];
+ requests: UserCCR[];
contributions: UserContribution[];
comments: UserComment[];
isFetchingInvites: boolean;
@@ -53,8 +56,10 @@ export const INITIAL_USER_STATE: UserState = {
isUpdating: false,
updateError: null,
pendingProposals: [],
+ pendingRequests: [],
arbitrated: [],
proposals: [],
+ requests: [],
contributions: [],
comments: [],
isFetchingInvites: false,
@@ -111,24 +116,6 @@ export default (state = INITIAL_STATE, action: any) => {
updateError: errorMessage,
});
// invites
- case types.FETCH_USER_INVITES_PENDING:
- return updateUserState(state, userFetchId, {
- isFetchingInvites: true,
- fetchErrorInvites: null,
- });
- case types.FETCH_USER_INVITES_FULFILLED:
- return updateUserState(state, userFetchId, {
- isFetchingInvites: false,
- hasFetchedInvites: true,
- invites,
- });
- case types.FETCH_USER_INVITES_REJECTED:
- return updateUserState(state, userFetchId, {
- isFetchingInvites: false,
- hasFetchedInvites: true,
- fetchErrorInvites: errorMessage,
- });
- // invites
case types.FETCH_USER_INVITES_PENDING:
return updateUserState(state, userFetchId, {
isFetchingInvites: true,
diff --git a/frontend/client/modules/users/types.ts b/frontend/client/modules/users/types.ts
index 4e6858c8..e633bd9a 100644
--- a/frontend/client/modules/users/types.ts
+++ b/frontend/client/modules/users/types.ts
@@ -26,6 +26,12 @@ enum UsersActions {
USER_PUBLISH_PROPOSAL = 'USER_PUBLISH_PROPOSAL',
USER_PUBLISH_PROPOSAL_FULFILLED = 'USER_PUBLISH_PROPOSAL_FULFILLED',
+
+ USER_DELETE_REQUEST = 'USER_DELETE_REQUEST',
+ USER_DELETE_REQUEST_FULFILLED = 'USER_DELETE_REQUEST_FULFILLED',
+
+ USER_PUBLISH_REQUEST = 'USER_PUBLISH_REQUEST',
+ USER_PUBLISH_REQUEST_FULFILLED = 'USER_PUBLISH_REQUEST_FULFILLED',
}
export default UsersActions;
diff --git a/frontend/client/pages/ccr.tsx b/frontend/client/pages/ccr.tsx
new file mode 100644
index 00000000..fc8d6b6b
--- /dev/null
+++ b/frontend/client/pages/ccr.tsx
@@ -0,0 +1,16 @@
+import React, { Component } from 'react';
+import CcrPreview from 'components/CCRFlow/CCRPreview'
+import { extractIdFromSlug } from 'utils/api';
+
+import { withRouter, RouteComponentProps } from 'react-router';
+
+type RouteProps = RouteComponentProps;
+
+class CcrPage extends Component {
+ render() {
+ const ccrId = extractIdFromSlug(this.props.match.params.id);
+ return ;
+ }
+}
+
+export default withRouter(CcrPage);
diff --git a/frontend/client/pages/create-request.tsx b/frontend/client/pages/create-request.tsx
new file mode 100644
index 00000000..fd61306a
--- /dev/null
+++ b/frontend/client/pages/create-request.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { withRouter, RouteComponentProps } from 'react-router';
+import CreateRequestDraftList from 'components/CCRDraftList';
+
+type Props = RouteComponentProps<{}>;
+
+class CreateRequestPage extends React.Component {
+ render() {
+ return (
+ <>
+
+ Community Request creation requires Javascript. You’ll need to enable it to
+ continue.
+
+
+ >
+ );
+ }
+}
+
+export default withRouter(CreateRequestPage);
diff --git a/frontend/client/pages/email-verify.tsx b/frontend/client/pages/email-verify.tsx
index 0a5da67c..f9d41cb8 100644
--- a/frontend/client/pages/email-verify.tsx
+++ b/frontend/client/pages/email-verify.tsx
@@ -28,7 +28,7 @@ class VerifyEmail extends React.Component {
this.setState({
hasVerified: true,
isVerifying: false,
- })
+ });
})
.catch(err => {
this.setState({
@@ -41,7 +41,7 @@ class VerifyEmail extends React.Component {
error: `
Missing code parameter from email.
Make sure you copied the full link.
- `
+ `,
});
}
}
@@ -51,19 +51,19 @@ class VerifyEmail extends React.Component {
const actions = (
-
+
- View profile
+ Start a proposal
-
+
- Browse proposals
+ Create a request
);
-
+
if (hasVerified) {
return (
{
}
}
-export default withRouter(VerifyEmail);
\ No newline at end of file
+export default withRouter(VerifyEmail);
diff --git a/frontend/client/pages/guide.tsx b/frontend/client/pages/guide.tsx
new file mode 100644
index 00000000..63d58a9b
--- /dev/null
+++ b/frontend/client/pages/guide.tsx
@@ -0,0 +1,7 @@
+import React from 'react';
+import MarkdownPage from 'components/MarkdownPage';
+import GUIDE from 'static/markdown/GUIDE.md';
+
+const Guide = () => ;
+
+export default Guide;
diff --git a/frontend/client/pages/proposal-tutorial.tsx b/frontend/client/pages/proposal-tutorial.tsx
new file mode 100644
index 00000000..2245bcba
--- /dev/null
+++ b/frontend/client/pages/proposal-tutorial.tsx
@@ -0,0 +1,7 @@
+import React from 'react';
+import MarkdownPage from 'components/MarkdownPage';
+import PROPOSAL_TUTORIAL from 'static/markdown/PROPOSAL_TUTORIAL.md';
+
+const ProposalTutorial = () => ;
+
+export default ProposalTutorial;
diff --git a/frontend/client/pages/request-edit.tsx b/frontend/client/pages/request-edit.tsx
new file mode 100644
index 00000000..73e6a567
--- /dev/null
+++ b/frontend/client/pages/request-edit.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { withRouter, RouteComponentProps } from 'react-router';
+import CCRFlow from 'components/CCRFlow';
+import { initializeForm } from 'modules/ccr/actions';
+import { AppState } from 'store/reducers';
+import Loader from 'components/Loader';
+
+interface StateProps {
+ form: AppState['ccr']['form'];
+ isInitializingForm: AppState['ccr']['isInitializingForm'];
+ initializeFormError: AppState['ccr']['initializeFormError'];
+}
+
+interface DispatchProps {
+ initializeForm: typeof initializeForm;
+}
+
+type Props = StateProps & DispatchProps & RouteComponentProps<{ id: string }>;
+
+class RequestEdit extends React.Component {
+ componentWillMount() {
+ const proposalId = parseInt(this.props.match.params.id, 10);
+ this.props.initializeForm(proposalId);
+ }
+
+ render() {
+ const { form, initializeFormError } = this.props;
+ if (form) {
+ return ;
+ } else if (initializeFormError) {
+ return {initializeFormError} ;
+ } else {
+ return ;
+ }
+ }
+}
+
+const ConnectedRequestEdit = connect(
+ state => ({
+ form: state.ccr.form,
+ isInitializingForm: state.ccr.isInitializingForm,
+ initializeFormError: state.ccr.initializeFormError,
+ }),
+ { initializeForm },
+)(RequestEdit);
+
+export default withRouter(ConnectedRequestEdit);
diff --git a/frontend/client/static/locales/en/common.json b/frontend/client/static/locales/en/common.json
index 430d808a..ae905fbf 100644
--- a/frontend/client/static/locales/en/common.json
+++ b/frontend/client/static/locales/en/common.json
@@ -8,15 +8,23 @@
"intro": {
"title": "Funding for Zcash ecosystem projects",
- "subtitle": "Development, research, and community growth",
+ "subtitle": "Development, research, and community growth.\nHelp us build a better Zcash by creating a proposal or requesting an improvement!",
"signup": "Sign up now",
+ "ccr": "Create a request",
"browse": "Browse proposals",
"learn": "or learn more below"
},
+ "latest": {
+ "proposalsTitle": "Latest proposals",
+ "proposalsPlaceholder": "No proposals found",
+ "requestsTitle": "Latest requests",
+ "requestsPlaceholder": "No requests found"
+ },
+
"requests": {
"title": "Open Requests from the ZF",
- "description": "The Zcash Foundation will periodically open up requests for proposals that have financial incentives attached to them in the form of fixed bounties, or pledges of contribution matching.\nProposals will be reviewed and chosen based on the ZF’s confidence in the team and their plan.\nTo be eligible for funding from the Zcash Foundation, teams must provide identifying information for legal reasons.\nIf none of the RFPs catch your eye, check out the list of promising ideas<\/a>!",
+ "description": "The Zcash Foundation will periodically open up requests for proposals that have financial incentives attached to them in the form of fixed bounties.\nProposals will be reviewed and chosen based on the ZF’s confidence in the team and their plan.\nTo be eligible for funding from the Zcash Foundation, teams must provide identifying information for legal reasons.\nIf none of the RFPs catch your eye, check out the list of promising ideas<\/a>!",
"more": "See all requests",
"emptyTitle": "No open requests at this time",
"emptySubtitle": "But don’t let that stop you! Proposals can be submitted at any time."
@@ -24,10 +32,10 @@
"guide": {
"title": "How it works",
- "submit": "Individuals and teams submit proposals against requests from the Zcash Foundation, or of their own ideas",
- "review": "Their proposal is reviewed by the Zcash Foundation, and potentially rewarded a bounty or met with matching contributions",
- "community": "The proposal is then opened up to the community to discuss, provide feedback, and help it reach its funding goal",
- "complete": "Once successfully funded, the proposal creator(s) post updates with their progress and are paid out as they reach project milestones"
+ "submit": "Individuals and teams submit proposals against requests from the community or the Zcash Foundation, or submit one of their own ideas",
+ "review": "The proposal is reviewed by the Zcash Foundation, after which the proposal may be accepted with or without funding. In cases where the proposal is accepted without funding, the community may donate directly when the team has set a tip address.",
+ "community": "The proposal is then opened up to the community to discuss, provide feedback, and optionally donate to the team",
+ "complete": "The proposal creator(s) post updates with their progress, and if having received a bounty, are paid out as they reach project milestones"
},
"actions": {
diff --git a/frontend/client/static/markdown/GUIDE.md b/frontend/client/static/markdown/GUIDE.md
new file mode 100644
index 00000000..fc7221f7
--- /dev/null
+++ b/frontend/client/static/markdown/GUIDE.md
@@ -0,0 +1,56 @@
+# Writing a Fantastic Grant Proposal
+
+Looking for a walk-through of the interface? [Click here](/proposal-tutorial).
+
+The Zcash Foundation awards grant funding to Zcash ecosystem projects that advance [our mission as a public charity](https://www.zfnd.org/about/#mission). In short, that mission is to build and support privacy tools for everyone and anyone, both serving and guided by the Zcash Community.
+
+We are seeking grant projects that will help people understand, access, or use Zcash and its powerful privacy protections. You should aim to achieve one of the following:
+
+* Improve the Zcash protocol or auxiliary software (inclusive of research).
+* Create resources for people who already save and spend ZEC.
+* Expand the Zcash community and/or increase Zcash adoption.
+
+Privacy is the Foundation's focus and priority, so an ideal grant project is one that promotes, encourages, facilitates, or incorporates shielded addresses and z2z transactions. An ideal grant _proposal_ defines **who will benefit from the project, as well as _how_ and _why_**. Lastly, an ideal grant proposal is clear, pragmatic, detailed, and justified by solid evidence or reasoning.
+
+* Clear = plainly stated and easy to understand
+* Pragmatic = has realistic goals and expectations
+* Detailed = includes a concrete plan of action
+* Justified = aligned with [ZF’s mission](https://www.zfnd.org/about/#mission) and priorities
+
+## ZF Grants Proposal Template
+
+You are not required to use the following template, but your proposal should provide equivalent information.
+
+### Background
+
+Summarize the qualifications that you and/or your team bring to the table. Show that you have the skills and expertise necessary for the project that you're proposing. (Institutional bona fides are not required, but we do want to hear about your track record!)
+
+### Motivation
+
+What are your high-level goals? Why are they important, and why is now the right time? How is your project connected to [ZF’s mission](https://www.zfnd.org/about/#mission) and priorities? Whose needs will it serve?
+
+### Technical Approach
+
+Dive into the _how_ of your project. Describe your approaches, components, workflows, methodology, etc. Bullet points and diagrams are appreciated!
+
+### Execution Risks
+
+What obstacles do you expect? What is most likely to go wrong? Which unknown factors or dependencies could jeopardize success? What are your contingency plans? Will subsequent activities be required to maximize impact?
+
+### Downsides
+
+What are the negative ramifications if your project is successful? Consider usability, stability, privacy, integrity, availability, decentralization, interoperability, maintainability, technical debt, requisite education, etc.
+
+### Evaluation Plan
+
+What will your project look like if successful? How will we be able to tell? Include quantifiable metrics if possible.
+
+### Schedule
+
+What is your expected timeline for the project? List the concrete milestones and major tasks required to complete each milestone.
+
+### Budget
+
+How much funding do you need, and how will it be allocated (e.g., compensation for your effort, specific equipment, specific external services)? Specify a total cost, break it up into budget items, and explain the rationale for each item. Feel free to present multiple options in terms of scope and cost.
+
+If you have any questions, reach out to [contact@zfnd.org](mailto:contact@zfnd.org). We're happy to help!
diff --git a/frontend/client/static/markdown/PROPOSAL_TUTORIAL.md b/frontend/client/static/markdown/PROPOSAL_TUTORIAL.md
new file mode 100644
index 00000000..a6085c3f
--- /dev/null
+++ b/frontend/client/static/markdown/PROPOSAL_TUTORIAL.md
@@ -0,0 +1,59 @@
+# Creating a Proposal on ZF Grants
+
+You're familiar with the Zcash Foundation's [guide to writing a great grant proposal](/guide), and you're ready to create one on ZF Grants. We'll walk you through the process.
+
+If you already have a ZF Grants account, sign in. If not, time to make one! [Creating an account on ZF Grants](/auth/sign-up) is easy:
+
+![](https://www.zfnd.org/images/zf-grants-tutorial-images/1-create-account.png)
+
+Your display name on ZF Grants is equivalent to a username on social media. In the "About you" field, write a brief description of who you are — community member, developer, etc.
+
+After creating an account, make sure to confirm your email!
+
+Next, click the "Start a Proposal" button on the right side of the ZF Grants header.
+
+![](https://www.zfnd.org/images/zf-grants-tutorial-images/2-header-button.png)
+
+After clicking through, you will land on this screen:
+
+![](https://www.zfnd.org/images/zf-grants-tutorial-images/3-get-started.png)
+
+Click the "Let's do this" button at the bottom for the next step! You'll find a similar button located in the same place throughout the process.
+
+![](https://www.zfnd.org/images/zf-grants-tutorial-images/4-basic-proposal-info.png)
+
+In order to comply with United States regulations, the Zcash Foundation must collect "know your customer" information from grant recipients.
+
+The title is the headline of your proposal, so to speak. The brief is a high-level summary of your project and its goals. The target amount, denominated in USD, is how much funding you want to request from ZF. Remember, you can adjust all of this later if you want to. Onward...
+
+![](https://www.zfnd.org/images/zf-grants-tutorial-images/5-invite-team.png)
+
+If you're working with teammates, invite them via email. If not, just hit "Continue."
+
+![](https://www.zfnd.org/images/zf-grants-tutorial-images/6-proposal-details.png)
+
+This section shoudl contain the meat of your proposal. Explain the _why_, _who_, _what_, and _when_ aspects of your project, as described in the [guide](/guide). Give us a breakdown of your strategy and plan.
+
+Next you'll set up milestones:
+
+![](https://www.zfnd.org/images/zf-grants-tutorial-images/7-single-milestone.png)
+
+In the simplest scenario, you have one milestone titled "Work begins," you set the payout percentage to 100, and you check the "Payout Immediately" box. If the Zcash Foundation elects to fund your project, you'll receive the total sum of ZEC right away.
+
+Alternately, you could choose to receive the funding _after_ completing the project, in which case you'd have one milestone titled "Project complete," set the payout percentage to 100, and _not_ check the "Payout Immediately" box.
+
+Multiple milestones can be used to split funding into several payouts, each to be issued when a certain amount of work is finished. Only the first milestone is eligible for immediate payout. Here is a basic example:
+
+![](https://www.zfnd.org/images/zf-grants-tutorial-images/8-multiple-milestones.png)
+
+In the first milestone, the estimate of 10 days is grayed out because the "Payout Immediately" checkbox overrides it.
+
+This grantee would receive 20% of their funding right away, 40% after the first 30-day phase, and the remaining 40% after the final 30-day phase.
+
+We ask you to strive for accurate assessments of the time you'll need, but we understand that reality doesn't always go according to plan.
+
+![](https://www.zfnd.org/images/zf-grants-tutorial-images/9-payout-addresses.png)
+
+ZF Grants only supports Sapling shielded addresses. The payout address is where the Foundation will send any funds awarded to the project. The optional tip address, which can be the same z-address or a different one, is made public so that people can easily send tips of ZEC to express appreciation.
+
+Now you can review, preview, and submit your proposal — or save it as a draft to work on later.
diff --git a/frontend/client/store/reducers.tsx b/frontend/client/store/reducers.tsx
index de10b389..76577b98 100644
--- a/frontend/client/store/reducers.tsx
+++ b/frontend/client/store/reducers.tsx
@@ -4,6 +4,7 @@ import proposal, {
ProposalState,
INITIAL_STATE as proposalInitialState,
} from 'modules/proposals';
+import ccr, { CCRState, INITIAL_STATE as ccrInitialState } from 'modules/ccr';
import create, { CreateState, INITIAL_STATE as createInitialState } from 'modules/create';
import auth, { AuthState, INITIAL_STATE as authInitialState } from 'modules/auth';
import users, { UsersState, INITIAL_STATE as usersInitialState } from 'modules/users';
@@ -13,6 +14,7 @@ import history from './history';
export interface AppState {
proposal: ProposalState;
create: CreateState;
+ ccr: CCRState;
users: UsersState;
auth: AuthState;
rfps: RFPState;
@@ -25,9 +27,11 @@ export const combineInitialState: Omit = {
users: usersInitialState,
auth: authInitialState,
rfps: rfpsInitialState,
+ ccr: ccrInitialState,
};
export default combineReducers({
+ ccr,
proposal,
create,
users,
diff --git a/frontend/client/store/sagas.ts b/frontend/client/store/sagas.ts
index 27348b0f..60a6e28e 100644
--- a/frontend/client/store/sagas.ts
+++ b/frontend/client/store/sagas.ts
@@ -1,8 +1,10 @@
import { fork } from 'redux-saga/effects';
import { authSagas } from 'modules/auth';
import { createSagas } from 'modules/create';
+import { ccrSagas } from 'modules/ccr';
export default function* rootSaga() {
yield fork(authSagas);
yield fork(createSagas);
+ yield fork(ccrSagas);
}
diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts
index 984d1ffa..5508986b 100644
--- a/frontend/client/utils/api.ts
+++ b/frontend/client/utils/api.ts
@@ -10,7 +10,7 @@ import {
} from 'types';
import { UserState } from 'modules/users/reducers';
import { AppState } from 'store/reducers';
-import { toZat } from './units';
+import { toZat, toUsd } from './units';
export function formatUserForPost(user: User) {
return {
@@ -21,8 +21,14 @@ export function formatUserForPost(user: User) {
export function formatUserFromGet(user: UserState) {
const bnUserProp = (p: any) => {
- p.funded = toZat(p.funded);
- p.target = toZat(p.target);
+ if (p.isVersionTwo) {
+ p.funded = toUsd(p.funded);
+ p.target = toUsd(p.target);
+ } else {
+ p.funded = toZat(p.funded);
+ p.target = toZat(p.target);
+ }
+
return p;
};
if (user.pendingProposals) {
@@ -84,17 +90,40 @@ export function formatProposalPageFromGet(page: any): ProposalPage {
export function formatProposalFromGet(p: any): Proposal {
const proposal = { ...p } as Proposal;
proposal.proposalUrlId = generateSlugUrl(proposal.proposalId, proposal.title);
- proposal.target = toZat(p.target);
- proposal.funded = toZat(p.funded);
- proposal.contributionBounty = toZat(p.contributionBounty);
- proposal.percentFunded = proposal.target.isZero()
- ? 0
- : proposal.funded.div(proposal.target.divn(100)).toNumber();
+
+ if (proposal.isVersionTwo) {
+ proposal.target = toUsd(p.target);
+ proposal.funded = toUsd(p.funded);
+
+ // not used in v2 proposals, but populated for completeness
+ proposal.contributionBounty = toUsd(p.contributionBounty);
+ proposal.percentFunded = 0;
+ } else {
+ proposal.target = toZat(p.target);
+ proposal.funded = toZat(p.funded);
+ proposal.contributionBounty = toZat(p.contributionBounty);
+ proposal.percentFunded = proposal.target.isZero()
+ ? 0
+ : proposal.funded.div(proposal.target.divn(100)).toNumber();
+ }
+
if (proposal.milestones) {
- const msToFe = (m: any) => ({
- ...m,
- amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
- });
+ const msToFe = (m: any) => {
+ let amount;
+
+ if (proposal.isVersionTwo) {
+ const target = parseFloat(proposal.target.toString());
+ const payoutPercent = parseFloat(m.payoutPercent);
+ amount = ((target * payoutPercent) / 100).toFixed(2);
+ } else {
+ amount = proposal.target.mul(new BN(m.payoutPercent)).divn(100);
+ }
+
+ return {
+ ...m,
+ amount,
+ };
+ };
proposal.milestones = proposal.milestones.map(msToFe);
proposal.currentMilestone = msToFe(proposal.currentMilestone);
}
@@ -107,7 +136,7 @@ export function formatProposalFromGet(p: any): Proposal {
export function formatRFPFromGet(rfp: RFP): RFP {
rfp.urlId = generateSlugUrl(rfp.id, rfp.title);
if (rfp.bounty) {
- rfp.bounty = toZat(rfp.bounty as any);
+ rfp.bounty = rfp.isVersionTwo ? toUsd(rfp.bounty as any) : toZat(rfp.bounty as any);
}
if (rfp.acceptedProposals) {
rfp.acceptedProposals = rfp.acceptedProposals.map(formatProposalFromGet);
@@ -139,42 +168,53 @@ export function extractIdFromSlug(slug: string) {
export function massageSerializedState(state: AppState) {
// proposal detail
if (state.proposal.detail) {
+ const { isVersionTwo } = state.proposal.detail
+ const base = isVersionTwo ? 10 : 16;
+
state.proposal.detail.target = new BN(
(state.proposal.detail.target as any) as string,
- 16,
+ base,
);
state.proposal.detail.funded = new BN(
(state.proposal.detail.funded as any) as string,
- 16,
+ base,
);
state.proposal.detail.contributionBounty = new BN((state.proposal.detail
.contributionBounty as any) as string);
state.proposal.detail.milestones = state.proposal.detail.milestones.map(m => ({
...m,
- amount: new BN((m.amount as any) as string, 16),
+ amount: isVersionTwo
+ ? m.amount
+ : new BN((m.amount as any) as string, 16),
}));
if (state.proposal.detail.rfp && state.proposal.detail.rfp.bounty) {
state.proposal.detail.rfp.bounty = new BN(
(state.proposal.detail.rfp.bounty as any) as string,
- 16,
+ base,
);
}
}
// proposals
- state.proposal.page.items = state.proposal.page.items.map(p => ({
- ...p,
- target: new BN((p.target as any) as string, 16),
- funded: new BN((p.funded as any) as string, 16),
- contributionBounty: new BN((p.contributionMatching as any) as string, 16),
- milestones: p.milestones.map(m => ({
- ...m,
- amount: new BN((m.amount as any) as string, 16),
- })),
- }));
+ state.proposal.page.items = state.proposal.page.items.map(p => {
+ const base = p.isVersionTwo ? 10 : 16;
+ return {
+ ...p,
+ target: new BN((p.target as any) as string, base),
+ funded: new BN((p.funded as any) as string, base),
+ contributionBounty: new BN((p.contributionMatching as any) as string, base),
+ milestones: p.milestones.map(m => ({
+ ...m,
+ amount: p.isVersionTwo
+ ? m.amount
+ : new BN((m.amount as any) as string, 16),
+ })),
+ };
+ });
// users
const bnUserProp = (p: UserProposal) => {
- p.funded = new BN(p.funded, 16);
- p.target = new BN(p.target, 16);
+ const base = p.isVersionTwo ? 10 : 16;
+ p.funded = new BN(p.funded, base);
+ p.target = new BN(p.target, base);
return p;
};
Object.values(state.users.map).forEach(user => {
@@ -190,8 +230,9 @@ export function massageSerializedState(state: AppState) {
});
// RFPs
state.rfps.rfps = state.rfps.rfps.map(rfp => {
+ const base = rfp.isVersionTwo ? 10 : 16;
if (rfp.bounty) {
- rfp.bounty = new BN(rfp.bounty, 16);
+ rfp.bounty = new BN(rfp.bounty, base);
}
return rfp;
});
diff --git a/frontend/client/utils/email.tsx b/frontend/client/utils/email.tsx
index c31b26d4..e061df06 100644
--- a/frontend/client/utils/email.tsx
+++ b/frontend/client/utils/email.tsx
@@ -48,7 +48,7 @@ export const EMAIL_SUBSCRIPTIONS: { [key in ESKey]: EmailSubscriptionInfo } = {
// MY PROPOSAL
myProposalApproval: {
- description: 'is approved or rejected',
+ description: 'is approved or has changes requested',
category: EMAIL_SUBSCRIPTION_CATEGORY.PROPOSAL,
value: false,
},
@@ -89,6 +89,16 @@ export const EMAIL_SUBSCRIPTIONS: { [key in ESKey]: EmailSubscriptionInfo } = {
category: EMAIL_SUBSCRIPTION_CATEGORY.ADMIN,
value: false,
},
+ followedProposal: {
+ description: `proposals you're following`,
+ category: EMAIL_SUBSCRIPTION_CATEGORY.PROPOSAL,
+ value: false,
+ },
+ adminApprovalCcr: {
+ description: 'CCR needs review',
+ category: EMAIL_SUBSCRIPTION_CATEGORY.ADMIN,
+ value: false,
+ },
};
export const EMAIL_SUBSCRIPTION_CATEGORIES: {
diff --git a/frontend/client/utils/formatters.ts b/frontend/client/utils/formatters.ts
index 87de277c..c825032b 100644
--- a/frontend/client/utils/formatters.ts
+++ b/frontend/client/utils/formatters.ts
@@ -92,3 +92,14 @@ export function formatTxExplorerUrl(txid: string) {
}
throw new Error('EXPLORER_URL env variable needs to be set!');
}
+
+export function formatUsd(
+ amount: number | string | undefined | null,
+ includeDollarSign: boolean = true,
+ digits: number = 0,
+) {
+ if (!amount) return includeDollarSign ? '$0' : '0';
+ const a = typeof amount === 'number' ? amount.toString() : amount;
+ const str = formatNumber(a, digits);
+ return includeDollarSign ? `$${str}` : str;
+}
diff --git a/frontend/client/utils/units.ts b/frontend/client/utils/units.ts
index d0f384e0..0966df12 100644
--- a/frontend/client/utils/units.ts
+++ b/frontend/client/utils/units.ts
@@ -9,6 +9,7 @@ export const Units = {
};
export type Zat = BN;
+export type Usd = BN;
export type UnitKey = keyof typeof Units;
export const handleValues = (input: string | BN) => {
@@ -61,4 +62,14 @@ export const toZat = (value: string | number): Zat => {
return Zat(zat);
};
+export const toUsd = (value: string | number): Usd => {
+ value = value.toString();
+ const hasDecimal = value.indexOf('.') !== -1;
+
+ // decimals aren't allowed for proposal targets,
+ // but remove decimal if it exists
+ value = hasDecimal ? value.split('.')[0] : value;
+ return new BN(value, 10);
+};
+
export const getDecimalFromUnitKey = (key: UnitKey) => Units[key].length - 1;
diff --git a/frontend/client/utils/validators.ts b/frontend/client/utils/validators.ts
index 0fa80bf4..9ed97bbe 100644
--- a/frontend/client/utils/validators.ts
+++ b/frontend/client/utils/validators.ts
@@ -1,3 +1,5 @@
+import { formatUsd } from './formatters';
+
export function getAmountError(amount: number, max: number = Infinity, min?: number) {
if (amount < 0) {
return 'Amount must be a positive number';
@@ -15,6 +17,40 @@ 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);
+ if (Number.isNaN(parsedAmount)) {
+ return 'Not a valid number';
+ }
+ // prevents "-0" from being valid...
+ if (amount[0] === '-') {
+ return 'Amount must be a positive number';
+ }
+ return getAmountError(parsedAmount, max, min);
+}
+
export function isValidEmail(email: string): boolean {
return /\S+@\S+\.\S+/.test(email);
}
@@ -42,5 +78,5 @@ export function isValidSaplingAddress(address: string): boolean {
}
export function isValidAddress(a: string): boolean {
- return isValidTAddress(a) || isValidSproutAddress(a) || isValidSaplingAddress(a);
+ return isValidSproutAddress(a) || isValidSaplingAddress(a);
}
diff --git a/frontend/config/webpack.config.js/loaders.js b/frontend/config/webpack.config.js/loaders.js
index 464fd680..7aa29fe7 100644
--- a/frontend/config/webpack.config.js/loaders.js
+++ b/frontend/config/webpack.config.js/loaders.js
@@ -61,12 +61,16 @@ const cssLoaderClient = {
test: /\.css$/,
exclude: [/node_modules/],
use: [
- isDev && 'style-loader',
- !isDev && MiniCssExtractPlugin.loader,
+ {
+ loader: MiniCssExtractPlugin.loader,
+ options: {
+ hmr: isDev,
+ },
+ },
{
loader: 'css-loader',
},
- ].filter(Boolean),
+ ]
};
const lessLoaderClient = {
@@ -188,21 +192,19 @@ const externalCssLoaderClient = {
test: /\.css$/,
include: [/node_modules/],
use: [
- isDev && 'style-loader',
- !isDev && MiniCssExtractPlugin.loader,
+ MiniCssExtractPlugin.loader,
'css-loader',
- ].filter(Boolean),
+ ]
};
const externalLessLoaderClient = {
test: /\.less$/,
include: [/node_modules/],
use: [
- isDev && 'style-loader',
- !isDev && MiniCssExtractPlugin.loader,
+ MiniCssExtractPlugin.loader,
'css-loader',
lessLoader,
- ].filter(Boolean),
+ ]
};
// Server build needs a loader to handle external .css files
diff --git a/frontend/package.json b/frontend/package.json
index 5021c581..3242b2f0 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -117,12 +117,13 @@
"less": "3.9.0",
"less-loader": "^4.1.0",
"lint-staged": "^7.2.2",
+ "local-storage": "^2.0.0",
"lodash": "^4.17.15",
"lodash-es": "^4.17.15",
"lodash.defaultsdeep": "^4.6.1",
"lodash.mergewith": "^4.6.2",
"markdown-loader": "^4.0.0",
- "mini-css-extract-plugin": "^0.4.2",
+ "mini-css-extract-plugin": "^0.8.0",
"moment": "^2.22.2",
"node-sass": "^4.9.2",
"nodemon": "^1.18.4",
diff --git a/frontend/server/components/HTML.tsx b/frontend/server/components/HTML.tsx
index ede71704..c6ecd701 100644
--- a/frontend/server/components/HTML.tsx
+++ b/frontend/server/components/HTML.tsx
@@ -22,6 +22,22 @@ const HTML: React.SFC = ({
extractor,
}) => {
const head = Helmet.renderStatic();
+ const extractedStyleElements = extractor.getStyleElements();
+
+ // Move `bundle.css` to beginning of array so custom styles don't get overwritten
+ const bundleIndex = extractedStyleElements.findIndex(element => {
+ const devBundle = /^.*\/bundle\.css$/;
+ const prodBundle = /^.*\/bundle\..*\.css$/;
+ return (
+ typeof element.key === 'string' &&
+ (devBundle.test(element.key) || prodBundle.test(element.key))
+ );
+ });
+ if (bundleIndex !== -1) {
+ const [bundle] = extractedStyleElements.splice(bundleIndex, 1);
+ extractedStyleElements.unshift(bundle);
+ }
+
return (
@@ -53,7 +69,8 @@ const HTML: React.SFC = ({
{css.map(href => {
return ;
})}
- {extractor.getStyleElements()}
+
+ {extractedStyleElements}