Merge pull request #261 from grant-project/anonymous-contributions
Anonymous contributions
This commit is contained in:
commit
db436f014b
|
@ -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,8 +98,9 @@ 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} />
|
||||
{c.user ? <UserItem {...c.user} /> : <em>Anonymous contribution</em>}
|
||||
</Collapse.Panel>
|
||||
|
||||
<Collapse.Panel key="proposal" header="proposal">
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -141,7 +141,7 @@ export interface Contribution {
|
|||
txId: null | string;
|
||||
amount: string;
|
||||
dateCreated: number;
|
||||
user: User;
|
||||
user: User | null;
|
||||
proposal: Proposal;
|
||||
addresses: {
|
||||
transparent: string;
|
||||
|
|
|
@ -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 didn’t confirm after 24 hours',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,8 +653,11 @@ 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 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
|
||||
|
|
|
@ -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) \
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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)
|
||||
|
@ -731,6 +735,15 @@ class ProposalContributionSchema(ma.Schema):
|
|||
'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()
|
||||
proposal_contributions_schema = ProposalContributionSchema(many=True)
|
||||
|
|
|
@ -375,6 +375,7 @@ 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:
|
||||
if c.user:
|
||||
send_email(c.user.email_address, 'contribution_update', {
|
||||
'proposal': g.current_proposal,
|
||||
'proposal_update': update,
|
||||
|
@ -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,6 +554,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
|
|||
|
||||
else:
|
||||
# Send to the user
|
||||
if contribution.user:
|
||||
send_email(contribution.user.email_address, 'contribution_confirmed', {
|
||||
'contribution': contribution,
|
||||
'proposal': contribution.proposal,
|
||||
|
@ -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
|
||||
|
|
|
@ -78,6 +78,7 @@ class ProposalDeadline:
|
|||
'proposal': proposal,
|
||||
})
|
||||
for c in proposal.contributions:
|
||||
if c.user:
|
||||
send_email(c.user.email_address, 'contribution_proposal_failed', {
|
||||
'contribution': c,
|
||||
'proposal': proposal,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 }}
|
|
@ -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,
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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!');
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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`);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,43 +48,96 @@ 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{' '}
|
||||
Your transaction should be confirmed in about 20 minutes.{' '}
|
||||
{isAnonymous
|
||||
? 'Once it’s confirmed, it’ll 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%' }}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
if (error) {
|
||||
okText = 'Done';
|
||||
onOk = handleClose;
|
||||
content = error;
|
||||
} else {
|
||||
okText = 'I’ve 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' : 'I’ve 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 });
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -203,7 +203,6 @@ export function generateProposal({
|
|||
|
||||
const props = {
|
||||
sendLoading: false,
|
||||
isMissingWeb3: false,
|
||||
proposal,
|
||||
...proposal, // yeah...
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue