Merge pull request #261 from grant-project/anonymous-contributions

Anonymous contributions
This commit is contained in:
Daniel Ternyak 2019-02-26 12:53:37 -06:00 committed by GitHub
commit db436f014b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 335 additions and 137 deletions

View File

@ -46,7 +46,7 @@ class ContributionDetail extends React.Component<Props, State> {
);
const renderSendRefund = () => {
if (c.staking || !c.refundAddress || c.refundTxId || !c.proposal.isFailed) {
if (c.staking || !c.refundAddress || c.refundTxId || !c.proposal.isFailed || !c.user) {
return;
}
const percent = c.proposal.milestones.reduce((prev, m) => {
@ -98,9 +98,10 @@ class ContributionDetail extends React.Component<Props, State> {
<pre>{JSON.stringify(c.addresses, null, 4)}</pre>
</Collapse.Panel>
<Collapse.Panel key="user" header="user">
<UserItem {...c.user} />
</Collapse.Panel>
<Collapse.Panel key="user" header="user">
{c.user ? <UserItem {...c.user} /> : <em>Anonymous contribution</em>}
</Collapse.Panel>
<Collapse.Panel key="proposal" header="proposal">
<ProposalItem {...c.proposal} />

View File

@ -40,7 +40,7 @@ class ContributionForm extends React.Component<Props> {
}
defaults = {
proposalId: contribution.proposal.proposalId,
userId: contribution.user.userid,
userId: contribution.user ? contribution.user.userid : '',
amount: contribution.amount,
txId: contribution.txId || '',
};
@ -68,14 +68,11 @@ class ContributionForm extends React.Component<Props> {
<Form.Item label="User ID">
{getFieldDecorator('userId', {
initialValue: defaults.userId,
rules: [
{ required: true, message: 'User ID is required' },
],
})(
<Input
autoComplete="off"
name="userId"
placeholder="Must be an existing user id"
placeholder="Existing user id or blank for anonymous"
/>,
)}
</Form.Item>
@ -152,6 +149,10 @@ class ContributionForm extends React.Component<Props> {
};
let msg;
if (id) {
// Explicitly make it zero of omitted to indicate to remove user
if (!args.userId) {
args.userId = 0;
}
await store.editContribution(id, args);
msg = 'Successfully updated contribution';
} else {

View File

@ -22,7 +22,7 @@ export default class ContributionItem extends React.PureComponent<Props> {
>
<Link to={`/contributions/${id}`}>
<h2>
{user.displayName} <small>for</small> {proposal.title}
{user ? user.displayName : <em>Anonymous</em>} <small>for</small> {proposal.title}
<Tooltip title={status.hint}>
<Tag color={status.tagColor}>{status.tagDisplay}</Tag>
</Tooltip>

View File

@ -141,7 +141,7 @@ export interface Contribution {
txId: null | string;
amount: string;
dateCreated: number;
user: User;
user: User | null;
proposal: Proposal;
addresses: {
transparent: string;

View File

@ -128,7 +128,8 @@ export const PROPOSAL_STAGES: Array<StatusSoT<PROPOSAL_STAGE>> = [
id: PROPOSAL_STAGE.CANCELED,
tagDisplay: 'Canceled',
tagColor: '#eb4118',
hint: 'Proposal was canceled by an admin and is currently refunding all contributors.',
hint:
'Proposal was canceled by an admin and is currently refunding all contributors.',
},
];
@ -190,9 +191,10 @@ export const CONTRIBUTION_STATUSES: Array<StatusSoT<CONTRIBUTION_STATUS>> = [
},
{
id: CONTRIBUTION_STATUS.DELETED,
tagDisplay: 'Closed',
tagDisplay: 'Deleted',
tagColor: '#eb4118',
hint: 'User deleted the contribution before it was sent or confirmed',
hint:
'User deleted the contribution before it was sent, or after it didnt confirm after 24 hours',
},
];

View File

@ -80,9 +80,11 @@ example_email_args = {
'proposal': proposal,
'contribution': contribution,
'contributor': user,
# 'contributor': None,
'funded': '50',
'proposal_url': 'http://someproposal.com',
'contributor_url': 'http://someuser.com',
# 'contributor_url': None,
},
'proposal_comment': {
'author': user,

View File

@ -590,7 +590,7 @@ def get_contributions(page, filters, search, sort):
@blueprint.route('/contributions', methods=['POST'])
@endpoint.api(
parameter('proposalId', type=int, required=True),
parameter('userId', type=int, required=True),
parameter('userId', type=int, required=False, default=None),
parameter('status', type=str, required=True),
parameter('amount', type=str, required=True),
parameter('txId', type=str, required=False),
@ -653,12 +653,15 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
if not proposal:
return {"message": "No proposal matching that id"}, 400
contribution.proposal_id = proposal_id
# User ID (must belong to an existing user)
if user_id:
user = User.query.filter(User.id == user_id).first()
if not user:
return {"message": "No user matching that id"}, 400
contribution.user_id = user_id
# User ID (must belong to an existing user or 0 to unset)
if user_id is not None:
if user_id == 0:
contribution.user_id = None
else:
user = User.query.filter(User.id == user_id).first()
if not user:
return {"message": "No user matching that id"}, 400
contribution.user_id = user_id
# Status (must be in list of statuses)
if status:
if not ContributionStatus.includes(status):

View File

@ -1,3 +1,5 @@
from datetime import datetime, timedelta
from grant.proposal.models import (
ProposalContribution,
proposal_contributions_schema,
@ -9,6 +11,7 @@ from grant.utils.enums import ContributionStatus
def make_bootstrap_data():
pending_contributions = ProposalContribution.query \
.filter_by(status=ContributionStatus.PENDING) \
.filter(ProposalContribution.date_created + timedelta(hours=24) > datetime.now()) \
.all()
latest_contribution = ProposalContribution.query \
.filter_by(status=ContributionStatus.CONFIRMED) \

View File

@ -86,7 +86,7 @@ def proposal_contribution(email_args):
'subject': 'You just got a contribution!',
'title': 'You just got a contribution',
'preview': '{} just contributed {} to your proposal {}'.format(
email_args['contributor'].display_name,
email_args['contributor'].display_name if email_args['contributor'] else 'An anonymous contributor',
email_args['contribution'].amount,
email_args['proposal'].title,
),

View File

@ -3,6 +3,7 @@ from functools import reduce
from sqlalchemy import func, or_
from sqlalchemy.ext.hybrid import hybrid_property
from decimal import Decimal
from marshmallow import post_dump
from grant.comment.models import Comment
from grant.email.send import send_email
@ -19,6 +20,7 @@ from grant.utils.enums import (
ProposalArbiterStatus,
MilestoneStage
)
from grant.utils.stubs import anonymous_user
proposal_team = db.Table(
'proposal_team', db.Model.metadata,
@ -87,13 +89,13 @@ class ProposalContribution(db.Model):
def __init__(
self,
proposal_id: int,
user_id: int,
amount: str,
user_id: int = None,
staking: bool = False,
):
self.proposal_id = proposal_id
self.user_id = user_id
self.amount = amount
self.user_id = user_id
self.staking = staking
self.date_created = datetime.datetime.now()
self.status = ContributionStatus.PENDING
@ -163,7 +165,7 @@ class ProposalContribution(db.Model):
@hybrid_property
def refund_address(self):
return self.user.settings.refund_address
return self.user.settings.refund_address if self.user else None
class ProposalArbiter(db.Model):
@ -337,11 +339,11 @@ class Proposal(db.Model):
self.deadline_duration = deadline_duration
Proposal.validate(vars(self))
def create_contribution(self, user_id: int, amount, staking: bool = False):
def create_contribution(self, amount, user_id: int = None, staking: bool = False):
contribution = ProposalContribution(
proposal_id=self.id,
user_id=user_id,
amount=amount,
user_id=user_id,
staking=staking,
)
db.session.add(contribution)
@ -713,12 +715,14 @@ class ProposalContributionSchema(ma.Schema):
"amount",
"date_created",
"addresses",
"is_anonymous",
)
proposal = ma.Nested("ProposalSchema")
user = ma.Nested("UserSchema")
user = ma.Nested("UserSchema", default=anonymous_user)
date_created = ma.Method("get_date_created")
addresses = ma.Method("get_addresses")
is_anonymous = ma.Method("get_is_anonymous")
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
@ -730,6 +734,15 @@ class ProposalContributionSchema(ma.Schema):
return {
'transparent': addresses['transparent'],
}
def get_is_anonymous(self, obj):
return not obj.user_id
@post_dump
def stub_anonymous_user(self, data):
if 'user' in data and data['user'] is None:
data['user'] = anonymous_user
return data
proposal_contribution_schema = ProposalContributionSchema()

View File

@ -375,11 +375,12 @@ def post_proposal_update(proposal_id, title, content):
# Send email to all contributors (even if contribution failed)
contributions = ProposalContribution.query.filter_by(proposal_id=proposal_id).all()
for c in contributions:
send_email(c.user.email_address, 'contribution_update', {
'proposal': g.current_proposal,
'proposal_update': update,
'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'),
})
if c.user:
send_email(c.user.email_address, 'contribution_update', {
'proposal': g.current_proposal,
'proposal_update': update,
'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'),
})
dumped_update = proposal_update_schema.dump(update)
return dumped_update, 201
@ -481,22 +482,31 @@ def get_proposal_contribution(proposal_id, contribution_id):
@blueprint.route("/<proposal_id>/contributions", methods=["POST"])
@requires_auth
@endpoint.api(
parameter('amount', type=str, required=True)
parameter('amount', type=str, required=True),
parameter('anonymous', type=bool, required=False)
)
def post_proposal_contribution(proposal_id, amount):
def post_proposal_contribution(proposal_id, amount, anonymous):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
code = 200
contribution = ProposalContribution \
.get_existing_contribution(g.current_user.id, proposal_id, amount)
user = None
contribution = None
if not anonymous:
user = get_authed_user()
if user:
contribution = ProposalContribution.get_existing_contribution(user.id, proposal_id, amount)
if not contribution:
code = 201
contribution = proposal.create_contribution(g.current_user.id, amount)
contribution = proposal.create_contribution(
amount=amount,
user_id=user.id if user else None,
)
dumped_contribution = proposal_contribution_schema.dump(contribution)
return dumped_contribution, code
@ -544,11 +554,12 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
else:
# Send to the user
send_email(contribution.user.email_address, 'contribution_confirmed', {
'contribution': contribution,
'proposal': contribution.proposal,
'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}',
})
if contribution.user:
send_email(contribution.user.email_address, 'contribution_confirmed', {
'contribution': contribution,
'proposal': contribution.proposal,
'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}',
})
# Send to the full proposal gang
for member in contribution.proposal.team:
@ -558,7 +569,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
'contributor': contribution.user,
'funded': contribution.proposal.funded,
'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'),
'contributor_url': make_url(f'/profile/{contribution.user.id}'),
'contributor_url': make_url(f'/profile/{contribution.user.id}') if contribution.user else '',
})
# TODO: Once we have a task queuer in place, queue emails to everyone

View File

@ -78,12 +78,13 @@ class ProposalDeadline:
'proposal': proposal,
})
for c in proposal.contributions:
send_email(c.user.email_address, 'contribution_proposal_failed', {
'contribution': c,
'proposal': proposal,
'refund_address': c.user.settings.refund_address,
'account_settings_url': make_url('/profile/settings?tab=account')
})
if c.user:
send_email(c.user.email_address, 'contribution_proposal_failed', {
'contribution': c,
'proposal': proposal,
'refund_address': c.user.settings.refund_address,
'account_settings_url': make_url('/profile/settings?tab=account')
})
JOBS = {

View File

@ -1,7 +1,11 @@
<p style="margin: 0 0 20px;">
Your proposal <strong>{{ args.proposal.title }}</strong> just got a
<strong>{{ args.contribution.amount }} ZEC</strong> contribution from
{% if args.contributor %}
<a href="{{ args.contributor_url }}" target="_blank">{{ args.contributor.display_name }}</a>.
{% else %}
an anonymous contributor.
{% endif %}
Your proposal is now at
<strong>{{ args.funded }} / {{ args.proposal.target }} ZEC</strong>.
</p>

View File

@ -1,6 +1,8 @@
Your proposal "{{ args.proposal.title }}" just got a
{{ args.contribution.amount }} ZEC contribution from
{{ args.contributor.display_name }}. Your proposal is now at {{ args.funded }} / {{ args.proposal.target }} ZEC.
{{ args.contribution.amount }} ZEC contribution from {{ args.contributor.display_name if args.contributor else 'an anonymous contributor' }}.
Your proposal is now at {{ args.funded }} / {{ args.proposal.target }} ZEC.
{% if args.contributor %}
See {{ args.contributor.display_name }}'s profile: {{ args.contributor_url }}
{% endif %}
View your proposal: {{ args.proposal_url }}

View File

@ -0,0 +1,9 @@
# This should match UserSchema in grant.user.models
anonymous_user = {
'userid': 0,
'display_name': 'Anonymous',
'title': 'N/A',
'avatar': None,
'social_medias': [],
'email_verified': True,
}

View File

@ -30,7 +30,7 @@
"typescript": "^3.2.1"
},
"dependencies": {
"@sentry/node": "4.5.2",
"@sentry/node": "4.4.2",
"@types/cors": "2.8.4",
"@types/dotenv": "^6.1.0",
"@types/ws": "^6.0.1",

View File

@ -63,7 +63,6 @@ async function scanBlock(height: number) {
consecutiveBlockFailures++;
// If we fail a certain number of times, it's reasonable to
// assume that the blockchain is down, and we should just quit.
// TODO: Scream at sentry or something!
if (consecutiveBlockFailures >= MAXIMUM_BLOCK_FAILURES) {
captureException(err);
log.error('Maximum consecutive failures reached, exiting!');

View File

@ -2,57 +2,62 @@
# yarn lockfile v1
"@sentry/core@4.5.2":
version "4.5.2"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-4.5.2.tgz#b103b659857e0fabf7219e9f34c4070bcb2af0c2"
"@sentry/core@4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-4.4.2.tgz#562526bc634c087f04bbca68b09cedc4b41cc64d"
integrity sha512-hJyAodTCf4sZfVdf41Rtuzj4EsyzYq5rdMZ+zc2Vinwdf8D0/brHe91fHeO0CKXEb2P0wJsrjwMidG/ccq/M8A==
dependencies:
"@sentry/hub" "4.5.2"
"@sentry/minimal" "4.5.2"
"@sentry/types" "4.5.0"
"@sentry/utils" "4.5.2"
"@sentry/hub" "4.4.2"
"@sentry/minimal" "4.4.2"
"@sentry/types" "4.4.2"
"@sentry/utils" "4.4.2"
tslib "^1.9.3"
"@sentry/hub@4.5.2":
version "4.5.2"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-4.5.2.tgz#b1df97dccc8644f6ef9b36e8747fe04342cb92ef"
"@sentry/hub@4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-4.4.2.tgz#1399556fda06fb83c4f186c4aa842725f520159c"
integrity sha512-oe9ytXkTWyD+QmOpVzHAqTbRV4Hc0ee2Nt6HvrDtRmlXzQxfvTWG2F8KYT6w8kzqg5klnuRpnsmgTTV3KuNBVQ==
dependencies:
"@sentry/types" "4.5.0"
"@sentry/utils" "4.5.2"
"@sentry/types" "4.4.2"
"@sentry/utils" "4.4.2"
tslib "^1.9.3"
"@sentry/minimal@4.5.2":
version "4.5.2"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-4.5.2.tgz#c090427f6ac276b4dc449e8be460ca5b35ce4fb3"
"@sentry/minimal@4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-4.4.2.tgz#13fffc6b17a2401b6a79947838a637626ab80b10"
integrity sha512-GEZZiNvVgqFAESZhAe3vjwTInn13lI2bSI3ItQN4RUWKL/W4n/fwVoDJbkb1U8aWxanuMnRDEpKwyQv6zYTZfw==
dependencies:
"@sentry/hub" "4.5.2"
"@sentry/types" "4.5.0"
"@sentry/hub" "4.4.2"
"@sentry/types" "4.4.2"
tslib "^1.9.3"
"@sentry/node@4.5.2":
version "4.5.2"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-4.5.2.tgz#b90747749cf919006ca44fd873da87fd3608f8e0"
"@sentry/node@4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-4.4.2.tgz#549921d2df3cbf58ebcfb525c3005c3fec4739a3"
integrity sha512-8/KlSdfVhledZ6PS6muxZY5r2pqhw8MNSXP7AODR2qRrHwsbnirVgV21WIAYAjKXEfYQGbm69lyoaTJGazlQ3Q==
dependencies:
"@sentry/core" "4.5.2"
"@sentry/hub" "4.5.2"
"@sentry/types" "4.5.0"
"@sentry/utils" "4.5.2"
"@sentry/core" "4.4.2"
"@sentry/hub" "4.4.2"
"@sentry/types" "4.4.2"
"@sentry/utils" "4.4.2"
"@types/stack-trace" "0.0.29"
cookie "0.3.1"
https-proxy-agent "2.2.1"
lru_map "0.3.3"
https-proxy-agent "^2.2.1"
lsmod "1.0.0"
stack-trace "0.0.10"
tslib "^1.9.3"
"@sentry/types@4.5.0":
version "4.5.0"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.5.0.tgz#59e2a27d48b01b44e8959aa5c8a30514fe1086a9"
"@sentry/types@4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.4.2.tgz#f38dd3bc671cd2f5983a85553aebeac9c2286b17"
integrity sha512-QyQd6PKKIyjJgaq/RQjsxPJEWbXcuiWZ9RvSnhBjS5jj53HEzkM1qkbAFqlYHJ1DTJJ1EuOM4+aTmGzHe93zuA==
"@sentry/utils@4.5.2":
version "4.5.2"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-4.5.2.tgz#0de5bef1c71e0dd800d378c010e5bfad91add91a"
"@sentry/utils@4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-4.4.2.tgz#e05a47e135ecef29e63a996f59aee8c8f792c222"
integrity sha512-j/Ad8G1abHlJdD2q7aWWbSOSeWB5M5v1R1VKL8YPlwEbSvvmEQWePhBKFI0qlnKd2ObdUQsj86pHEXJRSFNfCw==
dependencies:
"@sentry/types" "4.5.0"
"@sentry/types" "4.4.2"
tslib "^1.9.3"
"@types/body-parser@*", "@types/body-parser@1.17.0":
@ -1000,9 +1005,10 @@ http-errors@1.6.3, http-errors@~1.6.2, http-errors@~1.6.3:
setprototypeof "1.1.0"
statuses ">= 1.4.0 < 2"
https-proxy-agent@2.2.1:
https-proxy-agent@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz#51552970fa04d723e04c56d04178c3f92592bbc0"
integrity sha512-HPCTS1LW51bcyMYbxUIOO4HEOlQ1/1qRaFWcyxvwaqUS9TY88aoEuHUY33kuAh1YhVVaDQhLZsnPd+XNARWZlQ==
dependencies:
agent-base "^4.1.0"
debug "^3.1.0"
@ -1314,10 +1320,6 @@ lru-cache@^4.0.1:
pseudomap "^1.0.2"
yallist "^2.1.2"
lru_map@0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd"
lsmod@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/lsmod/-/lsmod-1.0.0.tgz#9a00f76dca36eb23fa05350afe1b585d4299e64b"

View File

@ -8,7 +8,7 @@ import {
TeamInvite,
TeamInviteWithProposal,
SOCIAL_SERVICE,
ContributionWithAddresses,
ContributionWithAddressesAndUser,
EmailSubscriptions,
RFP,
ProposalPageParams,
@ -309,9 +309,11 @@ export function putInviteResponse(
export function postProposalContribution(
proposalId: number,
amount: string,
): Promise<{ data: ContributionWithAddresses }> {
anonymous?: boolean,
): Promise<{ data: ContributionWithAddressesAndUser }> {
return axios.post(`/api/v1/proposals/${proposalId}/contributions`, {
amount,
anonymous,
});
}
@ -331,13 +333,13 @@ export function deleteProposalContribution(contributionId: string | number) {
export function getProposalContribution(
proposalId: number,
contributionId: number,
): Promise<{ data: ContributionWithAddresses }> {
): Promise<{ data: ContributionWithAddressesAndUser }> {
return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`);
}
export function getProposalStakingContribution(
proposalId: number,
): Promise<{ data: ContributionWithAddresses }> {
): Promise<{ data: ContributionWithAddressesAndUser }> {
return axios.get(`/api/v1/proposals/${proposalId}/stake`);
}

View File

@ -1,6 +1,11 @@
@import '~styles/variables.less';
.PaymentInfo {
&-anonymous {
margin-top: -0.5rem;
margin-bottom: -0.5rem;
}
&-text {
margin-top: -0.25rem;
font-size: 0.95rem;

View File

@ -26,8 +26,9 @@ export default class PaymentInfo extends React.Component<Props, State> {
};
render() {
const { contribution, text } = this.props;
const { contribution } = this.props;
const { sendType } = this.state;
let text = this.props.text;
let address;
let memo;
let amount;
@ -103,14 +104,24 @@ export default class PaymentInfo extends React.Component<Props, State> {
);
}
if (!text) {
if (contribution && contribution.isAnonymous) {
text = `
Thank you for contributing! Just send using whichever method works best for
you, and your contribution will show up anonymously once it's been confirmed.
`
} else {
text = `
Thank you for contributing! Just send using whichever method works best for
you, and we'll let you know once it's been confirmed.
`;
}
}
return (
<Form className="PaymentInfo" layout="vertical">
<div className="PaymentInfo-text">
{text ||
`
Thank you for contributing! Just send using whichever method works best for
you, and we'll let you know when your contribution has been confirmed.
`}
{text}
</div>
<Radio.Group
className="PaymentInfo-types"

View File

@ -1,17 +1,18 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Modal } from 'antd';
import { Modal, Alert } from 'antd';
import Result from 'ant-design-pro/lib/Result';
import { postProposalContribution, getProposalContribution } from 'api/api';
import { ContributionWithAddresses } from 'types';
import { ContributionWithAddressesAndUser } from 'types';
import PaymentInfo from './PaymentInfo';
interface OwnProps {
isVisible: boolean;
contribution?: ContributionWithAddresses | Falsy;
contribution?: ContributionWithAddressesAndUser | Falsy;
proposalId?: number;
contributionId?: number;
amount?: string;
isAnonymous?: boolean;
hasNoButtons?: boolean;
text?: React.ReactNode;
handleClose(): void;
@ -20,15 +21,19 @@ interface OwnProps {
type Props = OwnProps;
interface State {
hasConfirmedAnonymous: boolean;
hasSent: boolean;
contribution: ContributionWithAddresses | null;
contribution: ContributionWithAddressesAndUser | null;
isFetchingContribution: boolean;
error: string | null;
}
export default class ContributionModal extends React.Component<Props, State> {
state: State = {
hasConfirmedAnonymous: false,
hasSent: false,
contribution: null,
isFetchingContribution: false,
error: null,
};
@ -43,34 +48,83 @@ export default class ContributionModal extends React.Component<Props, State> {
}
componentWillUpdate(nextProps: Props) {
const { isVisible, proposalId, contributionId, contribution } = nextProps;
const {
isVisible,
isAnonymous,
proposalId,
contributionId,
contribution,
} = nextProps;
// When modal is opened and proposalId is provided or changed
if (isVisible && proposalId) {
// But not if we're anonymous, that will happen in confirmAnonymous
if (isVisible && proposalId && !isAnonymous) {
if (this.props.isVisible !== isVisible || proposalId !== this.props.proposalId) {
this.fetchAddresses(proposalId, contributionId);
}
}
// If contribution is provided
// If contribution is provided, update it
if (contribution !== this.props.contribution) {
this.setState({ contribution: contribution || null });
}
// When the modal is closed, clear out the contribution and anonymous check
if (this.props.isVisible && !isVisible) {
this.setState({
contribution: null,
hasConfirmedAnonymous: false,
});
}
}
render() {
const { isVisible, handleClose, hasNoButtons, text } = this.props;
const { hasSent, contribution, error } = this.state;
const { isVisible, isAnonymous, handleClose, hasNoButtons, text } = this.props;
const { hasSent, hasConfirmedAnonymous, contribution, error } = this.state;
let okText;
let onOk;
let content;
if (hasSent) {
if (isAnonymous && !hasConfirmedAnonymous) {
okText = 'I accept';
onOk = this.confirmAnonymous;
content = (
<Alert
className="PaymentInfo-anonymous"
type="warning"
message="This contribution is anonymous"
description={
<>
You are about to contribute anonymously. Your contribution will show up
without attribution, and even if you're logged in, will not
appear anywhere on your account after you close this modal.
<br /> <br />
In the case of a refund, your contribution will be treated as a donation to
the Zcash Foundation instead.
<br /> <br />
If you would like to have your contribution attached to an account, you
can close this modal, make sure you're logged in, and don't check the
"Contribute anonymously" checkbox.
</>
}
/>
);
} else if (hasSent) {
okText = 'Done';
onOk = handleClose;
content = (
<Result
type="success"
title="Thank you for your contribution!"
description={
<>
Your contribution should be confirmed in about 20 minutes. You can keep an
eye on it at the{' '}
<Link to="/profile?tab=funded">funded tab on your profile</Link>.
Your transaction should be confirmed in about 20 minutes.{' '}
{isAnonymous
? 'Once its confirmed, itll show up in the contributions tab.'
: (
<>
You can keep an eye on it at the{' '}
<Link to="/profile?tab=funded">funded tab on your profile</Link>.
</>
)
}
</>
}
style={{ width: '90%' }}
@ -78,8 +132,12 @@ export default class ContributionModal extends React.Component<Props, State> {
);
} else {
if (error) {
okText = 'Done';
onOk = handleClose;
content = error;
} else {
okText = 'Ive sent it';
onOk = this.confirmSend;
content = <PaymentInfo contribution={contribution} text={text} />;
}
}
@ -90,8 +148,8 @@ export default class ContributionModal extends React.Component<Props, State> {
visible={isVisible}
closable={hasSent || hasNoButtons}
maskClosable={hasSent || hasNoButtons}
okText={hasSent ? 'Done' : 'Ive sent it'}
onOk={hasSent ? handleClose : this.confirmSend}
okText={okText}
onOk={onOk}
onCancel={handleClose}
footer={hasNoButtons ? '' : undefined}
centered
@ -102,21 +160,31 @@ export default class ContributionModal extends React.Component<Props, State> {
}
private async fetchAddresses(proposalId: number, contributionId?: number) {
this.setState({ isFetchingContribution: true });
try {
const { amount, isAnonymous } = this.props;
let res;
if (contributionId) {
res = await getProposalContribution(proposalId, contributionId);
} else {
res = await postProposalContribution(proposalId, this.props.amount || '0');
res = await postProposalContribution(proposalId, amount || '0', isAnonymous);
}
this.setState({ contribution: res.data });
} catch (err) {
this.setState({ error: err.message });
}
this.setState({ isFetchingContribution: false });
}
private confirmAnonymous = () => {
const { state, props } = this;
this.setState({ hasConfirmedAnonymous: true });
if (!state.contribution && !props.contribution && props.proposalId) {
this.fetchAddresses(props.proposalId, props.contributionId);
}
};
private confirmSend = () => {
// TODO: Mark on backend
this.setState({ hasSent: true });
};
}

View File

@ -1,7 +1,7 @@
import React, { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Button, Popconfirm, message, Tag } from 'antd';
import { UserProposal, STATUS, ContributionWithAddresses } from 'types';
import { UserProposal, STATUS, ContributionWithAddressesAndUser } from 'types';
import ContributionModal from 'components/ContributionModal';
import { getProposalStakingContribution } from 'api/api';
import { deletePendingProposal, publishPendingProposal } from 'modules/users/actions';
@ -29,7 +29,7 @@ interface State {
isDeleting: boolean;
isPublishing: boolean;
isLoadingStake: boolean;
stakeContribution: ContributionWithAddresses | null;
stakeContribution: ContributionWithAddressesAndUser | null;
}
class ProfilePending extends React.Component<Props, State> {

View File

@ -1,6 +1,7 @@
import React from 'react';
import moment from 'moment';
import { Form, Input, Button, Icon, Popover } from 'antd';
import { Form, Input, Checkbox, Button, Icon, Popover, Tooltip } from 'antd';
import { CheckboxChangeEvent } from 'antd/lib/checkbox';
import { Proposal, STATUS } from 'types';
import classnames from 'classnames';
import { fromZat } from 'utils/units';
@ -21,7 +22,7 @@ interface OwnProps {
}
interface StateProps {
sendLoading: boolean;
authUser: AppState['auth']['user'];
}
type Props = OwnProps & StateProps;
@ -29,6 +30,7 @@ type Props = OwnProps & StateProps;
interface State {
amountToRaise: string;
amountError: string | null;
isAnonymous: boolean;
isContributing: boolean;
}
@ -38,13 +40,14 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
this.state = {
amountToRaise: '',
amountError: null,
isAnonymous: false,
isContributing: false,
};
}
render() {
const { proposal, sendLoading, isPreview } = this.props;
const { amountToRaise, amountError, isContributing } = this.state;
const { proposal, isPreview, authUser } = this.props;
const { amountToRaise, amountError, isAnonymous, isContributing } = this.state;
const amountFloat = parseFloat(amountToRaise) || 0;
let content;
if (proposal) {
@ -165,7 +168,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
/>
</div>
<Form layout="vertical">
<Form layout="vertical" className="ProposalCampaignBlock-contribute">
<Form.Item
validateStatus={amountError ? 'error' : undefined}
help={amountError}
@ -185,12 +188,20 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
disabled={isPreview}
/>
</Form.Item>
{amountToRaise &&
!!authUser && (
<Checkbox checked={isAnonymous} onChange={this.handleChangeAnonymity}>
Contribute anonymously
<Tooltip title="Contribute with no attribution to your account. This will make you ineligible for refunds.">
<Icon type="question-circle" />
</Tooltip>
</Checkbox>
)}
<Button
onClick={this.openContributionModal}
size="large"
type="primary"
disabled={isDisabled}
loading={sendLoading}
block
>
Fund this project
@ -203,6 +214,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
isVisible={isContributing}
proposalId={proposal.proposalId}
amount={amountToRaise}
isAnonymous={isAnonymous || !authUser}
handleClose={this.closeContributionModal}
/>
</React.Fragment>
@ -244,14 +256,17 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
this.setState({ amountToRaise: value, amountError });
};
private handleChangeAnonymity = (ev: CheckboxChangeEvent) => {
this.setState({ isAnonymous: ev.target.checked });
};
private openContributionModal = () => this.setState({ isContributing: true });
private closeContributionModal = () => this.setState({ isContributing: false });
}
function mapStateToProps(state: AppState) {
console.warn('TODO - new redux flag for sendLoading?', state);
return {
sendLoading: false,
authUser: state.auth.user,
};
}

View File

@ -81,6 +81,32 @@
}
}
&-contribute {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.ant-form-item {
width: 100%;
}
.ant-checkbox-wrapper {
margin-bottom: 0.5rem;
opacity: 0.7;
font-size: 0.8rem;
&:hover {
opacity: 1;
}
.anticon {
margin-left: 0.2rem;
color: @primary-color;
}
}
}
&-fundingOver {
display: flex;
justify-content: center;

View File

@ -9,8 +9,16 @@ interface Props {
extra?: React.ReactNode;
}
const Wrap = ({ user, children }: { user: User, children: React.ReactNode }) => {
if (user.userid) {
return <Link to={`/profile/${user.userid}`} className="UserRow" children={children} />;
} else {
return <div className="UserRow" children={children} />;
}
};
const UserRow = ({ user, extra }: Props) => (
<Link to={`/profile/${user.userid}`} className="UserRow">
<Wrap user={user}>
<div className="UserRow-avatar">
<UserAvatar user={user} className="UserRow-avatar-img" />
</div>
@ -23,7 +31,7 @@ const UserRow = ({ user, extra }: Props) => (
{extra}
</div>
)}
</Link>
</Wrap>
);
export default UserRow;

View File

@ -8,6 +8,13 @@ import 'components/Proposal/style.less';
import 'components/Proposal/Governance/style.less';
import { generateProposal } from './props';
const user = {
userid: 1,
displayName: 'Duderino',
title: 'The dudester',
socialMedias: [],
avatar: null,
};
const propsNoFunding = generateProposal({
amount: 5,
funded: 0,
@ -28,16 +35,16 @@ const propsNotFundedExpired = generateProposal({
const CampaignBlocks = ({ style }: { style: any }) => (
<React.Fragment>
<div style={style}>
<ProposalCampaignBlock {...propsNoFunding} />
<ProposalCampaignBlock {...propsNoFunding} authUser={user} />
</div>
<div style={style}>
<ProposalCampaignBlock {...propsHalfFunded} />
<ProposalCampaignBlock {...propsHalfFunded} authUser={user} />
</div>
<div style={style}>
<ProposalCampaignBlock {...propsFunded} />
<ProposalCampaignBlock {...propsFunded} authUser={user} />
</div>
<div style={style}>
<ProposalCampaignBlock {...propsNotFundedExpired} />
<ProposalCampaignBlock {...propsNotFundedExpired} authUser={user} />
</div>
</React.Fragment>
);

View File

@ -203,7 +203,6 @@ export function generateProposal({
const props = {
sendLoading: false,
isMissingWeb3: false,
proposal,
...proposal, // yeah...
};

View File

@ -7,6 +7,7 @@ export interface Contribution {
amount: string;
dateCreated: number;
status: 'PENDING' | 'CONFIRMED';
isAnonymous: boolean;
}
export interface ContributionWithAddresses extends Contribution {
@ -22,6 +23,9 @@ export interface ContributionWithUser extends Contribution {
user: User;
}
export type ContributionWithAddressesAndUser = ContributionWithAddresses &
ContributionWithUser;
export interface UserContribution extends Omit<Contribution, 'amount' | 'txId'> {
amount: Zat;
txId?: string;