Merge pull request #109 from grant-project/develop

Release 3
This commit is contained in:
Daniel Ternyak 2018-09-26 00:14:12 -05:00 committed by GitHub
commit 43f5737c33
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
213 changed files with 45865 additions and 6934 deletions

31
.travis.yml Normal file
View File

@ -0,0 +1,31 @@
matrix:
include:
# Frontend
- language: node_js
node_js: 8.11.4
before_install:
- cd frontend/
install: yarn
script:
- yarn run lint
- yarn run tsc
# Backend
- language: python
python: 3.6
before_install:
- cd backend/
- cp .env.example .env
install: pip install -r requirements/dev.txt
script:
- flask test
# Contracts
- language: node_js
node_js: 8.11.4
before_install:
- cd contract/
install: yarn && yarn add global truffle ganache-cli
before_script:
- ganache-cli > /dev/null &
- sleep 10
script:
- yarn run test

View File

@ -3,7 +3,7 @@
from flask import Flask
from flask_cors import CORS
from grant import commands, proposal, author, comment, milestone
from grant import commands, proposal, user, comment, milestone
from grant.extensions import bcrypt, migrate, db, ma
@ -31,13 +31,9 @@ def register_blueprints(app):
"""Register Flask blueprints."""
app.register_blueprint(comment.views.blueprint)
app.register_blueprint(proposal.views.blueprint)
app.register_blueprint(author.views.blueprint)
app.register_blueprint(user.views.blueprint)
app.register_blueprint(milestone.views.blueprint)
return None
def register_shellcontext(app):
"""Register shell context objects."""
@ -55,5 +51,4 @@ def register_commands(app):
app.cli.add_command(commands.clean)
app.cli.add_command(commands.urls)
app.cli.add_command(author.commands.create_author)
app.cli.add_command(proposal.commands.create_proposal)

View File

@ -1,13 +0,0 @@
import click
from flask.cli import with_appcontext
from .models import Author, db
@click.command()
@click.argument('account_address')
@with_appcontext
def create_author(account_address):
author = Author(account_address)
db.session.add(author)
db.session.commit()

View File

@ -1,43 +0,0 @@
from grant.comment.models import Comment
from grant.extensions import ma, db
from grant.proposal.models import Proposal
class Author(db.Model):
__tablename__ = "author"
id = db.Column(db.Integer(), primary_key=True)
account_address = db.Column(db.String(255), unique=True)
proposals = db.relationship(Proposal, backref="author", lazy=True)
comments = db.relationship(Comment, backref="author", lazy=True)
avatar = db.Column(db.String(255), unique=False, nullable=True)
# TODO - add create and validate methods
def __init__(self, account_address, avatar=None):
self.account_address = account_address
self.avatar = avatar
class AuthorSchema(ma.Schema):
class Meta:
model = Author
# Fields to expose
fields = ("account_address", "userid", "title", "avatar")
userid = ma.Method("get_userid")
title = ma.Method("get_title")
avatar = ma.Method("get_avatar")
def get_userid(self, obj):
return obj.id
def get_title(self, obj):
return ""
def get_avatar(self, obj):
return "https://forum.getmonero.org/uploads/profile/small_no_picture.jpg"
author_schema = AuthorSchema()
authors_schema = AuthorSchema(many=True)

View File

@ -1,13 +0,0 @@
from flask import Blueprint
from .models import Author, authors_schema
from grant import JSONResponse
blueprint = Blueprint('author', __name__, url_prefix='/api/authors')
@blueprint.route("/", methods=["GET"])
def get_authors():
all_authors = Author.query.all()
result = authors_schema.dump(all_authors)
return JSONResponse(result)

View File

@ -9,15 +9,14 @@ class Comment(db.Model):
id = db.Column(db.Integer(), primary_key=True)
date_created = db.Column(db.DateTime)
content = db.Column(db.Text, nullable=False)
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("author.id"), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
def __init__(self, proposal_id, author_id, content):
def __init__(self, proposal_id, user_id, content):
self.proposal_id = proposal_id
self.author_id = author_id
self.user_id = user_id
self.content = content
self.date_created = datetime.datetime.now()
@ -27,7 +26,7 @@ class CommentSchema(ma.Schema):
model = Comment
# Fields to expose
fields = (
"author_id",
"user_id",
"content",
"proposal_id",
"date_created",

View File

@ -4,7 +4,7 @@ from grant import JSONResponse
from .models import Comment, comments_schema
blueprint = Blueprint("comment", __name__, url_prefix="/api/comment")
blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment")
@blueprint.route("/", methods=["GET"])

View File

@ -1,3 +1,2 @@
from . import views
from . import models
from . import commands

View File

@ -1,13 +0,0 @@
# import click
# from flask.cli import with_appcontext
#
# from .models import Author, db
#
#
# @click.command()
# @click.argument('account_address')
# @with_appcontext
# def create_author(account_address):
# author = Author(account_address)
# db.session.add(author)
# db.session.commit()

View File

@ -2,23 +2,11 @@ import datetime
from grant.extensions import ma, db
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
COMPLETED = 'COMPLETED'
PROPOSAL_STAGES = [FUNDING_REQUIRED, COMPLETED]
NOT_REQUESTED = 'NOT_REQUESTED'
ONGOING_VOTE = 'ONGOING_VOTE'
PAID = 'PAID'
MILESTONE_STAGES = [NOT_REQUESTED, ONGOING_VOTE, PAID]
DAPP = "DAPP"
DEV_TOOL = "DEV_TOOL"
CORE_DEV = "CORE_DEV"
COMMUNITY = "COMMUNITY"
DOCUMENTATION = "DOCUMENTATION"
ACCESSIBILITY = "ACCESSIBILITY"
CATEGORIES = [DAPP, DEV_TOOL, CORE_DEV, COMMUNITY, DOCUMENTATION, ACCESSIBILITY]
class Milestone(db.Model):
__tablename__ = "milestone"

View File

@ -3,11 +3,11 @@ from flask import Blueprint
from grant import JSONResponse
from .models import Milestone, milestones_schema
blueprint = Blueprint('milestone', __name__, url_prefix='/api/milestones')
blueprint = Blueprint('milestone', __name__, url_prefix='/api/v1/milestones')
@blueprint.route("/", methods=["GET"])
def get_authors():
def get_users():
milestones = Milestone.query.all()
result = milestones_schema.dump(milestones)
return JSONResponse(result)

View File

@ -6,14 +6,14 @@ from .models import Proposal, db
@click.command()
@click.argument('stage')
@click.argument('author_id')
@click.argument('user_id')
@click.argument('proposal_id')
@click.argument('title')
@click.argument('content')
@with_appcontext
def create_proposal(stage, author_id, proposal_id, title, content):
def create_proposal(stage, user_id, proposal_id, title, content):
proposal = Proposal.create(stage=stage,
author_id=author_id,
user_id=user_id,
proposal_id=proposal_id,
title=title,
content=content)

View File

@ -21,6 +21,13 @@ class ValidationException(Exception):
pass
proposal_team = db.Table(
'proposal_team', db.Model.metadata,
db.Column('user_id', db.Integer, db.ForeignKey('user.id')),
db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id'))
)
class Proposal(db.Model):
__tablename__ = "proposal"
@ -33,21 +40,19 @@ class Proposal(db.Model):
content = db.Column(db.Text, nullable=False)
category = db.Column(db.String(255), nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("author.id"), nullable=False)
team = db.relationship("User", secondary=proposal_team)
comments = db.relationship(Comment, backref="proposal", lazy=True)
milestones = db.relationship("Milestone", backref="proposal", lazy=True)
def __init__(
self,
stage: str,
author_id: int,
proposal_id: str,
title: str,
content: str,
category: str
):
self.stage = stage
self.author_id = author_id
self.proposal_id = proposal_id
self.title = title
self.content = content
@ -57,7 +62,6 @@ class Proposal(db.Model):
@staticmethod
def validate(
stage: str,
author_id: int,
proposal_id: str,
title: str,
content: str,
@ -86,9 +90,9 @@ class ProposalSchema(ma.Schema):
"proposal_id",
"body",
"comments",
"author",
"milestones",
"category"
"category",
"team"
)
date_created = ma.Method("get_date_created")
@ -96,7 +100,7 @@ class ProposalSchema(ma.Schema):
body = ma.Method("get_body")
comments = ma.Nested("CommentSchema", many=True)
author = ma.Nested("AuthorSchema")
team = ma.Nested("UserSchema", many=True)
milestones = ma.Nested("MilestoneSchema", many=True)
def get_body(self, obj):

View File

@ -4,16 +4,12 @@ from flask import Blueprint, request
from sqlalchemy.exc import IntegrityError
from grant import JSONResponse
from .models import Proposal, proposals_schema, proposal_schema, db
from grant.comment.models import Comment, comment_schema
from grant.milestone.models import Milestone
from grant.user.models import User, SocialMedia, Avatar
from .models import Proposal, proposals_schema, proposal_schema, db
blueprint = Blueprint("proposal", __name__, url_prefix="/api/proposals")
def __adjust_dumped_proposal(proposal):
cur_author = proposal["author"]
proposal["team"] = [cur_author]
return proposal
blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
@blueprint.route("/<proposal_id>", methods=["GET"])
@ -21,25 +17,51 @@ def get_proposal(proposal_id):
proposal = Proposal.query.filter_by(proposal_id=proposal_id).first()
if proposal:
dumped_proposal = proposal_schema.dump(proposal)
return JSONResponse(__adjust_dumped_proposal(dumped_proposal))
return JSONResponse(dumped_proposal)
else:
return JSONResponse(message="No proposal matching id", _statusCode=404)
@blueprint.route("/<proposal_id>/comments", methods=["GET"])
def get_proposal_comments(proposal_id):
proposals = Proposal.query.filter_by(proposal_id=proposal_id).first()
if proposals:
results = proposal_schema.dump(proposals)
proposal = Proposal.query.filter_by(proposal_id=proposal_id).first()
if proposal:
dumped_proposal = proposal_schema.dump(proposal)
return JSONResponse(
proposal_id=proposal_id,
total_comments=len(results["comments"]),
comments=results["comments"]
total_comments=len(dumped_proposal["comments"]),
comments=dumped_proposal["comments"]
)
else:
return JSONResponse(message="No proposal matching id", _statusCode=404)
@blueprint.route("/<proposal_id>/comments", methods=["POST"])
def post_proposal_comments(proposal_id):
proposal = Proposal.query.filter_by(proposal_id=proposal_id).first()
if proposal:
incoming = request.get_json()
user_id = incoming["userId"]
content = incoming["content"]
user = User.query.filter_by(id=user_id).first()
if user:
comment = Comment(
proposal_id=proposal_id,
user_id=user_id,
content=content
)
db.session.add(comment)
db.session.commit()
dumped_comment = comment_schema.dump(comment)
return JSONResponse(dumped_comment, _statusCode=201)
else:
return JSONResponse(message="No user matching id", _statusCode=404)
else:
return JSONResponse(message="No proposal matching id", _statusCode=404)
@blueprint.route("/", methods=["GET"])
def get_proposals():
stage = request.args.get("stage")
@ -51,39 +73,63 @@ def get_proposals():
)
else:
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all()
results = map(__adjust_dumped_proposal, proposals_schema.dump(proposals))
return JSONResponse(results)
dumped_proposals = proposals_schema.dump(proposals)
return JSONResponse(dumped_proposals)
@blueprint.route("/create", methods=["POST"])
@blueprint.route("/", methods=["POST"])
def make_proposal():
from grant.author.models import Author
from grant.user.models import User
incoming = request.get_json()
account_address = incoming["accountAddress"]
proposal_id = incoming["crowdFundContractAddress"]
content = incoming["content"]
title = incoming["title"]
milestones = incoming["milestones"]
category = incoming["category"]
author = Author.query.filter_by(account_address=account_address).first()
if not author:
author = Author(account_address=account_address)
db.session.add(author)
db.session.commit()
proposal = Proposal.create(
stage="FUNDING_REQUIRED",
proposal_id=proposal_id,
content=content,
title=title,
author_id=author.id,
category=category
)
team = incoming["team"]
if not len(team) > 0:
return JSONResponse(message="Team must be at least 1", _statusCode=400)
for team_member in team:
account_address = team_member.get("accountAddress")
display_name = team_member.get("displayName")
email_address = team_member.get("emailAddress")
title = team_member.get("title")
user = User.query.filter((User.account_address == account_address) | (User.email_address == email_address)).first()
if not user:
user = User(
account_address=account_address,
email_address=email_address,
display_name=display_name,
title=title
)
db.session.add(user)
db.session.commit()
proposal.team.append(user)
avatar_data = team_member.get("avatar")
if avatar_data:
avatar = Avatar(image_url=avatar_data.get('link'), user_id=user.id)
db.session.add(avatar)
db.session.commit()
social_medias = team_member.get("socialMedias")
if social_medias:
for social_media in social_medias:
sm = SocialMedia(social_media_link=social_media.get("link"), user_id=user.id)
db.session.add(sm)
db.session.commit()
db.session.add(proposal)
db.session.commit()
@ -105,4 +151,4 @@ def make_proposal():
return JSONResponse(message="Proposal with that hash already exists", _statusCode=409)
results = proposal_schema.dump(proposal)
return JSONResponse(results, _statusCode=204)
return JSONResponse(results, _statusCode=201)

View File

@ -1,3 +1,2 @@
from . import views
from . import models
from . import commands

View File

@ -0,0 +1,77 @@
from grant.comment.models import Comment
from grant.extensions import ma, db
class SocialMedia(db.Model):
__tablename__ = "social_media"
id = db.Column(db.Integer(), primary_key=True)
# TODO replace this with something proper
social_media_link = db.Column(db.String(255), unique=False, nullable=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
def __init__(self, social_media_link, user_id):
self.social_media_link = social_media_link
self.user_id = user_id
class Avatar(db.Model):
__tablename__ = "avatar"
id = db.Column(db.Integer(), primary_key=True)
image_url = db.Column(db.String(255), unique=False, nullable=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
user = db.relationship("User", back_populates="avatar")
def __init__(self, image_url, user_id):
self.image_url = image_url
self.user_id = user_id
class User(db.Model):
__tablename__ = "user"
id = db.Column(db.Integer(), primary_key=True)
email_address = db.Column(db.String(255), unique=True, nullable=True)
account_address = db.Column(db.String(255), unique=True, nullable=True)
display_name = db.Column(db.String(255), unique=False, nullable=True)
title = db.Column(db.String(255), unique=False, nullable=True)
social_medias = db.relationship(SocialMedia, backref="user", lazy=True)
comments = db.relationship(Comment, backref="user", lazy=True)
avatar = db.relationship(Avatar, uselist=False, back_populates="user")
# TODO - add create and validate methods
def __init__(self, email_address=None, account_address=None, display_name=None, title=None):
if not email_address and not account_address:
raise ValueError("Either email_address or account_address is required to create a user")
self.email_address = email_address
self.account_address = account_address
self.display_name = display_name
self.title = title
class UserSchema(ma.Schema):
class Meta:
model = User
# Fields to expose
fields = ("account_address", "userid", "title", "email_address", "display_name", "title")
userid = ma.Method("get_userid")
title = ma.Method("get_title")
avatar = ma.Method("get_avatar")
def get_userid(self, obj):
return obj.id
def get_title(self, obj):
return ""
def get_avatar(self, obj):
return "https://forum.getmonero.org/uploads/profile/small_no_picture.jpg"
user_schema = UserSchema()
users_schema = UserSchema(many=True)

View File

@ -0,0 +1,20 @@
from flask import Blueprint, request
from .models import User, users_schema
from ..proposal.models import Proposal, proposal_team
from grant import JSONResponse
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
@blueprint.route("/", methods=["GET"])
def get_users():
proposal_query = request.args.get('proposalId')
proposal = Proposal.query.filter_by(proposal_id=proposal_query).first()
if not proposal:
users = User.query.all()
else:
users = User.query.join(proposal_team).join(Proposal)\
.filter(proposal_team.c.proposal_id == proposal.id).all()
result = users_schema.dump(users)
return JSONResponse(result)

View File

@ -1,8 +1,8 @@
"""empty message
Revision ID: 755ffe44f145
Revision ID: 5f38d8603897
Revises:
Create Date: 2018-09-07 23:29:13.564329
Create Date: 2018-09-24 20:20:47.181807
"""
from alembic import op
@ -10,7 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '755ffe44f145'
revision = '5f38d8603897'
down_revision = None
branch_labels = None
depends_on = None
@ -18,13 +18,6 @@ depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('author',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('account_address', sa.String(length=255), nullable=True),
sa.Column('avatar', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('account_address')
)
op.create_table('proposal',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
@ -33,19 +26,34 @@ def upgrade():
sa.Column('stage', sa.String(length=255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('category', sa.String(length=255), nullable=False),
sa.Column('author_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['author_id'], ['author.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('proposal_id')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email_address', sa.String(length=255), nullable=True),
sa.Column('account_address', sa.String(length=255), nullable=True),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('title', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('account_address'),
sa.UniqueConstraint('email_address')
)
op.create_table('avatar',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('image_url', sa.String(length=255), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('comment',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('author_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['author_id'], ['author.id'], ),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('milestone',
@ -61,13 +69,29 @@ def upgrade():
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('proposal_team',
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('proposal_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], )
)
op.create_table('social_media',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('social_media_link', sa.String(length=255), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('social_media')
op.drop_table('proposal_team')
op.drop_table('milestone')
op.drop_table('comment')
op.drop_table('avatar')
op.drop_table('user')
op.drop_table('proposal')
op.drop_table('author')
# ### end Alembic commands ###

View File

@ -15,4 +15,5 @@ flake8-isort==2.5
flake8-quotes==1.0.0
isort==4.3.4
pep8-naming==0.7.0
pre-commit
pre-commit
flask_testing

View File

@ -1 +0,0 @@
"""Tests for the app."""

19
backend/tests/config.py Normal file
View File

@ -0,0 +1,19 @@
from flask_testing import TestCase
from grant.app import create_app, db
class BaseTestConfig(TestCase):
def create_app(self):
app = create_app()
app.config.from_object('tests.settings')
return app
def setUp(self):
self.app = self.create_app().test_client()
db.create_all()
def tearDown(self):
db.session.remove()
db.drop_all()

View File

@ -7,8 +7,6 @@ from webtest import TestApp
from grant.app import create_app
from grant.app import db as _db
from .factories import UserFactory
@pytest.fixture
def app():
@ -40,11 +38,3 @@ def db(app):
# Explicitly close DB connection
_db.session.close()
_db.drop_all()
@pytest.fixture
def user(db):
"""A user for the tests."""
user = UserFactory(password='myprecious')
db.session.commit()
return user

View File

View File

@ -0,0 +1,94 @@
import json
import random
from grant.proposal.models import Proposal, CATEGORIES
from grant.user.models import User, SocialMedia
from ..config import BaseTestConfig
milestones = [
{
"title": "All the money straightaway",
"description": "cool stuff with it",
"date": "June 2019",
"payoutPercent": "100",
"immediatePayout": False
}
]
team = [
{
"accountAddress": "0x1",
"displayName": 'Groot',
"emailAddress": 'iam@groot.com',
"title": 'I am Groot!',
"avatar": {
"link": 'https://avatars2.githubusercontent.com/u/1393943?s=400&v=4'
},
"socialMedias": [
{
"link": 'https://github.com/groot'
}
]
}
]
proposal = {
"team": team,
"crowdFundContractAddress": "0x20000",
"content": "## My Proposal",
"title": "Give Me Money",
"milestones": milestones,
"category": random.choice(CATEGORIES)
}
class TestAPI(BaseTestConfig):
def test_create_new_proposal(self):
self.assertIsNone(Proposal.query.filter_by(
proposal_id=proposal["crowdFundContractAddress"]
).first())
self.app.post(
"/api/v1/proposals/",
data=json.dumps(proposal),
content_type='application/json'
)
proposal_db = Proposal.query.filter_by(
proposal_id=proposal["crowdFundContractAddress"]
).first()
self.assertEqual(proposal_db.title, proposal["title"])
# User
user_db = User.query.filter_by(email_address=team[0]["emailAddress"]).first()
self.assertEqual(user_db.display_name, team[0]["displayName"])
self.assertEqual(user_db.title, team[0]["title"])
self.assertEqual(user_db.account_address, team[0]["accountAddress"])
# SocialMedia
social_media_db = SocialMedia.query.filter_by(social_media_link=team[0]["socialMedias"][0]["link"]).first()
self.assertTrue(social_media_db)
# Avatar
self.assertEqual(user_db.avatar.image_url, team[0]["avatar"]["link"])
def test_create_new_proposal_comment(self):
proposal_res = self.app.post(
"/api/v1/proposals/",
data=json.dumps(proposal),
content_type='application/json'
)
proposal_json = proposal_res.json
proposal_id = proposal_json["proposalId"]
proposal_user_id = proposal_json["team"][0]["userid"]
comment_res = self.app.post(
"/api/v1/proposals/{}/comments".format(proposal_id),
data=json.dumps({
"userId": proposal_user_id,
"content": "What a comment"
}),
content_type='application/json'
)
self.assertTrue(comment_res.json)

View File

@ -1,7 +1,7 @@
"""Settings module for test app."""
ENV = 'development'
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite://'
SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/test.db'
SECRET_KEY = 'not-so-secret-in-tests'
BCRYPT_LOG_ROUNDS = 4 # For faster tests; needs at least 4 to avoid "ValueError: Invalid rounds"
DEBUG_TB_ENABLED = False

View File

@ -0,0 +1,4 @@
"""Sample test for CI"""
def test_runs():
assert True

View File

View File

@ -0,0 +1,108 @@
import copy
import json
import random
from grant.proposal.models import CATEGORIES
from grant.user.models import User
from ..config import BaseTestConfig
milestones = [
{
"title": "All the money straightaway",
"description": "cool stuff with it",
"date": "June 2019",
"payoutPercent": "100",
"immediatePayout": False
}
]
team = [
{
"accountAddress": "0x1",
"displayName": 'Groot',
"emailAddress": 'iam@groot.com',
"title": 'I am Groot!',
"avatar": {
"link": 'https://avatars2.githubusercontent.com/u/1393943?s=400&v=4'
},
"socialMedias": [
{
"link": 'https://github.com/groot'
}
]
}
]
proposal = {
"team": team,
"crowdFundContractAddress": "0x20000",
"content": "## My Proposal",
"title": "Give Me Money",
"milestones": milestones,
"category": random.choice(CATEGORIES)
}
class TestAPI(BaseTestConfig):
def test_create_new_user_via_proposal_by_account_address(self):
proposal_by_account = copy.deepcopy(proposal)
del proposal_by_account["team"][0]["emailAddress"]
self.app.post(
"/api/v1/proposals/",
data=json.dumps(proposal_by_account),
content_type='application/json'
)
# User
user_db = User.query.filter_by(account_address=proposal_by_account["team"][0]["accountAddress"]).first()
self.assertEqual(user_db.display_name, proposal_by_account["team"][0]["displayName"])
self.assertEqual(user_db.title, proposal_by_account["team"][0]["title"])
self.assertEqual(user_db.account_address, proposal_by_account["team"][0]["accountAddress"])
def test_create_new_user_via_proposal_by_email(self):
proposal_by_email = copy.deepcopy(proposal)
del proposal_by_email["team"][0]["accountAddress"]
self.app.post(
"/api/v1/proposals/",
data=json.dumps(proposal_by_email),
content_type='application/json'
)
# User
user_db = User.query.filter_by(email_address=proposal_by_email["team"][0]["emailAddress"]).first()
self.assertEqual(user_db.display_name, proposal_by_email["team"][0]["displayName"])
self.assertEqual(user_db.title, proposal_by_email["team"][0]["title"])
def test_get_all_users(self):
self.app.post(
"/api/v1/proposals/",
data=json.dumps(proposal),
content_type='application/json'
)
users_get_resp = self.app.get(
"/api/v1/users/"
)
users_json = users_get_resp.json
self.assertEqual(users_json[0]["displayName"], team[0]["displayName"])
def test_get_user_associated_with_proposal(self):
self.app.post(
"/api/v1/proposals/",
data=json.dumps(proposal),
content_type='application/json'
)
data = {
'proposalId': proposal["crowdFundContractAddress"]
}
users_get_resp = self.app.get(
"/api/v1/users/",
query_string=data
)
users_json = users_get_resp.json
self.assertEqual(users_json[0]["displayName"], team[0]["displayName"])

3
contract/.gitignore vendored
View File

@ -1,5 +1,6 @@
node_modules
build
.idea/
yarn-error.log
.env
build/abi
build/typedefs

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,14 @@ import "openzeppelin-solidity/contracts/math/SafeMath.sol";
contract CrowdFund {
using SafeMath for uint256;
enum FreezeReason {
CALLER_IS_TRUSTEE,
CROWD_FUND_FAILED,
MAJORITY_VOTING_TO_REFUND
}
FreezeReason freezeReason;
struct Milestone {
uint amount;
uint amountVotingAgainstPayout;
@ -30,6 +38,7 @@ contract CrowdFund {
uint public deadline;
uint public raiseGoal;
uint public amountRaised;
uint public frozenBalance;
uint public minimumContributionAmount;
uint public amountVotingForRefund;
address public beneficiary;
@ -197,7 +206,15 @@ contract CrowdFund {
bool crowdFundFailed = isFailed();
bool majorityVotingToRefund = isMajorityVoting(amountVotingForRefund);
require(callerIsTrustee || crowdFundFailed || majorityVotingToRefund, "Required conditions for refund are not met");
if (callerIsTrustee) {
freezeReason = FreezeReason.CALLER_IS_TRUSTEE;
} else if (crowdFundFailed) {
freezeReason = FreezeReason.CROWD_FUND_FAILED;
} else {
freezeReason = FreezeReason.MAJORITY_VOTING_TO_REFUND;
}
frozen = true;
frozenBalance = address(this).balance;
}
// anyone can refund a contributor if a crowdfund has been frozen
@ -207,8 +224,7 @@ contract CrowdFund {
require(!isRefunded, "Specified address is already refunded");
contributors[refundAddress].refunded = true;
uint contributionAmount = contributors[refundAddress].contributionAmount;
// TODO - maybe don't use address(this).balance
uint amountToRefund = contributionAmount.mul(address(this).balance).div(raiseGoal);
uint amountToRefund = contributionAmount.mul(address(this).balance).div(frozenBalance);
refundAddress.transfer(amountToRefund);
emit Withdrawn(refundAddress, amountToRefund);
}
@ -245,6 +261,14 @@ contract CrowdFund {
return contributors[contributorAddress].milestoneNoVotes[milestoneIndex];
}
function getContributorContributionAmount(address contributorAddress) public view returns (uint) {
return contributors[contributorAddress].contributionAmount;
}
function getFreezeReason() public view returns (uint) {
return uint(freezeReason);
}
modifier onlyFrozen() {
require(frozen, "CrowdFund is not frozen");
_;

View File

@ -1,7 +1,5 @@
const CrowdFundFactory = artifacts.require("./CrowdFundFactory.sol");
const PrivateFundFactory = artifacts.require("./PrivateFundFactory.sol");
module.exports = function(deployer) {
deployer.deploy(CrowdFundFactory);
deployer.deploy(PrivateFundFactory);
};

View File

@ -45,9 +45,13 @@ contract("CrowdFund Deadline", accounts => {
assert.equal(await crowdFund.isFailed.call(), true);
});
it("allows anyone to refund after time is up and goal is not reached", async () => {
it("allows anyone to refund after time is up and goal is not reached and sets refund reason to 1", async () => {
const fundAmount = raiseGoal / 10;
await crowdFund.contribute({ from: fourthAccount, value: fundAmount });
await crowdFund.contribute({
from: fourthAccount,
value: fundAmount,
gasPrice: 0,
});
assert.equal(
(await crowdFund.contributors(fourthAccount))[0].toNumber(),
fundAmount
@ -56,9 +60,10 @@ contract("CrowdFund Deadline", accounts => {
const initBalance = await web3.eth.getBalance(fourthAccount);
await increaseTime(AFTER_DEADLINE_EXPIRES);
await crowdFund.refund();
assert.equal((await crowdFund.getFreezeReason()), 1)
await crowdFund.withdraw(fourthAccount);
const finalBalance = await web3.eth.getBalance(fourthAccount);
assert.ok(finalBalance.greaterThan(initBalance)); // hard to be exact due to the gas usage
assert.ok(finalBalance.equals(initBalance.plus(fundAmount)));
});
it("refunds remaining proportionally when fundraiser has failed", async () => {

View File

@ -313,7 +313,7 @@ contract("CrowdFund", accounts => {
assertRevert(crowdFund.refund());
});
it("allows trustee to refund while the CrowdFund is on-going", async () => {
it("allows trustee to refund while the CrowdFund is on-going and sets reason to 0", async () => {
await crowdFund.contribute({
from: fourthAccount,
value: raiseGoal / 5
@ -323,6 +323,7 @@ contract("CrowdFund", accounts => {
fourthAccount
);
await crowdFund.refund({ from: firstTrusteeAccount });
assert.equal((await crowdFund.getFreezeReason()), 0);
await crowdFund.withdraw(fourthAccount);
const balanceAfterRefundFourthAccount = await web3.eth.getBalance(
fourthAccount
@ -375,7 +376,7 @@ contract("CrowdFund", accounts => {
assertRevert(crowdFund.refund());
});
it("refunds proportionally if majority is voting for refund after raise goal has been reached", async () => {
it("refunds proportionally if majority is voting for refund after raise goal has been reached and sets reason to 2", async () => {
const tenthOfRaiseGoal = raiseGoal / 10;
await crowdFund.contribute({
from: fourthAccount,
@ -402,6 +403,7 @@ contract("CrowdFund", accounts => {
);
await crowdFund.voteRefund(true, { from: thirdAccount });
await crowdFund.refund();
assert.equal((await crowdFund.getFreezeReason()), 2)
await crowdFund.withdraw(fourthAccount);
await crowdFund.withdraw(thirdAccount);
const finalBalanceFourthAccount = await web3.eth.getBalance(fourthAccount);
@ -410,6 +412,24 @@ contract("CrowdFund", accounts => {
assert.ok(finalBalanceThirdAccount.gt(initBalanceThirdAccount));
});
it("refunds full amounts even if raise goal isn't reached", async () => {
const initialBalance = await web3.eth.getBalance(fourthAccount);
const contribution = raiseGoal / 2;
const receipt = await crowdFund.contribute({
from: fourthAccount,
value: contribution,
gasPrice: 0,
});
await crowdFund.refund({ from: firstTrusteeAccount });
await crowdFund.withdraw(fourthAccount);
const balance = await web3.eth.getBalance(fourthAccount);
const diff = initialBalance.minus(balance);
assert(
balance.equals(initialBalance),
`Expected full refund, but refund was short ${diff.toString()} wei`
);
});
// [END] refund
// [BEGIN] getContributorMilestoneVote
@ -422,4 +442,16 @@ contract("CrowdFund", accounts => {
assert.equal(true, milestoneVote)
});
// [END] getContributorMilestoneVote
// [BEGIN] getContributorContributionAmount
it("returns amount a contributor has contributed", async () => {
const constributionAmount = raiseGoal / 5
await crowdFund.contribute({ from: thirdAccount, value: constributionAmount });
const contractContributionAmount = await crowdFund.getContributorContributionAmount(thirdAccount)
assert.equal(contractContributionAmount.toNumber(), constributionAmount)
});
});

View File

@ -65,9 +65,10 @@ module.exports = {
// Useful for deploying to a public network.
// NB: It's important to wrap the provider as a function.
ropsten: {
provider: function () {return new HDWallet(mnemonic, 'https://ropsten.infura.io/' + infuraKey)},
provider: function () { return new HDWallet(mnemonic, 'https://ropsten.infura.io/' + infuraKey) },
network_id: 3, // Ropsten's id
gas: 5500000, // Ropsten has a lower block limit than mainnet
gasPrice: 20,
confirmations: 2, // # of confs to wait between deployments. (default: 0)
timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50)
skipDryRun: true // Skip dry run before migrations? (default: false for public nets )

View File

@ -1,29 +0,0 @@
{
"presets": ["next/babel", "@zeit/next-typescript/babel"],
"env": {
"development": {
"plugins": ["inline-dotenv"]
},
"production": {
"plugins": ["transform-inline-environment-variables"]
}
},
"plugins": [
["import", { "libraryName": "antd", "style": false }],
[
"module-resolver",
{
"root": ["client"],
"extensions": [".js", ".tsx", ".ts"]
}
],
[
"styled-components",
{
"ssr": true,
"displayName": true,
"preprocess": false
}
]
]
}

View File

@ -1,2 +1,5 @@
# Funds these addresses when `npm run truffle` runs. Comma separated.
FUND_ETH_ADDRESSES=0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520,0xDECAF9CD2367cdbb726E904cD6397eDFcAe6068DEe0460DFa261520,0xDECAF9CD2367cdbb726E904cD6397eDFcAe6068D
FUND_ETH_ADDRESSES=0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520,0xDECAF9CD2367cdbb726E904cD6397eDFcAe6068DEe0460DFa261520,0xDECAF9CD2367cdbb726E904cD6397eDFcAe6068D
# Disable typescript checking for dev building (reduce build time & resource usage)
NO_DEV_TS_CHECK=true

3
frontend/.gitignore vendored
View File

@ -8,4 +8,5 @@ dist
*.log
.env
*.pid
client/lib/contracts
client/lib/contracts
.vscode

1
frontend/.npmignore Normal file
View File

@ -0,0 +1 @@
.gitignore

View File

@ -0,0 +1,11 @@
import { configure } from '@storybook/react';
import '@babel/polyfill'; // fix regeneratorruntime undefined
// automatically import all files ending in *.stories.tsx
const req = require.context('../stories', true, /.stories.tsx$/);
function loadStories() {
req.keys().forEach(filename => req(filename));
}
configure(loadStories, module);

View File

@ -0,0 +1,11 @@
const paths = require('../config/paths');
const { client: clientLoaders } = require('../config/webpack.config.js/loaders');
const { alias } = require('../config/webpack.config.js/resolvers');
module.exports = (baseConfig, env, defaultConfig) => {
const rules = [...baseConfig.module.rules, ...clientLoaders];
baseConfig.module.rules = rules;
baseConfig.resolve.extensions.push('.ts', '.tsx', '.json');
baseConfig.resolve.alias = alias;
return baseConfig;
};

29
frontend/bin/build.js Normal file
View File

@ -0,0 +1,29 @@
const webpack = require('webpack');
const rimraf = require('rimraf');
const webpackConfig = require('../config/webpack.config.js')(
process.env.NODE_ENV || 'production',
);
const paths = require('../config/paths');
const { logMessage } = require('./utils');
const build = async () => {
rimraf.sync(paths.clientBuild);
rimraf.sync(paths.serverBuild);
logMessage('Compiling, please wait...');
const [clientConfig, serverConfig] = webpackConfig;
const multiCompiler = webpack([clientConfig, serverConfig]);
multiCompiler.run((error, stats) => {
if (stats) {
console.log(stats.toString(clientConfig.stats));
}
if (error) {
logMessage('Compile error', error);
console.error(error);
}
});
};
build();

96
frontend/bin/dev.js Normal file
View File

@ -0,0 +1,96 @@
const fs = require('fs');
const webpack = require('webpack');
const nodemon = require('nodemon');
const rimraf = require('rimraf');
const webpackConfig = require('../config/webpack.config.js')(
process.env.NODE_ENV || 'development',
);
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleware');
const express = require('express');
const paths = require('../config/paths');
const truffleUtil = require('./truffle-util');
const { logMessage } = require('./utils');
const app = express();
const WEBPACK_PORT =
process.env.WEBPACK_PORT ||
(!isNaN(Number(process.env.PORT)) ? Number(process.env.PORT) + 1 : 3001);
const start = async () => {
rimraf.sync(paths.clientBuild);
rimraf.sync(paths.serverBuild);
await truffleUtil.ethereumCheck();
const [clientConfig, serverConfig] = webpackConfig;
clientConfig.entry.bundle = [
`webpack-hot-middleware/client?path=http://localhost:${WEBPACK_PORT}/__webpack_hmr`,
...clientConfig.entry.bundle,
];
clientConfig.output.hotUpdateMainFilename = 'updates/[hash].hot-update.json';
clientConfig.output.hotUpdateChunkFilename = 'updates/[id].[hash].hot-update.js';
const publicPath = clientConfig.output.publicPath;
clientConfig.output.publicPath = `http://localhost:${WEBPACK_PORT}${publicPath}`;
serverConfig.output.publicPath = `http://localhost:${WEBPACK_PORT}${publicPath}`;
const multiCompiler = webpack([clientConfig, serverConfig]);
const clientCompiler = multiCompiler.compilers[0];
const serverCompiler = multiCompiler.compilers[1];
serverCompiler.hooks.compile.tap('_', () => logMessage('Server compiling...', 'info'));
clientCompiler.hooks.compile.tap('_', () => logMessage('Client compiling...', 'info'));
const watchOptions = {
ignored: /node_modules/,
stats: clientConfig.stats,
};
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
return next();
});
const devMiddleware = webpackDevMiddleware(multiCompiler, {
publicPath: clientConfig.output.publicPath,
stats: clientConfig.stats,
watchOptions,
});
app.use(devMiddleware);
app.use(webpackHotMiddleware(clientCompiler));
app.use('/static', express.static(paths.clientBuild));
app.listen(WEBPACK_PORT);
// await first build...
await new Promise((res, rej) => devMiddleware.waitUntilValid(() => res()));
const script = nodemon({
script: `${paths.serverBuild}/server.js`,
watch: [paths.serverBuild],
verbose: true,
});
// uncomment to see nodemon details
// script.on('log', x => console.log(`LOG `, x.colour));
script.on('crash', () =>
logMessage(
'Server crashed, will attempt to restart after changes. Waiting...',
'error',
),
);
script.on('restart', () => {
logMessage('Server restarted.', 'warning');
});
script.on('error', () => {
logMessage('An error occured attempting to run the server. Exiting', 'error');
process.exit(1);
});
};
start();

View File

@ -0,0 +1,140 @@
const rimraf = require('rimraf');
const path = require('path');
const fs = require('fs');
const childProcess = require('child_process');
const Web3 = require('web3');
const paths = require('../config/paths');
const truffleConfig = require('../truffle');
const { logMessage } = require('./utils');
require('../config/env');
module.exports = {};
const clean = (module.exports.clean = () => {
rimraf.sync(paths.contractsBuild);
});
const compile = (module.exports.compile = () => {
childProcess.execSync('yarn build', { cwd: paths.contractsBase });
});
const migrate = (module.exports.migrate = () => {
childProcess.execSync('truffle migrate', { cwd: paths.contractsBase });
});
const makeWeb3Conn = () => {
const { host, port } = truffleConfig.networks.development;
return `ws://${host}:${port}`;
};
const createWeb3 = () => {
return new Web3(makeWeb3Conn());
};
const isGanacheUp = (module.exports.isGanacheUp = verbose =>
new Promise((res, rej) => {
verbose && logMessage(`Testing ganache @ ${makeWeb3Conn()}...`, 'info');
// console.log('curProv', web3.eth.currentProvider);
const web3 = createWeb3();
web3.eth.net
.isListening()
.then(() => {
verbose && logMessage('Ganache is UP!', 'info');
res(true);
web3.currentProvider.connection.close();
})
.catch(e => {
logMessage('Ganache appears to be down, unable to connect.', 'error');
res(false);
});
}));
const getGanacheNetworkId = (module.exports.getGanacheNetworkId = () => {
const web3 = createWeb3();
return web3.eth.net
.getId()
.then(id => {
web3.currentProvider.connection.close();
return id;
})
.catch(() => -1);
});
const checkContractsNetworkIds = (module.exports.checkContractsNetworkIds = id =>
new Promise((res, rej) => {
const buildDir = paths.contractsBuild;
fs.readdir(buildDir, (err, names) => {
if (err) {
logMessage(`No contracts build directory @ ${buildDir}`, 'error');
res(false);
} else {
const allHaveId = names.reduce((ok, name) => {
const contract = require(path.join(buildDir, name));
if (Object.keys(contract.networks).length > 0 && !contract.networks[id]) {
const actual = Object.keys(contract.networks).join(', ');
logMessage(`${name} should have networks[${id}], it has ${actual}`, 'error');
return false;
}
return true && ok;
}, true);
res(allHaveId);
}
});
}));
const fundWeb3v1 = (module.exports.fundWeb3v1 = () => {
// Fund ETH accounts
const ethAccounts = process.env.FUND_ETH_ADDRESSES
? process.env.FUND_ETH_ADDRESSES.split(',').map(a => a.trim())
: [];
const web3 = createWeb3();
return web3.eth.getAccounts().then(accts => {
if (ethAccounts.length) {
logMessage('Sending 50% of ETH balance from accounts...', 'info');
const txs = ethAccounts.map((addr, i) => {
return web3.eth
.getBalance(accts[i])
.then(parseInt)
.then(bal => {
const amount = '' + Math.round(bal / 2);
const amountEth = web3.utils.fromWei(amount);
return web3.eth
.sendTransaction({
to: addr,
from: accts[i],
value: amount,
})
.then(() => logMessage(` ${addr} <- ${amountEth} from ${accts[i]}`))
.catch(e =>
logMessage(` Error sending funds to ${addr} : ${e}`, 'error'),
);
});
});
return Promise.all(txs).then(() => web3.currentProvider.connection.close());
} else {
logMessage('No accounts specified for funding in .env file...', 'warning');
}
});
});
module.exports.ethereumCheck = () =>
isGanacheUp(true)
.then(isUp => !isUp && Promise.reject('network down'))
.then(getGanacheNetworkId)
.then(checkContractsNetworkIds)
.then(allHaveId => {
if (!allHaveId) {
logMessage('Contract problems, will compile & migrate.', 'warning');
clean();
logMessage('truffle compile, please wait...', 'info');
compile();
logMessage('truffle migrate, please wait...', 'info');
migrate();
fundWeb3v1();
} else {
logMessage('OK, Contracts have correct network id.', 'info');
}
})
.catch(e => logMessage('WARNING: ethereum setup has a problem: ' + e, 'error'));

27
frontend/bin/utils.js Normal file
View File

@ -0,0 +1,27 @@
const chalk = require('chalk');
const net = require('net');
const logMessage = (message, level = 'info') => {
const colors = { error: 'red', warning: 'yellow', info: 'blue' };
colors[undefined] = 'white';
console.log(`${chalk[colors[level]](message)}`);
};
const isPortTaken = port =>
new Promise((res, rej) => {
const tester = net
.createServer()
.once('error', function(err) {
err.code != 'EADDRINUSE' && rej();
err.code == 'EADDRINUSE' && res(true);
})
.once('listening', () => {
tester.once('close', () => res(false)).close();
})
.listen(port, '127.0.0.1');
});
module.exports = {
logMessage,
isPortTaken,
};

View File

@ -0,0 +1,28 @@
import React from 'react';
import { hot } from 'react-hot-loader';
import { Switch, Route, Redirect } from 'react-router';
import loadable from 'loadable-components';
// wrap components in loadable...import & they will be split
const Home = loadable(() => import('pages/index'));
const Create = loadable(() => import('pages/create'));
const Proposals = loadable(() => import('pages/proposals'));
const Proposal = loadable(() => import('pages/proposal'));
import 'styles/style.less';
class Routes extends React.Component<any> {
render() {
return (
<Switch>
<Route exact path="/" component={Home} />
<Route path="/create" component={Create} />
<Route exact path="/proposals" component={Proposals} />
<Route path="/proposals/:id" component={Proposal} />
<Route path="/*" render={() => <Redirect to="/" />} />
</Switch>
);
}
}
export default hot(module)(Routes);

View File

@ -1,28 +1,34 @@
import axios from './axios';
import { Proposal } from 'modules/proposals/reducers';
import { PROPOSAL_CATEGORY } from './constants';
export function getProposals(): Promise<{ data: Proposal[] }> {
return axios.get('/api/proposals/');
return axios.get('/api/v1/proposals/');
}
export function getProposal(proposalId: number | string): Promise<{ data: Proposal }> {
return axios.get(`/api/proposals/${proposalId}`);
return axios.get(`/api/v1/proposals/${proposalId}`);
}
export function getProposalComments(proposalId: number | string) {
return axios.get(`/api/proposals/${proposalId}/comments`);
return axios.get(`/api/v1/proposals/${proposalId}/comments`);
}
export function getProposalUpdates(proposalId: number | string) {
return axios.get(`/api/proposals/${proposalId}/updates`);
return axios.get(`/api/v1/proposals/${proposalId}/updates`);
}
export function postProposal(payload: {
accountAddress;
crowdFundContractAddress;
content;
title;
milestones;
// TODO type Milestone
accountAddress: string;
crowdFundContractAddress: string;
content: string;
title: string;
category: PROPOSAL_CATEGORY;
milestones: object[];
}) {
return axios.post(`/api/proposals/create`, payload);
return axios.post(`/api/v1/proposals/`, {
...payload,
team: [{ accountAddress: payload.accountAddress }],
});
}

View File

@ -4,7 +4,7 @@ import BasicHead from './BasicHead';
import Header from './Header';
import Footer from './Footer';
interface Props {
export interface Props {
title: string;
isHeaderTransparent?: boolean;
isFullScreen?: boolean;

View File

@ -1,7 +1,5 @@
import React from 'react';
import Head from 'next/head';
import 'styles/style.less';
import { Helmet } from 'react-helmet';
interface Props {
title: string;
@ -12,20 +10,16 @@ export default class BasicHead extends React.Component<Props> {
const { children, title } = this.props;
return (
<div>
<Head>
<title>Grant.io - {title}</title>
{/*TODO - bundle*/}
<Helmet>
<title>{`Grant.io - ${title}`}</title>
<meta name={`${title} page`} content={`${title} page stuff`} />
<link
rel="stylesheet"
href="https://use.fontawesome.com/releases/v5.2.0/css/all.css"
integrity="sha384-hWVjflwFxL6sNzntih27bfxkr27PmbbK/iSvJ+a4+0owXq79v+lsFkW54bOGbiDQ"
crossOrigin="anonymous"
/>
<link rel="stylesheet" href="/_next/static/style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
</Helmet>
{children}
</div>
);

View File

@ -1,43 +1,125 @@
import React from 'react';
import { connect } from 'react-redux';
import moment from 'moment';
import Markdown from 'react-markdown';
import { Comment as IComment } from 'modules/proposals/reducers';
import * as Styled from './styled';
import { Button } from 'antd';
import Markdown from 'components/Markdown';
import Identicon from 'components/Identicon';
import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor';
import { postProposalComment } from 'modules/proposals/actions';
import { Comment as IComment, Proposal } from 'modules/proposals/reducers';
import { AppState } from 'store/reducers';
import './style.less';
interface Props {
interface OwnProps {
comment: IComment;
proposalId: Proposal['proposalId'];
}
export default class Comment extends React.Component<Props> {
interface StateProps {
isPostCommentPending: AppState['proposal']['isPostCommentPending'];
postCommentError: AppState['proposal']['postCommentError'];
}
interface DispatchProps {
postProposalComment: typeof postProposalComment;
}
type Props = OwnProps & StateProps & DispatchProps;
interface State {
reply: string;
isReplying: boolean;
}
class Comment extends React.Component<Props> {
state: State = {
reply: '',
isReplying: false,
};
componentDidUpdate(prevProps: Props) {
// TODO: Come up with better check on if our comment post was a success
const { isPostCommentPending, postCommentError } = this.props;
if (!isPostCommentPending && !postCommentError && prevProps.isPostCommentPending) {
this.setState({ reply: '', isReplying: false });
}
}
public render(): React.ReactNode {
const { comment } = this.props;
const { comment, proposalId } = this.props;
const { isReplying, reply } = this.state;
return (
<Styled.Container>
<Styled.Info>
<Styled.InfoThumb src={comment.author.avatar['120x120']} />
<Styled.InfoName>{comment.author.username}</Styled.InfoName>
<Styled.InfoTime>
{moment(comment.dateCreated * 1000).fromNow()}
</Styled.InfoTime>
</Styled.Info>
<div className="Comment">
<div className="Comment-info">
<div className="Comment-info-thumb">
<Identicon address={comment.author.accountAddress} />
</div>
{/* <div className="Comment-info-thumb" src={comment.author.avatar['120x120']} /> */}
<div className="Comment-info-name">{comment.author.username}</div>
<div className="Comment-info-time">{moment(comment.dateCreated).fromNow()}</div>
</div>
<Styled.Body>
<Markdown source={comment.body} />
</Styled.Body>
<div className="Comment-body">
<Markdown source={comment.body} type={MARKDOWN_TYPE.REDUCED} />
</div>
<Styled.Controls>
<Styled.ControlButton>Reply</Styled.ControlButton>
{/*<Styled.ControlButton>Report</Styled.ControlButton>*/}
</Styled.Controls>
<div className="Comment-controls">
<a className="Comment-controls-button" onClick={this.toggleReply}>
{isReplying ? 'Cancel' : 'Reply'}
</a>
{/*<a className="Comment-controls-button">Report</a>*/}
</div>
{comment.replies && (
<Styled.Replies>
{comment.replies.map(reply => (
<Comment key={reply.commentId} comment={reply} />
{(comment.replies.length || isReplying) && (
<div className="Comment-replies">
{isReplying && (
<div className="Comment-replies-form">
<MarkdownEditor
onChange={this.handleChangeReply}
type={MARKDOWN_TYPE.REDUCED}
/>
<div style={{ marginTop: '0.5rem' }} />
<Button onClick={this.reply} disabled={!reply.length}>
Submit reply
</Button>
</div>
)}
{comment.replies.map(subComment => (
<ConnectedComment
key={subComment.commentId}
comment={subComment}
proposalId={proposalId}
/>
))}
</Styled.Replies>
</div>
)}
</Styled.Container>
</div>
);
}
private toggleReply = () => {
this.setState({ isReplying: !this.state.isReplying });
};
private handleChangeReply = (reply: string) => {
this.setState({ reply });
};
private reply = () => {
const { comment, proposalId } = this.props;
const { reply } = this.state;
this.props.postProposalComment(proposalId, reply, comment.commentId);
};
}
const ConnectedComment = connect<StateProps, DispatchProps, OwnProps, AppState>(
(state: AppState) => ({
isPostCommentPending: state.proposal.isPostCommentPending,
postCommentError: state.proposal.postCommentError,
}),
{
postProposalComment,
},
)(Comment);
export default ConnectedComment;

View File

@ -0,0 +1,74 @@
@info-height: 1.8rem;
.Comment {
position: relative;
margin-bottom: 2rem;
&:last-child {
margin-bottom: 0;
}
&-info {
display: flex;
line-height: @info-height;
margin-bottom: 1rem;
&-thumb {
display: block;
margin-right: 0.5rem;
width: @info-height;
height: @info-height;
img {
width: 100%;
height: 100%;
border-radius: 4px;
}
}
&-name {
font-size: 1.1rem;
margin-right: 0.5rem;
}
&-time {
font-size: 0.8rem;
opacity: 0.5;
}
}
&-body {
font-size: 1rem;
}
&-controls {
display: flex;
margin-left: -0.5rem;
&-button {
font-size: 0.65rem;
opacity: 0.5;
padding: 0 0.5rem;
background: none;
cursor: pointer;
color: #4c4c4c;
&:hover {
opacity: 0.7;
color: inherit;
}
}
}
&-replies {
margin: 1rem 0 1rem 1rem;
padding: 1rem 0rem 1rem 2rem;
border-left: 1px solid rgba(0, 0, 0, 0.12);
&-form {
margin-bottom: 1rem;
max-width: 600px;
width: 100%;
}
}
}

View File

@ -1,65 +0,0 @@
import styled from 'styled-components';
const infoHeight = '1.8rem';
export const Container = styled.div`
position: relative;
margin-bottom: 2rem;
&:last-child {
margin-bottom: 0;
}
`;
export const Info = styled.div`
display: flex;
line-height: ${infoHeight};
margin-bottom: 1rem;
`;
export const InfoThumb = styled.img`
display: block;
margin-right: 0.5rem;
width: ${infoHeight};
height: ${infoHeight};
border-radius: 4px;
`;
export const InfoName = styled.div`
font-size: 1.1rem;
margin-right: 0.5rem;
`;
export const InfoTime = styled.div`
font-size: 0.8rem;
opacity: 0.5;
`;
export const Body = styled.div`
font-size: 1rem;
`;
export const Controls = styled.div`
display: flex;
margin-left: -0.5rem;
`;
export const ControlButton = styled.a`
font-size: 0.65rem;
opacity: 0.5;
padding: 0 0.5rem;
background: none;
cursor: pointer;
color: #4c4c4c;
&:hover {
opacity: 0.7;
color: inherit;
}
`;
export const Replies = styled.div`
margin: 1rem;
padding: 1rem 0rem 1rem 2rem;
border-left: 1px solid rgba(0, 0, 0, 0.12);
`;

View File

@ -1,15 +1,16 @@
import React from 'react';
import { ProposalComments } from 'modules/proposals/reducers';
import { Proposal, ProposalComments } from 'modules/proposals/reducers';
import Comment from 'components/Comment';
interface Props {
comments: ProposalComments['comments'];
proposalId: Proposal['proposalId'];
}
const Comments = ({ comments }: Props) => (
const Comments = ({ comments, proposalId }: Props) => (
<React.Fragment>
{comments.map(c => (
<Comment key={c.commentId} comment={c} />
<Comment key={c.commentId} comment={c} proposalId={proposalId} />
))}
</React.Fragment>
);

View File

@ -0,0 +1,120 @@
import React from 'react';
import { Input, Form, Icon, Select } from 'antd';
import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants';
import { CreateFormState } from 'modules/create/types';
import { getCreateErrors } from 'modules/create/utils';
interface State {
title: string;
brief: string;
category: PROPOSAL_CATEGORY | null;
amountToRaise: string;
}
interface Props {
initialState?: Partial<State>;
updateForm(form: Partial<CreateFormState>): void;
}
export default class CreateFlowBasics extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
title: '',
brief: '',
category: null,
amountToRaise: '',
...(props.initialState || {}),
};
}
handleInputChange = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { value, name } = event.currentTarget;
this.setState({ [name]: value } as any, () => {
this.props.updateForm(this.state);
});
};
handleCategoryChange = (value: PROPOSAL_CATEGORY) => {
this.setState({ category: value }, () => {
this.props.updateForm(this.state);
});
};
render() {
const { title, brief, category, amountToRaise } = this.state;
const errors = getCreateErrors(this.state, true);
return (
<Form layout="vertical" style={{ maxWidth: 600, margin: '0 auto' }}>
<Form.Item
label="Title"
validateStatus={errors.title ? 'error' : undefined}
help={errors.title}
>
<Input
size="large"
name="title"
placeholder="Short and sweet"
type="text"
value={title}
onChange={this.handleInputChange}
/>
</Form.Item>
<Form.Item
label="Brief"
validateStatus={errors.brief ? 'error' : undefined}
help={errors.brief}
>
<Input.TextArea
name="brief"
placeholder="An elevator-pitch version of your proposal, max 140 chars"
value={brief}
onChange={this.handleInputChange}
rows={3}
/>
</Form.Item>
<Form.Item label="Category">
<Select
size="large"
placeholder="Select a category"
value={category || undefined}
onChange={this.handleCategoryChange}
>
{Object.keys(PROPOSAL_CATEGORY).map((c: PROPOSAL_CATEGORY) => (
<Select.Option value={c} key={c}>
<Icon
type={CATEGORY_UI[c].icon}
style={{ color: CATEGORY_UI[c].color }}
/>{' '}
{CATEGORY_UI[c].label}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label="Target amount"
validateStatus={errors.amountToRaise ? 'error' : undefined}
help={
errors.amountToRaise || 'This cannot be changed once your proposal starts'
}
>
<Input
size="large"
name="amountToRaise"
placeholder="1.5"
type="number"
value={amountToRaise}
onChange={this.handleInputChange}
addonAfter="ETH"
/>
</Form.Item>
</Form>
);
}
}

View File

@ -0,0 +1,40 @@
import React from 'react';
import { Form } from 'antd';
import MarkdownEditor from 'components/MarkdownEditor';
import { CreateFormState } from 'modules/create/types';
interface State {
details: string;
}
interface Props {
initialState?: Partial<State>;
updateForm(form: Partial<CreateFormState>): void;
}
export default class CreateFlowTeam extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
details: '',
...(props.initialState || {}),
};
}
render() {
return (
<Form layout="vertical" style={{ maxWidth: 980, margin: '0 auto' }}>
<MarkdownEditor
onChange={this.handleChange}
initialMarkdown={this.state.details}
/>
</Form>
);
}
private handleChange = (markdown: string) => {
this.setState({ details: markdown }, () => {
this.props.updateForm(this.state);
});
};
}

View File

@ -0,0 +1,37 @@
.CreateFinal {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
&-loader {
&-text {
opacity: 0.8;
font-size: 1.2rem;
margin-top: 1rem;
}
}
&-message {
display: flex;
justify-content: center;
align-items: center;
.anticon {
margin-right: 1rem;
font-size: 3.2rem;
}
&.is-error .anticon {
color: #e74c3c;
}
&.is-success .anticon {
color: #2ecc71;
}
&-text {
font-size: 1rem;
text-align: left;
}
}
}

View File

@ -0,0 +1,84 @@
import React from 'react';
import { connect } from 'react-redux';
import { Spin, Icon } from 'antd';
import { Link } from 'react-router-dom';
import { createActions } from 'modules/create';
import { AppState } from 'store/reducers';
import './Final.less';
interface StateProps {
form: AppState['create']['form'];
crowdFundError: AppState['web3']['crowdFundError'];
crowdFundCreatedAddress: AppState['web3']['crowdFundCreatedAddress'];
}
interface DispatchProps {
createProposal: typeof createActions['createProposal'];
resetForm: typeof createActions['resetForm'];
}
type Props = StateProps & DispatchProps;
class CreateFinal extends React.Component<Props> {
componentDidMount() {
this.create();
}
componentDidUpdate(prevProps: Props) {
if (!prevProps.crowdFundCreatedAddress && this.props.crowdFundCreatedAddress) {
this.props.resetForm();
}
}
render() {
const { crowdFundError, crowdFundCreatedAddress } = this.props;
let content;
if (crowdFundError) {
content = (
<div className="CreateFinal-message is-error">
<Icon type="close-circle" />
<div className="CreateFinal-message-text">
Something went wrong during creation: "{crowdFundError}"{' '}
<a onClick={this.create}>Click here</a> to try again.
</div>
</div>
);
} else if (crowdFundCreatedAddress) {
content = (
<div className="CreateFinal-message is-success">
<Icon type="check-circle" />
<div className="CreateFinal-message-text">
Your proposal is now live and on the blockchain!{' '}
<Link to={`/proposals/${crowdFundCreatedAddress}`}>Click here</Link> to check
it out.
</div>
</div>
);
} else {
content = (
<div className="CreateFinal-loader">
<Spin size="large" />
<div className="CreateFinal-loader-text">Deploying contract...</div>
</div>
);
}
return <div className="CreateFinal">{content}</div>;
}
private create = () => {
this.props.createProposal(this.props.form);
};
}
export default connect<StateProps, DispatchProps, {}, AppState>(
(state: AppState) => ({
form: state.create.form,
crowdFundError: state.web3.crowdFundError,
crowdFundCreatedAddress: state.web3.crowdFundCreatedAddress,
}),
{
createProposal: createActions.createProposal,
resetForm: createActions.resetForm,
},
)(CreateFinal);

View File

@ -0,0 +1,212 @@
import React from 'react';
import { Input, Form, Icon, Button, Radio } from 'antd';
import { RadioChangeEvent } from 'antd/lib/radio';
import { CreateFormState } from 'modules/create/types';
import { getCreateErrors } from 'modules/create/utils';
import { ONE_DAY } from 'utils/time';
interface State {
payOutAddress: string;
trustees: string[];
deadline: number;
milestoneDeadline: number;
}
interface Props {
initialState?: Partial<State>;
updateForm(form: Partial<CreateFormState>): void;
}
export default class CreateFlowTeam extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
payOutAddress: '',
trustees: [],
deadline: ONE_DAY * 60,
milestoneDeadline: ONE_DAY * 7,
...(props.initialState || {}),
};
}
render() {
const { payOutAddress, trustees, deadline, milestoneDeadline } = this.state;
const errors = getCreateErrors(this.state, true);
return (
<Form layout="vertical" style={{ maxWidth: 600, margin: '0 auto' }}>
<Form.Item
label="Payout address"
validateStatus={errors.payOutAddress ? 'error' : undefined}
help={errors.payOutAddress}
>
<Input
size="large"
name="payOutAddress"
placeholder="0xe12a34230e5e7fc73d094e52025135e4fbf24653"
type="text"
value={payOutAddress}
onChange={this.handleInputChange}
/>
</Form.Item>
<Form.Item label="Trustee addresses">
<Input
placeholder="Payout address will also become a trustee"
size="large"
type="text"
disabled
value={payOutAddress}
/>
</Form.Item>
{trustees.map((address, idx) => (
<TrusteeFields
key={idx}
value={address}
index={idx}
error={errors.trustees && errors.trustees[idx]}
onChange={this.handleTrusteeChange}
onRemove={this.removeTrustee}
/>
))}
{trustees.length < 9 && (
<Button
type="dashed"
onClick={this.addTrustee}
style={{ margin: '-1rem 0 2rem' }}
>
<Icon type="plus" /> Add another trustee
</Button>
)}
<Form.Item label="Funding Deadline">
<Radio.Group
name="deadline"
value={deadline}
onChange={this.handleRadioChange}
size="large"
style={{ display: 'flex', textAlign: 'center' }}
>
{deadline === 300 && (
<Radio.Button style={{ flex: 1 }} value={300}>
5 minutes
</Radio.Button>
)}
<Radio.Button style={{ flex: 1 }} value={ONE_DAY * 30}>
30 Days
</Radio.Button>
<Radio.Button style={{ flex: 1 }} value={ONE_DAY * 60}>
60 Days
</Radio.Button>
<Radio.Button style={{ flex: 1 }} value={ONE_DAY * 90}>
90 Days
</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label="Milestone Voting Period">
<Radio.Group
name="milestoneDeadline"
value={milestoneDeadline}
onChange={this.handleRadioChange}
size="large"
style={{ display: 'flex', textAlign: 'center' }}
>
{milestoneDeadline === 60 && (
<Radio.Button style={{ flex: 1 }} value={60}>
60 Seconds
</Radio.Button>
)}
<Radio.Button style={{ flex: 1 }} value={ONE_DAY * 3}>
3 Days
</Radio.Button>
<Radio.Button style={{ flex: 1 }} value={ONE_DAY * 7}>
7 Days
</Radio.Button>
<Radio.Button style={{ flex: 1 }} value={ONE_DAY * 10}>
10 Days
</Radio.Button>
</Radio.Group>
</Form.Item>
</Form>
);
}
private handleInputChange = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { value, name } = event.currentTarget;
this.setState({ [name]: value } as any, () => {
this.props.updateForm(this.state);
});
};
private handleRadioChange = (event: RadioChangeEvent) => {
const { value, name } = event.target;
this.setState({ [name]: value } as any, () => {
this.props.updateForm(this.state);
});
};
private handleTrusteeChange = (index: number, value: string) => {
const trustees = [...this.state.trustees];
trustees[index] = value;
this.setState({ trustees }, () => {
this.props.updateForm(this.state);
});
};
private addTrustee = () => {
const trustees = [...this.state.trustees, ''];
this.setState({ trustees });
};
private removeTrustee = (index: number) => {
const trustees = this.state.trustees.filter((_, i) => i !== index);
this.setState({ trustees }, () => {
this.props.updateForm(this.state);
});
};
}
interface TrusteeFieldsProps {
index: number;
value: string;
error: null | false | string;
onChange(index: number, value: string): void;
onRemove(index: number): void;
}
const TrusteeFields = ({
index,
value,
error,
onChange,
onRemove,
}: TrusteeFieldsProps) => (
<Form.Item
validateStatus={error ? 'error' : undefined}
help={error}
style={{ marginTop: '-1rem' }}
>
<div style={{ display: 'flex' }}>
<Input
size="large"
placeholder="0xe12a34230e5e7fc73d094e52025135e4fbf24653"
type="text"
value={value}
onChange={ev => onChange(index, ev.currentTarget.value)}
/>
<button
onClick={() => onRemove(index)}
style={{
paddingLeft: '0.5rem',
fontSize: '1.3rem',
cursor: 'pointer',
}}
>
<Icon type="close-circle-o" />
</button>
</div>
</Form.Item>
);

View File

@ -0,0 +1,202 @@
import React from 'react';
import { Form, Input, DatePicker, Card, Icon, Alert, Checkbox, Button } from 'antd';
import moment from 'moment';
import { CreateFormState, Milestone } from 'modules/create/types';
import { getCreateErrors } from 'modules/create/utils';
interface State {
milestones: Milestone[];
}
interface Props {
initialState: Partial<State>;
updateForm(form: Partial<CreateFormState>): void;
}
const DEFAULT_STATE: State = {
milestones: [
{
title: '',
description: '',
date: '',
payoutPercent: 100,
immediatePayout: false,
},
],
};
export default class CreateFlowMilestones extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
...DEFAULT_STATE,
...(props.initialState || {}),
};
// Don't allow for empty milestones array
if (!this.state.milestones.length) {
this.state = {
...this.state,
milestones: [...DEFAULT_STATE.milestones],
};
}
}
handleMilestoneChange = (index: number, milestone: Milestone) => {
const milestones = [...this.state.milestones];
milestones[index] = milestone;
this.setState({ milestones }, () => {
this.props.updateForm(this.state);
});
};
addMilestone = () => {
const { milestones: oldMilestones } = this.state;
const lastMilestone = oldMilestones[oldMilestones.length - 1];
const halfPayout = lastMilestone.payoutPercent / 2;
const milestones = [
...oldMilestones,
{
...DEFAULT_STATE.milestones[0],
payoutPercent: halfPayout,
},
];
milestones[milestones.length - 2] = {
...lastMilestone,
payoutPercent: halfPayout,
};
this.setState({ milestones });
};
removeMilestone = (index: number) => {
let milestones = this.state.milestones.filter((_, i) => i !== index);
if (milestones.length === 0) {
milestones = [...DEFAULT_STATE.milestones];
}
this.setState({ milestones }, () => {
this.props.updateForm(this.state);
});
};
render() {
const { milestones } = this.state;
const errors = getCreateErrors(this.state, true);
return (
<Form layout="vertical" style={{ maxWidth: 720, margin: '0 auto' }}>
{milestones.map((milestone, idx) => (
<MilestoneFields
key={idx}
milestone={milestone}
index={idx}
error={errors.milestones && errors.milestones[idx]}
onChange={this.handleMilestoneChange}
onRemove={this.removeMilestone}
/>
))}
{milestones.length < 10 && (
<Button type="dashed" onClick={this.addMilestone}>
<Icon type="plus" /> Add another milestone
</Button>
)}
</Form>
);
}
}
interface MilestoneFieldsProps {
index: number;
milestone: Milestone;
error: null | false | string;
onChange(index: number, milestone: Milestone): void;
onRemove(index: number): void;
}
const MilestoneFields = ({
index,
milestone,
error,
onChange,
onRemove,
}: MilestoneFieldsProps) => (
<Card style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', marginBottom: '0.5rem', alignItems: 'center' }}>
<Input
size="large"
placeholder="Title"
type="text"
name="title"
value={milestone.title}
onChange={ev => onChange(index, { ...milestone, title: ev.currentTarget.value })}
/>
<button
onClick={() => onRemove(index)}
style={{
paddingLeft: '0.5rem',
fontSize: '1.5rem',
cursor: 'pointer',
opacity: 0.8,
}}
>
<Icon type="close-circle-o" />
</button>
</div>
<div style={{ marginBottom: '0.5rem' }}>
<Input.TextArea
rows={3}
name="body"
placeholder="Description of the deliverable"
value={milestone.description}
onChange={ev =>
onChange(index, { ...milestone, description: ev.currentTarget.value })
}
/>
</div>
<div style={{ display: 'flex' }}>
<DatePicker.MonthPicker
style={{ flex: 1, marginRight: '0.5rem' }}
placeholder="Expected completion date"
value={milestone.date ? moment(milestone.date) : undefined}
format="MMMM YYYY"
allowClear={false}
onChange={(_, date) => onChange(index, { ...milestone, date })}
/>
<Input
min={1}
max={100}
type="number"
value={milestone.payoutPercent}
onChange={ev =>
onChange(index, {
...milestone,
payoutPercent: parseInt(ev.currentTarget.value, 10) || 0,
})
}
addonAfter="%"
style={{ maxWidth: '120px', width: '100%' }}
/>
{index === 0 && (
<div style={{ display: 'flex', alignItems: 'center', marginLeft: '0.5rem' }}>
<Checkbox
checked={milestone.immediatePayout}
onChange={ev =>
onChange(index, {
...milestone,
immediatePayout: ev.target.checked,
})
}
>
<span style={{ opacity: 0.7 }}>Payout Immediately</span>
</Checkbox>
</div>
)}
</div>
{error && (
<Alert style={{ marginTop: '1rem' }} type="error" message={error} showIcon />
)}
</Card>
);

View File

@ -0,0 +1,41 @@
import React from 'react';
import { connect } from 'react-redux';
import { Alert } from 'antd';
import { ProposalDetail } from 'components/Proposal';
import { AppState } from 'store/reducers';
import { makeProposalPreviewFromForm } from 'modules/create/utils';
interface StateProps {
form: AppState['create']['form'];
}
type Props = StateProps;
class CreateFlowPreview extends React.Component<Props> {
render() {
const { form } = this.props;
const proposal = makeProposalPreviewFromForm(form);
return (
<>
<Alert
style={{ margin: '-1rem 0 2rem', textAlign: 'center' }}
message="This is a preview of your proposal. It has not yet been published."
type="info"
showIcon={false}
banner
/>
<ProposalDetail
account="0x0"
proposalId="preview"
fetchProposal={() => null}
proposal={proposal}
isPreview
/>
</>
);
}
}
export default connect<StateProps, {}, {}, AppState>(state => ({
form: state.create.form,
}))(CreateFlowPreview);

View File

@ -0,0 +1,83 @@
.CreateReview {
&-section {
max-width: 980px;
margin: 0 auto;
}
}
.ReviewField {
display: flex;
flex-direction: row;
&-label {
width: 220px;
padding: 0 1.5rem 1rem 0;
font-size: 1.3rem;
opacity: 0.7;
text-align: right;
&-error {
color: #f5222d;
opacity: 0.8;
font-size: 0.8rem;
}
}
&-content {
flex: 1;
font-size: 1.3rem;
padding: 0 0 1rem 1.5rem;
border-left: 1px solid #ddd;
code {
font-size: 1rem;
}
&-empty {
font-size: 1.3rem;
opacity: 0.3;
letter-spacing: 0.1rem;
}
&-edit {
margin-bottom: 5rem;
padding: 0.5rem 1rem;
font-size: 1rem;
border: 1px solid #3498db;
color: #3498db;
opacity: 0.8;
border-radius: 2px;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
}
.ReviewMilestone {
padding-left: 0.5rem;
padding-bottom: 0.5rem;
transform: translateY(-0.3rem);
&:last-child {
padding-bottom: 0;
}
&-title {
font-size: 1.4rem;
margin: 0 0 0.3rem;
}
&-info {
font-size: 0.8rem;
margin-top: -0.3rem;
margin-bottom: 0.5rem;
opacity: 0.6;
}
&-description {
font-size: 1rem;
}
}

View File

@ -0,0 +1,197 @@
import React from 'react';
import { connect } from 'react-redux';
import { Icon, Timeline } from 'antd';
import moment from 'moment';
import { Milestone } from 'modules/create/types';
import { getCreateErrors, KeyOfForm, FIELD_NAME_MAP } from 'modules/create/utils';
import Markdown from 'components/Markdown';
import { AppState } from 'store/reducers';
import { CREATE_STEP } from './index';
import { CATEGORY_UI } from 'api/constants';
import './Review.less';
interface OwnProps {
setStep(step: CREATE_STEP): void;
}
interface StateProps {
form: AppState['create']['form'];
}
type Props = OwnProps & StateProps;
interface Field {
key: KeyOfForm;
content: React.ReactNode;
error: string | undefined | false;
}
interface Section {
step: CREATE_STEP;
name: string;
fields: Field[];
}
class CreateReview extends React.Component<Props> {
render() {
const { form } = this.props;
const errors = getCreateErrors(this.props.form);
const catUI = CATEGORY_UI[form.category] || ({} as any);
const sections: Section[] = [
{
step: CREATE_STEP.BASICS,
name: 'Basics',
fields: [
{
key: 'title',
content: <h2 style={{ fontSize: '1.6rem', margin: 0 }}>{form.title}</h2>,
error: errors.title,
},
{
key: 'brief',
content: form.brief,
error: errors.brief,
},
{
key: 'category',
content: (
<div style={{ color: catUI.color }}>
<Icon type={catUI.icon} /> {catUI.label}
</div>
),
error: errors.category,
},
{
key: 'amountToRaise',
content: <div style={{ fontSize: '1.2rem' }}>{form.amountToRaise} ETH</div>,
error: errors.amountToRaise,
},
],
},
// {
// step: CREATE_STEP.TEAM,
// name: 'Team',
// fields: [],
// },
{
step: CREATE_STEP.DETAILS,
name: 'Details',
fields: [
{
key: 'details',
content: <Markdown source={form.details} />,
error: errors.details,
},
],
},
{
step: CREATE_STEP.MILESTONES,
name: 'Milestones',
fields: [
{
key: 'milestones',
content: <ReviewMilestones milestones={form.milestones} />,
error: errors.milestones && errors.milestones.join(' '),
},
],
},
{
step: CREATE_STEP.GOVERNANCE,
name: 'Governance',
fields: [
{
key: 'payOutAddress',
content: <code>{form.payOutAddress}</code>,
error: errors.payOutAddress,
},
{
key: 'trustees',
content: form.trustees.map(t => (
<div key={t}>
<code>{t}</code>
</div>
)),
error: errors.trustees && errors.trustees.join(' '),
},
{
key: 'deadline',
content: `${Math.floor(moment.duration(form.deadline * 1000).asDays())} days`,
error: errors.deadline,
},
{
key: 'milestoneDeadline',
content: `${Math.floor(
moment.duration(form.milestoneDeadline * 1000).asDays(),
)} days`,
error: errors.milestoneDeadline,
},
],
},
];
return (
<div className="CreateReview">
{sections.map(s => (
<div className="CreateReview-section">
{s.fields.map(f => (
<div className="ReviewField" key={f.key}>
<div className="ReviewField-label">
{FIELD_NAME_MAP[f.key]}
{f.error && <div className="ReviewField-label-error">{f.error}</div>}
</div>
<div className="ReviewField-content">
{this.isEmpty(form[f.key]) ? (
<div className="ReviewField-content-empty">N/A</div>
) : (
f.content
)}
</div>
</div>
))}
<div className="ReviewField">
<div className="ReviewField-label" />
<div className="ReviewField-content">
<button
className="ReviewField-content-edit"
onClick={() => this.setStep(s.step)}
>
Edit {s.name}
</button>
</div>
</div>
</div>
))}
</div>
);
}
private setStep = (step: CREATE_STEP) => {
this.props.setStep(step);
};
private isEmpty(value: any) {
return !value || value.length === 0;
}
}
export default connect<StateProps, {}, OwnProps, AppState>(state => ({
form: state.create.form,
}))(CreateReview);
const ReviewMilestones = ({ milestones }: { milestones: Milestone[] }) => (
<Timeline>
{milestones.map(m => (
<Timeline.Item>
<div className="ReviewMilestone">
<div className="ReviewMilestone-title">{m.title}</div>
<div className="ReviewMilestone-info">
{moment(m.date).format('MMMM YYYY')}
{' '}
{m.payoutPercent}% of funds
</div>
<div className="ReviewMilestone-description">{m.description}</div>
</div>
</Timeline.Item>
))}
</Timeline>
);

View File

@ -0,0 +1,29 @@
import React from 'react';
import Placeholder from 'components/Placeholder';
import { CreateFormState } from 'modules/create/types';
type State = object;
interface Props {
initialState?: Partial<State>;
updateForm(form: Partial<CreateFormState>): void;
}
export default class CreateFlowTeam extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
...(props.initialState || {}),
};
}
render() {
return (
<Placeholder
style={{ maxWidth: 580, margin: '0 auto' }}
title="Team isnt implemented yet"
subtitle="We dont yet have users built out. Skip this step for now."
/>
);
}
}

View File

@ -0,0 +1,44 @@
import { PROPOSAL_CATEGORY } from 'api/constants';
const createExampleProposal = (payOutAddress: string, trustees: string[]) => {
return {
title: 'Grant.io T-Shirts',
brief: "The most stylish wear, sporting your favorite brand's logo",
category: PROPOSAL_CATEGORY.COMMUNITY,
details:
'![](https://i.imgur.com/aQagS0D.png)\n\nWe all know it, Grant.io is the bee\'s knees. But wouldn\'t it be great if you could show all your friends and family how much you love it? Well that\'s what we\'re here to offer today.\n\n# What We\'re Building\n\nWhy, T-Shirts of course! These beautiful shirts made out of 100% cotton and laser printed for long lasting goodness come from American Apparel. We\'ll be offering them in 4 styles:\n\n* Crew neck (wrinkled)\n* Crew neck (straight)\n* Scoop neck (fitted)\n* V neck (fitted)\n\nShirt sizings will be as follows:\n\n| Size | S | M | L | XL |\n|--------|-----|-----|-----|------|\n| **Width** | 18" | 20" | 22" | 24" |\n| **Length** | 28" | 29" | 30" | 31" |\n\n# Who We Are\n\nWe are the team behind grant.io. In addition to our software engineering experience, we have over 78 years of T-Shirt printing expertise combined. Sometimes I wake up at night and realize I was printing shirts in my dreams. Weird, man.\n\n# Expense Breakdown\n\n* $1,000 - A professional designer will hand-craft each letter on the shirt.\n* $500 - We\'ll get the shirt printed from 5 different factories and choose the best quality one.\n* $3,000 - The full run of prints, with 20 smalls, 20 mediums, and 20 larges.\n* $500 - Pizza. Lots of pizza.\n\n**Total**: $5,000',
amountToRaise: '5',
payOutAddress,
trustees,
milestones: [
{
title: 'Initial Funding',
description:
'This will be used to pay for a professional designer to hand-craft each letter on the shirt.',
date: 'October 2018',
payoutPercent: 30,
immediatePayout: true,
},
{
title: 'Test Prints',
description:
"We'll get test prints from 5 different factories and choose the highest quality shirts. Once we've decided, we'll order a full batch of prints.",
date: 'November 2018',
payoutPercent: 20,
immediatePayout: false,
},
{
title: 'All Shirts Printed',
description:
"All of the shirts have been printed, hooray! They'll be given out at conferences and meetups.",
date: 'December 2018',
payoutPercent: 50,
immediatePayout: false,
},
],
deadline: 300,
milestoneDeadline: 60,
};
};
export default createExampleProposal;

View File

@ -0,0 +1,126 @@
@keyframes draft-notification-popup {
from {
opacity: 0;
transform: translateY(0.5rem);
}
to {
opacity: 0.3;
transform: translateY(0);
}
}
.CreateFlow {
&-header {
max-width: 860px;
padding: 0 1rem;
margin: 1rem auto 3rem;
&-title {
font-size: 2rem;
margin: 3rem auto 0.5rem;
margin-bottom: 0.5rem;
text-align: center;
}
&-subtitle {
font-size: 1.4rem;
margin-bottom: 0;
opacity: 0.7;
text-align: center;
}
}
&-content {
padding-bottom: 2rem;
}
&-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
height: 7rem;
padding: 0 1rem;
background: #fff;
border-top: 1px solid #eee;
z-index: 1000;
&-help {
font-size: 1rem;
margin-right: 1rem;
max-width: 380px;
text-align: right;
}
&-button {
display: block;
height: 4rem;
line-height: 4rem;
width: 100%;
max-width: 12rem;
padding: 0;
margin: 0 0.5rem;
font-size: 1.4rem;
border: 1px solid #999;
color: #777;
background: transparent;
border-radius: 4px;
cursor: pointer;
transition-property: background, color, border-color, opacity;
transition-duration: 100ms;
transition-timing-function: ease;
&.is-primary {
background: #3498db;
color: #FFF;
border: none;
}
&:hover {
opacity: 0.8;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.anticon {
font-size: 1.2rem;
margin-left: 0.2rem;
}
}
&-example {
position: absolute;
bottom: 10px;
right: 10px;
opacity: 0.08;
font-size: 1rem;
&:hover {
opacity: 0.5;
}
}
}
&-draftNotification {
position: fixed;
bottom: 8rem;
right: 1rem;
text-align: right;
font-size: 0.8rem;
opacity: 0.3;
animation: draft-notification-popup 120ms ease 1;
}
&-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}

View File

@ -0,0 +1,354 @@
import React from 'react';
import { connect } from 'react-redux';
import { compose } from 'recompose';
import { Steps, Icon, Spin, Alert } from 'antd';
import qs from 'query-string';
import { withRouter, RouteComponentProps } from 'react-router';
import { debounce } from 'underscore';
import Basics from './Basics';
// import Team from './Team';
import Details from './Details';
import Milestones from './Milestones';
import Governance from './Governance';
import Review from './Review';
import Preview from './Preview';
import Final from './Final';
import createExampleProposal from './example';
import { createActions } from 'modules/create';
import { CreateFormState } from 'modules/create/types';
import { getCreateErrors } from 'modules/create/utils';
import { web3Actions } from 'modules/web3';
import { AppState } from 'store/reducers';
import { Web3RenderProps } from 'lib/Web3Container';
import './index.less';
export enum CREATE_STEP {
BASICS = 'BASICS',
// TEAM = 'TEAM',
DETAILS = 'DETAILS',
MILESTONES = 'MILESTONES',
GOVERNANCE = 'GOVERNANCE',
REVIEW = 'REVIEW',
}
const STEP_ORDER = [
CREATE_STEP.BASICS,
// CREATE_STEP.TEAM,
CREATE_STEP.DETAILS,
CREATE_STEP.MILESTONES,
CREATE_STEP.GOVERNANCE,
CREATE_STEP.REVIEW,
];
interface StepInfo {
short: string;
title: React.ReactNode;
subtitle: React.ReactNode;
help: React.ReactNode;
component: React.ComponentClass<any>;
}
const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = {
[CREATE_STEP.BASICS]: {
short: 'Basics',
title: 'Lets start with the basics',
subtitle: 'Dont worry, you can come back and change things before publishing',
help:
'You dont have to fill out everything at once right now, you can come back later.',
component: Basics,
},
// [CREATE_STEP.TEAM]: {
// short: 'Team',
// title: 'Assemble your team',
// subtitle: 'Let everyone know if youre flying solo, or who youre working with',
// help:
// 'More team members, real names, and linked social accounts adds legitimacy to your proposal',
// component: Team,
// },
[CREATE_STEP.DETAILS]: {
short: 'Details',
title: 'Dive into the details',
subtitle: 'Heres your chance to lay out the full proposal, in all its glory',
help:
'Make sure people know what youre building, why youre qualified, and where the moneys going',
component: Details,
},
[CREATE_STEP.MILESTONES]: {
short: 'Milestones',
title: 'Set up milestones for deliverables',
subtitle: 'Make a timeline of when youll complete tasks, and receive funds',
help:
'Contributors are more willing to fund proposals with funding spread across multiple deadlines',
component: Milestones,
},
[CREATE_STEP.GOVERNANCE]: {
short: 'Governance',
title: 'Choose how you get paid, and whos in control',
subtitle:
'Everything here cannot be changed after publishing, so make sure its right',
help:
'Double check everything! This data powers the smart contract, and is immutable once its deployed.',
component: Governance,
},
[CREATE_STEP.REVIEW]: {
short: 'Review',
title: 'Review your proposal',
subtitle: 'Feel free to edit any field that doesnt look right',
help: 'Youll get a chance to preview your proposal next before you publish it',
component: Review,
},
};
interface OwnProps {
accounts: Web3RenderProps['accounts'];
}
interface StateProps {
form: AppState['create']['form'];
isSavingDraft: AppState['create']['isSavingDraft'];
hasSavedDraft: AppState['create']['hasSavedDraft'];
isFetchingDraft: AppState['create']['isFetchingDraft'];
hasFetchedDraft: AppState['create']['hasFetchedDraft'];
}
interface DispatchProps {
updateForm: typeof createActions['updateForm'];
resetForm: typeof createActions['resetForm'];
fetchDraft: typeof createActions['fetchDraft'];
resetCreateCrowdFund: typeof web3Actions['resetCreateCrowdFund'];
}
type Props = OwnProps & StateProps & DispatchProps & RouteComponentProps<any>;
interface State {
step: CREATE_STEP;
isPreviewing: boolean;
isPublishing: boolean;
isExample: boolean;
}
class CreateFlow extends React.Component<Props, State> {
private historyUnlisten: () => void;
private debouncedUpdateForm: (form: Partial<CreateFormState>) => void;
constructor(props: Props) {
super(props);
const searchValues = qs.parse(props.location.search);
const step =
searchValues.step && CREATE_STEP[searchValues.step]
? (CREATE_STEP[searchValues.step] as CREATE_STEP)
: CREATE_STEP.BASICS;
this.state = {
step,
isPreviewing: false,
isPublishing: false,
isExample: false,
};
this.debouncedUpdateForm = debounce(this.updateForm, 800);
}
componentDidMount() {
this.props.resetCreateCrowdFund();
this.props.fetchDraft();
this.historyUnlisten = this.props.history.listen((location, action) => {
if (action === 'POP') {
const searchValues = qs.parse(location.search);
const urlStep = searchValues.step && searchValues.step.toUpperCase();
if (urlStep && CREATE_STEP[urlStep]) {
this.setStep(urlStep as CREATE_STEP, true);
} else {
this.setStep(CREATE_STEP.BASICS, true);
}
}
});
}
componentWillUnmount() {
if (this.historyUnlisten) {
this.historyUnlisten();
}
}
render() {
const { isFetchingDraft, isSavingDraft, hasFetchedDraft } = this.props;
const { step, isPreviewing, isPublishing } = this.state;
if (isFetchingDraft) {
return (
<div className="CreateFlow-loading">
<Spin size="large" />
</div>
);
}
const info = STEP_INFO[step];
const currentIndex = STEP_ORDER.indexOf(step);
const isLastStep = STEP_ORDER.indexOf(step) === STEP_ORDER.length - 1;
const StepComponent = info.component;
let content;
let showFooter = true;
if (isPublishing) {
content = <Final />;
showFooter = false;
} else if (isPreviewing) {
content = <Preview />;
} else {
content = (
<div className="CreateFlow">
<div className="CreateFlow-header">
<Steps current={currentIndex}>
{STEP_ORDER.slice(0, 4).map(s => (
<Steps.Step
key={s}
title={STEP_INFO[s].short}
onClick={() => this.setStep(s)}
style={{ cursor: 'pointer' }}
/>
))}
</Steps>
{hasFetchedDraft && (
<Alert
style={{ margin: '2rem auto -2rem', maxWidth: '520px' }}
type="success"
closable
message="Welcome back"
description={
<span>
We've restored your state from before. If you want to start over,{' '}
<a onClick={this.props.resetForm}>click here</a>.
</span>
}
/>
)}
<h1 className="CreateFlow-header-title">{info.title}</h1>
<div className="CreateFlow-header-subtitle">{info.subtitle}</div>
</div>
<div className="CreateFlow-content">
<StepComponent
initialState={this.props.form}
updateForm={this.debouncedUpdateForm}
setStep={this.setStep}
/>
</div>
</div>
);
}
return (
<div>
{content}
{showFooter && (
<div className="CreateFlow-footer">
{isLastStep ? (
<>
<button
className="CreateFlow-footer-button"
key="preview"
onClick={this.togglePreview}
>
{isPreviewing ? 'Back to Edit' : 'Preview'}
</button>
<button
className="CreateFlow-footer-button is-primary"
key="publish"
onClick={this.startPublish}
disabled={this.checkFormErrors()}
>
Publish
</button>
</>
) : (
<>
<div className="CreateFlow-footer-help">{info.help}</div>
<button
className="CreateFlow-footer-button"
key="next"
onClick={this.nextStep}
>
Continue <Icon type="right-circle-o" />
</button>
</>
)}
<button className="CreateFlow-footer-example" onClick={this.fillInExample}>
<Icon type="fast-forward" />
</button>
</div>
)}
{isSavingDraft && (
<div className="CreateFlow-draftNotification">Saving draft...</div>
)}
</div>
);
}
private updateForm = (form: Partial<CreateFormState>) => {
this.props.updateForm(form);
};
private setStep = (step: CREATE_STEP, skipHistory?: boolean) => {
this.setState({ step });
if (!skipHistory) {
const { history, location } = this.props;
history.push(`${location.pathname}?step=${step.toLowerCase()}`);
}
};
private nextStep = () => {
const idx = STEP_ORDER.indexOf(this.state.step);
if (idx !== STEP_ORDER.length - 1) {
this.setStep(STEP_ORDER[idx + 1]);
}
};
private togglePreview = () => {
this.setState({ isPreviewing: !this.state.isPreviewing });
};
private startPublish = () => {
this.setState({ isPublishing: true });
};
private checkFormErrors = () => {
const errors = getCreateErrors(this.props.form);
return !!Object.keys(errors).length;
};
private fillInExample = () => {
const { accounts } = this.props;
const [payoutAddress, ...trustees] = accounts;
this.updateForm(createExampleProposal(payoutAddress, trustees || []));
setTimeout(() => {
this.setState({
isExample: true,
step: CREATE_STEP.REVIEW,
});
}, 50);
};
}
const withConnect = connect<StateProps, DispatchProps, OwnProps, AppState>(
(state: AppState) => ({
form: state.create.form,
isSavingDraft: state.create.isSavingDraft,
hasSavedDraft: state.create.hasSavedDraft,
isFetchingDraft: state.create.isFetchingDraft,
hasFetchedDraft: state.create.hasFetchedDraft,
crowdFundLoading: state.web3.crowdFundLoading,
crowdFundError: state.web3.crowdFundError,
crowdFundCreatedAddress: state.web3.crowdFundCreatedAddress,
}),
{
updateForm: createActions.updateForm,
resetForm: createActions.resetForm,
fetchDraft: createActions.fetchDraft,
resetCreateCrowdFund: web3Actions.resetCreateCrowdFund,
},
);
export default compose<Props, OwnProps>(
withRouter,
withConnect,
)(CreateFlow);

View File

@ -1,28 +0,0 @@
import React from 'react';
import Link from 'next/link';
import { Icon } from 'antd';
import * as Styled from './styled';
interface Props {
crowdFundCreatedAddress: string;
}
const CreateSuccess = ({ crowdFundCreatedAddress }: Props) => (
<Styled.Success>
<Styled.SuccessIcon>
<Icon type="check-circle-o" />
</Styled.SuccessIcon>
<Styled.SuccessText>
<h2>Contract was succesfully deployed!</h2>
<div>
Your proposal is now live and on the blockchain!{' '}
<Link href={`/proposals/${crowdFundCreatedAddress}`}>
<a>Click here</a>
</Link>{' '}
to check it out.
</div>
</Styled.SuccessText>
</Styled.Success>
);
export default CreateSuccess;

View File

@ -1,102 +0,0 @@
import { Input, DatePicker, Card, Icon, Alert, Checkbox } from 'antd';
import moment from 'moment';
export interface Milestone {
title: string;
description: string;
date: string;
payoutPercent: number;
immediatePayout: boolean;
}
interface Props {
index: number;
milestone: Milestone;
error: null | false | string;
onChange(index: number, milestone: Milestone): void;
onRemove(index: number): void;
}
const MilestoneFields = ({ index, milestone, error, onChange, onRemove }: Props) => (
<Card style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', marginBottom: '0.5rem', alignItems: 'center' }}>
<Input
size="large"
placeholder="Title"
type="text"
name="title"
value={milestone.title}
onChange={ev => onChange(index, { ...milestone, title: ev.currentTarget.value })}
/>
<button
onClick={() => onRemove(index)}
style={{
paddingLeft: '0.5rem',
fontSize: '1.5rem',
cursor: 'pointer',
opacity: 0.8,
}}
>
<Icon type="close-circle-o" />
</button>
</div>
<div style={{ marginBottom: '0.5rem' }}>
<Input.TextArea
rows={3}
name="body"
placeholder="Description of the deliverable"
value={milestone.description}
onChange={ev =>
onChange(index, { ...milestone, description: ev.currentTarget.value })
}
/>
</div>
<div style={{ display: 'flex' }}>
<DatePicker.MonthPicker
style={{ flex: 1, marginRight: '0.5rem' }}
placeholder="Expected completion date"
value={milestone.date ? moment(milestone.date) : undefined}
format="MMMM YYYY"
allowClear={false}
onChange={(_, date) => onChange(index, { ...milestone, date })}
/>
<Input
min={1}
max={100}
type="number"
value={milestone.payoutPercent}
onChange={ev =>
onChange(index, {
...milestone,
payoutPercent: parseInt(ev.currentTarget.value, 10) || 0,
})
}
addonAfter="%"
style={{ maxWidth: '120px', width: '100%' }}
/>
{index === 0 && (
<div style={{ display: 'flex', alignItems: 'center', marginLeft: '0.5rem' }}>
<Checkbox
checked={milestone.immediatePayout}
onChange={ev =>
onChange(index, {
...milestone,
immediatePayout: ev.target.checked,
})
}
>
<span style={{ opacity: 0.7 }}>Payout Immediately</span>
</Checkbox>
</div>
)}
</div>
{error && (
<Alert style={{ marginTop: '1rem' }} type="error" message={error} showIcon />
)}
</Card>
);
export default MilestoneFields;

View File

@ -1,35 +0,0 @@
import { Input, Form, Icon } from 'antd';
interface Props {
index: number;
value: string;
error: null | false | string;
onChange(index: number, value: string): void;
onRemove(index: number): void;
}
const TrusteeFields = ({ index, value, error, onChange, onRemove }: Props) => (
<Form.Item validateStatus={error ? 'error' : undefined} help={error}>
<div style={{ display: 'flex' }}>
<Input
size="large"
placeholder="0xe12a34230e5e7fc73d094e52025135e4fbf24653"
type="text"
value={value}
onChange={ev => onChange(index, ev.currentTarget.value)}
/>
<button
onClick={() => onRemove(index)}
style={{
paddingLeft: '0.5rem',
fontSize: '1.3rem',
cursor: 'pointer',
}}
>
<Icon type="close-circle-o" />
</button>
</div>
</Form.Item>
);
export default TrusteeFields;

View File

@ -1,528 +0,0 @@
// TODO: Make each section its own page. Reduce size of this component!
import React from 'react';
import Web3Container from 'lib/Web3Container';
import { bindActionCreators, Dispatch } from 'redux';
import { connect } from 'react-redux';
import { compose } from 'recompose';
import { AppState } from 'store/reducers';
import { web3Actions } from 'modules/web3';
import { Button, Input, Form, Alert, Spin, Divider, Icon, Radio, Select } from 'antd';
import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants';
import { RadioChangeEvent } from 'antd/lib/radio';
import TrusteeFields from './TrusteeFields';
import MilestoneFields, { Milestone } from './MilestoneFields';
import CreateSuccess from './CreateSuccess';
import { computePercentage } from 'utils/helpers';
import { getAmountError } from 'utils/validators';
import MarkdownEditor from 'components/MarkdownEditor';
import * as Styled from './styled';
interface Errors {
title?: string;
amountToRaise?: string;
payOutAddress?: string;
trustees?: string[];
milestones?: string[];
}
interface State {
title: string;
proposalBody: string;
category: PROPOSAL_CATEGORY | undefined;
amountToRaise: string;
payOutAddress: string;
trustees: string[];
milestones: Milestone[];
deadline: number | null;
milestoneDeadline: number | null;
}
const DEFAULT_STATE: State = {
title: '',
proposalBody: '',
category: undefined,
amountToRaise: '',
payOutAddress: '',
trustees: [],
milestones: [
{
title: '',
description: '',
date: '',
payoutPercent: 100,
immediatePayout: false,
},
],
deadline: 60 * 60 * 24 * 60,
milestoneDeadline: 60 * 60 * 24 * 7,
};
function milestoneToMilestoneAmount(milestone: Milestone, raiseGoal: number) {
return computePercentage(raiseGoal, milestone.payoutPercent);
}
class CreateProposal extends React.Component<any, State> {
constructor(props: any) {
super(props);
this.state = { ...DEFAULT_STATE };
}
componentWillUpdate() {
if (this.props.crowdFundLoading) {
this.setState({ ...DEFAULT_STATE });
}
}
handleInputChange = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { value, name } = event.currentTarget;
this.setState({ [name]: value } as any);
};
handleProposalBodyChange = (markdown: string) => {
this.setState({ proposalBody: markdown });
};
handleCategoryChange = (value: PROPOSAL_CATEGORY) => {
this.setState({ category: value });
};
handleRadioChange = (event: RadioChangeEvent) => {
const { value, name } = event.target;
this.setState({ [name]: value } as any);
};
handleTrusteeChange = (index: number, value: string) => {
const trustees = [...this.state.trustees];
trustees[index] = value;
this.setState({ trustees });
};
addTrustee = () => {
const trustees = [...this.state.trustees, ''];
this.setState({ trustees });
};
removeTrustee = (index: number) => {
const trustees = this.state.trustees.filter((_, i) => i !== index);
this.setState({ trustees });
};
handleMilestoneChange = (index: number, milestone: Milestone) => {
const milestones = [...this.state.milestones];
milestones[index] = milestone;
this.setState({ milestones });
};
addMilestone = () => {
const { milestones: oldMilestones } = this.state;
const lastMilestone = oldMilestones[oldMilestones.length - 1];
const halfPayout = lastMilestone.payoutPercent / 2;
const milestones = [
...oldMilestones,
{
...DEFAULT_STATE.milestones[0],
payoutPercent: halfPayout,
},
];
milestones[milestones.length - 2] = {
...lastMilestone,
payoutPercent: halfPayout,
};
this.setState({ milestones });
};
removeMilestone = (index: number) => {
let milestones = this.state.milestones.filter((_, i) => i !== index);
if (milestones.length === 0) {
milestones = [...DEFAULT_STATE.milestones];
}
this.setState({ milestones });
};
createCrowdFund = async () => {
const { contract, createCrowdFund, web3 } = this.props;
const {
title,
proposalBody,
amountToRaise,
payOutAddress,
trustees,
deadline,
milestoneDeadline,
milestones,
category,
} = this.state;
const backendData = { content: proposalBody, title, category };
const targetInWei = web3.utils.toWei(String(amountToRaise), 'ether');
const milestoneAmounts = milestones.map(milestone =>
milestoneToMilestoneAmount(milestone, targetInWei),
);
const immediateFirstMilestonePayout = milestones[0].immediatePayout;
const contractData = {
ethAmount: targetInWei,
payOutAddress,
trusteesAddresses: trustees,
milestoneAmounts,
milestones,
durationInMinutes: deadline,
milestoneVotingPeriodInMinutes: milestoneDeadline,
immediateFirstMilestonePayout,
category,
};
createCrowdFund(contract, contractData, backendData);
};
// TODO: Replace me with ant form validation?
getFormErrors = () => {
const { web3 } = this.props;
const { title, amountToRaise, payOutAddress, trustees, milestones } = this.state;
const errors: Errors = {};
// Title
if (title.length > 60) {
errors.title = 'Title can be 60 characters maximum';
}
// Amount to raise
const amountFloat = parseFloat(amountToRaise);
if (amountToRaise && !Number.isNaN(amountFloat)) {
const amountError = getAmountError(amountFloat, 10);
if (amountError) {
errors.amountToRaise = amountError;
}
}
// Payout address
if (payOutAddress && !web3.utils.isAddress(payOutAddress)) {
errors.payOutAddress = 'That doesnt look like a valid address';
}
// Trustees
let didTrusteeError = false;
const trusteeErrors = trustees.map((address, idx) => {
if (!address) {
return '';
}
let err = '';
if (!web3.utils.isAddress(address)) {
err = 'That doesnt look like a valid address';
} else if (trustees.indexOf(address) !== idx) {
err = 'That address is already a trustee';
} else if (payOutAddress === address) {
err = 'That address is already a trustee';
}
didTrusteeError = didTrusteeError || !!err;
return err;
});
if (didTrusteeError) {
errors.trustees = trusteeErrors;
}
// Milestones
let didMilestoneError = false;
let cumulativeMilestonePct = 0;
const milestoneErrors = milestones.map((ms, idx) => {
if (isMilestoneUnfilled(ms)) {
didMilestoneError = true;
return '';
}
let err = '';
if (ms.title.length > 40) {
err = 'Title length can be 40 characters maximum';
} else if (ms.description.length > 200) {
err = 'Description can be 200 characters maximum';
}
// Last one shows percentage errors
cumulativeMilestonePct += ms.payoutPercent;
if (idx === milestones.length - 1 && cumulativeMilestonePct !== 100) {
err = `Payout percentages doesnt add up to 100% (currently ${cumulativeMilestonePct}%)`;
}
didMilestoneError = didMilestoneError || !!err;
return err;
});
if (didMilestoneError) {
errors.milestones = milestoneErrors;
}
return errors;
};
render() {
const { crowdFundLoading, crowdFundError, crowdFundCreatedAddress } = this.props;
const {
title,
category,
proposalBody,
amountToRaise,
payOutAddress,
trustees,
milestones,
deadline,
milestoneDeadline,
} = this.state;
if (crowdFundCreatedAddress) {
return <CreateSuccess crowdFundCreatedAddress={crowdFundCreatedAddress} />;
}
const errors = this.getFormErrors();
const hasErrors = Object.keys(errors).length !== 0;
const isMissingFields =
!title ||
!category ||
!proposalBody ||
!amountToRaise ||
trustees.includes('') ||
!!milestones.find(isMilestoneUnfilled);
const isDisabled = hasErrors || isMissingFields || crowdFundLoading;
return (
<Form layout="vertical">
<Styled.Title>Create a proposal</Styled.Title>
<Styled.HelpText>All fields are required</Styled.HelpText>
<Form.Item
label="Title"
validateStatus={errors.title ? 'error' : undefined}
help={errors.title}
>
<Input
size="large"
name="title"
placeholder="Short and sweet"
type="text"
value={title}
onChange={this.handleInputChange}
/>
</Form.Item>
<Form.Item label="Category">
<Select
size="large"
placeholder="Select a category"
value={category}
onChange={this.handleCategoryChange}
>
{Object.keys(PROPOSAL_CATEGORY).map((c: PROPOSAL_CATEGORY) => (
<Select.Option value={c} key={c}>
<Icon
type={CATEGORY_UI[c].icon}
style={{ color: CATEGORY_UI[c].color }}
/>{' '}
{CATEGORY_UI[c].label}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label="Target amount"
validateStatus={errors.amountToRaise ? 'error' : undefined}
help={errors.amountToRaise}
>
<Input
size="large"
name="amountToRaise"
placeholder="1.5"
type="number"
value={amountToRaise}
onChange={this.handleInputChange}
addonAfter="ETH"
/>
</Form.Item>
<Divider style={{ margin: '3rem 0' }}>Description</Divider>
<Styled.BodyField>
<MarkdownEditor onChange={this.handleProposalBodyChange} />
</Styled.BodyField>
<Divider style={{ margin: '3rem 0' }}>Addresses</Divider>
<Form.Item
label="Payout address"
validateStatus={errors.payOutAddress ? 'error' : undefined}
help={errors.payOutAddress}
>
<Input
size="large"
name="payOutAddress"
placeholder="0xe12a34230e5e7fc73d094e52025135e4fbf24653"
type="text"
value={payOutAddress}
onChange={this.handleInputChange}
/>
</Form.Item>
<Form.Item label="Trustee addresses">
<Input
placeholder="Payout address will also become a trustee"
size="large"
type="text"
disabled
value={payOutAddress}
/>
</Form.Item>
{trustees.map((address, idx) => (
<TrusteeFields
key={idx}
value={address}
index={idx}
error={errors.trustees && errors.trustees[idx]}
onChange={this.handleTrusteeChange}
onRemove={this.removeTrustee}
/>
))}
{trustees.length < 9 && (
<Button type="dashed" onClick={this.addTrustee}>
<Icon type="plus" /> Add another trustee
</Button>
)}
<Divider style={{ margin: '3rem 0' }}>Milestones</Divider>
{milestones.map((milestone, idx) => (
<MilestoneFields
key={idx}
milestone={milestone}
index={idx}
error={errors.milestones && errors.milestones[idx]}
onChange={this.handleMilestoneChange}
onRemove={this.removeMilestone}
/>
))}
{milestones.length < 10 && (
<Button type="dashed" onClick={this.addMilestone}>
<Icon type="plus" /> Add another milestone
</Button>
)}
<Divider style={{ margin: '3rem 0' }}>Deadlines</Divider>
<Form.Item label="Funding Deadline">
<Radio.Group
name="deadline"
value={deadline}
onChange={this.handleRadioChange}
size="large"
style={{ display: 'flex', textAlign: 'center' }}
>
<Radio.Button style={{ flex: 1 }} value={60 * 60 * 24 * 30}>
30 Days
</Radio.Button>
<Radio.Button style={{ flex: 1 }} value={60 * 60 * 24 * 60}>
60 Days
</Radio.Button>
<Radio.Button style={{ flex: 1 }} value={60 * 60 * 24 * 90}>
90 Days
</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label="Milestone Voting Period">
<Radio.Group
name="milestoneDeadline"
value={milestoneDeadline}
onChange={this.handleRadioChange}
size="large"
style={{ display: 'flex', textAlign: 'center' }}
>
<Radio.Button style={{ flex: 1 }} value={60 * 60 * 24 * 3}>
3 Days
</Radio.Button>
<Radio.Button style={{ flex: 1 }} value={60 * 60 * 24 * 7}>
7 Days
</Radio.Button>
<Radio.Button style={{ flex: 1 }} value={60 * 60 * 24 * 10}>
10 Days
</Radio.Button>
</Radio.Group>
</Form.Item>
{crowdFundError && (
<Alert
style={{ marginBottom: '2rem' }}
message="Something went wrong"
description={crowdFundError}
type="error"
showIcon
/>
)}
<Button
onClick={this.createCrowdFund}
size="large"
type="primary"
disabled={isDisabled}
style={{ marginTop: '3rem' }}
block
>
{crowdFundLoading ? <Spin /> : 'Create Proposal'}
</Button>
{isMissingFields && (
<Alert
message="It looks like some fields are still missing. All fields are required."
type="info"
style={{ marginTop: '1rem' }}
showIcon
/>
)}
{!isMissingFields &&
hasErrors && (
<Alert
message="It looks like some fields still have errors. They must be fixed before continuing."
type="error"
style={{ marginTop: '1rem' }}
showIcon
/>
)}
</Form>
);
}
}
function isMilestoneUnfilled(milestone: Milestone) {
return !milestone.title || !milestone.description || !milestone.date;
}
function mapDispatchToProps(dispatch: Dispatch) {
return bindActionCreators(web3Actions, dispatch);
}
function mapStateToProps(state: AppState) {
return {
crowdFundLoading: state.web3.crowdFundLoading,
crowdFundError: state.web3.crowdFundError,
crowdFundCreatedAddress: state.web3.crowdFundCreatedAddress,
};
}
const withConnect = connect(
mapStateToProps,
mapDispatchToProps,
);
const ConnectedCreateProposal = compose(withConnect)(CreateProposal);
export default () => (
<Web3Container
renderLoading={() => <div>Loading Dapp Page...</div>}
render={({ web3, contracts }) => (
<ConnectedCreateProposal contract={contracts[0]} web3={web3} />
)}
/>
);

View File

@ -1,41 +0,0 @@
import styled from 'styled-components';
export const BodyField = styled.div`
margin: 0 -10rem 0;
@media (max-width: 980px) {
margin: 0;
}
`;
export const Title = styled.h1`
border-bottom: 4px solid #ddd;
font-size: 1.6rem;
max-width: 15rem;
margin: 0 auto 0.4rem;
text-align: center;
`;
export const HelpText = styled.p`
text-align: center;
opacity: 0.4;
margin-bottom: 2rem;
font-size: 0.8rem;
`;
export const Success = styled.div`
display: flex;
justify-content: center;
align-items: center;
margin-top: 1rem;
`;
export const SuccessIcon = styled.div`
margin-right: 1rem;
font-size: 4rem;
color: #2ecc71;
`;
export const SuccessText = styled.div`
font-size: 1.05rem;
`;

View File

@ -1,112 +0,0 @@
import React from 'react';
import {
Form,
Select,
InputNumber,
Switch,
Radio,
Slider,
Button,
Upload,
Icon,
Rate,
} from 'antd';
const FormItem = Form.Item;
const Option = Select.Option;
const RadioButton = Radio.Button;
const RadioGroup = Radio.Group;
class Demo extends React.Component {
handleSubmit = e => {
e.preventDefault();
this.props.form.validateFields((err, values) => {
if (!err) {
console.log('Received values of form: ', values);
}
});
};
normFile = e => {
console.log('Upload event:', e);
if (Array.isArray(e)) {
return e;
}
return e && e.fileList;
};
render() {
const { getFieldDecorator } = this.props.form;
const formItemLayout = {
labelCol: { span: 6 },
wrapperCol: { span: 14 },
};
return (
<Form onSubmit={this.handleSubmit}>
<FormItem {...formItemLayout} label="InputNumber">
{getFieldDecorator('input-number', { initialValue: 3 })(
<InputNumber min={1} max={10} />,
)}
<span className="ant-form-text"> machines</span>
</FormItem>
<FormItem {...formItemLayout} label="Switch">
{getFieldDecorator('switch', { valuePropName: 'checked' })(<Switch />)}
</FormItem>
<FormItem {...formItemLayout} label="Slider">
{getFieldDecorator('slider')(
<Slider marks={{ 0: 'A', 20: 'B', 40: 'C', 60: 'D', 80: 'E', 100: 'F' }} />,
)}
</FormItem>
<FormItem {...formItemLayout} label="Radio.Group">
{getFieldDecorator('radio-group')(
<RadioGroup>
<Radio value="a">item 1</Radio>
<Radio value="b">item 2</Radio>
<Radio value="c">item 3</Radio>
</RadioGroup>,
)}
</FormItem>
<FormItem {...formItemLayout} label="Radio.Button">
{getFieldDecorator('radio-button')(
<RadioGroup>
<RadioButton value="a">item 1</RadioButton>
<RadioButton value="b">item 2</RadioButton>
<RadioButton value="c">item 3</RadioButton>
</RadioGroup>,
)}
</FormItem>
<FormItem {...formItemLayout} label="Dragger">
<div className="dropbox">
{getFieldDecorator('dragger', {
valuePropName: 'fileList',
getValueFromEvent: this.normFile,
})(
<Upload.Dragger name="files" action="/upload.do">
<p className="ant-upload-drag-icon">
<Icon type="inbox" />
</p>
<p className="ant-upload-text">
Click or drag file to this area to upload
</p>
<p className="ant-upload-hint">Support for a single or bulk upload.</p>
</Upload.Dragger>,
)}
</div>
</FormItem>
<FormItem wrapperCol={{ span: 12, offset: 6 }}>
<Button type="primary" htmlType="submit">
Submit
</Button>
</FormItem>
</Form>
);
}
}
export default Form.create()(Demo);

View File

@ -1,70 +0,0 @@
import React from 'react';
import ReactMde, { ReactMdeTypes } from 'react-mde';
import Showdown from 'showdown';
import * as Styled from './styled';
import { Input } from 'antd';
import { Row, Col } from 'antd';
import { InputNumber } from 'antd';
import Form from './Form';
export interface AppState {
mdeState: ReactMdeTypes.MdeState;
}
export default class App extends React.Component<{}, AppState> {
converter: Showdown.Converter;
constructor(props: {}) {
super(props);
this.state = {
mdeState: null,
};
this.converter = new Showdown.Converter({
tables: true,
simplifiedAutoLink: true,
});
}
handleValueChange = (mdeState: ReactMdeTypes.MdeState) => {
this.setState({ mdeState });
};
onChange = () => {};
render() {
// https://github.com/andrerpena/react-mde
return (
<div>
<Row gutter={16}>
<Form />
<Col xs={32} sm={28} md={24} lg={20} xl={18}>
<Styled.Header>Create a new proposal! </Styled.Header>
<InputNumber
size="large"
min={1}
max={100000}
defaultValue={3}
onChange={this.onChange}
/>
<Input
size="large"
placeholder="My Awesome Proposal"
onChange={this.onChange}
/>
<ReactMde
onChange={this.handleValueChange}
editorState={this.state.mdeState}
generateMarkdownPreview={markdown =>
Promise.resolve(this.converter.makeHtml(markdown))
}
/>
</Col>
</Row>
</div>
);
}
}

View File

@ -1,5 +0,0 @@
import styled from 'styled-components';
export const Header = styled.h1`
font-size: 1.5rem;
`;

View File

@ -1,16 +1,18 @@
import React from 'react';
import Link from 'next/link';
import * as Styled from './styled';
import { Link } from 'react-router-dom';
import './style.less';
export default () => (
<Styled.Footer>
<Link href="/">
<Styled.Title>Grant.io</Styled.Title>
<footer className="Footer">
<Link className="Footer-title" to="/">
Grant.io
</Link>
{/*<Styled.Links>
<Styled.Link>about</Styled.Link>
<Styled.Link>legal</Styled.Link>
<Styled.Link>privacy policy</Styled.Link>
</Styled.Links>*/}
</Styled.Footer>
{/*
<div className="Footer-links">
<a className="Footer-links-link">about</a>
<a className="Footer-links-link">legal</a>
<a className="Footer-links-link">privacy policy</a>
</div>
*/}
</footer>
);

View File

@ -0,0 +1,43 @@
.Footer {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
color: #fff;
background: #4c4c4c;
height: 140px;
&-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #fff;
transition: transform 100ms ease;
&:hover,
&:focus,
&:active {
transform: translateY(-1px);
color: inherit;
}
}
&-links {
display: flex;
justify-content: center;
align-items: center;
&-link {
font-size: 1rem;
padding: 0 1rem;
color: #fff;
opacity: 0.8;
transition: opacity 100ms ease;
&:hover {
color: inherit;
opacity: 1;
}
}
}
}

View File

@ -1,45 +0,0 @@
import styled from 'styled-components';
export const Footer = styled.footer`
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
color: #fff;
background: #4c4c4c;
height: 140px;
`;
export const Title = styled.a`
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #fff;
transition: transform 100ms ease;
&:hover,
&:focus,
&:active {
transform: translateY(-1px);
color: inherit;
}
`;
export const Links = styled.div`
display: flex;
justify-content: center;
align-items: center;
`;
export const Link = styled.a`
font-size: 1rem;
padding: 0 1rem;
color: #fff;
opacity: 0.8;
transition: opacity 100ms ease;
&:hover {
color: inherit;
opacity: 1;
}
`;

View File

@ -1,7 +1,8 @@
import React from 'react';
import { Icon } from 'antd';
import Link from 'next/link';
import * as Styled from './styled';
import { Link } from 'react-router-dom';
import classnames from 'classnames';
import './style.less';
interface OwnProps {
isTransparent?: boolean;
@ -13,37 +14,32 @@ export default class Header extends React.Component<Props> {
render() {
const { isTransparent } = this.props;
return (
<React.Fragment>
<Styled.Header isTransparent={isTransparent}>
<div style={{ display: 'flex' }}>
<Link href="/proposals">
<Styled.Button>
<Styled.ButtonIcon>
<Icon type="shop" />
</Styled.ButtonIcon>
<Styled.ButtonText>Explore</Styled.ButtonText>
</Styled.Button>
</Link>
</div>
<div
className={classnames({
Header: true,
['is-transparent']: isTransparent,
})}
>
<Link to="/proposals" className="Header-button" style={{ display: 'flex' }}>
<span className="Header-button-icon">
<Icon type="appstore" />
</span>
<span className="Header-button-text">Explore</span>
</Link>
<Link href="/">
<Styled.Title>Grant.io</Styled.Title>
</Link>
<Link className="Header-title" to="/">
Grant.io
</Link>
<React.Fragment>
<Link href="/create">
<Styled.Button style={{ marginLeft: '1.5rem' }}>
<Styled.ButtonIcon>
<Icon type="form" />
</Styled.ButtonIcon>
<Styled.ButtonText>Start a Proposal</Styled.ButtonText>
</Styled.Button>
</Link>
</React.Fragment>
<Link to="/create" className="Header-button">
<span className="Header-button-icon">
<Icon type="form" />
</span>
<span className="Header-button-text">Start a Proposal</span>
</Link>
{!isTransparent && <Styled.AlphaBanner>Alpha</Styled.AlphaBanner>}
</Styled.Header>
</React.Fragment>
{!isTransparent && <div className="Header-alphaBanner">Alpha</div>}
</div>
);
}
}

View File

@ -0,0 +1,109 @@
@header-height: 78px;
@small-query: ~'(max-width: 520px)';
.Header {
top: 0;
left: 0;
right: 0;
height: @header-height;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 3rem;
z-index: 999;
position: relative;
color: #333;
background: #fff;
text-shadow: none;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
&.is-transparent {
position: absolute;
color: #fff;
background: transparent;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
box-shadow: none;
}
&-title {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 2.2rem;
margin: 0;
color: inherit;
letter-spacing: 0.08rem;
font-weight: 500;
transition: transform 100ms ease;
flex-grow: 1;
text-align: center;
&:hover,
&:focus,
&:active {
color: inherit;
transform: translateY(-2px) translate(-50%, -50%);
}
}
&-button {
display: block;
background: none;
padding: 0;
font-size: 1.2rem;
font-weight: 300;
color: inherit;
letter-spacing: 0.05rem;
cursor: pointer;
opacity: 0.8;
transition: transform 100ms ease, opacity 100ms ease;
&:hover,
&:focus,
&:active {
opacity: 1;
transform: translateY(-1px);
color: inherit;
text-decoration-color: transparent;
}
&-text {
font-size: 1.1rem;
@media @small-query {
display: none;
}
}
&-icon {
padding-right: 10px;
@media @small-query {
padding: 0;
font-weight: 400;
font-size: 1.5rem;
}
}
}
&-alphaBanner {
position: absolute;
bottom: 0;
left: 50%;
transform: translate(-50%, 50%);
background: linear-gradient(to right, #8e2de2, #4a00e0);
color: #fff;
width: 80px;
height: 22px;
border-radius: 11px;
line-height: 22px;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.2rem;
font-size: 10px;
font-weight: bold;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}
}

View File

@ -1,107 +0,0 @@
import styled from 'styled-components';
const headerHeight = '78px';
const smallQuery = '520px';
export const Placeholder = styled.div`
height: ${headerHeight};
`;
export const Header = styled.header`
position: ${(p: any) => (p.isTransparent ? 'absolute' : 'relative')};
top: 0;
left: 0;
right: 0;
height: ${headerHeight};
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 3rem;
z-index: 999;
color: ${(p: any) => (p.isTransparent ? '#FFF' : '#333')};
background: ${(p: any) => (p.isTransparent ? 'transparent' : '#FFF')};
text-shadow: ${(p: any) => (p.isTransparent ? '0 2px 4px rgba(0, 0, 0, 0.4)' : 'none')};
box-shadow: ${(p: any) => (p.isTransparent ? 'none' : '0 1px 2px rgba(0, 0, 0, 0.3)')};
`;
export const Title = styled.a`
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 2.2rem;
margin: 0;
color: inherit;
letter-spacing: 0.08rem;
font-weight: 500;
transition: transform 100ms ease;
flex-grow: 1;
text-align: center;
&:hover,
&:focus,
&:active {
color: inherit;
transform: translateY(-2px) translate(-50%, -50%);
}
`;
export const Button = styled.a`
display: block;
background: none;
padding: 0;
font-size: 1.2rem;
font-weight: 300;
color: inherit;
letter-spacing: 0.05rem;
cursor: pointer;
transition: transform 100ms ease;
&:hover,
&:focus,
&:active {
transform: translateY(-1px);
color: inherit;
}
`;
interface ButtonTextProps {
size?: number;
}
export const ButtonText = styled.span`
@media (max-width: ${smallQuery}) {
display: none;
}
font-size: ${(props: ButtonTextProps) => (props.size ? props.size + 'rem' : '1.1rem')};
`;
export const ButtonIcon = styled.span`
padding-right: 10px;
@media (max-width: ${smallQuery}) {
padding: 0;
font-weight: 400;
font-size: 1.5rem;
}
`;
export const AlphaBanner = styled.div`
position: absolute;
bottom: 0;
left: 50%;
transform: translate(-50%, 50%);
background: linear-gradient(to right, #8e2de2, #4a00e0);
color: #fff;
width: 80px;
height: 22px;
border-radius: 11px;
line-height: 22px;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.2rem;
font-size: 10px;
font-weight: bold;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
`;

View File

@ -1,20 +1,23 @@
import React from 'react';
import * as Styled from './styled';
import Link from 'next/link';
import './style.less';
import { Link } from 'react-router-dom';
import { Icon } from 'antd';
import AntWrap from 'components/AntWrap';
import TeamsSvg from 'static/images/intro-teams.svg';
import FundingSvg from 'static/images/intro-funding.svg';
import CommunitySvg from 'static/images/intro-community.svg';
const introBlobs = [
{
image: 'static/images/intro-teams.svg',
Svg: TeamsSvg,
text: 'Developers and teams propose projects for improving the ecosystem',
},
{
image: 'static/images/intro-funding.svg',
Svg: FundingSvg,
text: 'Projects are funded by the community and paid as its built',
},
{
image: 'static/images/intro-community.svg',
Svg: CommunitySvg,
text: 'Open discussion and project updates bring devs and the community together',
},
];
@ -23,39 +26,43 @@ export default class Home extends React.Component {
render() {
return (
<AntWrap title="Home" isHeaderTransparent isFullScreen>
<Styled.Hero>
<Styled.HeroTitle>Community-first project funding</Styled.HeroTitle>
<div className="Home">
<div className="Home-hero">
<h1 className="Home-hero-title">
Decentralized funding for <br /> Blockchain ecosystem improvements
</h1>
<Styled.HeroButtons>
<Link href="/create">
<Styled.HeroButton isPrimary>Propose a Project</Styled.HeroButton>
</Link>
<Link href="/proposals">
<Styled.HeroButton>Explore Projects</Styled.HeroButton>
</Link>
</Styled.HeroButtons>
<div className="Home-hero-buttons">
<Link className="Home-hero-buttons-button is-primary" to="/create">
Propose a Project
</Link>
<Link className="Home-hero-buttons-button" to="/proposals">
Explore Projects
</Link>
</div>
<Styled.HeroScroll>
Learn More
<Icon type="down" />
</Styled.HeroScroll>
</Styled.Hero>
<button className="Home-hero-scroll">
Learn More
<Icon type="down" />
</button>
</div>
<Styled.Intro>
<Styled.IntroText>
Grant.io organizes creators and community members to incentivize ecosystem
improvements
</Styled.IntroText>
<div className="Home-intro">
<h3 className="Home-intro-text">
Grant.io organizes creators and community members to incentivize ecosystem
improvements
</h3>
<Styled.IntroBlobs>
{introBlobs.map((blob, i) => (
<Styled.IntroBlob key={i}>
<img src={blob.image} />
<p>{blob.text}</p>
</Styled.IntroBlob>
))}
</Styled.IntroBlobs>
</Styled.Intro>
<div className="Home-intro-blobs">
{introBlobs.map((blob, i) => (
<div className="Home-intro-blobs-blob" key={i}>
<blob.Svg />
<p>{blob.text}</p>
</div>
))}
</div>
</div>
</div>
</AntWrap>
);
}

View File

@ -0,0 +1,246 @@
@background-image: url('~static/images/earth.jpg');
.Home {
&-hero {
position: relative;
height: 100vh;
min-height: 480px;
padding: 2rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #0d2739;
background-image: @background-image;
background-position: top center;
background-size: cover;
box-shadow: 0 80px 50px -40px rgba(0, 0, 0, 0.4) inset;
@media (max-width: 600px) {
box-shadow: 0 70px 40px -30px rgba(0, 0, 0, 0.2) inset;
}
&-title {
color: #fff;
font-size: 3.4rem;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
letter-spacing: 0.06rem;
text-align: center;
margin-bottom: 2rem;
}
&-buttons {
display: flex;
align-items: center;
justify-content: center;
@media (max-width: 600px) {
flex-direction: column;
width: 100%;
}
&-button {
height: 3.6rem;
line-height: 3.6rem;
width: 16rem;
padding: 0;
margin: 0 10px;
background: linear-gradient(-180deg, #ffffff 0%, #fafafa 98%);
color: #4c4c4c;
text-align: center;
font-size: 1.4rem;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
transition: transform 200ms ease, box-shadow 200ms ease;
&:hover,
&:focus {
transform: translateY(-2px);
color: #4c4c4c;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.25);
}
&:active {
transform: translateY(0px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
}
@media (max-width: 600px) {
width: 100%;
max-width: 250px;
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
&.is-primary {
color: #fff;
background: linear-gradient(-180deg, #3498db 0%, #2c8aca 100%);
&hover,
&:focus {
color: #fff;
}
}
}
}
&-scroll {
position: absolute;
bottom: 15px;
left: 50%;
background: none;
padding: 0;
transform: translateX(-50%);
color: #fff;
text-align: center;
font-size: 1.2rem;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
transition: transform 200ms ease, opacity 200ms ease;
cursor: pointer;
opacity: 0.9;
font-weight: 300;
letter-spacing: 0.2rem;
.anticon {
display: block;
}
&:hover {
opacity: 1;
transform: translateX(-50%) translateY(3px);
}
}
}
&-intro {
text-align: center;
padding: 6rem 2rem;
box-shadow: 0 30px 30px -30px rgba(0, 0, 0, 0.3) inset,
0 -30px 30px -30px rgba(0, 0, 0, 0.3) inset;
&-text {
max-width: 760px;
font-size: 1.7rem;
margin: 0 auto 4rem;
font-weight: normal;
}
&-blobs {
display: flex;
justify-content: space-between;
max-width: 960px;
margin: 0 auto;
@media (max-width: 800px) {
flex-direction: column;
}
&-blob {
margin: 0 1.5rem;
@media (max-width: 800px) {
margin: 0 auto;
margin-bottom: 3rem;
max-width: 320px;
}
svg {
margin-bottom: 1rem;
opacity: 0.75;
height: 100px;
}
p {
font-size: 1rem;
@media (max-width: 800px) {
font-size: 1.4rem;
}
}
}
}
}
// **** Should these unused styles be deleted? ****
// &-features {
// background: #4c4c4c;
// color: #fff;
// padding: 7rem 2rem;
// &-feature {
// display: flex;
// flex-direction: row;
// justify-content: space-between;
// align-items: center;
// max-width: 920px;
// margin: 0 auto 10rem;
// &:nth-child(odd) {
// flex-direction: row-reverse;
// }
// &:last-child {
// margin-bottom: 0;
// }
// @media (max-width: 1000px) {
// flex-direction: column !important;
// margin-bottom: 5rem;
// }
// .image {
// width: 100%;
// max-width: 400px;
// background: #666;
// box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
// &:before {
// content: '';
// display: block;
// padding-top: 60%;
// }
// @media (max-width: 1000px) {
// max-width: 480px;
// margin-bottom: 2rem;
// }
// }
// .info {
// flex: 1;
// max-width: 480px;
// &-title {
// color: inherit;
// font-size: 2.2rem;
// font-weight: normal;
// }
// &-text {
// font-size: 1.1rem;
// }
// }
// }
// }
// &-final {
// background: #fff;
// height: 40vh;
// min-height: 480px;
// padding: 0 2rem;
// display: flex;
// flex-direction: column;
// justify-content: center;
// align-items: center;
// box-shadow: 0 30px 30px -30px rgba(0, 0, 0, 0.3) inset,
// 0 -30px 30px -30px rgba(0, 0, 0, 0.3) inset;
// &-text {
// color: inherit;
// font-size: 3rem;
// text-align: center;
// }
// }
}

View File

@ -1,238 +0,0 @@
import styled from 'styled-components';
import backgroundImage from 'static/images/earth.jpg';
export const Hero = styled.div`
position: relative;
height: 100vh;
min-height: 480px;
padding: 2rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #0D2739;
background-image: url('${backgroundImage}');
background-position: top center;
background-size: cover;
box-shadow: 0 80px 50px -40px rgba(0, 0, 0, 0.4) inset;
@media (max-width: 600px) {
box-shadow: 0 70px 40px -30px rgba(0, 0, 0, 0.2) inset;
}
`;
export const HeroTitle = styled.h1`
color: #fff;
font-size: 3.4rem;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
letter-spacing: 0.06rem;
text-align: center;
margin-bottom: 2rem;
`;
export const HeroButtons = styled.div`
display: flex;
align-items: center;
justify-content: center;
@media (max-width: 600px) {
flex-direction: column;
width: 100%;
}
`;
export const HeroButton = styled.a`
height: 3.6rem;
line-height: 3.6rem;
width: 16rem;
padding: 0;
margin: 0 10px;
background: ${(p: any) =>
p.isPrimary
? 'linear-gradient(-180deg, #3498DB 0%, #2C8ACA 100%)'
: 'linear-gradient(-180deg, #FFFFFF 0%, #FAFAFA 98%)'};
color: ${(p: any) => (p.isPrimary ? '#FFF' : '#4C4C4C')};
text-align: center;
font-size: 1.4rem;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
transition: transform 200ms ease, box-shadow 200ms ease;
&:hover,
&:focus {
transform: translateY(-2px);
color: ${(p: any) => (p.isPrimary ? '#FFF' : '#4C4C4C')};
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.25);
}
&:active {
transform: translateY(0px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
}
@media (max-width: 600px) {
width: 100%;
max-width: 250px;
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
`;
export const HeroScroll = styled.button`
position: absolute;
bottom: 15px;
left: 50%;
background: none;
padding: 0;
transform: translateX(-50%);
color: #fff;
text-align: center;
font-size: 1.2rem;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
transition: transform 200ms ease, opacity 200ms ease;
cursor: pointer;
opacity: 0.9;
font-weight: 300;
letter-spacing: 0.2rem;
.anticon {
display: block;
}
&:hover {
opacity: 1;
transform: translateX(-50%) translateY(3px);
}
`;
export const Intro = styled.div`
text-align: center;
padding: 6rem 2rem;
box-shadow: 0 30px 30px -30px rgba(0, 0, 0, 0.3) inset,
0 -30px 30px -30px rgba(0, 0, 0, 0.3) inset;
`;
export const IntroText = styled.h3`
max-width: 760px;
font-size: 1.7rem;
margin: 0 auto 4rem;
font-weight: normal;
`;
export const IntroBlobs = styled.div`
display: flex;
justify-content: space-between;
max-width: 960px;
margin: 0 auto;
@media (max-width: 800px) {
flex-direction: column;
}
`;
export const IntroBlob = styled.div`
margin: 0 1.5rem;
@media (max-width: 800px) {
margin: 0 auto;
margin-bottom: 3rem;
max-width: 320px;
}
img {
margin-bottom: 1rem;
opacity: 0.75;
height: 100px;
}
p {
font-size: 1rem;
@media (max-width: 800px) {
font-size: 1.4rem;
}
}
`;
export const Features = styled.div`
background: #4c4c4c;
color: #fff;
padding: 7rem 2rem;
`;
export const Feature = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
max-width: 920px;
margin: 0 auto 10rem;
&:nth-child(odd) {
flex-direction: row-reverse;
}
&:last-child {
margin-bottom: 0;
}
@media (max-width: 1000px) {
flex-direction: column !important;
margin-bottom: 5rem;
}
.image {
width: 100%;
max-width: 400px;
background: #666;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
&:before {
content: '';
display: block;
padding-top: 60%;
}
@media (max-width: 1000px) {
max-width: 480px;
margin-bottom: 2rem;
}
}
.info {
flex: 1;
max-width: 480px;
&-title {
color: inherit;
font-size: 2.2rem;
font-weight: normal;
}
&-text {
font-size: 1.1rem;
}
}
`;
export const Final = styled.div`
background: #fff;
height: 40vh;
min-height: 480px;
padding: 0 2rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
box-shadow: 0 30px 30px -30px rgba(0, 0, 0, 0.3) inset,
0 -30px 30px -30px rgba(0, 0, 0, 0.3) inset;
`;
export const FinalText = styled.h4`
color: inherit;
font-size: 3rem;
text-align: center;
`;

View File

@ -0,0 +1,5 @@
@import '~styles/markdown-styles-mixin.less';
.Markdown {
.markdown-styles-mixin();
}

View File

@ -0,0 +1,18 @@
import React from 'react';
import { convert, MARKDOWN_TYPE } from 'utils/markdown';
import './Markdown.less';
interface Props extends React.HTMLAttributes<any> {
source: string;
type?: MARKDOWN_TYPE;
}
export default class Markdown extends React.PureComponent<Props> {
render() {
const { source, type, ...rest } = this.props;
const html = convert(source, type);
return (
<div className="Markdown" {...rest} dangerouslySetInnerHTML={{ __html: html }} />
);
}
}

View File

@ -1,8 +1,40 @@
import React from 'react';
import ReactMde, { ReactMdeTypes, DraftUtil } from 'react-mde';
import Showdown from 'showdown';
import ReactMde, { ReactMdeTypes, ReactMdeCommands, ReactMdeProps } from 'react-mde';
import classnames from 'classnames';
import { convert, MARKDOWN_TYPE } from 'utils/markdown';
import './style.less';
const commands: { [key in MARKDOWN_TYPE]: ReactMdeProps['commands'] } = {
[MARKDOWN_TYPE.FULL]: [
[
ReactMdeCommands.headerCommand,
ReactMdeCommands.boldCommand,
ReactMdeCommands.italicCommand,
ReactMdeCommands.codeCommand,
ReactMdeCommands.strikethroughCommand,
],
[
ReactMdeCommands.linkCommand,
ReactMdeCommands.quoteCommand,
ReactMdeCommands.imageCommand,
],
[ReactMdeCommands.orderedListCommand, ReactMdeCommands.unorderedListCommand],
],
[MARKDOWN_TYPE.REDUCED]: [
[
ReactMdeCommands.boldCommand,
ReactMdeCommands.italicCommand,
ReactMdeCommands.codeCommand,
ReactMdeCommands.strikethroughCommand,
],
[ReactMdeCommands.linkCommand, ReactMdeCommands.quoteCommand],
[ReactMdeCommands.orderedListCommand, ReactMdeCommands.unorderedListCommand],
],
};
interface Props {
type?: MARKDOWN_TYPE;
initialMarkdown?: string;
onChange(markdown: string): void;
}
@ -11,12 +43,14 @@ interface State {
}
export default class MarkdownEditor extends React.PureComponent<Props, State> {
converter: Showdown.Converter;
state: State = {
mdeState: null,
};
constructor(props: Props) {
super(props);
this.state = { mdeState: null };
this.converter = new Showdown.Converter({ simplifiedAutoLink: true });
const mdeState = props.initialMarkdown ? { markdown: props.initialMarkdown } : null;
this.state = { mdeState };
}
handleChange = (mdeState: ReactMdeTypes.MdeState) => {
@ -25,17 +59,28 @@ export default class MarkdownEditor extends React.PureComponent<Props, State> {
};
generatePreview = (md: string) => {
return Promise.resolve(this.converter.makeHtml(md));
return Promise.resolve(convert(md, this.props.type));
};
render() {
const type = this.props.type || MARKDOWN_TYPE.FULL;
return (
<ReactMde
onChange={this.handleChange}
editorState={this.state.mdeState}
generateMarkdownPreview={this.generatePreview}
layout="tabbed"
/>
<div
className={classnames({
MarkdownEditor: true,
['is-reduced']: type === MARKDOWN_TYPE.REDUCED,
})}
>
<ReactMde
onChange={this.handleChange}
editorState={this.state.mdeState}
generateMarkdownPreview={this.generatePreview}
commands={commands[type]}
layout="tabbed"
/>
</div>
);
}
}
export { MARKDOWN_TYPE } from 'utils/markdown';

View File

@ -0,0 +1,39 @@
@import '~styles/markdown-styles-mixin.less';
.MarkdownEditor {
.mde-preview .mde-preview-content {
font-size: 1.1rem;
.markdown-styles-mixin();
}
&.is-reduced {
.mde-preview .mde-preview-content {
font-size: 0.9rem;
}
.react-mde,
.mde-header {
border-color: rgba(0, 0, 0, 0.12);
}
.mde-header {
font-size: 0.8rem;
background: none;
ul.mde-header-group {
padding: 0.25rem 0.1rem;
margin-right: 0.75rem;
}
}
.react-mde-tabbed-layout .mde-tabs .mde-tab {
padding: 0.25rem 0.1rem;
margin-right: 0;
}
.mde-text .public-DraftEditor-content,
.mde-preview {
min-height: 100px;
}
}
}

View File

@ -1,70 +0,0 @@
import React from 'react';
import { Spin, Icon } from 'antd';
import * as Styled from './styled';
interface State {
email: string;
isLoading: boolean;
isSuccess: boolean;
}
export default class NewsletterForm extends React.PureComponent<{}, State> {
state = {
email: '',
isLoading: false,
isSuccess: false,
};
handleChange = (ev: React.FormEvent<HTMLInputElement>) => {
this.setState({ email: ev.currentTarget.value });
};
handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
if (this.state.isLoading || this.state.isSuccess) {
return;
}
this.setState({ isLoading: true });
setTimeout(() => {
this.setState({
isLoading: false,
isSuccess: true,
});
}, 3000);
};
render() {
const { email, isLoading, isSuccess } = this.state;
let buttonText = <span>Submit</span>;
if (isLoading) {
buttonText = (
<Styled.ButtonIcon key="loading">
<Spin indicator={<Icon spin type="loading" style={{ color: '#FFF' }} />} />
</Styled.ButtonIcon>
);
} else if (isSuccess) {
buttonText = (
<Styled.ButtonIcon key="check">
<Icon type="check" style={{ fontSize: 24 }} />
</Styled.ButtonIcon>
);
}
return (
<Styled.Form onSubmit={this.handleSubmit}>
<Styled.Input
value={email}
placeholder="email@example.com"
onChange={this.handleChange}
isSuccess={isSuccess}
/>
<Styled.Button isLoading={isLoading} isSuccess={isSuccess}>
{buttonText}
</Styled.Button>
</Styled.Form>
);
}
}

View File

@ -1,118 +0,0 @@
import styled, { keyframes, css } from 'styled-components';
const smallWidth = '560px';
const inputHeight = '66px';
export const Form = styled.form`
display: flex;
position: relative;
left: -10px;
max-width: 440px;
width: 100%;
margin: 0 auto;
@media (max-width: ${smallWidth}) {
left: auto;
}
`;
export const Input = styled.input`
display: block;
height: ${inputHeight};
width: 100%;
padding: 0 18px;
background: #FFF;
font-size: 1.3rem;
font-weight: 300;
letter-spacing: 0.1rem;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
border: none;
outline: none;
border-radius: 2px;
border: 1px solid rgba(0, 0, 0, 0.05);
color: #333;
transition: border 150ms ease, box-shadow 150ms ease;
&:hover {
border-color: rgba(0, 0, 0, 0.1);
}
&:focus,
&:active {
border-color: #3498DB;
}
${p =>
p.isSuccess &&
css`
&,
&:hover,
&:focus,
&:active {
border-color: #2ecc71;
}
`}
@media (max-width: ${smallWidth}) {
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border-right: none;
}
`;
export const Button = styled.button`
display: block;
position: absolute;
top: 50%;
right: 0;
height: 48px;
padding: 0;
width: ${({ isLoading, isSuccess }: any) =>
isLoading || isSuccess ? '48px' : '100px'};
transform: translateX(50%) translateY(-50%);
background: ${({ isSuccess }: any) => (isSuccess ? '#2ECC71' : '#3498DB')};
color: #fff;
border-radius: ${({ isLoading, isSuccess }: any) =>
isLoading || isSuccess ? '100%' : '2px'};
transition-property: border-radius, background, width, transform;
transition-duration: 250ms;
transition-timing-function: ease;
text-align: center;
cursor: pointer;
outline: none;
text-transform: uppercase;
font-weight: bold;
font-size: 12px;
letter-spacing: 0.2rem;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2);
@media (max-width: ${smallWidth}) {
position: relative;
top: auto;
right: auto;
width: 120px;
height: ${inputHeight};
border-radius: 2px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
transform: none;
transition: none;
}
`;
const iconPop = keyframes`
0%, 20% {
opacity: 0;
transform: scale(0);
}
100% {
opacity: 1;
transform: scale(1);
}
`;
export const ButtonIcon = styled.div`
animation: ${iconPop} 400ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
letter-spacing: normal;
transform-origin: 50%;
`;

View File

@ -0,0 +1,17 @@
import React from 'react';
import './style.less';
interface Props {
title?: React.ReactNode;
subtitle?: React.ReactNode;
style?: React.CSSProperties;
}
const Placeholder: React.SFC<Props> = ({ style = {}, title, subtitle }) => (
<div className="Placeholder" style={style}>
{title && <h3 className="Placeholder-title">{title}</h3>}
{subtitle && <div className="Placeholder-subtitle">{subtitle}</div>}
</div>
);
export default Placeholder;

View File

@ -0,0 +1,26 @@
.Placeholder {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
border: 2px dashed #d9d9d9;
padding: 3rem;
border-radius: 8px;
&-title {
margin-bottom: 0;
color: rgba(0, 0, 0, 0.6);
font-size: 1.6rem;
font-weight: 600;
& + div {
margin-top: 1rem;
}
}
&-subtitle {
color: rgba(0, 0, 0, 0.4);
font-size: 1rem;
}
}

View File

@ -1,49 +0,0 @@
import React from 'react';
import { AppState } from 'store/reducers';
import { authActions } from 'modules/auth';
import { getEmail } from 'modules/auth/selectors';
import { connect } from 'react-redux';
import { compose } from 'recompose';
import { bindActionCreators, Dispatch } from 'redux';
import { Button } from 'antd';
interface StateProps {
email: string | null;
}
interface DispatchProps {
logoutAndRedirect: authActions.TLogoutAndRedirect;
}
type Props = DispatchProps & StateProps;
class Profile extends React.Component<Props> {
render() {
return (
<div>
<Button type="primary" onClick={() => this.props.logoutAndRedirect()}>
Logout
</Button>
<h1>hi profile. {this.props.email}</h1>
</div>
);
}
}
function mapStateToProps(state: AppState) {
return {
email: getEmail(state),
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return bindActionCreators(authActions, dispatch);
}
const withConnect = connect(
mapStateToProps,
mapDispatchToProps,
);
export default compose(withConnect)(Profile);

View File

@ -2,21 +2,23 @@ import React from 'react';
import moment from 'moment';
import { Spin, Form, Input, Button, Icon } from 'antd';
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
import * as Styled from './styled';
import * as ProposalStyled from '../styled';
import './style.less';
import classnames from 'classnames';
import { connect } from 'react-redux';
import { compose } from 'recompose';
import { AppState } from 'store/reducers';
import { web3Actions } from 'modules/web3';
import { withRouter } from 'next/router';
import Web3Container from 'lib/Web3Container';
import { withRouter } from 'react-router';
import Web3Container, { Web3RenderProps } from 'lib/Web3Container';
import ShortAddress from 'components/ShortAddress';
import UnitDisplay from 'components/UnitDisplay';
import { getAmountError } from 'utils/validators';
import { CATEGORY_UI } from 'api/constants';
interface OwnProps {
proposal: ProposalWithCrowdFund;
isPreview?: boolean;
}
interface StateProps {
@ -27,14 +29,18 @@ interface ActionProps {
fundCrowdFund: typeof web3Actions['fundCrowdFund'];
}
type Props = OwnProps & StateProps & ActionProps;
interface Web3Props {
web3: Web3RenderProps['web3'];
}
type Props = OwnProps & StateProps & ActionProps & Web3Props;
interface State {
amountToRaise: string;
amountError: string | null;
}
class CampaignBlock extends React.Component<Props, State> {
export class ProposalCampaignBlock extends React.Component<Props, State> {
constructor(props: any) {
super(props);
this.state = {
@ -52,15 +58,17 @@ class CampaignBlock extends React.Component<Props, State> {
return;
}
const { crowdFund } = this.props.proposal;
const remainingTarget = crowdFund.target - crowdFund.funded;
const { proposal, web3 } = this.props;
const { crowdFund } = proposal;
const remainingTarget = crowdFund.target.sub(crowdFund.funded);
const amount = parseFloat(value);
let amountError = null;
if (Number.isNaN(amount)) {
// They're entering some garbage, theyll work it out
} else {
amountError = getAmountError(amount, remainingTarget);
const remainingEthNum = parseFloat(web3.utils.fromWei(remainingTarget, 'ether'));
amountError = getAmountError(amount, remainingEthNum);
}
this.setState({ amountToRaise: value, amountError });
@ -74,55 +82,68 @@ class CampaignBlock extends React.Component<Props, State> {
};
render() {
const { proposal, sendLoading } = this.props;
const { proposal, sendLoading, web3, isPreview } = this.props;
const { amountToRaise, amountError } = this.state;
const amountFloat = parseFloat(amountToRaise) || 0;
let content;
if (proposal) {
const { crowdFund } = proposal;
const isFundingOver =
crowdFund.isRaiseGoalReached || crowdFund.deadline < Date.now();
const isDisabled = isFundingOver || !!amountError || !amountFloat;
crowdFund.isRaiseGoalReached ||
crowdFund.deadline < Date.now() ||
crowdFund.isFrozen;
const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview;
const remainingEthNum = parseFloat(
web3.utils.fromWei(crowdFund.target.sub(crowdFund.funded), 'ether'),
);
content = (
<React.Fragment>
<Styled.Info>
<Styled.InfoLabel>Started</Styled.InfoLabel>
<Styled.InfoValue>
<div className="ProposalCampaignBlock-info">
<div className="ProposalCampaignBlock-info-label">Started</div>
<div className="ProposalCampaignBlock-info-value">
{moment(proposal.dateCreated * 1000).fromNow()}
</Styled.InfoValue>
</Styled.Info>
<Styled.Info>
<Styled.InfoLabel>Category</Styled.InfoLabel>
<Styled.InfoValue>
</div>
</div>
<div className="ProposalCampaignBlock-info">
<div className="ProposalCampaignBlock-info-label">Category</div>
<div className="ProposalCampaignBlock-info-value">
<Icon
type={CATEGORY_UI[proposal.category].icon}
style={{ color: CATEGORY_UI[proposal.category].color }}
/>{' '}
{CATEGORY_UI[proposal.category].label}
</Styled.InfoValue>
</Styled.Info>
</div>
</div>
{!isFundingOver && (
<Styled.Info>
<Styled.InfoLabel>Deadline</Styled.InfoLabel>
<Styled.InfoValue>{moment(crowdFund.deadline).fromNow()}</Styled.InfoValue>
</Styled.Info>
<div className="ProposalCampaignBlock-info">
<div className="ProposalCampaignBlock-info-label">Deadline</div>
<div className="ProposalCampaignBlock-info-value">
{moment(crowdFund.deadline).fromNow()}
</div>
</div>
)}
<Styled.Info>
<Styled.InfoLabel>Beneficiary</Styled.InfoLabel>
<Styled.InfoValue>
<div className="ProposalCampaignBlock-info">
<div className="ProposalCampaignBlock-info-label">Beneficiary</div>
<div className="ProposalCampaignBlock-info-value">
<ShortAddress address={crowdFund.beneficiary} />
</Styled.InfoValue>
</Styled.Info>
<Styled.Info>
<Styled.InfoLabel>Funding</Styled.InfoLabel>
<Styled.InfoValue>
{crowdFund.funded} / {crowdFund.target} ETH
</Styled.InfoValue>
</Styled.Info>
</div>
</div>
<div className="ProposalCampaignBlock-info">
<div className="ProposalCampaignBlock-info-label">Funding</div>
<div className="ProposalCampaignBlock-info-value">
<UnitDisplay value={crowdFund.funded} /> /{' '}
<UnitDisplay value={crowdFund.target} symbol="ETH" />
</div>
</div>
{isFundingOver ? (
<Styled.FundingOverMessage isSuccess={crowdFund.isRaiseGoalReached}>
<div
className={classnames({
['ProposalCampaignBlock-fundingOver']: true,
['is-success']: crowdFund.isRaiseGoalReached,
})}
>
{crowdFund.isRaiseGoalReached ? (
<>
<Icon type="check-circle-o" />
@ -131,19 +152,20 @@ class CampaignBlock extends React.Component<Props, State> {
) : (
<>
<Icon type="close-circle-o" />
<span>Proposal didnt reach target</span>
<span>Proposal didnt get funded</span>
</>
)}
</Styled.FundingOverMessage>
</div>
) : (
<>
<Styled.Bar>
<Styled.BarInner
<div className="ProposalCampaignBlock-bar">
<div
className="ProposalCampaignBlock-bar-inner"
style={{
width: `${(crowdFund.funded / crowdFund.target) * 100}%`,
width: `${crowdFund.percentFunded}%`,
}}
/>
</Styled.Bar>
</div>
<Form layout="vertical">
<Form.Item
validateStatus={amountError ? 'error' : undefined}
@ -157,10 +179,11 @@ class CampaignBlock extends React.Component<Props, State> {
value={amountToRaise}
placeholder="0.5"
min={0}
max={crowdFund.target - crowdFund.funded}
max={remainingEthNum}
step={0.1}
onChange={this.handleAmountChange}
addonAfter="ETH"
disabled={isPreview}
/>
</Form.Item>
@ -184,10 +207,10 @@ class CampaignBlock extends React.Component<Props, State> {
}
return (
<ProposalStyled.SideBlock>
<ProposalStyled.BlockTitle>Campaign</ProposalStyled.BlockTitle>
<ProposalStyled.Block>{content}</ProposalStyled.Block>
</ProposalStyled.SideBlock>
<div className="ProposalCampaignBlock Proposal-top-side-block">
<h1 className="Proposal-top-main-block-title">Campaign</h1>
<div className="Proposal-top-main-block">{content}</div>
</div>
);
}
}
@ -203,25 +226,21 @@ const withConnect = connect(
{ fundCrowdFund: web3Actions.fundCrowdFund },
);
const ConnectedCampaignBlock = withRouter(compose(withConnect)(CampaignBlock));
const ConnectedProposalCampaignBlock = compose<Props, OwnProps & Web3Props>(
withRouter,
withConnect,
)(ProposalCampaignBlock);
export default (props: OwnProps) => (
<Web3Container
renderLoading={() => (
<ProposalStyled.SideBlock>
<ProposalStyled.BlockTitle>Campaign</ProposalStyled.BlockTitle>
<ProposalStyled.Block>
<div className="ProposalCampaignBlock Proposal-top-side-block">
<h1 className="Proposal-top-main-block-title">Campaign</h1>
<div className="Proposal-top-main-block">
<Spin />
</ProposalStyled.Block>
</ProposalStyled.SideBlock>
)}
render={({ web3, accounts, contracts }) => (
<ConnectedCampaignBlock
web3={web3}
accounts={accounts}
contract={contracts[0]}
{...props}
/>
</div>
</div>
)}
render={({ web3 }) => <ConnectedProposalCampaignBlock {...props} web3={web3} />}
/>
);

View File

@ -0,0 +1,61 @@
.ProposalCampaignBlock {
&-info {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
overflow: hidden;
line-height: 1.7rem;
&-label {
font-size: 0.95rem;
font-weight: 300;
opacity: 0.8;
letter-spacing: 0.05rem;
flex: 0 0 auto;
margin-right: 1.5rem;
}
&-value {
font-size: 1.1rem;
flex-basis: 0;
flex-grow: 1;
overflow: hidden;
text-align: right;
}
}
&-bar {
position: relative;
height: 14px;
background: #eaeaea;
border-radius: 0.5rem;
margin: 1rem 0;
&-inner {
position: absolute;
top: 0;
left: 0;
bottom: 0;
background: #1890ff;
border-radius: 0.5rem;
}
}
&-fundingOver {
display: flex;
justify-content: center;
align-items: center;
margin: 0.5rem -1rem 0;
font-size: 1.15rem;
color: #e74c3c;
.anticon {
font-size: 1.5rem;
margin-right: 0.4rem;
}
&.is-success {
color: #2ecc71;
}
}
}

View File

@ -1,79 +0,0 @@
import styled from 'styled-components';
export const Info = styled.div`
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
overflow: hidden;
`;
export const InfoLabel = styled.div`
font-size: 1.1rem;
font-weight: 300;
opacity: 0.8;
letter-spacing: 0.1rem;
flex: 0 0 auto;
margin-right: 1rem;
`;
export const InfoValue = styled.div`
font-size: 1.1rem;
flex-basis: 0;
flex-grow: 1;
overflow: hidden;
text-align: right;
`;
export const Bar = styled.div`
position: relative;
height: 14px;
background: #eaeaea;
border-radius: 0.5rem;
margin: 1rem 0;
`;
export const BarInner = styled.div`
position: absolute;
top: 0;
left: 0;
bottom: 0;
background: #1890ff;
border-radius: 0.5rem;
`;
export const Button = styled.a`
display: block;
width: 100%;
height: 3rem;
line-height: 3rem;
padding: 0 0.25rem;
font-size: 1.2rem;
text-align: center;
background: #1890ff;
border-radius: 4px;
transition: opacity 100ms ease;
&,
&:hover,
&[disabled] {
color: #fff;
}
&:hover {
opacity: 0.9;
}
`;
export const FundingOverMessage = styled.div`
display: flex;
justify-content: center;
align-items: center;
margin: 0.5rem -1rem 0;
font-size: 1.15rem;
color: ${(p: any) => (p.isSuccess ? '#2ECC71' : '#E74C3C')};
.anticon {
font-size: 1.5rem;
margin-right: 0.4rem;
}
`;

View File

@ -0,0 +1,105 @@
import React from 'react';
import { connect } from 'react-redux';
import { Modal, Alert } from 'antd';
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
import { web3Actions } from 'modules/web3';
import { AppState } from 'store/reducers';
interface OwnProps {
proposal: ProposalWithCrowdFund;
isVisible: boolean;
handleClose(): void;
}
interface StateProps {
isRefundActionPending: AppState['web3']['isRefundActionPending'];
refundActionError: AppState['web3']['refundActionError'];
}
interface DispatchProps {
triggerRefund: typeof web3Actions['triggerRefund'];
}
type Props = StateProps & DispatchProps & OwnProps;
class CancelModal extends React.Component<Props> {
componentDidUpdate() {
if (this.props.proposal.crowdFund.isFrozen) {
this.props.handleClose();
}
}
render() {
const { proposal, isVisible, isRefundActionPending, refundActionError } = this.props;
const hasBeenFunded = proposal.crowdFund.isRaiseGoalReached;
const hasContributors = !!proposal.crowdFund.contributors.length;
const disabled = isRefundActionPending;
return (
<Modal
title={<>Cancel proposal</>}
visible={isVisible}
okText="Confirm"
cancelText="Never mind"
onOk={this.cancelProposal}
onCancel={this.closeModal}
okButtonProps={{ type: 'danger', loading: disabled }}
cancelButtonProps={{ disabled }}
>
{hasBeenFunded ? (
<p>
Are you sure you would like to issue a refund?{' '}
<strong>This cannot be undone</strong>. Once you issue a refund, all
contributors will be able to receive a refund of the remaining proposal
balance.
</p>
) : (
<p>
Are you sure you would like to cancel this proposal?{' '}
<strong>This cannot be undone</strong>. Once you cancel it, all contributors
will be able to receive refunds.
</p>
)}
<p>
Canceled proposals cannot be deleted and will still be viewable by contributors
or anyone with a direct link. However, they will be de-listed everywhere else on
Grant.io.
</p>
{hasContributors && (
<p>
Should you choose to cancel, we highly recommend posting an update to let your
contributors know why youve decided to do so.
</p>
)}
{refundActionError && (
<Alert
type="error"
message={`Failed to ${hasBeenFunded ? 'refund' : 'cancel'} proposal`}
description={refundActionError}
showIcon
/>
)}
</Modal>
);
}
private closeModal = () => {
if (!this.props.isRefundActionPending) {
this.props.handleClose();
}
};
private cancelProposal = () => {
this.props.triggerRefund(this.props.proposal.crowdFundContract);
};
}
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
state => ({
isRefundActionPending: state.web3.isRefundActionPending,
refundActionError: state.web3.refundActionError,
}),
{
triggerRefund: web3Actions.triggerRefund,
},
)(CancelModal);

View File

@ -1,16 +1,18 @@
import React from 'react';
import { connect } from 'react-redux';
import { Spin, Icon } from 'antd';
import { Spin, Button } from 'antd';
import { AppState } from 'store/reducers';
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
import { fetchProposalComments } from 'modules/proposals/actions';
import { fetchProposalComments, postProposalComment } from 'modules/proposals/actions';
import {
getProposalComments,
getIsFetchingComments,
getCommentsError,
} from 'modules/proposals/selectors';
import Comments from 'components/Comments';
import * as Styled from './styled';
import Placeholder from 'components/Placeholder';
import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor';
import './style.less';
interface OwnProps {
proposalId: ProposalWithCrowdFund['proposalId'];
@ -24,11 +26,20 @@ interface StateProps {
interface DispatchProps {
fetchProposalComments: typeof fetchProposalComments;
postProposalComment: typeof postProposalComment;
}
type Props = DispatchProps & OwnProps & StateProps;
class ProposalComments extends React.Component<Props> {
interface State {
comment: string;
}
class ProposalComments extends React.Component<Props, State> {
state: State = {
comment: '',
};
componentDidMount() {
if (this.props.proposalId) {
this.props.fetchProposalComments(this.props.proposalId);
@ -42,7 +53,8 @@ class ProposalComments extends React.Component<Props> {
}
render() {
const { comments, isFetchingComments, commentsError } = this.props;
const { proposalId, comments, isFetchingComments, commentsError } = this.props;
const { comment } = this.state;
let content = null;
if (isFetchingComments) {
@ -56,21 +68,41 @@ class ProposalComments extends React.Component<Props> {
);
} else if (comments) {
if (comments.length) {
content = (
<>
<Comments comments={comments} />
<Styled.ForumButton>
Join the conversation <Icon type="message" />
</Styled.ForumButton>
</>
);
content = <Comments comments={comments} proposalId={proposalId} />;
} else {
content = <h2>No comments have been made yet</h2>;
content = (
<Placeholder
title="No comments have been made yet"
subtitle="Why not be the first?"
/>
);
}
}
return content;
return (
<>
<div className="ProposalComments-post">
<MarkdownEditor
onChange={this.handleCommentChange}
type={MARKDOWN_TYPE.REDUCED}
/>
<div style={{ marginTop: '0.5rem' }} />
<Button onClick={this.postComment} disabled={!comment.length}>
Submit comment
</Button>
</div>
{content}
</>
);
}
private handleCommentChange = (comment: string) => {
this.setState({ comment });
};
private postComment = () => {
this.props.postProposalComment(this.props.proposalId, this.state.comment);
};
}
export default connect(
@ -81,5 +113,6 @@ export default connect(
}),
{
fetchProposalComments,
postProposalComment,
},
)(ProposalComments);

View File

@ -0,0 +1,6 @@
.ProposalComments {
&-post {
margin-bottom: 2rem;
max-width: 780px;
}
}

View File

@ -1,15 +0,0 @@
import styled from 'styled-components';
export const ForumButton = styled.a`
display: block;
max-width: 320px;
margin: 4rem auto 2rem;
border: 1px solid #4a90e2;
color: #4a90e2;
font-size: 1rem;
height: 3rem;
line-height: 3rem;
padding: 0 1rem;
text-align: center;
border-radius: 4px;
`;

Some files were not shown because too many files have changed in this diff Show More