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;
|
||||
},
|
||||
|
||||
|
||||
async getEmailExample(type: string) {
|
||||
try {
|
||||
const example = await getEmailExample(type);
|
||||
|
|
|
@ -22,6 +22,5 @@ TWITTER_CLIENT_SECRET=twitter-client-secret
|
|||
LINKEDIN_CLIENT_ID=linkedin-client-id
|
||||
LINKEDIN_CLIENT_SECRET=linkedin-client-secret
|
||||
|
||||
BLOCKCHAIN_WS_API_URL="http://localhost:5050"
|
||||
BLOCKCHAIN_REST_API_URL="http://localhost:5051"
|
||||
BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca"
|
||||
BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca"
|
||||
|
|
|
@ -2,24 +2,26 @@ import requests
|
|||
import json
|
||||
from grant.settings import BLOCKCHAIN_REST_API_URL, BLOCKCHAIN_API_SECRET
|
||||
|
||||
### REST API ###
|
||||
|
||||
def handle_res(res):
|
||||
j = res.json()
|
||||
if j.get('error'):
|
||||
raise Exception('Blockchain API Error: {}'.format(j['error']))
|
||||
return j['data']
|
||||
j = res.json()
|
||||
if j.get('error'):
|
||||
raise Exception('Blockchain API Error: {}'.format(j['error']))
|
||||
return j['data']
|
||||
|
||||
def blockchain_get(path, params = None):
|
||||
res = requests.get(
|
||||
f'{BLOCKCHAIN_REST_API_URL}{path}',
|
||||
headers={ 'authorization': BLOCKCHAIN_API_SECRET },
|
||||
params=params,
|
||||
)
|
||||
return handle_res(res)
|
||||
res = requests.get(
|
||||
f'{BLOCKCHAIN_REST_API_URL}{path}',
|
||||
headers={ 'authorization': BLOCKCHAIN_API_SECRET },
|
||||
params=params,
|
||||
)
|
||||
return handle_res(res)
|
||||
|
||||
def blockchain_post(path, data = None):
|
||||
res = requests.post(
|
||||
f'{BLOCKCHAIN_REST_API_URL}{path}',
|
||||
headers={ 'authorization': BLOCKCHAIN_API_SECRET },
|
||||
data=json.dumps(data),
|
||||
)
|
||||
return handle_res(res)
|
||||
res = requests.post(
|
||||
f'{BLOCKCHAIN_REST_API_URL}{path}',
|
||||
headers={ 'authorization': BLOCKCHAIN_API_SECRET },
|
||||
data=json.dumps(data),
|
||||
)
|
||||
return handle_res(res)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import datetime
|
||||
from typing import List
|
||||
from sqlalchemy import func, or_
|
||||
from functools import reduce
|
||||
|
||||
from grant.comment.models import Comment
|
||||
from grant.extensions import ma, db
|
||||
|
@ -95,6 +96,8 @@ class ProposalContribution(db.Model):
|
|||
amount = db.Column(db.String(255), nullable=False)
|
||||
tx_id = db.Column(db.String(255))
|
||||
|
||||
user = db.relationship("User")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
proposal_id: int,
|
||||
|
@ -108,10 +111,21 @@ class ProposalContribution(db.Model):
|
|||
self.status = PENDING
|
||||
|
||||
@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 \
|
||||
.filter_by(user_id=user_id, proposal_id=proposal_id, amount=amount) \
|
||||
.first()
|
||||
.filter(ProposalContribution.user_id == user_id) \
|
||||
.filter(ProposalContribution.status != DELETED) \
|
||||
.order_by(ProposalContribution.date_created.desc()) \
|
||||
.all()
|
||||
|
||||
def confirm(self, tx_id: str, amount: str):
|
||||
self.status = CONFIRMED
|
||||
|
@ -300,7 +314,6 @@ class ProposalSchema(ma.Schema):
|
|||
"content",
|
||||
"comments",
|
||||
"updates",
|
||||
"contributions",
|
||||
"milestones",
|
||||
"category",
|
||||
"team",
|
||||
|
@ -317,7 +330,6 @@ class ProposalSchema(ma.Schema):
|
|||
|
||||
comments = ma.Nested("CommentSchema", many=True)
|
||||
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
||||
contributions = ma.Nested("ProposalContributionSchema", many=True, exclude=['proposal'])
|
||||
team = ma.Nested("UserSchema", many=True)
|
||||
milestones = ma.Nested("MilestoneSchema", 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
|
||||
|
||||
def get_funded(self, obj):
|
||||
# TODO: Add up all contributions and return that
|
||||
return "0"
|
||||
contributions = ProposalContribution.query \
|
||||
.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()
|
||||
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 Meta:
|
||||
|
@ -433,7 +462,7 @@ class ProposalContributionSchema(ma.Schema):
|
|||
)
|
||||
|
||||
proposal = ma.Nested("ProposalSchema")
|
||||
user = ma.Nested("UserSchema")
|
||||
user = ma.Nested("UserSchema", exclude=["email_address"])
|
||||
date_created = ma.Method("get_date_created")
|
||||
addresses = ma.Method("get_addresses")
|
||||
|
||||
|
@ -445,41 +474,9 @@ class ProposalContributionSchema(ma.Schema):
|
|||
|
||||
|
||||
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.user.models import User, SocialMedia, Avatar
|
||||
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.misc import is_email, make_url
|
||||
from .models import(
|
||||
from grant.utils.misc import is_email, make_url, from_zat
|
||||
from .models import (
|
||||
Proposal,
|
||||
proposals_schema,
|
||||
proposal_schema,
|
||||
|
@ -25,13 +25,15 @@ from .models import(
|
|||
proposal_team,
|
||||
ProposalTeamInvite,
|
||||
proposal_team_invite_schema,
|
||||
proposal_proposal_contributions_schema,
|
||||
db,
|
||||
DRAFT,
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED,
|
||||
LIVE,
|
||||
DELETED
|
||||
DELETED,
|
||||
CONFIRMED,
|
||||
)
|
||||
import traceback
|
||||
|
||||
|
@ -328,11 +330,25 @@ def delete_proposal_team_invite(proposal_id, id_or_address):
|
|||
@endpoint.api()
|
||||
def get_proposal_contributions(proposal_id):
|
||||
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||
if proposal:
|
||||
dumped_proposal = proposal_schema.dump(proposal)
|
||||
return dumped_proposal["contributions"]
|
||||
else:
|
||||
if not proposal:
|
||||
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"])
|
||||
|
@ -361,7 +377,7 @@ def post_proposal_contribution(proposal_id, amount):
|
|||
|
||||
code = 200
|
||||
contribution = ProposalContribution \
|
||||
.getExistingContribution(g.current_user.id, proposal_id, amount)
|
||||
.get_existing_contribution(g.current_user.id, proposal_id, amount)
|
||||
|
||||
if not contribution:
|
||||
code = 201
|
||||
|
@ -375,3 +391,49 @@ def post_proposal_contribution(proposal_id, amount):
|
|||
|
||||
dumped_contribution = proposal_contribution_schema.dump(contribution)
|
||||
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_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_API_SECRET = env.str("BLOCKCHAIN_API_SECRET")
|
||||
|
|
|
@ -8,10 +8,13 @@ from grant.proposal.models import (
|
|||
proposal_team,
|
||||
ProposalTeamInvite,
|
||||
invites_with_proposal_schema,
|
||||
ProposalContribution,
|
||||
user_proposal_contributions_schema,
|
||||
user_proposals_schema,
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED
|
||||
REJECTED,
|
||||
CONFIRMED
|
||||
)
|
||||
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
|
||||
|
@ -62,19 +65,21 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending):
|
|||
user = User.get_by_id(user_id)
|
||||
if user:
|
||||
result = user_schema.dump(user)
|
||||
authed_user = get_authed_user()
|
||||
if with_proposals:
|
||||
proposals = Proposal.get_by_user(user)
|
||||
proposals_dump = user_proposals_schema.dump(proposals)
|
||||
result["createdProposals"] = proposals_dump
|
||||
result["proposals"] = proposals_dump
|
||||
if with_funded:
|
||||
contributions = Proposal.get_by_user_contribution(user)
|
||||
contributions_dump = user_proposals_schema.dump(contributions)
|
||||
result["fundedProposals"] = contributions_dump
|
||||
contributions = ProposalContribution.get_by_userid(user_id)
|
||||
if not authed_user or user.id != authed_user.id:
|
||||
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:
|
||||
comments = Comment.get_by_user(user)
|
||||
comments_dump = user_comments_schema.dump(comments)
|
||||
result["comments"] = comments_dump
|
||||
authed_user = get_authed_user()
|
||||
if with_pending and authed_user and authed_user.id == user.id:
|
||||
pending = Proposal.get_by_user(user, [PENDING, APPROVED, REJECTED])
|
||||
pending_dump = user_proposals_schema.dump(pending)
|
||||
|
|
|
@ -7,7 +7,7 @@ from flask_security.core import current_user
|
|||
from flask import request, g, jsonify
|
||||
import sentry_sdk
|
||||
|
||||
from grant.settings import SECRET_KEY
|
||||
from grant.settings import SECRET_KEY, BLOCKCHAIN_API_SECRET
|
||||
from ..proposal.models import Proposal
|
||||
from ..user.models import User
|
||||
|
||||
|
@ -67,3 +67,16 @@ def requires_team_member_auth(f):
|
|||
return f(*args, **kwargs)
|
||||
|
||||
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):
|
||||
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_KEY="ef0b48e41f78d3ae85b1379b386f1bca"
|
||||
|
||||
# WebSocket Config
|
||||
WS_PORT="5050"
|
||||
# Webhooks Config
|
||||
WEBHOOK_URL="http://localhost:5000/api/v1"
|
||||
|
||||
# REST Server Config
|
||||
REST_SERVER_PORT="5051"
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
"@types/cors": "2.8.4",
|
||||
"@types/dotenv": "^6.1.0",
|
||||
"@types/ws": "^6.0.1",
|
||||
"axios": "0.18.0",
|
||||
"body-parser": "1.18.3",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "^6.1.0",
|
||||
|
|
|
@ -6,7 +6,7 @@ interface CustomEnvironment {
|
|||
API_SECRET_HASH: string;
|
||||
API_SECRET_KEY: string;
|
||||
|
||||
WS_PORT: string;
|
||||
WEBHOOK_URL: string;
|
||||
REST_SERVER_PORT: 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 { initNode } from './node';
|
||||
|
||||
async function start() {
|
||||
console.log("============== Starting services ==============");
|
||||
await initNode();
|
||||
await Websocket.start();
|
||||
await Webhooks.start();
|
||||
await RestServer.start();
|
||||
console.log("===============================================");
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
console.log('Shutting down services...');
|
||||
Websocket.exit();
|
||||
Webhooks.exit();
|
||||
RestServer.exit();
|
||||
console.log('Exiting!');
|
||||
process.exit();
|
||||
});
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { randomBytes, createHmac } from "crypto";
|
||||
import { IncomingMessage } from "http";
|
||||
import { HDPublicKey, Address } from "zcash-bitcore-lib";
|
||||
import { parse } from 'url';
|
||||
import env from "./env";
|
||||
|
||||
function sha256(input: string) {
|
||||
|
@ -14,22 +15,6 @@ export function generateApiKey() {
|
|||
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) {
|
||||
const hash = env.API_SECRET_HASH;
|
||||
if (!hash) {
|
||||
|
@ -38,14 +23,6 @@ export function authenticate(secret: string) {
|
|||
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,
|
||||
// do more work to ensure this is reliable.
|
||||
export function deriveTransparentAddress(index: number, network: any) {
|
||||
|
|
|
@ -1,47 +1,25 @@
|
|||
import WebSocket from "ws";
|
||||
import axios from 'axios';
|
||||
import { initializeNotifiers } from "./notifiers";
|
||||
import { Notifier } from "./notifiers/notifier";
|
||||
import { getIpFromRequest, authenticateRequest } from "../util";
|
||||
import node, { rpcOptions } from "../node";
|
||||
import node from "../node";
|
||||
import env from "../env";
|
||||
|
||||
const log = console.log;
|
||||
|
||||
export type Send = (message: Message) => void;
|
||||
export type Receive = (message: Message) => void;
|
||||
export type Send = (route: string, method: string, payload: object) => 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 consecutiveBlockFailures = 0;
|
||||
const MAXIMUM_BLOCK_FAILURES = 5;
|
||||
|
||||
export async function start() {
|
||||
await initNode();
|
||||
initWebsocketServer();
|
||||
initNotifiers();
|
||||
}
|
||||
|
||||
export function exit() {
|
||||
notifiers.forEach(n => n.destroy && n.destroy());
|
||||
wss && wss.close();
|
||||
wss = null;
|
||||
console.log('WebSocket server has been closed');
|
||||
console.log('Webhook notifiers have exited');
|
||||
}
|
||||
|
||||
|
||||
|
@ -82,47 +60,30 @@ async function initNode() {
|
|||
}, 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() {
|
||||
const send: Send = message =>
|
||||
wss &&
|
||||
wss.clients.forEach(ws => {
|
||||
try {
|
||||
ws.send(JSON.stringify(message));
|
||||
} catch (e) {
|
||||
log(`Send error: ${e}`);
|
||||
const send: Send = (route, method, payload) => {
|
||||
console.log('About to send to', route);
|
||||
axios.request({
|
||||
method,
|
||||
url: `${env.WEBHOOK_URL}${route}`,
|
||||
data: payload,
|
||||
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.forEach(n => n.registerSend(send));
|
|
@ -1,4 +1,4 @@
|
|||
import { Send, Message } from "../../index";
|
||||
import { Send } from "../../index";
|
||||
import { Notifier } from "../notifier";
|
||||
import node, { BlockWithTransactions } from "../../../node";
|
||||
import {
|
||||
|
@ -43,7 +43,6 @@ export default class ContributionNotifier implements Notifier {
|
|||
// generate one, so all of our addresses will only have addresses[0]
|
||||
const to = vout.scriptPubKey.addresses[0];
|
||||
if (tAddressIdMap[to]) {
|
||||
console.info(`Transaction found for contribution ${tAddressIdMap[to]}, +${vout.value} ZEC`);
|
||||
this.sendContributionConfirmation({
|
||||
to,
|
||||
amount: vout.valueZat.toString(),
|
||||
|
@ -67,10 +66,11 @@ export default class ContributionNotifier implements Notifier {
|
|||
const newReceived = received.filter(r => !this.confirmedTxIds.includes(r.txid));
|
||||
|
||||
newReceived.forEach(receipt => {
|
||||
console.info(`Received new tx ${receipt.txid}`);
|
||||
this.confirmedTxIds.push(receipt.txid);
|
||||
const contributionId = getContributionIdFromMemo(receipt.memo);
|
||||
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,
|
||||
decodedMemo: decodeHexMemo(receipt.memo)
|
||||
});
|
||||
|
@ -134,10 +134,8 @@ export default class ContributionNotifier implements Notifier {
|
|||
}
|
||||
};
|
||||
|
||||
private sendContributionConfirmation = (payload: ContributionConfirmationPayload) => {
|
||||
this.send({
|
||||
payload,
|
||||
type: 'contribution:confirmation',
|
||||
});
|
||||
private sendContributionConfirmation = (p: ContributionConfirmationPayload) => {
|
||||
console.info(`Contribution confirmed for contribution ${p.contributionId}, +${p.amount} ZEC`);
|
||||
this.send(`/proposals/contribution/${p.contributionId}/confirm`, 'POST', p);
|
||||
};
|
||||
}
|
|
@ -1,9 +1,8 @@
|
|||
import { Send, Message } from "../index";
|
||||
import { Send } from "../index";
|
||||
import { Block } from "../../node";
|
||||
|
||||
export interface Notifier {
|
||||
registerSend(send: Send): void;
|
||||
receive?(message: Message): void;
|
||||
onNewBlock?(block: Block): 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`);
|
||||
}
|
||||
|
||||
export function getProposalContributions(proposalId: number | string) {
|
||||
return axios.get(`/api/v1/proposals/${proposalId}/contributions`);
|
||||
}
|
||||
|
||||
export function postProposal(payload: ProposalDraft) {
|
||||
return axios.post(`/api/v1/proposals/`, {
|
||||
...payload,
|
||||
|
@ -216,6 +220,7 @@ export function postProposalContribution(
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
export function postProposalComment(payload: {
|
||||
proposalId: number;
|
||||
parentCommentId?: number;
|
||||
|
@ -224,3 +229,14 @@ export function postProposalComment(payload: {
|
|||
const { proposalId, ...args } = payload;
|
||||
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);
|
||||
|
||||
&-qr {
|
||||
position: relative;
|
||||
padding: 0.5rem;
|
||||
margin-right: 1rem;
|
||||
border-radius: 4px;
|
||||
|
@ -28,6 +29,16 @@
|
|||
canvas {
|
||||
display: block;
|
||||
}
|
||||
&.is-loading canvas {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ant-spin {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
&-info {
|
||||
|
|
|
@ -67,8 +67,13 @@ export default class PaymentInfo extends React.Component<Props, State> {
|
|||
</Radio.Group>
|
||||
|
||||
<div className="PaymentInfo-uri">
|
||||
<div className="PaymentInfo-uri-qr">
|
||||
{uri ? <QRCode value={uri} /> : <Spin />}
|
||||
<div className={
|
||||
classnames('PaymentInfo-uri-qr', !uri && 'is-loading')
|
||||
}>
|
||||
<span style={{ opacity: uri ? 1 : 0 }}>
|
||||
<QRCode value={uri || ''} />
|
||||
</span>
|
||||
{!uri && <Spin size="large" />}
|
||||
</div>
|
||||
<div className="PaymentInfo-uri-info">
|
||||
<CopyInput
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import React from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import Result from 'ant-design-pro/lib/Result';
|
||||
import { postProposalContribution } from 'api/api';
|
||||
import { postProposalContribution, getProposalContribution } from 'api/api';
|
||||
import { ContributionWithAddresses } from 'types';
|
||||
import PaymentInfo from './PaymentInfo';
|
||||
|
||||
interface OwnProps {
|
||||
isVisible: boolean;
|
||||
proposalId: number;
|
||||
proposalId?: number;
|
||||
contributionId?: number;
|
||||
amount?: string;
|
||||
hasNoButtons?: boolean;
|
||||
handleClose(): void;
|
||||
}
|
||||
|
||||
|
@ -28,17 +30,21 @@ export default class ContributionModal extends React.Component<Props, State> {
|
|||
};
|
||||
|
||||
componentWillUpdate(nextProps: Props) {
|
||||
const { isVisible, proposalId } = nextProps
|
||||
if (isVisible && this.props.isVisible !== isVisible) {
|
||||
this.fetchAddresses(proposalId);
|
||||
}
|
||||
else if (proposalId !== this.props.proposalId) {
|
||||
this.fetchAddresses(proposalId);
|
||||
const { isVisible, proposalId, contributionId } = nextProps;
|
||||
// When modal is opened and proposalId is provided or changed
|
||||
if (isVisible && proposalId) {
|
||||
if (
|
||||
this.props.isVisible !== isVisible ||
|
||||
proposalId !== this.props.proposalId
|
||||
) {
|
||||
this.fetchAddresses(proposalId, contributionId);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isVisible, handleClose } = this.props;
|
||||
const { isVisible, handleClose, hasNoButtons } = this.props;
|
||||
const { hasSent, contribution, error } = this.state;
|
||||
let content;
|
||||
|
||||
|
@ -68,11 +74,12 @@ export default class ContributionModal extends React.Component<Props, State> {
|
|||
<Modal
|
||||
title="Make your contribution"
|
||||
visible={isVisible}
|
||||
closable={hasSent}
|
||||
maskClosable={hasSent}
|
||||
closable={hasSent || hasNoButtons}
|
||||
maskClosable={hasSent || hasNoButtons}
|
||||
okText={hasSent ? 'Done' : 'I’ve sent it'}
|
||||
onOk={hasSent ? handleClose : this.confirmSend}
|
||||
onCancel={handleClose}
|
||||
footer={hasNoButtons ? '' : undefined}
|
||||
centered
|
||||
>
|
||||
{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 {
|
||||
const res = await postProposalContribution(
|
||||
proposalId,
|
||||
this.props.amount || '0',
|
||||
);
|
||||
let res;
|
||||
if (contributionId) {
|
||||
res = await getProposalContribution(proposalId, contributionId);
|
||||
} else {
|
||||
res = await postProposalContribution(
|
||||
proposalId,
|
||||
this.props.amount || '0',
|
||||
);
|
||||
}
|
||||
this.setState({ contribution: res.data });
|
||||
} catch(err) {
|
||||
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 ProfilePendingList from './ProfilePendingList';
|
||||
import ProfileProposal from './ProfileProposal';
|
||||
import ProfileContribution from './ProfileContribution';
|
||||
import ProfileComment from './ProfileComment';
|
||||
import ProfileInvite from './ProfileInvite';
|
||||
import Placeholder from 'components/Placeholder';
|
||||
import Exception from 'pages/exception';
|
||||
import ContributionModal from 'components/ContributionModal';
|
||||
import './style.less';
|
||||
import { UserContribution } from 'types';
|
||||
|
||||
interface StateProps {
|
||||
usersMap: AppState['users']['map'];
|
||||
|
@ -34,7 +37,15 @@ interface 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() {
|
||||
this.fetchData();
|
||||
}
|
||||
|
@ -49,6 +60,8 @@ class Profile extends React.Component<Props> {
|
|||
render() {
|
||||
const userLookupParam = this.props.match.params.id;
|
||||
const { authUser, match } = this.props;
|
||||
const { activeContribution } = this.state;
|
||||
|
||||
if (!userLookupParam) {
|
||||
if (authUser && authUser.userid) {
|
||||
return <Redirect to={`/profile/${authUser.userid}`} />;
|
||||
|
@ -69,16 +82,10 @@ class Profile extends React.Component<Props> {
|
|||
return <Exception code="404" />;
|
||||
}
|
||||
|
||||
const {
|
||||
pendingProposals,
|
||||
createdProposals,
|
||||
fundedProposals,
|
||||
comments,
|
||||
invites,
|
||||
} = user;
|
||||
const { proposals, pendingProposals, contributions, comments, invites } = user;
|
||||
const nonePending = pendingProposals.length === 0;
|
||||
const noneCreated = createdProposals.length === 0;
|
||||
const noneFunded = fundedProposals.length === 0;
|
||||
const noneCreated = proposals.length === 0;
|
||||
const noneFunded = contributions.length === 0;
|
||||
const noneCommented = comments.length === 0;
|
||||
const noneInvites = user.hasFetchedInvites && invites.length === 0;
|
||||
|
||||
|
@ -119,21 +126,26 @@ class Profile extends React.Component<Props> {
|
|||
</div>
|
||||
</Tabs.TabPane>
|
||||
)}
|
||||
<Tabs.TabPane tab={TabTitle('Created', createdProposals.length)} key="created">
|
||||
<Tabs.TabPane tab={TabTitle('Created', proposals.length)} key="created">
|
||||
<div>
|
||||
{noneCreated && (
|
||||
<Placeholder subtitle="Has not created any proposals yet" />
|
||||
)}
|
||||
{createdProposals.map(p => (
|
||||
{proposals.map(p => (
|
||||
<ProfileProposal key={p.proposalId} proposal={p} />
|
||||
))}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={TabTitle('Funded', fundedProposals.length)} key="funded">
|
||||
<Tabs.TabPane tab={TabTitle('Funded', contributions.length)} key="funded">
|
||||
<div>
|
||||
{noneFunded && <Placeholder subtitle="Has not funded any proposals yet" />}
|
||||
{fundedProposals.map(p => (
|
||||
<ProfileProposal key={p.proposalId} proposal={p} />
|
||||
{contributions.map(c => (
|
||||
<ProfileContribution
|
||||
key={c.id}
|
||||
userId={user.userid}
|
||||
contribution={c}
|
||||
showSendInstructions={this.openContributionModal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
@ -165,9 +177,20 @@ class Profile extends React.Component<Props> {
|
|||
</Tabs.TabPane>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
<ContributionModal
|
||||
isVisible={!!activeContribution}
|
||||
proposalId={
|
||||
activeContribution ? activeContribution.proposal.proposalId : undefined
|
||||
}
|
||||
contributionId={activeContribution ? activeContribution.id : undefined}
|
||||
hasNoButtons
|
||||
handleClose={this.closeContributionModal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private fetchData() {
|
||||
const { match } = this.props;
|
||||
const userLookupId = match.params.id;
|
||||
|
@ -176,6 +199,9 @@ class Profile extends React.Component<Props> {
|
|||
this.props.fetchUserInvites(userLookupId);
|
||||
}
|
||||
}
|
||||
|
||||
private openContributionModal = (c: UserContribution) => this.setState({ activeContribution: c });
|
||||
private closeContributionModal = () => this.setState({ activeContribution: null });
|
||||
}
|
||||
|
||||
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 { connect } from 'react-redux';
|
||||
import { Spin } from 'antd';
|
||||
import AddressRow from 'components/AddressRow';
|
||||
import UserRow from 'components/UserRow';
|
||||
import Placeholder from 'components/Placeholder';
|
||||
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 = () => {
|
||||
// TODO: Get contributors from proposal
|
||||
console.warn('TODO: Get contributors from proposal for Proposal/Contributors/index.tsx');
|
||||
const proposal = { contributors: [] as any };
|
||||
let content;
|
||||
if (proposal) {
|
||||
if (proposal.contributors.length) {
|
||||
content = proposal.contributors.map((contributor: any) => (
|
||||
<AddressRow
|
||||
key={contributor.address}
|
||||
address={contributor.address}
|
||||
secondary={
|
||||
<UnitDisplay value={contributor.contributionAmount} symbol="ZEC" />
|
||||
}
|
||||
/>
|
||||
));
|
||||
} 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.
|
||||
`}
|
||||
/>
|
||||
);
|
||||
interface OwnProps {
|
||||
proposalId: number;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
contributions: ReturnType<typeof getProposalContributions>;
|
||||
isFetchingContributions: ReturnType<typeof getIsFetchingContributions>;
|
||||
fetchContributionsError: ReturnType<typeof getFetchContributionsError>;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
fetchProposalContributions: typeof fetchProposalContributions;
|
||||
}
|
||||
|
||||
type Props = OwnProps & StateProps & DispatchProps;
|
||||
|
||||
class ProposalContributors extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (this.props.proposalId) {
|
||||
this.props.fetchProposalContributions(this.props.proposalId);
|
||||
}
|
||||
} else {
|
||||
content = <Spin />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Proposal-top-side-block">
|
||||
{proposal.contributors.length ? (
|
||||
<>
|
||||
<h1 className="Proposal-top-main-block-title">Contributors</h1>
|
||||
<div className="Proposal-top-main-block">{content}</div>
|
||||
</>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
componentWillReceiveProps(nextProps: Props) {
|
||||
if (nextProps.proposalId && nextProps.proposalId !== this.props.proposalId) {
|
||||
this.props.fetchProposalContributions(nextProps.proposalId);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { contributions, fetchContributionsError } = this.props;
|
||||
|
||||
let content;
|
||||
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 UpdatesTab from './Updates';
|
||||
import ContributorsTab from './Contributors';
|
||||
// import CommunityTab from './Community';
|
||||
import UpdateModal from './UpdateModal';
|
||||
import CancelModal from './CancelModal';
|
||||
import classnames from 'classnames';
|
||||
|
@ -244,8 +243,8 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
<Tabs.TabPane tab="Updates" key="updates" disabled={!isLive}>
|
||||
<UpdatesTab proposalId={proposal.proposalId} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="Contributors" key="contributors" disabled={!isLive}>
|
||||
<ContributorsTab />
|
||||
<Tabs.TabPane tab="Contributors" key="contributors">
|
||||
<ContributorsTab proposalId={proposal.proposalId} />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
|
|
|
@ -6,9 +6,10 @@ import './style.less';
|
|||
|
||||
interface Props {
|
||||
user: User;
|
||||
extra?: React.ReactNode;
|
||||
}
|
||||
|
||||
const UserRow = ({ user }: Props) => (
|
||||
const UserRow = ({ user, extra }: Props) => (
|
||||
<Link to={`/profile/${user.userid}`} className="UserRow">
|
||||
<div className="UserRow-avatar">
|
||||
<UserAvatar user={user} className="UserRow-avatar-img" />
|
||||
|
@ -17,6 +18,11 @@ const UserRow = ({ user }: Props) => (
|
|||
<div className="UserRow-info-main">{user.displayName}</div>
|
||||
<p className="UserRow-info-secondary">{user.title}</p>
|
||||
</div>
|
||||
{extra && (
|
||||
<div className="UserRow-extra">
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
.UserRow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: @height;
|
||||
margin-bottom: 1rem;
|
||||
color: inherit;
|
||||
|
@ -40,9 +41,16 @@
|
|||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
&-extra {
|
||||
text-align: right;
|
||||
margin-left: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
getProposal,
|
||||
getProposalComments,
|
||||
getProposalUpdates,
|
||||
getProposalContributions,
|
||||
postProposalComment as apiPostProposalComment,
|
||||
} from 'api/api';
|
||||
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(
|
||||
proposalId: Proposal['proposalId'],
|
||||
comment: string,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import types from './types';
|
||||
import { findComment } from 'utils/helpers';
|
||||
import { Proposal, ProposalComments, ProposalUpdates, Comment } from 'types';
|
||||
import { Proposal, ProposalComments, ProposalUpdates, Comment, ProposalContributions } from 'types';
|
||||
|
||||
export interface ProposalState {
|
||||
proposals: Proposal[];
|
||||
|
@ -15,8 +15,15 @@ export interface ProposalState {
|
|||
updatesError: null | string;
|
||||
isFetchingUpdates: boolean;
|
||||
|
||||
proposalContributions: { [id: string]: ProposalContributions };
|
||||
fetchContributionsError: null | string;
|
||||
isFetchingContributions: boolean;
|
||||
|
||||
isPostCommentPending: boolean;
|
||||
postCommentError: null | string;
|
||||
|
||||
isDeletingContribution: boolean;
|
||||
deleteContributionError: null | string;
|
||||
}
|
||||
|
||||
export const INITIAL_STATE: ProposalState = {
|
||||
|
@ -32,8 +39,15 @@ export const INITIAL_STATE: ProposalState = {
|
|||
updatesError: null,
|
||||
isFetchingUpdates: false,
|
||||
|
||||
proposalContributions: {},
|
||||
fetchContributionsError: null,
|
||||
isFetchingContributions: false,
|
||||
|
||||
isPostCommentPending: false,
|
||||
postCommentError: null,
|
||||
|
||||
isDeletingContribution: false,
|
||||
deleteContributionError: null,
|
||||
};
|
||||
|
||||
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 {
|
||||
proposalId: Proposal['proposalId'];
|
||||
comment: Comment;
|
||||
|
@ -184,6 +209,22 @@ export default (state = INITIAL_STATE, action: any) => {
|
|||
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:
|
||||
return {
|
||||
...state,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { AppState } from 'store/reducers';
|
||||
import { Proposal, ProposalComments, ProposalUpdates } from 'types';
|
||||
import { Proposal, ProposalComments, ProposalUpdates, ProposalContributions } from 'types';
|
||||
|
||||
export function getProposals(state: AppState) {
|
||||
return state.proposal.proposals;
|
||||
|
@ -74,3 +74,19 @@ export function getIsFetchingUpdates(state: AppState) {
|
|||
export function getUpdatesError(state: AppState) {
|
||||
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_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_FULFILLED = 'POST_PROPOSAL_COMMENT_FULFILLED',
|
||||
POST_PROPOSAL_COMMENT_REJECTED = 'POST_PROPOSAL_COMMENT_REJECTED',
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
updateUser as apiUpdateUser,
|
||||
fetchUserInvites as apiFetchUserInvites,
|
||||
putInviteResponse,
|
||||
deleteProposalContribution,
|
||||
deleteProposalDraft,
|
||||
putProposalPublish,
|
||||
} 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) {
|
||||
return async (dispatch: Dispatch<any>) => {
|
||||
await dispatch({
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import lodash from 'lodash';
|
||||
import { UserProposal, UserComment, TeamInviteWithProposal } from 'types';
|
||||
import { User, UserProposal, UserComment, UserContribution, TeamInviteWithProposal } from 'types';
|
||||
import types from './types';
|
||||
import { User } from 'types';
|
||||
|
||||
export interface TeamInviteWithResponse extends TeamInviteWithProposal {
|
||||
isResponding: boolean;
|
||||
|
@ -15,8 +14,8 @@ export interface UserState extends User {
|
|||
isUpdating: boolean;
|
||||
updateError: string | null;
|
||||
pendingProposals: UserProposal[];
|
||||
createdProposals: UserProposal[];
|
||||
fundedProposals: UserProposal[];
|
||||
proposals: UserProposal[];
|
||||
contributions: UserContribution[];
|
||||
comments: UserComment[];
|
||||
isFetchingInvites: boolean;
|
||||
hasFetchedInvites: boolean;
|
||||
|
@ -45,8 +44,8 @@ export const INITIAL_USER_STATE: UserState = {
|
|||
isUpdating: false,
|
||||
updateError: null,
|
||||
pendingProposals: [],
|
||||
createdProposals: [],
|
||||
fundedProposals: [],
|
||||
proposals: [],
|
||||
contributions: [],
|
||||
comments: [],
|
||||
isFetchingInvites: false,
|
||||
hasFetchedInvites: false,
|
||||
|
@ -150,6 +149,13 @@ export default (state = INITIAL_STATE, action: any) => {
|
|||
isResponding: false,
|
||||
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
|
||||
case types.USER_DELETE_PROPOSAL_FULFILLED:
|
||||
return removePendingProposal(state, payload.userId, payload.proposalId);
|
||||
|
@ -198,7 +204,7 @@ function updatePublishedProposal(
|
|||
) {
|
||||
const withoutPending = removePendingProposal(state, userId, proposal.proposalId);
|
||||
const userUpdates = {
|
||||
createdProposals: [proposal, ...state.map[userId].createdProposals],
|
||||
proposals: [proposal, ...state.map[userId].proposals],
|
||||
};
|
||||
return updateUserState(withoutPending, userId, userUpdates);
|
||||
}
|
||||
|
|
|
@ -19,6 +19,8 @@ enum UsersActions {
|
|||
RESPOND_TO_INVITE_FULFILLED = 'RESPOND_TO_INVITE_FULFILLED',
|
||||
RESPOND_TO_INVITE_REJECTED = 'RESPOND_TO_INVITE_REJECTED',
|
||||
|
||||
DELETE_CONTRIBUTION = 'DELETE_CONTRIBUTION',
|
||||
|
||||
USER_DELETE_PROPOSAL = 'USER_DELETE_PROPOSAL',
|
||||
USER_DELETE_PROPOSAL_FULFILLED = 'USER_DELETE_PROPOSAL_FULFILLED',
|
||||
|
||||
|
|
|
@ -20,8 +20,11 @@ export function formatUserFromGet(user: UserState) {
|
|||
if (user.pendingProposals) {
|
||||
user.pendingProposals = user.pendingProposals.map(bnUserProp);
|
||||
}
|
||||
user.createdProposals = user.createdProposals.map(bnUserProp);
|
||||
user.fundedProposals = user.fundedProposals.map(bnUserProp);
|
||||
user.proposals = user.proposals.map(bnUserProp);
|
||||
user.contributions = user.contributions.map(c => {
|
||||
c.amount = toZat(c.amount as any as string);
|
||||
return c;
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
|
@ -30,17 +33,21 @@ export function formatProposalFromGet(p: any): Proposal {
|
|||
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
|
||||
proposal.target = toZat(p.target);
|
||||
proposal.funded = toZat(p.funded);
|
||||
proposal.percentFunded = proposal.funded.div(proposal.target.divn(100)).toNumber();
|
||||
proposal.milestones = proposal.milestones.map((m: any, index: number) => {
|
||||
return {
|
||||
...m,
|
||||
index,
|
||||
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
|
||||
// TODO: Get data from backend
|
||||
state: MILESTONE_STATE.WAITING,
|
||||
isPaid: false,
|
||||
};
|
||||
});
|
||||
proposal.percentFunded = proposal.target.isZero()
|
||||
? 0
|
||||
: proposal.funded.div(proposal.target.divn(100)).toNumber();
|
||||
if (proposal.milestones) {
|
||||
proposal.milestones = proposal.milestones.map((m: any, index: number) => {
|
||||
return {
|
||||
...m,
|
||||
index,
|
||||
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
|
||||
// TODO: Get data from backend
|
||||
state: MILESTONE_STATE.WAITING,
|
||||
isPaid: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
return proposal;
|
||||
}
|
||||
|
||||
|
@ -83,10 +90,14 @@ export function massageSerializedState(state: AppState) {
|
|||
return p;
|
||||
};
|
||||
Object.values(state.users.map).forEach(user => {
|
||||
user.createdProposals.forEach(bnUserProp);
|
||||
user.fundedProposals.forEach(bnUserProp);
|
||||
user.comments.forEach(c => {
|
||||
user.proposals = user.proposals.map(bnUserProp);
|
||||
user.contributions = user.contributions.map(c => {
|
||||
c.amount = new BN(c.amount, 16);
|
||||
return c;
|
||||
});
|
||||
user.comments = user.comments.map(c => {
|
||||
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])}'`;
|
||||
}
|
||||
|
||||
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 { ProposalCampaignBlock } from 'components/Proposal/CampaignBlock';
|
||||
import Contributors from 'components/Proposal/Contributors';
|
||||
|
||||
import 'styles/style.less';
|
||||
import 'components/Proposal/style.less';
|
||||
|
@ -53,9 +52,4 @@ storiesOf('Proposal', module)
|
|||
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||
<CampaignBlocks style={{ margin: '0 12px' }} />
|
||||
</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 {
|
||||
id: string;
|
||||
id: number;
|
||||
txId: string;
|
||||
amount: string;
|
||||
dateCreated: number;
|
||||
status: 'PENDING' | 'CONFIRMED';
|
||||
}
|
||||
|
||||
export interface ContributionWithAddresses extends Contribution {
|
||||
|
@ -12,3 +16,13 @@ export interface ContributionWithAddresses extends Contribution {
|
|||
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 { PROPOSAL_CATEGORY } from 'api/constants';
|
||||
import { CreateMilestone, Update, User, Comment } from 'types';
|
||||
import { CreateMilestone, Update, User, Comment, ContributionWithUser } from 'types';
|
||||
import { ProposalMilestone } from './milestone';
|
||||
|
||||
export interface TeamInvite {
|
||||
|
@ -40,8 +39,8 @@ export interface ProposalDraft {
|
|||
export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
||||
proposalAddress: string;
|
||||
proposalUrlId: string;
|
||||
target: BN;
|
||||
funded: BN;
|
||||
target: Zat;
|
||||
funded: Zat;
|
||||
percentFunded: number;
|
||||
milestones: ProposalMilestone[];
|
||||
datePublished: number;
|
||||
|
@ -62,13 +61,19 @@ export interface ProposalUpdates {
|
|||
updates: Update[];
|
||||
}
|
||||
|
||||
export interface ProposalContributions {
|
||||
proposalId: Proposal['proposalId'];
|
||||
top: ContributionWithUser[];
|
||||
latest: ContributionWithUser[];
|
||||
}
|
||||
|
||||
export interface UserProposal {
|
||||
proposalId: number;
|
||||
status: STATUS;
|
||||
title: string;
|
||||
brief: string;
|
||||
funded: BN;
|
||||
target: BN;
|
||||
funded: Zat;
|
||||
target: Zat;
|
||||
dateCreated: number;
|
||||
dateApproved: number;
|
||||
datePublished: number;
|
||||
|
|
Loading…
Reference in New Issue