commit
43f5737c33
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
|
@ -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)
|
|
@ -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)
|
|
@ -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",
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
from . import views
|
||||
from . import models
|
||||
from . import commands
|
||||
|
|
|
@ -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()
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
from . import views
|
||||
from . import models
|
||||
from . import commands
|
|
@ -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)
|
|
@ -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)
|
|
@ -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 ###
|
|
@ -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
|
|
@ -1 +0,0 @@
|
|||
"""Tests for the app."""
|
|
@ -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()
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
"""Sample test for CI"""
|
||||
|
||||
def test_runs():
|
||||
assert True
|
|
@ -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"])
|
|
@ -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
|
@ -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");
|
||||
_;
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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)
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
@ -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 )
|
||||
|
|
|
@ -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
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
|
@ -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
|
|
@ -8,4 +8,5 @@ dist
|
|||
*.log
|
||||
.env
|
||||
*.pid
|
||||
client/lib/contracts
|
||||
client/lib/contracts
|
||||
.vscode
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
.gitignore
|
|
@ -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);
|
|
@ -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;
|
||||
};
|
|
@ -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();
|
|
@ -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();
|
|
@ -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'));
|
|
@ -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,
|
||||
};
|
|
@ -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);
|
|
@ -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 }],
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
`;
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
|
@ -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 isn’t implemented yet"
|
||||
subtitle="We don’t yet have users built out. Skip this step for now."
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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%);
|
||||
}
|
||||
}
|
|
@ -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: 'Let’s start with the basics',
|
||||
subtitle: 'Don’t worry, you can come back and change things before publishing',
|
||||
help:
|
||||
'You don’t 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 you’re flying solo, or who you’re 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: 'Here’s your chance to lay out the full proposal, in all its glory',
|
||||
help:
|
||||
'Make sure people know what you’re building, why you’re qualified, and where the money’s going',
|
||||
component: Details,
|
||||
},
|
||||
[CREATE_STEP.MILESTONES]: {
|
||||
short: 'Milestones',
|
||||
title: 'Set up milestones for deliverables',
|
||||
subtitle: 'Make a timeline of when you’ll 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 who’s in control',
|
||||
subtitle:
|
||||
'Everything here cannot be changed after publishing, so make sure it’s right',
|
||||
help:
|
||||
'Double check everything! This data powers the smart contract, and is immutable once it’s deployed.',
|
||||
component: Governance,
|
||||
},
|
||||
[CREATE_STEP.REVIEW]: {
|
||||
short: 'Review',
|
||||
title: 'Review your proposal',
|
||||
subtitle: 'Feel free to edit any field that doesn’t look right',
|
||||
help: 'You’ll 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);
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 doesn’t 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 doesn’t 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 doesn’t 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} />
|
||||
)}
|
||||
/>
|
||||
);
|
|
@ -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;
|
||||
`;
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import styled from 'styled-components';
|
||||
|
||||
export const Header = styled.h1`
|
||||
font-size: 1.5rem;
|
||||
`;
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
`;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
`;
|
|
@ -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 it’s 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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
// }
|
||||
// }
|
||||
}
|
|
@ -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;
|
||||
`;
|
|
@ -0,0 +1,5 @@
|
|||
@import '~styles/markdown-styles-mixin.less';
|
||||
|
||||
.Markdown {
|
||||
.markdown-styles-mixin();
|
||||
}
|
|
@ -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 }} />
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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%;
|
||||
`;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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, they’ll 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 didn’t reach target</span>
|
||||
<span>Proposal didn’t 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} />}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
`;
|
|
@ -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 you’ve 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);
|
|
@ -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);
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
.ProposalComments {
|
||||
&-post {
|
||||
margin-bottom: 2rem;
|
||||
max-width: 780px;
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue