Merge pull request #50 from dternyak/contribution-validation
Blockchain validation into develop
This commit is contained in:
commit
f6ba6e3dcb
|
@ -206,7 +206,7 @@ const app = store({
|
||||||
}
|
}
|
||||||
app.proposalDetailApproving = false;
|
app.proposalDetailApproving = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getEmailExample(type: string) {
|
async getEmailExample(type: string) {
|
||||||
try {
|
try {
|
||||||
const example = await getEmailExample(type);
|
const example = await getEmailExample(type);
|
||||||
|
|
|
@ -22,6 +22,5 @@ TWITTER_CLIENT_SECRET=twitter-client-secret
|
||||||
LINKEDIN_CLIENT_ID=linkedin-client-id
|
LINKEDIN_CLIENT_ID=linkedin-client-id
|
||||||
LINKEDIN_CLIENT_SECRET=linkedin-client-secret
|
LINKEDIN_CLIENT_SECRET=linkedin-client-secret
|
||||||
|
|
||||||
BLOCKCHAIN_WS_API_URL="http://localhost:5050"
|
|
||||||
BLOCKCHAIN_REST_API_URL="http://localhost:5051"
|
BLOCKCHAIN_REST_API_URL="http://localhost:5051"
|
||||||
BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca"
|
BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca"
|
||||||
|
|
|
@ -2,24 +2,26 @@ import requests
|
||||||
import json
|
import json
|
||||||
from grant.settings import BLOCKCHAIN_REST_API_URL, BLOCKCHAIN_API_SECRET
|
from grant.settings import BLOCKCHAIN_REST_API_URL, BLOCKCHAIN_API_SECRET
|
||||||
|
|
||||||
|
### REST API ###
|
||||||
|
|
||||||
def handle_res(res):
|
def handle_res(res):
|
||||||
j = res.json()
|
j = res.json()
|
||||||
if j.get('error'):
|
if j.get('error'):
|
||||||
raise Exception('Blockchain API Error: {}'.format(j['error']))
|
raise Exception('Blockchain API Error: {}'.format(j['error']))
|
||||||
return j['data']
|
return j['data']
|
||||||
|
|
||||||
def blockchain_get(path, params = None):
|
def blockchain_get(path, params = None):
|
||||||
res = requests.get(
|
res = requests.get(
|
||||||
f'{BLOCKCHAIN_REST_API_URL}{path}',
|
f'{BLOCKCHAIN_REST_API_URL}{path}',
|
||||||
headers={ 'authorization': BLOCKCHAIN_API_SECRET },
|
headers={ 'authorization': BLOCKCHAIN_API_SECRET },
|
||||||
params=params,
|
params=params,
|
||||||
)
|
)
|
||||||
return handle_res(res)
|
return handle_res(res)
|
||||||
|
|
||||||
def blockchain_post(path, data = None):
|
def blockchain_post(path, data = None):
|
||||||
res = requests.post(
|
res = requests.post(
|
||||||
f'{BLOCKCHAIN_REST_API_URL}{path}',
|
f'{BLOCKCHAIN_REST_API_URL}{path}',
|
||||||
headers={ 'authorization': BLOCKCHAIN_API_SECRET },
|
headers={ 'authorization': BLOCKCHAIN_API_SECRET },
|
||||||
data=json.dumps(data),
|
data=json.dumps(data),
|
||||||
)
|
)
|
||||||
return handle_res(res)
|
return handle_res(res)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import datetime
|
import datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
|
from functools import reduce
|
||||||
|
|
||||||
from grant.comment.models import Comment
|
from grant.comment.models import Comment
|
||||||
from grant.extensions import ma, db
|
from grant.extensions import ma, db
|
||||||
|
@ -95,6 +96,8 @@ class ProposalContribution(db.Model):
|
||||||
amount = db.Column(db.String(255), nullable=False)
|
amount = db.Column(db.String(255), nullable=False)
|
||||||
tx_id = db.Column(db.String(255))
|
tx_id = db.Column(db.String(255))
|
||||||
|
|
||||||
|
user = db.relationship("User")
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
proposal_id: int,
|
proposal_id: int,
|
||||||
|
@ -108,10 +111,21 @@ class ProposalContribution(db.Model):
|
||||||
self.status = PENDING
|
self.status = PENDING
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def getExistingContribution(user_id: int, proposal_id: int, amount: str):
|
def get_existing_contribution(user_id: int, proposal_id: int, amount: str):
|
||||||
|
return ProposalContribution.query.filter_by(
|
||||||
|
user_id=user_id,
|
||||||
|
proposal_id=proposal_id,
|
||||||
|
amount=amount,
|
||||||
|
status=PENDING,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_userid(user_id):
|
||||||
return ProposalContribution.query \
|
return ProposalContribution.query \
|
||||||
.filter_by(user_id=user_id, proposal_id=proposal_id, amount=amount) \
|
.filter(ProposalContribution.user_id == user_id) \
|
||||||
.first()
|
.filter(ProposalContribution.status != DELETED) \
|
||||||
|
.order_by(ProposalContribution.date_created.desc()) \
|
||||||
|
.all()
|
||||||
|
|
||||||
def confirm(self, tx_id: str, amount: str):
|
def confirm(self, tx_id: str, amount: str):
|
||||||
self.status = CONFIRMED
|
self.status = CONFIRMED
|
||||||
|
@ -300,7 +314,6 @@ class ProposalSchema(ma.Schema):
|
||||||
"content",
|
"content",
|
||||||
"comments",
|
"comments",
|
||||||
"updates",
|
"updates",
|
||||||
"contributions",
|
|
||||||
"milestones",
|
"milestones",
|
||||||
"category",
|
"category",
|
||||||
"team",
|
"team",
|
||||||
|
@ -317,7 +330,6 @@ class ProposalSchema(ma.Schema):
|
||||||
|
|
||||||
comments = ma.Nested("CommentSchema", many=True)
|
comments = ma.Nested("CommentSchema", many=True)
|
||||||
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
||||||
contributions = ma.Nested("ProposalContributionSchema", many=True, exclude=['proposal'])
|
|
||||||
team = ma.Nested("UserSchema", many=True)
|
team = ma.Nested("UserSchema", many=True)
|
||||||
milestones = ma.Nested("MilestoneSchema", many=True)
|
milestones = ma.Nested("MilestoneSchema", many=True)
|
||||||
invites = ma.Nested("ProposalTeamInviteSchema", many=True)
|
invites = ma.Nested("ProposalTeamInviteSchema", many=True)
|
||||||
|
@ -335,13 +347,30 @@ class ProposalSchema(ma.Schema):
|
||||||
return dt_to_unix(obj.date_published) if obj.date_published else None
|
return dt_to_unix(obj.date_published) if obj.date_published else None
|
||||||
|
|
||||||
def get_funded(self, obj):
|
def get_funded(self, obj):
|
||||||
# TODO: Add up all contributions and return that
|
contributions = ProposalContribution.query \
|
||||||
return "0"
|
.filter_by(proposal_id=obj.id, status=CONFIRMED) \
|
||||||
|
.all()
|
||||||
|
funded = reduce(lambda prev, c: prev + float(c.amount), contributions, 0)
|
||||||
|
return str(funded)
|
||||||
|
|
||||||
|
|
||||||
proposal_schema = ProposalSchema()
|
proposal_schema = ProposalSchema()
|
||||||
proposals_schema = ProposalSchema(many=True)
|
proposals_schema = ProposalSchema(many=True)
|
||||||
|
user_fields = [
|
||||||
|
"proposal_id",
|
||||||
|
"status",
|
||||||
|
"title",
|
||||||
|
"brief",
|
||||||
|
"target",
|
||||||
|
"funded",
|
||||||
|
"date_created",
|
||||||
|
"date_approved",
|
||||||
|
"date_published",
|
||||||
|
"reject_reason",
|
||||||
|
"team",
|
||||||
|
]
|
||||||
|
user_proposal_schema = ProposalSchema(only=user_fields)
|
||||||
|
user_proposals_schema = ProposalSchema(many=True, only=user_fields)
|
||||||
|
|
||||||
class ProposalUpdateSchema(ma.Schema):
|
class ProposalUpdateSchema(ma.Schema):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -433,7 +462,7 @@ class ProposalContributionSchema(ma.Schema):
|
||||||
)
|
)
|
||||||
|
|
||||||
proposal = ma.Nested("ProposalSchema")
|
proposal = ma.Nested("ProposalSchema")
|
||||||
user = ma.Nested("UserSchema")
|
user = ma.Nested("UserSchema", exclude=["email_address"])
|
||||||
date_created = ma.Method("get_date_created")
|
date_created = ma.Method("get_date_created")
|
||||||
addresses = ma.Method("get_addresses")
|
addresses = ma.Method("get_addresses")
|
||||||
|
|
||||||
|
@ -445,41 +474,9 @@ class ProposalContributionSchema(ma.Schema):
|
||||||
|
|
||||||
|
|
||||||
proposal_contribution_schema = ProposalContributionSchema()
|
proposal_contribution_schema = ProposalContributionSchema()
|
||||||
proposals_contribution_schema = ProposalContributionSchema(many=True)
|
proposal_contributions_schema = ProposalContributionSchema(many=True)
|
||||||
|
user_proposal_contribution_schema = ProposalContributionSchema(exclude=['user', 'addresses'])
|
||||||
|
user_proposal_contributions_schema = ProposalContributionSchema(many=True, exclude=['user', 'addresses'])
|
||||||
|
proposal_proposal_contribution_schema = ProposalContributionSchema(exclude=['proposal', 'addresses'])
|
||||||
|
proposal_proposal_contributions_schema = ProposalContributionSchema(many=True, exclude=['proposal', 'addresses'])
|
||||||
|
|
||||||
|
|
||||||
class UserProposalSchema(ma.Schema):
|
|
||||||
class Meta:
|
|
||||||
model = Proposal
|
|
||||||
# Fields to expose
|
|
||||||
fields = (
|
|
||||||
"proposal_id",
|
|
||||||
"status",
|
|
||||||
"title",
|
|
||||||
"brief",
|
|
||||||
"target",
|
|
||||||
"funded",
|
|
||||||
"date_created",
|
|
||||||
"date_approved",
|
|
||||||
"date_published",
|
|
||||||
"reject_reason",
|
|
||||||
"team",
|
|
||||||
)
|
|
||||||
date_created = ma.Method("get_date_created")
|
|
||||||
proposal_id = ma.Method("get_proposal_id")
|
|
||||||
funded = ma.Method("get_funded")
|
|
||||||
team = ma.Nested("UserSchema", many=True)
|
|
||||||
|
|
||||||
def get_proposal_id(self, obj):
|
|
||||||
return obj.id
|
|
||||||
|
|
||||||
def get_date_created(self, obj):
|
|
||||||
return dt_to_unix(obj.date_created) * 1000
|
|
||||||
|
|
||||||
def get_funded(self, obj):
|
|
||||||
# TODO: Add up all contributions and return that
|
|
||||||
return "0"
|
|
||||||
|
|
||||||
|
|
||||||
user_proposal_schema = UserProposalSchema()
|
|
||||||
user_proposals_schema = UserProposalSchema(many=True)
|
|
||||||
|
|
|
@ -11,10 +11,10 @@ from grant.comment.models import Comment, comment_schema, comments_schema
|
||||||
from grant.milestone.models import Milestone
|
from grant.milestone.models import Milestone
|
||||||
from grant.user.models import User, SocialMedia, Avatar
|
from grant.user.models import User, SocialMedia, Avatar
|
||||||
from grant.email.send import send_email
|
from grant.email.send import send_email
|
||||||
from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user
|
from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user, internal_webhook
|
||||||
from grant.utils.exceptions import ValidationException
|
from grant.utils.exceptions import ValidationException
|
||||||
from grant.utils.misc import is_email, make_url
|
from grant.utils.misc import is_email, make_url, from_zat
|
||||||
from .models import(
|
from .models import (
|
||||||
Proposal,
|
Proposal,
|
||||||
proposals_schema,
|
proposals_schema,
|
||||||
proposal_schema,
|
proposal_schema,
|
||||||
|
@ -25,13 +25,15 @@ from .models import(
|
||||||
proposal_team,
|
proposal_team,
|
||||||
ProposalTeamInvite,
|
ProposalTeamInvite,
|
||||||
proposal_team_invite_schema,
|
proposal_team_invite_schema,
|
||||||
|
proposal_proposal_contributions_schema,
|
||||||
db,
|
db,
|
||||||
DRAFT,
|
DRAFT,
|
||||||
PENDING,
|
PENDING,
|
||||||
APPROVED,
|
APPROVED,
|
||||||
REJECTED,
|
REJECTED,
|
||||||
LIVE,
|
LIVE,
|
||||||
DELETED
|
DELETED,
|
||||||
|
CONFIRMED,
|
||||||
)
|
)
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
@ -328,11 +330,25 @@ def delete_proposal_team_invite(proposal_id, id_or_address):
|
||||||
@endpoint.api()
|
@endpoint.api()
|
||||||
def get_proposal_contributions(proposal_id):
|
def get_proposal_contributions(proposal_id):
|
||||||
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||||
if proposal:
|
if not proposal:
|
||||||
dumped_proposal = proposal_schema.dump(proposal)
|
|
||||||
return dumped_proposal["contributions"]
|
|
||||||
else:
|
|
||||||
return {"message": "No proposal matching id"}, 404
|
return {"message": "No proposal matching id"}, 404
|
||||||
|
|
||||||
|
top_contributions = ProposalContribution.query \
|
||||||
|
.filter_by(proposal_id=proposal_id, status=CONFIRMED) \
|
||||||
|
.order_by(ProposalContribution.amount.desc()) \
|
||||||
|
.limit(5) \
|
||||||
|
.all()
|
||||||
|
latest_contributions = ProposalContribution.query \
|
||||||
|
.filter_by(proposal_id=proposal_id, status=CONFIRMED) \
|
||||||
|
.order_by(ProposalContribution.date_created.desc()) \
|
||||||
|
.limit(5) \
|
||||||
|
.all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'top': proposal_proposal_contributions_schema.dump(top_contributions),
|
||||||
|
'latest': proposal_proposal_contributions_schema.dump(latest_contributions),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/contributions/<contribution_id>", methods=["GET"])
|
@blueprint.route("/<proposal_id>/contributions/<contribution_id>", methods=["GET"])
|
||||||
|
@ -361,7 +377,7 @@ def post_proposal_contribution(proposal_id, amount):
|
||||||
|
|
||||||
code = 200
|
code = 200
|
||||||
contribution = ProposalContribution \
|
contribution = ProposalContribution \
|
||||||
.getExistingContribution(g.current_user.id, proposal_id, amount)
|
.get_existing_contribution(g.current_user.id, proposal_id, amount)
|
||||||
|
|
||||||
if not contribution:
|
if not contribution:
|
||||||
code = 201
|
code = 201
|
||||||
|
@ -375,3 +391,49 @@ def post_proposal_contribution(proposal_id, amount):
|
||||||
|
|
||||||
dumped_contribution = proposal_contribution_schema.dump(contribution)
|
dumped_contribution = proposal_contribution_schema.dump(contribution)
|
||||||
return dumped_contribution, code
|
return dumped_contribution, code
|
||||||
|
|
||||||
|
|
||||||
|
# Can't use <proposal_id> since webhook doesn't know proposal id
|
||||||
|
@blueprint.route("/contribution/<contribution_id>/confirm", methods=["POST"])
|
||||||
|
@internal_webhook
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('to', type=str, required=True),
|
||||||
|
parameter('amount', type=str, required=True),
|
||||||
|
parameter('txid', type=str, required=True),
|
||||||
|
)
|
||||||
|
def post_contribution_confirmation(contribution_id, to, amount, txid):
|
||||||
|
contribution = contribution = ProposalContribution.query.filter_by(
|
||||||
|
id=contribution_id).first()
|
||||||
|
|
||||||
|
if not contribution:
|
||||||
|
# TODO: Log in sentry
|
||||||
|
print(f'Unknown contribution {contribution_id} confirmed with txid {txid}')
|
||||||
|
return {"message": "No contribution matching id"}, 404
|
||||||
|
|
||||||
|
# Convert to whole zcash coins from zats
|
||||||
|
zec_amount = str(from_zat(int(amount)))
|
||||||
|
|
||||||
|
contribution.confirm(tx_id=txid, amount=zec_amount)
|
||||||
|
db.session.add(contribution)
|
||||||
|
db.session.commit()
|
||||||
|
return None, 200
|
||||||
|
|
||||||
|
@blueprint.route("/contribution/<contribution_id>", methods=["DELETE"])
|
||||||
|
@requires_auth
|
||||||
|
@endpoint.api()
|
||||||
|
def delete_proposal_contribution(contribution_id):
|
||||||
|
contribution = contribution = ProposalContribution.query.filter_by(
|
||||||
|
id=contribution_id).first()
|
||||||
|
if not contribution:
|
||||||
|
return {"message": "No contribution matching id"}, 404
|
||||||
|
|
||||||
|
if contribution.status == CONFIRMED:
|
||||||
|
return {"message": "Cannot delete confirmed contributions"}, 400
|
||||||
|
|
||||||
|
if contribution.user_id != g.current_user.id:
|
||||||
|
return {"message": "Must be the user of the contribution to delete it"}, 403
|
||||||
|
|
||||||
|
contribution.status = DELETED
|
||||||
|
db.session.add(contribution)
|
||||||
|
db.session.commit()
|
||||||
|
return None, 202
|
||||||
|
|
|
@ -56,6 +56,5 @@ TWITTER_CLIENT_SECRET = env.str("TWITTER_CLIENT_SECRET")
|
||||||
LINKEDIN_CLIENT_ID = env.str("LINKEDIN_CLIENT_ID")
|
LINKEDIN_CLIENT_ID = env.str("LINKEDIN_CLIENT_ID")
|
||||||
LINKEDIN_CLIENT_SECRET = env.str("LINKEDIN_CLIENT_SECRET")
|
LINKEDIN_CLIENT_SECRET = env.str("LINKEDIN_CLIENT_SECRET")
|
||||||
|
|
||||||
BLOCKCHAIN_WS_API_URL = env.str("BLOCKCHAIN_WS_API_URL")
|
|
||||||
BLOCKCHAIN_REST_API_URL = env.str("BLOCKCHAIN_REST_API_URL")
|
BLOCKCHAIN_REST_API_URL = env.str("BLOCKCHAIN_REST_API_URL")
|
||||||
BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET")
|
BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET")
|
||||||
|
|
|
@ -8,10 +8,13 @@ from grant.proposal.models import (
|
||||||
proposal_team,
|
proposal_team,
|
||||||
ProposalTeamInvite,
|
ProposalTeamInvite,
|
||||||
invites_with_proposal_schema,
|
invites_with_proposal_schema,
|
||||||
|
ProposalContribution,
|
||||||
|
user_proposal_contributions_schema,
|
||||||
user_proposals_schema,
|
user_proposals_schema,
|
||||||
PENDING,
|
PENDING,
|
||||||
APPROVED,
|
APPROVED,
|
||||||
REJECTED
|
REJECTED,
|
||||||
|
CONFIRMED
|
||||||
)
|
)
|
||||||
from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user
|
from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user
|
||||||
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
|
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
|
||||||
|
@ -62,19 +65,21 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending):
|
||||||
user = User.get_by_id(user_id)
|
user = User.get_by_id(user_id)
|
||||||
if user:
|
if user:
|
||||||
result = user_schema.dump(user)
|
result = user_schema.dump(user)
|
||||||
|
authed_user = get_authed_user()
|
||||||
if with_proposals:
|
if with_proposals:
|
||||||
proposals = Proposal.get_by_user(user)
|
proposals = Proposal.get_by_user(user)
|
||||||
proposals_dump = user_proposals_schema.dump(proposals)
|
proposals_dump = user_proposals_schema.dump(proposals)
|
||||||
result["createdProposals"] = proposals_dump
|
result["proposals"] = proposals_dump
|
||||||
if with_funded:
|
if with_funded:
|
||||||
contributions = Proposal.get_by_user_contribution(user)
|
contributions = ProposalContribution.get_by_userid(user_id)
|
||||||
contributions_dump = user_proposals_schema.dump(contributions)
|
if not authed_user or user.id != authed_user.id:
|
||||||
result["fundedProposals"] = contributions_dump
|
contributions = [c for c in contributions if c.status == CONFIRMED]
|
||||||
|
contributions_dump = user_proposal_contributions_schema.dump(contributions)
|
||||||
|
result["contributions"] = contributions_dump
|
||||||
if with_comments:
|
if with_comments:
|
||||||
comments = Comment.get_by_user(user)
|
comments = Comment.get_by_user(user)
|
||||||
comments_dump = user_comments_schema.dump(comments)
|
comments_dump = user_comments_schema.dump(comments)
|
||||||
result["comments"] = comments_dump
|
result["comments"] = comments_dump
|
||||||
authed_user = get_authed_user()
|
|
||||||
if with_pending and authed_user and authed_user.id == user.id:
|
if with_pending and authed_user and authed_user.id == user.id:
|
||||||
pending = Proposal.get_by_user(user, [PENDING, APPROVED, REJECTED])
|
pending = Proposal.get_by_user(user, [PENDING, APPROVED, REJECTED])
|
||||||
pending_dump = user_proposals_schema.dump(pending)
|
pending_dump = user_proposals_schema.dump(pending)
|
||||||
|
|
|
@ -7,7 +7,7 @@ from flask_security.core import current_user
|
||||||
from flask import request, g, jsonify
|
from flask import request, g, jsonify
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
|
|
||||||
from grant.settings import SECRET_KEY
|
from grant.settings import SECRET_KEY, BLOCKCHAIN_API_SECRET
|
||||||
from ..proposal.models import Proposal
|
from ..proposal.models import Proposal
|
||||||
from ..user.models import User
|
from ..user.models import User
|
||||||
|
|
||||||
|
@ -67,3 +67,16 @@ def requires_team_member_auth(f):
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
return requires_auth(decorated)
|
return requires_auth(decorated)
|
||||||
|
|
||||||
|
def internal_webhook(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
secret = request.headers.get('authorization')
|
||||||
|
if not secret:
|
||||||
|
print('Internal webhook missing "Authorization" header')
|
||||||
|
return jsonify(message="Invalid 'Authorization' header"), 403
|
||||||
|
if BLOCKCHAIN_API_SECRET not in secret:
|
||||||
|
print(f'Internal webhook provided invalid "Authorization" header: {secret}')
|
||||||
|
return jsonify(message="Invalid 'Authorization' header"), 403
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
|
@ -30,3 +30,9 @@ def make_url(path: str):
|
||||||
|
|
||||||
def is_email(email: str):
|
def is_email(email: str):
|
||||||
return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email))
|
return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email))
|
||||||
|
|
||||||
|
def from_zat(zat: int):
|
||||||
|
return zat / 100000000
|
||||||
|
|
||||||
|
def to_zat(zec: float):
|
||||||
|
return zec * 100000000
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
API_SECRET_HASH="4747c3a7d043640cd8ed4e6b72bb9562d2585cee65681eb9c21ffec03c0bf560"
|
API_SECRET_HASH="4747c3a7d043640cd8ed4e6b72bb9562d2585cee65681eb9c21ffec03c0bf560"
|
||||||
API_SECRET_KEY="ef0b48e41f78d3ae85b1379b386f1bca"
|
API_SECRET_KEY="ef0b48e41f78d3ae85b1379b386f1bca"
|
||||||
|
|
||||||
# WebSocket Config
|
# Webhooks Config
|
||||||
WS_PORT="5050"
|
WEBHOOK_URL="http://localhost:5000/api/v1"
|
||||||
|
|
||||||
# REST Server Config
|
# REST Server Config
|
||||||
REST_SERVER_PORT="5051"
|
REST_SERVER_PORT="5051"
|
||||||
|
|
|
@ -31,6 +31,7 @@
|
||||||
"@types/cors": "2.8.4",
|
"@types/cors": "2.8.4",
|
||||||
"@types/dotenv": "^6.1.0",
|
"@types/dotenv": "^6.1.0",
|
||||||
"@types/ws": "^6.0.1",
|
"@types/ws": "^6.0.1",
|
||||||
|
"axios": "0.18.0",
|
||||||
"body-parser": "1.18.3",
|
"body-parser": "1.18.3",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"dotenv": "^6.1.0",
|
"dotenv": "^6.1.0",
|
||||||
|
|
|
@ -6,7 +6,7 @@ interface CustomEnvironment {
|
||||||
API_SECRET_HASH: string;
|
API_SECRET_HASH: string;
|
||||||
API_SECRET_KEY: string;
|
API_SECRET_KEY: string;
|
||||||
|
|
||||||
WS_PORT: string;
|
WEBHOOK_URL: string;
|
||||||
REST_SERVER_PORT: string;
|
REST_SERVER_PORT: string;
|
||||||
|
|
||||||
ZCASH_NODE_URL: string;
|
ZCASH_NODE_URL: string;
|
||||||
|
|
|
@ -1,18 +1,19 @@
|
||||||
import * as Websocket from "./websocket";
|
import * as Webhooks from "./webhooks";
|
||||||
import * as RestServer from "./server";
|
import * as RestServer from "./server";
|
||||||
import { initNode } from './node';
|
import { initNode } from './node';
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
console.log("============== Starting services ==============");
|
console.log("============== Starting services ==============");
|
||||||
await initNode();
|
await initNode();
|
||||||
await Websocket.start();
|
await Webhooks.start();
|
||||||
await RestServer.start();
|
await RestServer.start();
|
||||||
console.log("===============================================");
|
console.log("===============================================");
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
console.log('Shutting down services...');
|
console.log('Shutting down services...');
|
||||||
Websocket.exit();
|
Webhooks.exit();
|
||||||
|
RestServer.exit();
|
||||||
console.log('Exiting!');
|
console.log('Exiting!');
|
||||||
process.exit();
|
process.exit();
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { randomBytes, createHmac } from "crypto";
|
import { randomBytes, createHmac } from "crypto";
|
||||||
import { IncomingMessage } from "http";
|
import { IncomingMessage } from "http";
|
||||||
import { HDPublicKey, Address } from "zcash-bitcore-lib";
|
import { HDPublicKey, Address } from "zcash-bitcore-lib";
|
||||||
|
import { parse } from 'url';
|
||||||
import env from "./env";
|
import env from "./env";
|
||||||
|
|
||||||
function sha256(input: string) {
|
function sha256(input: string) {
|
||||||
|
@ -14,22 +15,6 @@ export function generateApiKey() {
|
||||||
return { key, hash };
|
return { key, hash };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIpFromRequest(req: IncomingMessage) {
|
|
||||||
const xffHeader = req.headers["x-forwarded-for"];
|
|
||||||
if (xffHeader && typeof xffHeader === "string") {
|
|
||||||
return xffHeader.split(/\s*,\s*/)[0];
|
|
||||||
}
|
|
||||||
return req.connection.remoteAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAuthFromRequest(req: IncomingMessage) {
|
|
||||||
const swpHeader = req.headers["sec-websocket-protocol"];
|
|
||||||
if (swpHeader && typeof swpHeader === "string") {
|
|
||||||
return swpHeader;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function authenticate(secret: string) {
|
export function authenticate(secret: string) {
|
||||||
const hash = env.API_SECRET_HASH;
|
const hash = env.API_SECRET_HASH;
|
||||||
if (!hash) {
|
if (!hash) {
|
||||||
|
@ -38,14 +23,6 @@ export function authenticate(secret: string) {
|
||||||
return hash === sha256(secret);
|
return hash === sha256(secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authenticateRequest(req: IncomingMessage) {
|
|
||||||
const secret = getAuthFromRequest(req);
|
|
||||||
if (!secret) {
|
|
||||||
console.log(`Client must set 'sec-websocket-protocal' header with secret.`);
|
|
||||||
}
|
|
||||||
return secret ? authenticate(secret) : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Not fully confident in compatibility with most bip32 wallets,
|
// TODO: Not fully confident in compatibility with most bip32 wallets,
|
||||||
// do more work to ensure this is reliable.
|
// do more work to ensure this is reliable.
|
||||||
export function deriveTransparentAddress(index: number, network: any) {
|
export function deriveTransparentAddress(index: number, network: any) {
|
||||||
|
|
|
@ -1,47 +1,25 @@
|
||||||
import WebSocket from "ws";
|
import axios from 'axios';
|
||||||
import { initializeNotifiers } from "./notifiers";
|
import { initializeNotifiers } from "./notifiers";
|
||||||
import { Notifier } from "./notifiers/notifier";
|
import { Notifier } from "./notifiers/notifier";
|
||||||
import { getIpFromRequest, authenticateRequest } from "../util";
|
import node from "../node";
|
||||||
import node, { rpcOptions } from "../node";
|
|
||||||
import env from "../env";
|
import env from "../env";
|
||||||
|
|
||||||
const log = console.log;
|
const log = console.log;
|
||||||
|
|
||||||
export type Send = (message: Message) => void;
|
export type Send = (route: string, method: string, payload: object) => void;
|
||||||
export type Receive = (message: Message) => void;
|
|
||||||
|
|
||||||
export interface Message {
|
|
||||||
type: string;
|
|
||||||
payload: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parse = (data: WebSocket.Data) => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(data.toString());
|
|
||||||
} catch (e) {
|
|
||||||
log(
|
|
||||||
`unable to parse message, it was probably not JSON, data: ${data}`
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let wss: null | WebSocket.Server = null;
|
|
||||||
let notifiers = [] as Notifier[];
|
let notifiers = [] as Notifier[];
|
||||||
let consecutiveBlockFailures = 0;
|
let consecutiveBlockFailures = 0;
|
||||||
const MAXIMUM_BLOCK_FAILURES = 5;
|
const MAXIMUM_BLOCK_FAILURES = 5;
|
||||||
|
|
||||||
export async function start() {
|
export async function start() {
|
||||||
await initNode();
|
await initNode();
|
||||||
initWebsocketServer();
|
|
||||||
initNotifiers();
|
initNotifiers();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exit() {
|
export function exit() {
|
||||||
notifiers.forEach(n => n.destroy && n.destroy());
|
notifiers.forEach(n => n.destroy && n.destroy());
|
||||||
wss && wss.close();
|
console.log('Webhook notifiers have exited');
|
||||||
wss = null;
|
|
||||||
console.log('WebSocket server has been closed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -82,47 +60,30 @@ async function initNode() {
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initWebsocketServer() {
|
|
||||||
if (wss) return;
|
|
||||||
|
|
||||||
wss = new WebSocket.Server({
|
|
||||||
port: parseInt(env.WS_PORT as string, 10)
|
|
||||||
});
|
|
||||||
log(`WebSocket Server started on port ${env.WS_PORT}`);
|
|
||||||
|
|
||||||
wss.on("connection", function connection(ws, req) {
|
|
||||||
log(`${getIpFromRequest(req)} connected`);
|
|
||||||
const isAuth = authenticateRequest(req);
|
|
||||||
if (!isAuth) {
|
|
||||||
log(`Connection ${getIpFromRequest(req)} rejected, unauthorized.`);
|
|
||||||
ws.send(JSON.stringify({ type: "auth", payload: "rejected" }));
|
|
||||||
ws.terminate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.on("message", message => {
|
|
||||||
const parsedMsg = parse(message);
|
|
||||||
if (parsedMsg) {
|
|
||||||
notifiers.forEach(n => n.receive && n.receive(parsedMsg));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ws.on("close", () => {
|
|
||||||
log(`${getIpFromRequest(req)} closed.`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function initNotifiers() {
|
function initNotifiers() {
|
||||||
const send: Send = message =>
|
const send: Send = (route, method, payload) => {
|
||||||
wss &&
|
console.log('About to send to', route);
|
||||||
wss.clients.forEach(ws => {
|
axios.request({
|
||||||
try {
|
method,
|
||||||
ws.send(JSON.stringify(message));
|
url: `${env.WEBHOOK_URL}${route}`,
|
||||||
} catch (e) {
|
data: payload,
|
||||||
log(`Send error: ${e}`);
|
headers: {
|
||||||
|
'Authorization': `Bearer ${env.API_SECRET_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status >= 400) {
|
||||||
|
console.error(`Webhook server responded to ${method} ${route} with status code ${res.status}`);
|
||||||
|
console.error(res.data);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
console.error('Webhook server request failed! See above for details.');
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
notifiers = initializeNotifiers();
|
notifiers = initializeNotifiers();
|
||||||
notifiers.forEach(n => n.registerSend(send));
|
notifiers.forEach(n => n.registerSend(send));
|
|
@ -1,4 +1,4 @@
|
||||||
import { Send, Message } from "../../index";
|
import { Send } from "../../index";
|
||||||
import { Notifier } from "../notifier";
|
import { Notifier } from "../notifier";
|
||||||
import node, { BlockWithTransactions } from "../../../node";
|
import node, { BlockWithTransactions } from "../../../node";
|
||||||
import {
|
import {
|
||||||
|
@ -43,7 +43,6 @@ export default class ContributionNotifier implements Notifier {
|
||||||
// generate one, so all of our addresses will only have addresses[0]
|
// generate one, so all of our addresses will only have addresses[0]
|
||||||
const to = vout.scriptPubKey.addresses[0];
|
const to = vout.scriptPubKey.addresses[0];
|
||||||
if (tAddressIdMap[to]) {
|
if (tAddressIdMap[to]) {
|
||||||
console.info(`Transaction found for contribution ${tAddressIdMap[to]}, +${vout.value} ZEC`);
|
|
||||||
this.sendContributionConfirmation({
|
this.sendContributionConfirmation({
|
||||||
to,
|
to,
|
||||||
amount: vout.valueZat.toString(),
|
amount: vout.valueZat.toString(),
|
||||||
|
@ -67,10 +66,11 @@ export default class ContributionNotifier implements Notifier {
|
||||||
const newReceived = received.filter(r => !this.confirmedTxIds.includes(r.txid));
|
const newReceived = received.filter(r => !this.confirmedTxIds.includes(r.txid));
|
||||||
|
|
||||||
newReceived.forEach(receipt => {
|
newReceived.forEach(receipt => {
|
||||||
|
console.info(`Received new tx ${receipt.txid}`);
|
||||||
this.confirmedTxIds.push(receipt.txid);
|
this.confirmedTxIds.push(receipt.txid);
|
||||||
const contributionId = getContributionIdFromMemo(receipt.memo);
|
const contributionId = getContributionIdFromMemo(receipt.memo);
|
||||||
if (!contributionId) {
|
if (!contributionId) {
|
||||||
console.warn('Sprout address received transaction without memo:\n', {
|
console.warn(`Sprout address ${env.SPROUT_ADDRESS} received transaction with invalid memo:\n`, {
|
||||||
txid: receipt.txid,
|
txid: receipt.txid,
|
||||||
decodedMemo: decodeHexMemo(receipt.memo)
|
decodedMemo: decodeHexMemo(receipt.memo)
|
||||||
});
|
});
|
||||||
|
@ -134,10 +134,8 @@ export default class ContributionNotifier implements Notifier {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private sendContributionConfirmation = (payload: ContributionConfirmationPayload) => {
|
private sendContributionConfirmation = (p: ContributionConfirmationPayload) => {
|
||||||
this.send({
|
console.info(`Contribution confirmed for contribution ${p.contributionId}, +${p.amount} ZEC`);
|
||||||
payload,
|
this.send(`/proposals/contribution/${p.contributionId}/confirm`, 'POST', p);
|
||||||
type: 'contribution:confirmation',
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
}
|
}
|
|
@ -1,9 +1,8 @@
|
||||||
import { Send, Message } from "../index";
|
import { Send } from "../index";
|
||||||
import { Block } from "../../node";
|
import { Block } from "../../node";
|
||||||
|
|
||||||
export interface Notifier {
|
export interface Notifier {
|
||||||
registerSend(send: Send): void;
|
registerSend(send: Send): void;
|
||||||
receive?(message: Message): void;
|
|
||||||
onNewBlock?(block: Block): void;
|
onNewBlock?(block: Block): void;
|
||||||
destroy?(): void;
|
destroy?(): void;
|
||||||
}
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -33,6 +33,10 @@ export function getProposalUpdates(proposalId: number | string) {
|
||||||
return axios.get(`/api/v1/proposals/${proposalId}/updates`);
|
return axios.get(`/api/v1/proposals/${proposalId}/updates`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProposalContributions(proposalId: number | string) {
|
||||||
|
return axios.get(`/api/v1/proposals/${proposalId}/contributions`);
|
||||||
|
}
|
||||||
|
|
||||||
export function postProposal(payload: ProposalDraft) {
|
export function postProposal(payload: ProposalDraft) {
|
||||||
return axios.post(`/api/v1/proposals/`, {
|
return axios.post(`/api/v1/proposals/`, {
|
||||||
...payload,
|
...payload,
|
||||||
|
@ -216,6 +220,7 @@ export function postProposalContribution(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function postProposalComment(payload: {
|
export function postProposalComment(payload: {
|
||||||
proposalId: number;
|
proposalId: number;
|
||||||
parentCommentId?: number;
|
parentCommentId?: number;
|
||||||
|
@ -224,3 +229,14 @@ export function postProposalComment(payload: {
|
||||||
const { proposalId, ...args } = payload;
|
const { proposalId, ...args } = payload;
|
||||||
return axios.post(`/api/v1/proposals/${proposalId}/comments`, args);
|
return axios.post(`/api/v1/proposals/${proposalId}/comments`, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteProposalContribution(contributionId: string | number) {
|
||||||
|
return axios.delete(`/api/v1/proposals/contribution/${contributionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProposalContribution(
|
||||||
|
proposalId: number,
|
||||||
|
contributionId: number,
|
||||||
|
): Promise<{ data: ContributionWithAddresses }> {
|
||||||
|
return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`);
|
||||||
|
}
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ShortAddress from 'components/ShortAddress';
|
|
||||||
import Identicon from 'components/Identicon';
|
|
||||||
import './style.less';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
address: string;
|
|
||||||
secondary?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddressRow = ({ address, secondary }: Props) => (
|
|
||||||
<div className="AddressRow">
|
|
||||||
<div className="AddressRow-avatar">
|
|
||||||
<Identicon address={address} />
|
|
||||||
</div>
|
|
||||||
<div className="AddressRow-info">
|
|
||||||
<div className="AddressRow-info-main">
|
|
||||||
<ShortAddress address={address} />
|
|
||||||
</div>
|
|
||||||
{secondary && <p className="AddressRow-info-secondary">{secondary}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default AddressRow;
|
|
|
@ -1,47 +0,0 @@
|
||||||
@height: 3rem;
|
|
||||||
|
|
||||||
.AddressRow {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
height: @height;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-avatar {
|
|
||||||
display: block;
|
|
||||||
height: @height;
|
|
||||||
width: @height;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
&-main {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-bottom: 0.1rem;
|
|
||||||
min-width: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-secondary {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
min-width: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -19,6 +19,7 @@
|
||||||
border-bottom: 1px solid rgba(#000, 0.06);
|
border-bottom: 1px solid rgba(#000, 0.06);
|
||||||
|
|
||||||
&-qr {
|
&-qr {
|
||||||
|
position: relative;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
@ -28,6 +29,16 @@
|
||||||
canvas {
|
canvas {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
&.is-loading canvas {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-info {
|
&-info {
|
||||||
|
|
|
@ -67,8 +67,13 @@ export default class PaymentInfo extends React.Component<Props, State> {
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
|
|
||||||
<div className="PaymentInfo-uri">
|
<div className="PaymentInfo-uri">
|
||||||
<div className="PaymentInfo-uri-qr">
|
<div className={
|
||||||
{uri ? <QRCode value={uri} /> : <Spin />}
|
classnames('PaymentInfo-uri-qr', !uri && 'is-loading')
|
||||||
|
}>
|
||||||
|
<span style={{ opacity: uri ? 1 : 0 }}>
|
||||||
|
<QRCode value={uri || ''} />
|
||||||
|
</span>
|
||||||
|
{!uri && <Spin size="large" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="PaymentInfo-uri-info">
|
<div className="PaymentInfo-uri-info">
|
||||||
<CopyInput
|
<CopyInput
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Modal } from 'antd';
|
import { Modal } from 'antd';
|
||||||
import Result from 'ant-design-pro/lib/Result';
|
import Result from 'ant-design-pro/lib/Result';
|
||||||
import { postProposalContribution } from 'api/api';
|
import { postProposalContribution, getProposalContribution } from 'api/api';
|
||||||
import { ContributionWithAddresses } from 'types';
|
import { ContributionWithAddresses } from 'types';
|
||||||
import PaymentInfo from './PaymentInfo';
|
import PaymentInfo from './PaymentInfo';
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
proposalId: number;
|
proposalId?: number;
|
||||||
|
contributionId?: number;
|
||||||
amount?: string;
|
amount?: string;
|
||||||
|
hasNoButtons?: boolean;
|
||||||
handleClose(): void;
|
handleClose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,17 +30,21 @@ export default class ContributionModal extends React.Component<Props, State> {
|
||||||
};
|
};
|
||||||
|
|
||||||
componentWillUpdate(nextProps: Props) {
|
componentWillUpdate(nextProps: Props) {
|
||||||
const { isVisible, proposalId } = nextProps
|
const { isVisible, proposalId, contributionId } = nextProps;
|
||||||
if (isVisible && this.props.isVisible !== isVisible) {
|
// When modal is opened and proposalId is provided or changed
|
||||||
this.fetchAddresses(proposalId);
|
if (isVisible && proposalId) {
|
||||||
}
|
if (
|
||||||
else if (proposalId !== this.props.proposalId) {
|
this.props.isVisible !== isVisible ||
|
||||||
this.fetchAddresses(proposalId);
|
proposalId !== this.props.proposalId
|
||||||
|
) {
|
||||||
|
this.fetchAddresses(proposalId, contributionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { isVisible, handleClose } = this.props;
|
const { isVisible, handleClose, hasNoButtons } = this.props;
|
||||||
const { hasSent, contribution, error } = this.state;
|
const { hasSent, contribution, error } = this.state;
|
||||||
let content;
|
let content;
|
||||||
|
|
||||||
|
@ -68,11 +74,12 @@ export default class ContributionModal extends React.Component<Props, State> {
|
||||||
<Modal
|
<Modal
|
||||||
title="Make your contribution"
|
title="Make your contribution"
|
||||||
visible={isVisible}
|
visible={isVisible}
|
||||||
closable={hasSent}
|
closable={hasSent || hasNoButtons}
|
||||||
maskClosable={hasSent}
|
maskClosable={hasSent || hasNoButtons}
|
||||||
okText={hasSent ? 'Done' : 'I’ve sent it'}
|
okText={hasSent ? 'Done' : 'I’ve sent it'}
|
||||||
onOk={hasSent ? handleClose : this.confirmSend}
|
onOk={hasSent ? handleClose : this.confirmSend}
|
||||||
onCancel={handleClose}
|
onCancel={handleClose}
|
||||||
|
footer={hasNoButtons ? '' : undefined}
|
||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
@ -80,12 +87,20 @@ export default class ContributionModal extends React.Component<Props, State> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchAddresses(proposalId: number) {
|
private async fetchAddresses(
|
||||||
|
proposalId: number,
|
||||||
|
contributionId?: number,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const res = await postProposalContribution(
|
let res;
|
||||||
proposalId,
|
if (contributionId) {
|
||||||
this.props.amount || '0',
|
res = await getProposalContribution(proposalId, contributionId);
|
||||||
);
|
} else {
|
||||||
|
res = await postProposalContribution(
|
||||||
|
proposalId,
|
||||||
|
this.props.amount || '0',
|
||||||
|
);
|
||||||
|
}
|
||||||
this.setState({ contribution: res.data });
|
this.setState({ contribution: res.data });
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
this.setState({ error: err.message });
|
this.setState({ error: err.message });
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
@small-query: ~'(max-width: 640px)';
|
||||||
|
|
||||||
|
.ProfileContribution {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
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;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: inherit;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
.ant-tag {
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-state {
|
||||||
|
margin-left: 1.2rem;
|
||||||
|
min-width: 15rem;
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
@media @small-query {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-amount {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
// UnitDisplay symbol
|
||||||
|
> span {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-actions {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
&:after {
|
||||||
|
content: '·';
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child:after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Tag, Popconfirm } from 'antd';
|
||||||
|
import UnitDisplay from 'components/UnitDisplay';
|
||||||
|
import { ONE_DAY } from 'utils/time';
|
||||||
|
import { formatTxExplorerUrl } from 'utils/formatters';
|
||||||
|
import { deleteContribution } from 'modules/users/actions';
|
||||||
|
import { UserContribution } from 'types';
|
||||||
|
import './ProfileContribution.less';
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
userId: number;
|
||||||
|
contribution: UserContribution;
|
||||||
|
showSendInstructions(contribution: UserContribution): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
deleteContribution: typeof deleteContribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = OwnProps & DispatchProps;
|
||||||
|
|
||||||
|
class ProfileContribution extends React.Component<Props> {
|
||||||
|
render() {
|
||||||
|
const { contribution } = this.props;
|
||||||
|
const { proposal } = contribution;
|
||||||
|
const isConfirmed = contribution.status === 'CONFIRMED';
|
||||||
|
const isExpired = !isConfirmed && contribution.dateCreated < Date.now() / 1000 - ONE_DAY;
|
||||||
|
|
||||||
|
let tag;
|
||||||
|
let actions: React.ReactNode;
|
||||||
|
if (isConfirmed) {
|
||||||
|
actions = (
|
||||||
|
<a
|
||||||
|
href={formatTxExplorerUrl(contribution.txId as string)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener nofollow"
|
||||||
|
>
|
||||||
|
View transaction
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} else if (isExpired) {
|
||||||
|
tag = <Tag color="red">Expired</Tag>;
|
||||||
|
// TODO: Link to support
|
||||||
|
actions = <>
|
||||||
|
<Popconfirm
|
||||||
|
title="Are you sure?"
|
||||||
|
onConfirm={this.deleteContribution}
|
||||||
|
>
|
||||||
|
<a>Delete</a>
|
||||||
|
</Popconfirm>
|
||||||
|
<Link to="/support">Contact support</Link>
|
||||||
|
</>;
|
||||||
|
} else {
|
||||||
|
tag = <Tag color="orange">Pending</Tag>;
|
||||||
|
actions = (
|
||||||
|
<a onClick={() => this.props.showSendInstructions(contribution)}>
|
||||||
|
View send instructions
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ProfileContribution">
|
||||||
|
<div className="ProfileContribution-info">
|
||||||
|
<Link
|
||||||
|
className="ProfileContribution-info-title"
|
||||||
|
to={`/proposals/${proposal.proposalId}`}
|
||||||
|
>
|
||||||
|
{proposal.title} {tag}
|
||||||
|
</Link>
|
||||||
|
<div className="ProfileContribution-info-brief">{proposal.brief}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ProfileContribution-state">
|
||||||
|
<div className="ProfileContribution-state-amount">
|
||||||
|
+<UnitDisplay value={contribution.amount} symbol="ZEC" />
|
||||||
|
</div>
|
||||||
|
<div className="ProfileContribution-state-actions">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleteContribution = () => {
|
||||||
|
this.props.deleteContribution(this.props.userId, this.props.contribution.id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect<{}, DispatchProps, OwnProps, {}>(undefined, {
|
||||||
|
deleteContribution,
|
||||||
|
})(ProfileContribution);
|
|
@ -16,11 +16,14 @@ import ProfileUser from './ProfileUser';
|
||||||
import ProfileEdit from './ProfileEdit';
|
import ProfileEdit from './ProfileEdit';
|
||||||
import ProfilePendingList from './ProfilePendingList';
|
import ProfilePendingList from './ProfilePendingList';
|
||||||
import ProfileProposal from './ProfileProposal';
|
import ProfileProposal from './ProfileProposal';
|
||||||
|
import ProfileContribution from './ProfileContribution';
|
||||||
import ProfileComment from './ProfileComment';
|
import ProfileComment from './ProfileComment';
|
||||||
import ProfileInvite from './ProfileInvite';
|
import ProfileInvite from './ProfileInvite';
|
||||||
import Placeholder from 'components/Placeholder';
|
import Placeholder from 'components/Placeholder';
|
||||||
import Exception from 'pages/exception';
|
import Exception from 'pages/exception';
|
||||||
|
import ContributionModal from 'components/ContributionModal';
|
||||||
import './style.less';
|
import './style.less';
|
||||||
|
import { UserContribution } from 'types';
|
||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
usersMap: AppState['users']['map'];
|
usersMap: AppState['users']['map'];
|
||||||
|
@ -34,7 +37,15 @@ interface DispatchProps {
|
||||||
|
|
||||||
type Props = RouteComponentProps<any> & StateProps & DispatchProps;
|
type Props = RouteComponentProps<any> & StateProps & DispatchProps;
|
||||||
|
|
||||||
class Profile extends React.Component<Props> {
|
interface State {
|
||||||
|
activeContribution: UserContribution | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Profile extends React.Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
activeContribution: null,
|
||||||
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.fetchData();
|
this.fetchData();
|
||||||
}
|
}
|
||||||
|
@ -49,6 +60,8 @@ class Profile extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
const userLookupParam = this.props.match.params.id;
|
const userLookupParam = this.props.match.params.id;
|
||||||
const { authUser, match } = this.props;
|
const { authUser, match } = this.props;
|
||||||
|
const { activeContribution } = this.state;
|
||||||
|
|
||||||
if (!userLookupParam) {
|
if (!userLookupParam) {
|
||||||
if (authUser && authUser.userid) {
|
if (authUser && authUser.userid) {
|
||||||
return <Redirect to={`/profile/${authUser.userid}`} />;
|
return <Redirect to={`/profile/${authUser.userid}`} />;
|
||||||
|
@ -69,16 +82,10 @@ class Profile extends React.Component<Props> {
|
||||||
return <Exception code="404" />;
|
return <Exception code="404" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { proposals, pendingProposals, contributions, comments, invites } = user;
|
||||||
pendingProposals,
|
|
||||||
createdProposals,
|
|
||||||
fundedProposals,
|
|
||||||
comments,
|
|
||||||
invites,
|
|
||||||
} = user;
|
|
||||||
const nonePending = pendingProposals.length === 0;
|
const nonePending = pendingProposals.length === 0;
|
||||||
const noneCreated = createdProposals.length === 0;
|
const noneCreated = proposals.length === 0;
|
||||||
const noneFunded = fundedProposals.length === 0;
|
const noneFunded = contributions.length === 0;
|
||||||
const noneCommented = comments.length === 0;
|
const noneCommented = comments.length === 0;
|
||||||
const noneInvites = user.hasFetchedInvites && invites.length === 0;
|
const noneInvites = user.hasFetchedInvites && invites.length === 0;
|
||||||
|
|
||||||
|
@ -119,21 +126,26 @@ class Profile extends React.Component<Props> {
|
||||||
</div>
|
</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
)}
|
)}
|
||||||
<Tabs.TabPane tab={TabTitle('Created', createdProposals.length)} key="created">
|
<Tabs.TabPane tab={TabTitle('Created', proposals.length)} key="created">
|
||||||
<div>
|
<div>
|
||||||
{noneCreated && (
|
{noneCreated && (
|
||||||
<Placeholder subtitle="Has not created any proposals yet" />
|
<Placeholder subtitle="Has not created any proposals yet" />
|
||||||
)}
|
)}
|
||||||
{createdProposals.map(p => (
|
{proposals.map(p => (
|
||||||
<ProfileProposal key={p.proposalId} proposal={p} />
|
<ProfileProposal key={p.proposalId} proposal={p} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
<Tabs.TabPane tab={TabTitle('Funded', fundedProposals.length)} key="funded">
|
<Tabs.TabPane tab={TabTitle('Funded', contributions.length)} key="funded">
|
||||||
<div>
|
<div>
|
||||||
{noneFunded && <Placeholder subtitle="Has not funded any proposals yet" />}
|
{noneFunded && <Placeholder subtitle="Has not funded any proposals yet" />}
|
||||||
{fundedProposals.map(p => (
|
{contributions.map(c => (
|
||||||
<ProfileProposal key={p.proposalId} proposal={p} />
|
<ProfileContribution
|
||||||
|
key={c.id}
|
||||||
|
userId={user.userid}
|
||||||
|
contribution={c}
|
||||||
|
showSendInstructions={this.openContributionModal}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
|
@ -165,9 +177,20 @@ class Profile extends React.Component<Props> {
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
)}
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<ContributionModal
|
||||||
|
isVisible={!!activeContribution}
|
||||||
|
proposalId={
|
||||||
|
activeContribution ? activeContribution.proposal.proposalId : undefined
|
||||||
|
}
|
||||||
|
contributionId={activeContribution ? activeContribution.id : undefined}
|
||||||
|
hasNoButtons
|
||||||
|
handleClose={this.closeContributionModal}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchData() {
|
private fetchData() {
|
||||||
const { match } = this.props;
|
const { match } = this.props;
|
||||||
const userLookupId = match.params.id;
|
const userLookupId = match.params.id;
|
||||||
|
@ -176,6 +199,9 @@ class Profile extends React.Component<Props> {
|
||||||
this.props.fetchUserInvites(userLookupId);
|
this.props.fetchUserInvites(userLookupId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private openContributionModal = (c: UserContribution) => this.setState({ activeContribution: c });
|
||||||
|
private closeContributionModal = () => this.setState({ activeContribution: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
const TabTitle = (disp: string, count: number) => (
|
const TabTitle = (disp: string, count: number) => (
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
@import '~styles/variables.less';
|
||||||
|
|
||||||
|
.ProposalContributors {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
@media @mobile-query {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-block {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin: 1rem;
|
||||||
|
background: #FFF;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 1px 1px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media @mobile-query {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
margin-top: -0.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-contributor {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,53 +1,106 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
import { Spin } from 'antd';
|
import { Spin } from 'antd';
|
||||||
import AddressRow from 'components/AddressRow';
|
import UserRow from 'components/UserRow';
|
||||||
import Placeholder from 'components/Placeholder';
|
import Placeholder from 'components/Placeholder';
|
||||||
import UnitDisplay from 'components/UnitDisplay';
|
import UnitDisplay from 'components/UnitDisplay';
|
||||||
|
import { toZat } from 'utils/units';
|
||||||
|
import { fetchProposalContributions } from 'modules/proposals/actions';
|
||||||
|
import {
|
||||||
|
getProposalContributions,
|
||||||
|
getIsFetchingContributions,
|
||||||
|
getFetchContributionsError,
|
||||||
|
} from 'modules/proposals/selectors';
|
||||||
|
import { ContributionWithUser } from 'types';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
const ContributorsBlock = () => {
|
interface OwnProps {
|
||||||
// TODO: Get contributors from proposal
|
proposalId: number;
|
||||||
console.warn('TODO: Get contributors from proposal for Proposal/Contributors/index.tsx');
|
}
|
||||||
const proposal = { contributors: [] as any };
|
|
||||||
let content;
|
interface StateProps {
|
||||||
if (proposal) {
|
contributions: ReturnType<typeof getProposalContributions>;
|
||||||
if (proposal.contributors.length) {
|
isFetchingContributions: ReturnType<typeof getIsFetchingContributions>;
|
||||||
content = proposal.contributors.map((contributor: any) => (
|
fetchContributionsError: ReturnType<typeof getFetchContributionsError>;
|
||||||
<AddressRow
|
}
|
||||||
key={contributor.address}
|
|
||||||
address={contributor.address}
|
interface DispatchProps {
|
||||||
secondary={
|
fetchProposalContributions: typeof fetchProposalContributions;
|
||||||
<UnitDisplay value={contributor.contributionAmount} symbol="ZEC" />
|
}
|
||||||
}
|
|
||||||
/>
|
type Props = OwnProps & StateProps & DispatchProps;
|
||||||
));
|
|
||||||
} else {
|
class ProposalContributors extends React.Component<Props> {
|
||||||
content = (
|
componentDidMount() {
|
||||||
<Placeholder
|
if (this.props.proposalId) {
|
||||||
style={{ minHeight: '220px' }}
|
this.props.fetchProposalContributions(this.props.proposalId);
|
||||||
title="No contributors found"
|
|
||||||
subtitle={`
|
|
||||||
No contributions have been made to this proposal.
|
|
||||||
Check back later once there's been at least one contribution.
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
content = <Spin />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
componentWillReceiveProps(nextProps: Props) {
|
||||||
<div className="Proposal-top-side-block">
|
if (nextProps.proposalId && nextProps.proposalId !== this.props.proposalId) {
|
||||||
{proposal.contributors.length ? (
|
this.props.fetchProposalContributions(nextProps.proposalId);
|
||||||
<>
|
}
|
||||||
<h1 className="Proposal-top-main-block-title">Contributors</h1>
|
}
|
||||||
<div className="Proposal-top-main-block">{content}</div>
|
|
||||||
</>
|
render() {
|
||||||
) : (
|
const { contributions, fetchContributionsError } = this.props;
|
||||||
content
|
|
||||||
)}
|
let content;
|
||||||
</div>
|
if (contributions) {
|
||||||
);
|
if (contributions.top.length && contributions.latest.length) {
|
||||||
|
const makeContributionRow = (c: ContributionWithUser) => (
|
||||||
|
<div className="ProposalContributors-block-contributor" key={c.id}>
|
||||||
|
<UserRow
|
||||||
|
user={c.user}
|
||||||
|
extra={<>+<UnitDisplay value={toZat(c.amount)} symbol="ZEC" /></>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
content = <>
|
||||||
|
<div className="ProposalContributors-block">
|
||||||
|
<h3 className="ProposalContributors-block-title">Latest contributors</h3>
|
||||||
|
{contributions.latest.map(makeContributionRow)}
|
||||||
|
</div>
|
||||||
|
<div className="ProposalContributors-block">
|
||||||
|
<h3 className="ProposalContributors-block-title">Top contributors</h3>
|
||||||
|
{contributions.top.map(makeContributionRow)}
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<Placeholder
|
||||||
|
style={{ minHeight: '220px' }}
|
||||||
|
title="No contributors found"
|
||||||
|
subtitle={`
|
||||||
|
No contributions have been made to this proposal.
|
||||||
|
Check back later once there's been at least one contribution.
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (fetchContributionsError) {
|
||||||
|
content = <Placeholder title="Something went wrong" subtitle={fetchContributionsError} />;
|
||||||
|
} else {
|
||||||
|
content = <Spin />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ProposalContributors">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ContributorsBlock;
|
export default connect(
|
||||||
|
(state: AppState, ownProps: OwnProps) => ({
|
||||||
|
contributions: getProposalContributions(state, ownProps.proposalId),
|
||||||
|
isFetchingContributions: getIsFetchingContributions(state),
|
||||||
|
fetchContributionsError: getFetchContributionsError(state),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
fetchProposalContributions,
|
||||||
|
},
|
||||||
|
)(ProposalContributors);
|
||||||
|
|
|
@ -16,7 +16,6 @@ import Milestones from './Milestones';
|
||||||
import CommentsTab from './Comments';
|
import CommentsTab from './Comments';
|
||||||
import UpdatesTab from './Updates';
|
import UpdatesTab from './Updates';
|
||||||
import ContributorsTab from './Contributors';
|
import ContributorsTab from './Contributors';
|
||||||
// import CommunityTab from './Community';
|
|
||||||
import UpdateModal from './UpdateModal';
|
import UpdateModal from './UpdateModal';
|
||||||
import CancelModal from './CancelModal';
|
import CancelModal from './CancelModal';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
|
@ -244,8 +243,8 @@ export class ProposalDetail extends React.Component<Props, State> {
|
||||||
<Tabs.TabPane tab="Updates" key="updates" disabled={!isLive}>
|
<Tabs.TabPane tab="Updates" key="updates" disabled={!isLive}>
|
||||||
<UpdatesTab proposalId={proposal.proposalId} />
|
<UpdatesTab proposalId={proposal.proposalId} />
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
<Tabs.TabPane tab="Contributors" key="contributors" disabled={!isLive}>
|
<Tabs.TabPane tab="Contributors" key="contributors">
|
||||||
<ContributorsTab />
|
<ContributorsTab proposalId={proposal.proposalId} />
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|
|
@ -6,9 +6,10 @@ import './style.less';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
|
extra?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserRow = ({ user }: Props) => (
|
const UserRow = ({ user, extra }: Props) => (
|
||||||
<Link to={`/profile/${user.userid}`} className="UserRow">
|
<Link to={`/profile/${user.userid}`} className="UserRow">
|
||||||
<div className="UserRow-avatar">
|
<div className="UserRow-avatar">
|
||||||
<UserAvatar user={user} className="UserRow-avatar-img" />
|
<UserAvatar user={user} className="UserRow-avatar-img" />
|
||||||
|
@ -17,6 +18,11 @@ const UserRow = ({ user }: Props) => (
|
||||||
<div className="UserRow-info-main">{user.displayName}</div>
|
<div className="UserRow-info-main">{user.displayName}</div>
|
||||||
<p className="UserRow-info-secondary">{user.title}</p>
|
<p className="UserRow-info-secondary">{user.title}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{extra && (
|
||||||
|
<div className="UserRow-extra">
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
.UserRow {
|
.UserRow {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
height: @height;
|
height: @height;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
@ -40,9 +41,16 @@
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-extra {
|
||||||
|
text-align: right;
|
||||||
|
margin-left: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
getProposal,
|
getProposal,
|
||||||
getProposalComments,
|
getProposalComments,
|
||||||
getProposalUpdates,
|
getProposalUpdates,
|
||||||
|
getProposalContributions,
|
||||||
postProposalComment as apiPostProposalComment,
|
postProposalComment as apiPostProposalComment,
|
||||||
} from 'api/api';
|
} from 'api/api';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
|
@ -54,6 +55,18 @@ export function fetchProposalUpdates(proposalId: Proposal['proposalId']) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchProposalContributions(proposalId: Proposal['proposalId']) {
|
||||||
|
return (dispatch: Dispatch<any>) => {
|
||||||
|
dispatch({
|
||||||
|
type: types.PROPOSAL_CONTRIBUTIONS,
|
||||||
|
payload: getProposalContributions(proposalId).then(res => ({
|
||||||
|
proposalId,
|
||||||
|
...res.data,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function postProposalComment(
|
export function postProposalComment(
|
||||||
proposalId: Proposal['proposalId'],
|
proposalId: Proposal['proposalId'],
|
||||||
comment: string,
|
comment: string,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import types from './types';
|
import types from './types';
|
||||||
import { findComment } from 'utils/helpers';
|
import { findComment } from 'utils/helpers';
|
||||||
import { Proposal, ProposalComments, ProposalUpdates, Comment } from 'types';
|
import { Proposal, ProposalComments, ProposalUpdates, Comment, ProposalContributions } from 'types';
|
||||||
|
|
||||||
export interface ProposalState {
|
export interface ProposalState {
|
||||||
proposals: Proposal[];
|
proposals: Proposal[];
|
||||||
|
@ -15,8 +15,15 @@ export interface ProposalState {
|
||||||
updatesError: null | string;
|
updatesError: null | string;
|
||||||
isFetchingUpdates: boolean;
|
isFetchingUpdates: boolean;
|
||||||
|
|
||||||
|
proposalContributions: { [id: string]: ProposalContributions };
|
||||||
|
fetchContributionsError: null | string;
|
||||||
|
isFetchingContributions: boolean;
|
||||||
|
|
||||||
isPostCommentPending: boolean;
|
isPostCommentPending: boolean;
|
||||||
postCommentError: null | string;
|
postCommentError: null | string;
|
||||||
|
|
||||||
|
isDeletingContribution: boolean;
|
||||||
|
deleteContributionError: null | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INITIAL_STATE: ProposalState = {
|
export const INITIAL_STATE: ProposalState = {
|
||||||
|
@ -32,8 +39,15 @@ export const INITIAL_STATE: ProposalState = {
|
||||||
updatesError: null,
|
updatesError: null,
|
||||||
isFetchingUpdates: false,
|
isFetchingUpdates: false,
|
||||||
|
|
||||||
|
proposalContributions: {},
|
||||||
|
fetchContributionsError: null,
|
||||||
|
isFetchingContributions: false,
|
||||||
|
|
||||||
isPostCommentPending: false,
|
isPostCommentPending: false,
|
||||||
postCommentError: null,
|
postCommentError: null,
|
||||||
|
|
||||||
|
isDeletingContribution: false,
|
||||||
|
deleteContributionError: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function addProposal(state: ProposalState, payload: Proposal) {
|
function addProposal(state: ProposalState, payload: Proposal) {
|
||||||
|
@ -89,6 +103,17 @@ function addUpdates(state: ProposalState, payload: ProposalUpdates) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addContributions(state: ProposalState, payload: ProposalContributions) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
proposalContributions: {
|
||||||
|
...state.proposalContributions,
|
||||||
|
[payload.proposalId]: payload,
|
||||||
|
},
|
||||||
|
isFetchingContributions: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface PostCommentPayload {
|
interface PostCommentPayload {
|
||||||
proposalId: Proposal['proposalId'];
|
proposalId: Proposal['proposalId'];
|
||||||
comment: Comment;
|
comment: Comment;
|
||||||
|
@ -184,6 +209,22 @@ export default (state = INITIAL_STATE, action: any) => {
|
||||||
isFetchingUpdates: false,
|
isFetchingUpdates: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case types.PROPOSAL_CONTRIBUTIONS_PENDING:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
fetchContributionsError: null,
|
||||||
|
isFetchingContributions: true,
|
||||||
|
};
|
||||||
|
case types.PROPOSAL_CONTRIBUTIONS_FULFILLED:
|
||||||
|
return addContributions(state, payload);
|
||||||
|
case types.PROPOSAL_CONTRIBUTIONS_REJECTED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
// TODO: Get action to send real error
|
||||||
|
fetchContributionsError: 'Failed to fetch updates',
|
||||||
|
isFetchingContributions: false,
|
||||||
|
};
|
||||||
|
|
||||||
case types.POST_PROPOSAL_COMMENT_PENDING:
|
case types.POST_PROPOSAL_COMMENT_PENDING:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import { Proposal, ProposalComments, ProposalUpdates } from 'types';
|
import { Proposal, ProposalComments, ProposalUpdates, ProposalContributions } from 'types';
|
||||||
|
|
||||||
export function getProposals(state: AppState) {
|
export function getProposals(state: AppState) {
|
||||||
return state.proposal.proposals;
|
return state.proposal.proposals;
|
||||||
|
@ -74,3 +74,19 @@ export function getIsFetchingUpdates(state: AppState) {
|
||||||
export function getUpdatesError(state: AppState) {
|
export function getUpdatesError(state: AppState) {
|
||||||
return state.proposal.updatesError;
|
return state.proposal.updatesError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProposalContributions(
|
||||||
|
state: AppState,
|
||||||
|
proposalId: Proposal['proposalId'],
|
||||||
|
): Omit<ProposalContributions, 'proposalId'> | null {
|
||||||
|
const pc = state.proposal.proposalContributions[proposalId];
|
||||||
|
return pc ? { top: pc.top, latest: pc.latest } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIsFetchingContributions(state: AppState) {
|
||||||
|
return state.proposal.isFetchingContributions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFetchContributionsError(state: AppState) {
|
||||||
|
return state.proposal.fetchContributionsError;
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,11 @@ enum proposalTypes {
|
||||||
PROPOSAL_UPDATES_REJECTED = 'PROPOSAL_UPDATES_REJECTED',
|
PROPOSAL_UPDATES_REJECTED = 'PROPOSAL_UPDATES_REJECTED',
|
||||||
PROPOSAL_UPDATES_PENDING = 'PROPOSAL_UPDATES_PENDING',
|
PROPOSAL_UPDATES_PENDING = 'PROPOSAL_UPDATES_PENDING',
|
||||||
|
|
||||||
|
PROPOSAL_CONTRIBUTIONS = 'PROPOSAL_CONTRIBUTIONS',
|
||||||
|
PROPOSAL_CONTRIBUTIONS_FULFILLED = 'PROPOSAL_CONTRIBUTIONS_FULFILLED',
|
||||||
|
PROPOSAL_CONTRIBUTIONS_REJECTED = 'PROPOSAL_CONTRIBUTIONS_REJECTED',
|
||||||
|
PROPOSAL_CONTRIBUTIONS_PENDING = 'PROPOSAL_CONTRIBUTIONS_PENDING',
|
||||||
|
|
||||||
POST_PROPOSAL_COMMENT = 'POST_PROPOSAL_COMMENT',
|
POST_PROPOSAL_COMMENT = 'POST_PROPOSAL_COMMENT',
|
||||||
POST_PROPOSAL_COMMENT_FULFILLED = 'POST_PROPOSAL_COMMENT_FULFILLED',
|
POST_PROPOSAL_COMMENT_FULFILLED = 'POST_PROPOSAL_COMMENT_FULFILLED',
|
||||||
POST_PROPOSAL_COMMENT_REJECTED = 'POST_PROPOSAL_COMMENT_REJECTED',
|
POST_PROPOSAL_COMMENT_REJECTED = 'POST_PROPOSAL_COMMENT_REJECTED',
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
updateUser as apiUpdateUser,
|
updateUser as apiUpdateUser,
|
||||||
fetchUserInvites as apiFetchUserInvites,
|
fetchUserInvites as apiFetchUserInvites,
|
||||||
putInviteResponse,
|
putInviteResponse,
|
||||||
|
deleteProposalContribution,
|
||||||
deleteProposalDraft,
|
deleteProposalDraft,
|
||||||
putProposalPublish,
|
putProposalPublish,
|
||||||
} from 'api/api';
|
} from 'api/api';
|
||||||
|
@ -92,6 +93,18 @@ export function respondToInvite(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteContribution(userId: string | number, contributionId: string | number) {
|
||||||
|
// Fire and forget
|
||||||
|
deleteProposalContribution(contributionId);
|
||||||
|
return {
|
||||||
|
type: types.DELETE_CONTRIBUTION,
|
||||||
|
payload: {
|
||||||
|
userId,
|
||||||
|
contributionId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function deletePendingProposal(userId: number, proposalId: number) {
|
export function deletePendingProposal(userId: number, proposalId: number) {
|
||||||
return async (dispatch: Dispatch<any>) => {
|
return async (dispatch: Dispatch<any>) => {
|
||||||
await dispatch({
|
await dispatch({
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import { UserProposal, UserComment, TeamInviteWithProposal } from 'types';
|
import { User, UserProposal, UserComment, UserContribution, TeamInviteWithProposal } from 'types';
|
||||||
import types from './types';
|
import types from './types';
|
||||||
import { User } from 'types';
|
|
||||||
|
|
||||||
export interface TeamInviteWithResponse extends TeamInviteWithProposal {
|
export interface TeamInviteWithResponse extends TeamInviteWithProposal {
|
||||||
isResponding: boolean;
|
isResponding: boolean;
|
||||||
|
@ -15,8 +14,8 @@ export interface UserState extends User {
|
||||||
isUpdating: boolean;
|
isUpdating: boolean;
|
||||||
updateError: string | null;
|
updateError: string | null;
|
||||||
pendingProposals: UserProposal[];
|
pendingProposals: UserProposal[];
|
||||||
createdProposals: UserProposal[];
|
proposals: UserProposal[];
|
||||||
fundedProposals: UserProposal[];
|
contributions: UserContribution[];
|
||||||
comments: UserComment[];
|
comments: UserComment[];
|
||||||
isFetchingInvites: boolean;
|
isFetchingInvites: boolean;
|
||||||
hasFetchedInvites: boolean;
|
hasFetchedInvites: boolean;
|
||||||
|
@ -45,8 +44,8 @@ export const INITIAL_USER_STATE: UserState = {
|
||||||
isUpdating: false,
|
isUpdating: false,
|
||||||
updateError: null,
|
updateError: null,
|
||||||
pendingProposals: [],
|
pendingProposals: [],
|
||||||
createdProposals: [],
|
proposals: [],
|
||||||
fundedProposals: [],
|
contributions: [],
|
||||||
comments: [],
|
comments: [],
|
||||||
isFetchingInvites: false,
|
isFetchingInvites: false,
|
||||||
hasFetchedInvites: false,
|
hasFetchedInvites: false,
|
||||||
|
@ -150,6 +149,13 @@ export default (state = INITIAL_STATE, action: any) => {
|
||||||
isResponding: false,
|
isResponding: false,
|
||||||
respondError: errorMessage,
|
respondError: errorMessage,
|
||||||
});
|
});
|
||||||
|
// delete contribution
|
||||||
|
case types.DELETE_CONTRIBUTION:
|
||||||
|
return updateUserState(state, payload.userId, {
|
||||||
|
contributions: state.map[payload.userId].contributions.filter(
|
||||||
|
c => c.id !== payload.contributionId
|
||||||
|
),
|
||||||
|
});
|
||||||
// proposal delete
|
// proposal delete
|
||||||
case types.USER_DELETE_PROPOSAL_FULFILLED:
|
case types.USER_DELETE_PROPOSAL_FULFILLED:
|
||||||
return removePendingProposal(state, payload.userId, payload.proposalId);
|
return removePendingProposal(state, payload.userId, payload.proposalId);
|
||||||
|
@ -198,7 +204,7 @@ function updatePublishedProposal(
|
||||||
) {
|
) {
|
||||||
const withoutPending = removePendingProposal(state, userId, proposal.proposalId);
|
const withoutPending = removePendingProposal(state, userId, proposal.proposalId);
|
||||||
const userUpdates = {
|
const userUpdates = {
|
||||||
createdProposals: [proposal, ...state.map[userId].createdProposals],
|
proposals: [proposal, ...state.map[userId].proposals],
|
||||||
};
|
};
|
||||||
return updateUserState(withoutPending, userId, userUpdates);
|
return updateUserState(withoutPending, userId, userUpdates);
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,8 @@ enum UsersActions {
|
||||||
RESPOND_TO_INVITE_FULFILLED = 'RESPOND_TO_INVITE_FULFILLED',
|
RESPOND_TO_INVITE_FULFILLED = 'RESPOND_TO_INVITE_FULFILLED',
|
||||||
RESPOND_TO_INVITE_REJECTED = 'RESPOND_TO_INVITE_REJECTED',
|
RESPOND_TO_INVITE_REJECTED = 'RESPOND_TO_INVITE_REJECTED',
|
||||||
|
|
||||||
|
DELETE_CONTRIBUTION = 'DELETE_CONTRIBUTION',
|
||||||
|
|
||||||
USER_DELETE_PROPOSAL = 'USER_DELETE_PROPOSAL',
|
USER_DELETE_PROPOSAL = 'USER_DELETE_PROPOSAL',
|
||||||
USER_DELETE_PROPOSAL_FULFILLED = 'USER_DELETE_PROPOSAL_FULFILLED',
|
USER_DELETE_PROPOSAL_FULFILLED = 'USER_DELETE_PROPOSAL_FULFILLED',
|
||||||
|
|
||||||
|
|
|
@ -20,8 +20,11 @@ export function formatUserFromGet(user: UserState) {
|
||||||
if (user.pendingProposals) {
|
if (user.pendingProposals) {
|
||||||
user.pendingProposals = user.pendingProposals.map(bnUserProp);
|
user.pendingProposals = user.pendingProposals.map(bnUserProp);
|
||||||
}
|
}
|
||||||
user.createdProposals = user.createdProposals.map(bnUserProp);
|
user.proposals = user.proposals.map(bnUserProp);
|
||||||
user.fundedProposals = user.fundedProposals.map(bnUserProp);
|
user.contributions = user.contributions.map(c => {
|
||||||
|
c.amount = toZat(c.amount as any as string);
|
||||||
|
return c;
|
||||||
|
});
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,17 +33,21 @@ export function formatProposalFromGet(p: any): Proposal {
|
||||||
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
|
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
|
||||||
proposal.target = toZat(p.target);
|
proposal.target = toZat(p.target);
|
||||||
proposal.funded = toZat(p.funded);
|
proposal.funded = toZat(p.funded);
|
||||||
proposal.percentFunded = proposal.funded.div(proposal.target.divn(100)).toNumber();
|
proposal.percentFunded = proposal.target.isZero()
|
||||||
proposal.milestones = proposal.milestones.map((m: any, index: number) => {
|
? 0
|
||||||
return {
|
: proposal.funded.div(proposal.target.divn(100)).toNumber();
|
||||||
...m,
|
if (proposal.milestones) {
|
||||||
index,
|
proposal.milestones = proposal.milestones.map((m: any, index: number) => {
|
||||||
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
|
return {
|
||||||
// TODO: Get data from backend
|
...m,
|
||||||
state: MILESTONE_STATE.WAITING,
|
index,
|
||||||
isPaid: false,
|
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
|
||||||
};
|
// TODO: Get data from backend
|
||||||
});
|
state: MILESTONE_STATE.WAITING,
|
||||||
|
isPaid: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
return proposal;
|
return proposal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,10 +90,14 @@ export function massageSerializedState(state: AppState) {
|
||||||
return p;
|
return p;
|
||||||
};
|
};
|
||||||
Object.values(state.users.map).forEach(user => {
|
Object.values(state.users.map).forEach(user => {
|
||||||
user.createdProposals.forEach(bnUserProp);
|
user.proposals = user.proposals.map(bnUserProp);
|
||||||
user.fundedProposals.forEach(bnUserProp);
|
user.contributions = user.contributions.map(c => {
|
||||||
user.comments.forEach(c => {
|
c.amount = new BN(c.amount, 16);
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
user.comments = user.comments.map(c => {
|
||||||
c.proposal = bnUserProp(c.proposal);
|
c.proposal = bnUserProp(c.proposal);
|
||||||
|
return c;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -85,3 +85,7 @@ export function formatZcashCLI(address: string, amount?: string | number, memo?:
|
||||||
}
|
}
|
||||||
return `zcash-cli z_sendmany YOUR_ADDRESS '${JSON.stringify([tx])}'`;
|
return `zcash-cli z_sendmany YOUR_ADDRESS '${JSON.stringify([tx])}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatTxExplorerUrl(txid: string) {
|
||||||
|
return `https://explorer.zcha.in/transactions/${txid}`;
|
||||||
|
}
|
||||||
|
|
|
@ -2,7 +2,6 @@ import * as React from 'react';
|
||||||
import { storiesOf } from '@storybook/react';
|
import { storiesOf } from '@storybook/react';
|
||||||
|
|
||||||
import { ProposalCampaignBlock } from 'components/Proposal/CampaignBlock';
|
import { ProposalCampaignBlock } from 'components/Proposal/CampaignBlock';
|
||||||
import Contributors from 'components/Proposal/Contributors';
|
|
||||||
|
|
||||||
import 'styles/style.less';
|
import 'styles/style.less';
|
||||||
import 'components/Proposal/style.less';
|
import 'components/Proposal/style.less';
|
||||||
|
@ -53,9 +52,4 @@ storiesOf('Proposal', module)
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||||
<CampaignBlocks style={{ margin: '0 12px' }} />
|
<CampaignBlocks style={{ margin: '0 12px' }} />
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
.add('Contributors', () => (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
|
||||||
<Contributors />
|
|
||||||
</div>
|
|
||||||
));
|
));
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
|
import { Zat } from 'utils/units';
|
||||||
|
import { Proposal, User } from 'types';
|
||||||
|
|
||||||
export interface Contribution {
|
export interface Contribution {
|
||||||
id: string;
|
id: number;
|
||||||
txId: string;
|
txId: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
|
status: 'PENDING' | 'CONFIRMED';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContributionWithAddresses extends Contribution {
|
export interface ContributionWithAddresses extends Contribution {
|
||||||
|
@ -12,3 +16,13 @@ export interface ContributionWithAddresses extends Contribution {
|
||||||
memo: string;
|
memo: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContributionWithUser extends Contribution {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserContribution extends Omit<Contribution, 'amount' | 'txId'> {
|
||||||
|
amount: Zat;
|
||||||
|
txId?: string;
|
||||||
|
proposal: Proposal;
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import BN from 'bn.js';
|
|
||||||
import { Zat } from 'utils/units';
|
import { Zat } from 'utils/units';
|
||||||
import { PROPOSAL_CATEGORY } from 'api/constants';
|
import { PROPOSAL_CATEGORY } from 'api/constants';
|
||||||
import { CreateMilestone, Update, User, Comment } from 'types';
|
import { CreateMilestone, Update, User, Comment, ContributionWithUser } from 'types';
|
||||||
import { ProposalMilestone } from './milestone';
|
import { ProposalMilestone } from './milestone';
|
||||||
|
|
||||||
export interface TeamInvite {
|
export interface TeamInvite {
|
||||||
|
@ -40,8 +39,8 @@ export interface ProposalDraft {
|
||||||
export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
||||||
proposalAddress: string;
|
proposalAddress: string;
|
||||||
proposalUrlId: string;
|
proposalUrlId: string;
|
||||||
target: BN;
|
target: Zat;
|
||||||
funded: BN;
|
funded: Zat;
|
||||||
percentFunded: number;
|
percentFunded: number;
|
||||||
milestones: ProposalMilestone[];
|
milestones: ProposalMilestone[];
|
||||||
datePublished: number;
|
datePublished: number;
|
||||||
|
@ -62,13 +61,19 @@ export interface ProposalUpdates {
|
||||||
updates: Update[];
|
updates: Update[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProposalContributions {
|
||||||
|
proposalId: Proposal['proposalId'];
|
||||||
|
top: ContributionWithUser[];
|
||||||
|
latest: ContributionWithUser[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserProposal {
|
export interface UserProposal {
|
||||||
proposalId: number;
|
proposalId: number;
|
||||||
status: STATUS;
|
status: STATUS;
|
||||||
title: string;
|
title: string;
|
||||||
brief: string;
|
brief: string;
|
||||||
funded: BN;
|
funded: Zat;
|
||||||
target: BN;
|
target: Zat;
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
dateApproved: number;
|
dateApproved: number;
|
||||||
datePublished: number;
|
datePublished: number;
|
||||||
|
|
Loading…
Reference in New Issue