commit
a455f7d9b9
|
@ -108,7 +108,8 @@ def make_proposal():
|
|||
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()
|
||||
user = User.query.filter(
|
||||
(User.account_address == account_address) | (User.email_address == email_address)).first()
|
||||
if not user:
|
||||
user = User(
|
||||
account_address=account_address,
|
||||
|
@ -130,7 +131,6 @@ def make_proposal():
|
|||
sm = SocialMedia(social_media_link=social_media.get("link"), user_id=user.id)
|
||||
db.session.add(sm)
|
||||
|
||||
|
||||
proposal.team.append(user)
|
||||
|
||||
for each_milestone in milestones:
|
||||
|
|
|
@ -57,21 +57,45 @@ class UserSchema(ma.Schema):
|
|||
class Meta:
|
||||
model = User
|
||||
# Fields to expose
|
||||
fields = ("account_address", "userid", "title", "email_address", "display_name", "title")
|
||||
fields = (
|
||||
"account_address",
|
||||
"title",
|
||||
"email_address",
|
||||
"social_medias",
|
||||
"avatar",
|
||||
"display_name",
|
||||
"userid"
|
||||
|
||||
)
|
||||
|
||||
social_medias = ma.Nested("SocialMediaSchema", many=True)
|
||||
avatar = ma.Nested("AvatarSchema")
|
||||
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)
|
||||
|
||||
|
||||
class SocialMediaSchema(ma.Schema):
|
||||
class Meta:
|
||||
model = SocialMedia
|
||||
# Fields to expose
|
||||
fields = ("social_media_link",)
|
||||
|
||||
social_media_schema = SocialMediaSchema()
|
||||
social_media_schemas = SocialMediaSchema(many=True)
|
||||
|
||||
|
||||
class AvatarSchema(ma.Schema):
|
||||
class Meta:
|
||||
model = SocialMedia
|
||||
# Fields to expose
|
||||
fields = ("image_url",)
|
||||
|
||||
|
||||
avatar_schema = AvatarSchema()
|
||||
avatar_schemas = AvatarSchema(many=True)
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
from flask import Blueprint, request
|
||||
|
||||
from .models import User, users_schema
|
||||
from ..proposal.models import Proposal, proposal_team
|
||||
from grant import JSONResponse
|
||||
from .models import User, users_schema, user_schema, db
|
||||
from ..proposal.models import Proposal, proposal_team
|
||||
|
||||
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
|
||||
|
||||
|
@ -18,3 +18,46 @@ def get_users():
|
|||
.filter(proposal_team.c.proposal_id == proposal.id).all()
|
||||
result = users_schema.dump(users)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@blueprint.route("/<user_identity>", methods=["GET"])
|
||||
def get_user(user_identity):
|
||||
user = User.query.filter(
|
||||
(User.account_address == user_identity) | (User.email_address == user_identity)).first()
|
||||
if user:
|
||||
result = user_schema.dump(user)
|
||||
return JSONResponse(result)
|
||||
else:
|
||||
return JSONResponse(
|
||||
message="User with account_address or user_identity matching {} not found".format(user_identity),
|
||||
_statusCode=404)
|
||||
|
||||
@blueprint.route("/", methods=["POST"])
|
||||
def create_user():
|
||||
incoming = request.get_json()
|
||||
account_address = incoming["accountAddress"]
|
||||
email_address = incoming["emailAddress"]
|
||||
display_name = incoming["displayName"]
|
||||
title = incoming["title"]
|
||||
|
||||
# TODO: Move create and validation stuff into User model
|
||||
existing_user = User.query.filter(
|
||||
(User.account_address == account_address) | (User.email_address == email_address)).first()
|
||||
if existing_user:
|
||||
return JSONResponse(
|
||||
message="User with that address or email already exists",
|
||||
_statusCode=400)
|
||||
|
||||
# TODO: Handle avatar & social stuff too
|
||||
user = User(
|
||||
account_address=account_address,
|
||||
email_address=email_address,
|
||||
display_name=display_name,
|
||||
title=title
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
db.session.commit()
|
||||
|
||||
result = user_schema.dump(user)
|
||||
return JSONResponse(result)
|
|
@ -3,6 +3,7 @@ import json
|
|||
import random
|
||||
|
||||
from grant.proposal.models import CATEGORIES
|
||||
from grant.proposal.models import Proposal
|
||||
from grant.user.models import User
|
||||
from ..config import BaseTestConfig
|
||||
|
||||
|
@ -75,6 +76,62 @@ class TestAPI(BaseTestConfig):
|
|||
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_associate_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"])
|
||||
proposal_db = Proposal.query.filter_by(
|
||||
proposal_id=proposal["crowdFundContractAddress"]
|
||||
).first()
|
||||
self.assertEqual(proposal_db.team[0].id, user_db.id)
|
||||
|
||||
def test_associate_user_via_proposal_by_email_when_user_already_exists(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"])
|
||||
proposal_db = Proposal.query.filter_by(
|
||||
proposal_id=proposal["crowdFundContractAddress"]
|
||||
).first()
|
||||
self.assertEqual(proposal_db.team[0].id, user_db.id)
|
||||
|
||||
new_proposal_by_email = copy.deepcopy(proposal)
|
||||
new_proposal_by_email["crowdFundContractAddress"] = "0x2222"
|
||||
del new_proposal_by_email["team"][0]["accountAddress"]
|
||||
|
||||
self.app.post(
|
||||
"/api/v1/proposals/",
|
||||
data=json.dumps(new_proposal_by_email),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
user_db = User.query.filter_by(email_address=new_proposal_by_email["team"][0]["emailAddress"]).first()
|
||||
self.assertEqual(user_db.display_name, new_proposal_by_email["team"][0]["displayName"])
|
||||
self.assertEqual(user_db.title, new_proposal_by_email["team"][0]["title"])
|
||||
proposal_db = Proposal.query.filter_by(
|
||||
proposal_id=proposal["crowdFundContractAddress"]
|
||||
).first()
|
||||
self.assertEqual(proposal_db.team[0].id, user_db.id)
|
||||
|
||||
def test_get_all_users(self):
|
||||
self.app.post(
|
||||
"/api/v1/proposals/",
|
||||
|
@ -105,4 +162,22 @@ class TestAPI(BaseTestConfig):
|
|||
)
|
||||
|
||||
users_json = users_get_resp.json
|
||||
self.assertEqual(users_json[0]["avatar"]["imageUrl"], team[0]["avatar"]["link"])
|
||||
self.assertEqual(users_json[0]["socialMedias"][0]["socialMediaLink"], team[0]["socialMedias"][0]["link"])
|
||||
self.assertEqual(users_json[0]["displayName"], team[0]["displayName"])
|
||||
|
||||
def test_get_single_user(self):
|
||||
self.app.post(
|
||||
"/api/v1/proposals/",
|
||||
data=json.dumps(proposal),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
users_get_resp = self.app.get(
|
||||
"/api/v1/users/{}".format(proposal["team"][0]["emailAddress"])
|
||||
)
|
||||
|
||||
users_json = users_get_resp.json
|
||||
self.assertEqual(users_json["avatar"]["imageUrl"], team[0]["avatar"]["link"])
|
||||
self.assertEqual(users_json["socialMedias"][0]["socialMediaLink"], team[0]["socialMedias"][0]["link"])
|
||||
self.assertEqual(users_json["displayName"], team[0]["displayName"])
|
||||
|
|
|
@ -165,7 +165,7 @@ contract CrowdFund {
|
|||
contributors[msg.sender].milestoneNoVotes[index] = vote;
|
||||
if (!vote) {
|
||||
milestones[index].amountVotingAgainstPayout = milestones[index].amountVotingAgainstPayout.sub(contributors[msg.sender].contributionAmount);
|
||||
} else if (vote) {
|
||||
} else {
|
||||
milestones[index].amountVotingAgainstPayout = milestones[index].amountVotingAgainstPayout.add(contributors[msg.sender].contributionAmount);
|
||||
}
|
||||
}
|
||||
|
@ -195,8 +195,7 @@ contract CrowdFund {
|
|||
contributors[msg.sender].refundVote = vote;
|
||||
if (!vote) {
|
||||
amountVotingForRefund = amountVotingForRefund.sub(contributors[msg.sender].contributionAmount);
|
||||
}
|
||||
else if (vote) {
|
||||
} else {
|
||||
amountVotingForRefund = amountVotingForRefund.add(contributors[msg.sender].contributionAmount);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,28 +1,179 @@
|
|||
import React from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { Switch, Route, Redirect } from 'react-router';
|
||||
import {
|
||||
Switch,
|
||||
Route,
|
||||
RouteProps,
|
||||
RouteComponentProps,
|
||||
withRouter,
|
||||
matchPath,
|
||||
} from 'react-router';
|
||||
import loadable from 'loadable-components';
|
||||
import AuthRoute from 'components/AuthRoute';
|
||||
import Template, { TemplateProps } from 'components/Template';
|
||||
|
||||
// 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'));
|
||||
const Auth = loadable(() => import('pages/auth'));
|
||||
const SignOut = loadable(() => import('pages/sign-out'));
|
||||
const Profile = loadable(() => import('pages/profile'));
|
||||
const Settings = loadable(() => import('pages/settings'));
|
||||
const Exception = loadable(() => import('pages/exception'));
|
||||
|
||||
import 'styles/style.less';
|
||||
|
||||
class Routes extends React.Component<any> {
|
||||
interface RouteConfig extends RouteProps {
|
||||
route: RouteProps;
|
||||
template: TemplateProps;
|
||||
requiresWeb3?: boolean;
|
||||
onlyLoggedIn?: boolean;
|
||||
onlyLoggedOut?: boolean;
|
||||
}
|
||||
|
||||
const routeConfigs: RouteConfig[] = [
|
||||
{
|
||||
// Homepage
|
||||
route: {
|
||||
path: '/',
|
||||
component: Home,
|
||||
exact: true,
|
||||
},
|
||||
template: {
|
||||
title: 'Home',
|
||||
isHeaderTransparent: true,
|
||||
isFullScreen: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Create proposal
|
||||
route: {
|
||||
path: '/create',
|
||||
component: Create,
|
||||
},
|
||||
template: {
|
||||
title: 'Create a Proposal',
|
||||
isFullScreen: true,
|
||||
hideFooter: true,
|
||||
requiresWeb3: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Browse proposals
|
||||
route: {
|
||||
path: '/proposals',
|
||||
component: Proposals,
|
||||
exact: true,
|
||||
},
|
||||
template: {
|
||||
title: 'Browse proposals',
|
||||
requiresWeb3: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Proposal detail page
|
||||
route: {
|
||||
path: '/proposals/:id',
|
||||
component: Proposal,
|
||||
},
|
||||
template: {
|
||||
title: 'Proposal',
|
||||
requiresWeb3: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Self profile
|
||||
route: {
|
||||
path: '/profile',
|
||||
component: Profile,
|
||||
exact: true,
|
||||
},
|
||||
template: {
|
||||
title: 'Profile',
|
||||
},
|
||||
onlyLoggedIn: true,
|
||||
},
|
||||
{
|
||||
// Settings page
|
||||
route: {
|
||||
path: '/profile/settings',
|
||||
component: Settings,
|
||||
exact: true,
|
||||
},
|
||||
template: {
|
||||
title: 'Settings',
|
||||
},
|
||||
onlyLoggedIn: true,
|
||||
},
|
||||
{
|
||||
// User profile
|
||||
route: {
|
||||
path: '/profile/:id',
|
||||
component: Profile,
|
||||
},
|
||||
template: {
|
||||
title: 'Profile',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Sign in / sign up
|
||||
route: {
|
||||
path: '/auth',
|
||||
component: Auth,
|
||||
exact: true,
|
||||
},
|
||||
template: {
|
||||
title: 'Sign in',
|
||||
},
|
||||
onlyLoggedOut: true,
|
||||
},
|
||||
{
|
||||
// Sign out
|
||||
route: {
|
||||
path: '/auth/sign-out',
|
||||
component: SignOut,
|
||||
exact: true,
|
||||
},
|
||||
template: {
|
||||
title: 'Signed out',
|
||||
},
|
||||
},
|
||||
{
|
||||
// 404
|
||||
route: {
|
||||
path: '/*',
|
||||
render: () => <Exception type="404" />,
|
||||
},
|
||||
template: {
|
||||
title: 'Page not found',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
type Props = RouteComponentProps<any>;
|
||||
|
||||
class Routes extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const { pathname } = this.props.location;
|
||||
const currentRoute = routeConfigs.find(config => !!matchPath(pathname, config.route));
|
||||
const routeComponents = routeConfigs.map(config => {
|
||||
const { route, onlyLoggedIn, onlyLoggedOut } = config;
|
||||
if (onlyLoggedIn || onlyLoggedOut) {
|
||||
return <AuthRoute key={route.path} onlyLoggedOut={onlyLoggedOut} {...route} />;
|
||||
} else {
|
||||
return <Route key={route.path} {...route} />;
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
<Template {...currentRoute.template}>
|
||||
<Switch>{routeComponents}</Switch>
|
||||
</Template>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default hot(module)(Routes);
|
||||
const RouterAwareRoutes = withRouter(Routes);
|
||||
export default hot(module)(RouterAwareRoutes);
|
||||
|
|
|
@ -1,13 +1,23 @@
|
|||
import axios from './axios';
|
||||
import { Proposal } from 'modules/proposals/reducers';
|
||||
import { Proposal, TeamMember } from 'types';
|
||||
import { formatTeamMemberForPost, formatTeamMemberFromGet } from 'utils/api';
|
||||
import { PROPOSAL_CATEGORY } from './constants';
|
||||
|
||||
export function getProposals(): Promise<{ data: Proposal[] }> {
|
||||
return axios.get('/api/v1/proposals/');
|
||||
return axios.get('/api/v1/proposals/').then(res => {
|
||||
res.data = res.data.map((proposal: any) => {
|
||||
proposal.team = proposal.team.map(formatTeamMemberFromGet);
|
||||
return proposal;
|
||||
});
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
export function getProposal(proposalId: number | string): Promise<{ data: Proposal }> {
|
||||
return axios.get(`/api/v1/proposals/${proposalId}`);
|
||||
return axios.get(`/api/v1/proposals/${proposalId}`).then(res => {
|
||||
res.data.team = res.data.team.map(formatTeamMemberFromGet);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
export function getProposalComments(proposalId: number | string) {
|
||||
|
@ -26,9 +36,31 @@ export function postProposal(payload: {
|
|||
title: string;
|
||||
category: PROPOSAL_CATEGORY;
|
||||
milestones: object[];
|
||||
team: TeamMember[];
|
||||
}) {
|
||||
return axios.post(`/api/v1/proposals/`, {
|
||||
...payload,
|
||||
team: [{ accountAddress: payload.accountAddress }],
|
||||
// Team has a different shape for POST
|
||||
team: payload.team.map(formatTeamMemberForPost),
|
||||
});
|
||||
}
|
||||
|
||||
export function getUser(address: string): Promise<{ data: TeamMember }> {
|
||||
return axios.get(`/api/v1/users/${address}`).then(res => {
|
||||
res.data = formatTeamMemberFromGet(res.data);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
export function createUser(payload: {
|
||||
accountAddress: string;
|
||||
emailAddress: string;
|
||||
displayName: string;
|
||||
title: string;
|
||||
token: string;
|
||||
}): Promise<{ data: TeamMember }> {
|
||||
return axios.post(`/api/v1/users/`, payload).then(res => {
|
||||
res.data = formatTeamMemberFromGet(res.data);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import ShortAddress from 'components/ShortAddress';
|
||||
import Identicon from 'components/Identicon';
|
||||
import './style.less';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
secondary?: React.ReactNode;
|
||||
}
|
||||
|
||||
const AddressRow = ({ address, secondary }: Props) => (
|
||||
<div className="AddressRow">
|
||||
<div className="AddressRow-avatar">
|
||||
<Identicon address={address} />
|
||||
</div>
|
||||
<div className="AddressRow-info">
|
||||
<div className="AddressRow-info-main">
|
||||
<ShortAddress address={address} />
|
||||
</div>
|
||||
{secondary && <p className="AddressRow-info-secondary">{secondary}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AddressRow;
|
|
@ -0,0 +1,47 @@
|
|||
@height: 3rem;
|
||||
|
||||
.AddressRow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: @height;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
display: block;
|
||||
height: @height;
|
||||
width: @height;
|
||||
margin-right: 0.75rem;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&-main {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.1rem;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&-secondary {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Layout, Breadcrumb } from 'antd';
|
||||
import BasicHead from './BasicHead';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
isHeaderTransparent?: boolean;
|
||||
isFullScreen?: boolean;
|
||||
hideFooter?: boolean;
|
||||
withBreadcrumb?: boolean | null;
|
||||
centerContent?: boolean;
|
||||
}
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
class AntWrap extends React.Component<Props> {
|
||||
render() {
|
||||
const {
|
||||
children,
|
||||
withBreadcrumb,
|
||||
title,
|
||||
isHeaderTransparent,
|
||||
isFullScreen,
|
||||
hideFooter,
|
||||
centerContent,
|
||||
} = this.props;
|
||||
return (
|
||||
<BasicHead title={title}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<Header isTransparent={isHeaderTransparent} />
|
||||
<Content
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
padding: isFullScreen ? '0' : '0 2.5rem',
|
||||
}}
|
||||
>
|
||||
{withBreadcrumb && (
|
||||
<Breadcrumb style={{ margin: '16px 0' }}>
|
||||
<Breadcrumb.Item>Home</Breadcrumb.Item>
|
||||
<Breadcrumb.Item>List</Breadcrumb.Item>
|
||||
<Breadcrumb.Item>App</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
paddingTop: isFullScreen ? 0 : 50,
|
||||
paddingBottom: isFullScreen ? 0 : 50,
|
||||
minHeight: 280,
|
||||
alignSelf: centerContent ? 'center' : 'initial',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Content>
|
||||
{!hideFooter && <Footer />}
|
||||
</div>
|
||||
</BasicHead>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default AntWrap;
|
|
@ -0,0 +1,14 @@
|
|||
.ProvideIdentity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
&-back {
|
||||
margin-top: 2rem;
|
||||
opacity: 0.7;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import loadable from 'loadable-components';
|
||||
import { AUTH_PROVIDER } from 'utils/auth';
|
||||
import './ProvideIdentity.less';
|
||||
|
||||
const AddressProvider = loadable(() => import('./providers/Address'));
|
||||
const LedgerProvider = loadable(() => import('./providers/Ledger'));
|
||||
const TrezorProvider = loadable(() => import('./providers/Trezor'));
|
||||
const Web3Provider = loadable(() => import('./providers/Web3'));
|
||||
|
||||
const PROVIDER_COMPONENTS = {
|
||||
[AUTH_PROVIDER.ADDRESS]: AddressProvider,
|
||||
[AUTH_PROVIDER.LEDGER]: LedgerProvider,
|
||||
[AUTH_PROVIDER.TREZOR]: TrezorProvider,
|
||||
[AUTH_PROVIDER.WEB3]: Web3Provider,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
provider: AUTH_PROVIDER;
|
||||
onSelectAddress(addr: string): void;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export default (props: Props) => {
|
||||
const ProviderComponent = PROVIDER_COMPONENTS[props.provider];
|
||||
return (
|
||||
<div className="ProvideIdentity">
|
||||
<ProviderComponent onSelectAddress={props.onSelectAddress} />
|
||||
<p className="ProvideIdentity-back">
|
||||
Want to use a different method? <a onClick={props.reset}>Click here</a>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
.SelectProvider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
&-provider {
|
||||
display: flex;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
height: 4rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid rgba(#000, 0.15);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid rgba(#000, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import { AUTH_PROVIDER, AUTH_PROVIDERS } from 'utils/auth';
|
||||
import './SelectProvider.less';
|
||||
|
||||
interface Props {
|
||||
onSelect(provider: AUTH_PROVIDER): void;
|
||||
}
|
||||
|
||||
export default class SelectProvider extends React.PureComponent<Props> {
|
||||
render() {
|
||||
return (
|
||||
<div className="SelectProvider">
|
||||
{Object.values(AUTH_PROVIDERS).map(provider => (
|
||||
<button
|
||||
key={provider.type}
|
||||
className="SelectProvider-provider"
|
||||
onClick={() => this.props.onSelect(provider.type)}
|
||||
>
|
||||
Connect with {provider.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
.SignIn {
|
||||
&-container {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 2px rgba(#000, 0.2);
|
||||
}
|
||||
|
||||
&-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
&-identicon {
|
||||
border-radius: 100%;
|
||||
width: 3.6rem;
|
||||
height: 3.6rem;
|
||||
margin-right: 0.75rem;
|
||||
box-shadow: 0 1px 2px rgba(#000, 0.3);
|
||||
}
|
||||
|
||||
&-info {
|
||||
width: 0;
|
||||
flex: 1 1 auto;
|
||||
|
||||
&-name {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
&-address {
|
||||
font-size: 0.8rem;
|
||||
// Bug: <code /> doesn't seem to like opacity, so apply to children
|
||||
> * {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-back {
|
||||
margin-top: 2rem;
|
||||
opacity: 0.7;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Button } from 'antd';
|
||||
import { authActions } from 'modules/auth';
|
||||
import { TeamMember } from 'types';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { AUTH_PROVIDER } from 'utils/auth';
|
||||
import Identicon from 'components/Identicon';
|
||||
import ShortAddress from 'components/ShortAddress';
|
||||
import './SignIn.less';
|
||||
|
||||
interface StateProps {
|
||||
isAuthingUser: AppState['auth']['isAuthingUser'];
|
||||
authUserError: AppState['auth']['authUserError'];
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
authUser: typeof authActions['authUser'];
|
||||
}
|
||||
|
||||
interface OwnProps {
|
||||
// TODO: Use common use User type instead
|
||||
user: TeamMember;
|
||||
provider: AUTH_PROVIDER;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
type Props = StateProps & DispatchProps & OwnProps;
|
||||
|
||||
class SignIn extends React.Component<Props> {
|
||||
render() {
|
||||
const { user } = this.props;
|
||||
return (
|
||||
<div className="SignIn">
|
||||
<div className="SignIn-container">
|
||||
<div className="SignIn-identity">
|
||||
<Identicon address={user.ethAddress} className="SignIn-identity-identicon" />
|
||||
<div className="SignIn-identity-info">
|
||||
<div className="SignIn-identity-info-name">{user.name}</div>
|
||||
<code className="SignIn-identity-info-address">
|
||||
<ShortAddress address={user.ethAddress} />
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="primary" size="large" block onClick={this.authUser}>
|
||||
Prove identity
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="SignIn-back">
|
||||
Want to use a different identity? <a onClick={this.props.reset}>Click here</a>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private authUser = () => {
|
||||
this.props.authUser(this.props.user.ethAddress);
|
||||
};
|
||||
}
|
||||
|
||||
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||
state => ({
|
||||
isAuthingUser: state.auth.isAuthingUser,
|
||||
authUserError: state.auth.authUserError,
|
||||
}),
|
||||
{
|
||||
authUser: authActions.authUser,
|
||||
},
|
||||
)(SignIn);
|
|
@ -0,0 +1,47 @@
|
|||
.SignUp {
|
||||
&-container {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 2px rgba(#000, 0.2);
|
||||
}
|
||||
|
||||
&-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&-identicon {
|
||||
border-radius: 100%;
|
||||
width: 3.6rem;
|
||||
height: 3.6rem;
|
||||
margin-right: 0.75rem;
|
||||
box-shadow: 0 1px 2px rgba(#000, 0.3);
|
||||
}
|
||||
|
||||
&-address {
|
||||
width: 0;
|
||||
flex: 1 1 auto;
|
||||
font-size: 1rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&-form {
|
||||
&-item {
|
||||
margin-bottom: 0.4rem;
|
||||
|
||||
.ant-form-item-label {
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-back {
|
||||
margin-top: 2rem;
|
||||
opacity: 0.7;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Form, Input, Button, Alert } from 'antd';
|
||||
import Identicon from 'components/Identicon';
|
||||
import ShortAddress from 'components/ShortAddress';
|
||||
import { AUTH_PROVIDER } from 'utils/auth';
|
||||
import { authActions } from 'modules/auth';
|
||||
import { AppState } from 'store/reducers';
|
||||
import './SignUp.less';
|
||||
|
||||
interface StateProps {
|
||||
isCreatingUser: AppState['auth']['isCreatingUser'];
|
||||
createUserError: AppState['auth']['createUserError'];
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
createUser: typeof authActions['createUser'];
|
||||
}
|
||||
|
||||
interface OwnProps {
|
||||
address: string;
|
||||
provider: AUTH_PROVIDER;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
type Props = StateProps & DispatchProps & OwnProps;
|
||||
|
||||
interface State {
|
||||
name: string;
|
||||
title: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
class SignUp extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
name: '',
|
||||
title: '',
|
||||
email: '',
|
||||
};
|
||||
|
||||
render() {
|
||||
const { address, isCreatingUser, createUserError } = this.props;
|
||||
const { name, title, email } = this.state;
|
||||
|
||||
return (
|
||||
<div className="SignUp">
|
||||
<div className="SignUp-container">
|
||||
<div className="SignUp-identity">
|
||||
<Identicon address={address} className="SignUp-identity-identicon" />
|
||||
<ShortAddress address={address} className="SignUp-identity-address" />
|
||||
</div>
|
||||
|
||||
<Form className="SignUp-form" onSubmit={this.handleSubmit} layout="vertical">
|
||||
<Form.Item className="SignUp-form-item" label="Display name">
|
||||
<Input
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={this.handleChange}
|
||||
placeholder="Non-unique name that others will see you as"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="SignUp-form-item" label="Title">
|
||||
<Input
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={this.handleChange}
|
||||
placeholder="A short description about you, e.g. Core Ethereum Developer"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="SignUp-form-item" label="Email address">
|
||||
<Input
|
||||
name="email"
|
||||
value={email}
|
||||
onChange={this.handleChange}
|
||||
placeholder="We promise not to spam you or share your email"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
block
|
||||
loading={isCreatingUser}
|
||||
>
|
||||
Claim Identity
|
||||
</Button>
|
||||
|
||||
{createUserError && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={createUserError}
|
||||
showIcon
|
||||
closable
|
||||
style={{ marginTop: '1rem' }}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<p className="SignUp-back">
|
||||
Want to use a different identity? <a onClick={this.props.reset}>Click here</a>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = ev.currentTarget;
|
||||
this.setState({ [name]: value } as any);
|
||||
};
|
||||
|
||||
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
|
||||
ev.preventDefault();
|
||||
const { address, createUser } = this.props;
|
||||
const { name, title, email } = this.state;
|
||||
createUser({ address, name, title, email });
|
||||
};
|
||||
}
|
||||
|
||||
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||
state => ({
|
||||
isCreatingUser: state.auth.isCreatingUser,
|
||||
createUserError: state.auth.createUserError,
|
||||
}),
|
||||
{
|
||||
createUser: authActions.createUser,
|
||||
},
|
||||
)(SignUp);
|
|
@ -0,0 +1,14 @@
|
|||
.AuthFlow {
|
||||
&-title {
|
||||
font-size: 1.8rem;
|
||||
margin: 0 auto 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&-subtitle {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.7;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,160 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Spin } from 'antd';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { AUTH_PROVIDER } from 'utils/auth';
|
||||
import { authActions } from 'modules/auth';
|
||||
import SignIn from './SignIn';
|
||||
import SignUp from './SignUp';
|
||||
import SelectProvider from './SelectProvider';
|
||||
import ProvideIdentity from './ProvideIdentity';
|
||||
import './index.less';
|
||||
|
||||
interface StateProps {
|
||||
web3Accounts: AppState['web3']['accounts'];
|
||||
checkedUsers: AppState['auth']['checkedUsers'];
|
||||
isCheckingUser: AppState['auth']['isCheckingUser'];
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
checkUser: typeof authActions['checkUser'];
|
||||
}
|
||||
|
||||
type Props = StateProps & DispatchProps;
|
||||
|
||||
interface State {
|
||||
provider: AUTH_PROVIDER | null;
|
||||
address: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: State = {
|
||||
provider: null,
|
||||
address: null,
|
||||
};
|
||||
|
||||
class AuthFlow extends React.Component<Props> {
|
||||
state: State = { ...DEFAULT_STATE };
|
||||
|
||||
private pages = {
|
||||
SIGN_IN: {
|
||||
title: () => 'Prove your Identity',
|
||||
subtitle: () => 'Log into your Grant.io account by proving your identity',
|
||||
render: () => {
|
||||
const user = this.props.checkedUsers[this.state.address];
|
||||
return (
|
||||
user && (
|
||||
<SignIn provider={this.state.provider} user={user} reset={this.resetState} />
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
SIGN_UP: {
|
||||
title: () => 'Claim your Identity',
|
||||
subtitle: () => 'Create a Grant.io account by claiming your identity',
|
||||
render: () => (
|
||||
<SignUp
|
||||
address={this.state.address}
|
||||
provider={this.state.provider}
|
||||
reset={this.resetState}
|
||||
/>
|
||||
),
|
||||
},
|
||||
SELECT_PROVIDER: {
|
||||
title: () => 'Provide an Identity',
|
||||
subtitle: () =>
|
||||
'Sign in or create a new account by selecting your identity provider',
|
||||
render: () => <SelectProvider onSelect={this.setProvider} />,
|
||||
},
|
||||
PROVIDE_IDENTITY: {
|
||||
title: () => 'Provide an Identity',
|
||||
subtitle: () => {
|
||||
switch (this.state.provider) {
|
||||
case AUTH_PROVIDER.ADDRESS:
|
||||
return 'Enter your Ethereum Address';
|
||||
case AUTH_PROVIDER.LEDGER:
|
||||
return 'Connect with your Ledger';
|
||||
case AUTH_PROVIDER.TREZOR:
|
||||
return 'Connect with your TREZOR';
|
||||
case AUTH_PROVIDER.WEB3:
|
||||
// TODO: Dynamically use web3 name
|
||||
return 'Connect with MetaMask';
|
||||
}
|
||||
},
|
||||
render: () => (
|
||||
<ProvideIdentity
|
||||
provider={this.state.provider}
|
||||
onSelectAddress={this.setAddress}
|
||||
reset={this.resetState}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// If web3 is available, default to it
|
||||
const { web3Accounts } = this.props;
|
||||
if (web3Accounts && web3Accounts[0]) {
|
||||
this.setState({
|
||||
provider: AUTH_PROVIDER.WEB3,
|
||||
address: web3Accounts[0],
|
||||
});
|
||||
this.props.checkUser(web3Accounts[0]);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { checkedUsers, isCheckingUser } = this.props;
|
||||
const { provider, address } = this.state;
|
||||
const checkedUser = checkedUsers[address];
|
||||
let page;
|
||||
|
||||
if (provider) {
|
||||
if (address) {
|
||||
// TODO: If address results in user, show SIGN_IN.
|
||||
if (isCheckingUser) {
|
||||
return <Spin size="large" />;
|
||||
} else if (checkedUser) {
|
||||
page = this.pages.SIGN_IN;
|
||||
} else {
|
||||
page = this.pages.SIGN_UP;
|
||||
}
|
||||
} else {
|
||||
page = this.pages.PROVIDE_IDENTITY;
|
||||
}
|
||||
} else {
|
||||
page = this.pages.SELECT_PROVIDER;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="AuthFlow">
|
||||
<h1 className="AuthFlow-title">{page.title()}</h1>
|
||||
<p className="AuthFlow-subtitle">{page.subtitle()}</p>
|
||||
<div className="AuthFlow-content">{page.render()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private setProvider = (provider: AUTH_PROVIDER) => {
|
||||
this.setState({ provider });
|
||||
};
|
||||
|
||||
private setAddress = (address: string) => {
|
||||
this.setState({ address });
|
||||
this.props.checkUser(address);
|
||||
};
|
||||
|
||||
private resetState = () => {
|
||||
this.setState({ ...DEFAULT_STATE });
|
||||
};
|
||||
}
|
||||
|
||||
export default connect<StateProps, DispatchProps, {}, AppState>(
|
||||
state => ({
|
||||
web3Accounts: state.web3.accounts,
|
||||
checkedUsers: state.auth.checkedUsers,
|
||||
isCheckingUser: state.auth.isCheckingUser,
|
||||
}),
|
||||
{
|
||||
checkUser: authActions.checkUser,
|
||||
},
|
||||
)(AuthFlow);
|
|
@ -0,0 +1,9 @@
|
|||
.AddressProvider {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
margin: -0.5rem auto 0;
|
||||
|
||||
&-address {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import React from 'react';
|
||||
import { Form, Input, Button } from 'antd';
|
||||
import { isValidEthAddress } from 'utils/validators';
|
||||
import './Address.less';
|
||||
|
||||
interface Props {
|
||||
onSelectAddress(addr: string): void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
address: string;
|
||||
}
|
||||
|
||||
export default class AddressProvider extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
address: '',
|
||||
};
|
||||
|
||||
render() {
|
||||
const { address } = this.state;
|
||||
return (
|
||||
<Form className="AddressProvider" onSubmit={this.handleSubmit}>
|
||||
<Form.Item className="AddressProvider-address">
|
||||
<Input
|
||||
size="large"
|
||||
value={address}
|
||||
onChange={this.handleChange}
|
||||
placeholder="0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
disabled={!isValidEthAddress(address)}
|
||||
block
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
private handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ address: ev.currentTarget.value });
|
||||
};
|
||||
|
||||
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
|
||||
ev.preventDefault();
|
||||
this.props.onSelectAddress(this.state.address);
|
||||
};
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
@addresses-max-width: 36rem;
|
||||
@addresses-width: 10rem;
|
||||
@addresses-padding: 1rem;
|
||||
|
||||
|
||||
.ChooseAddress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
// Shared styles between addresses and loader
|
||||
&-addresses,
|
||||
&-loading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 36rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
&-button {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 30rem;
|
||||
margin: 0 auto 2rem;
|
||||
|
||||
.ant-alert {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.AddressChoice {
|
||||
width: 10rem;
|
||||
padding: 1rem;
|
||||
margin: 0 0.75rem 1rem;
|
||||
background: #FFF;
|
||||
border: 1px solid rgba(#000, 0.12);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: transform 100ms ease, border-color 100ms ease;
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(#000, 0.2);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
border-color: rgba(#000, 0.28);
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
display: block;
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
margin: 0 auto 1rem;
|
||||
border-radius: 100%;
|
||||
|
||||
.is-fake & {
|
||||
background: #000;
|
||||
color: #000;
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
&-name,
|
||||
&-address {
|
||||
margin: 0 auto;
|
||||
|
||||
.is-fake & {
|
||||
background: #000;
|
||||
color: #000;
|
||||
transform: scaleY(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&-name {
|
||||
font-size: 1rem;
|
||||
|
||||
.is-fake & {
|
||||
opacity: 0.2;
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
&-address {
|
||||
opacity: 0.6;
|
||||
font-size: 0.8rem;
|
||||
|
||||
.is-fake & {
|
||||
opacity: 0.1;
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,162 @@
|
|||
import React from 'react';
|
||||
import { Button, Spin, Icon, Alert } from 'antd';
|
||||
import classnames from 'classnames';
|
||||
import Identicon from 'components/Identicon';
|
||||
import ShortAddress from 'components/ShortAddress';
|
||||
import './ChooseAddress.less';
|
||||
|
||||
interface Props {
|
||||
addresses: string[];
|
||||
loadingMessage: string;
|
||||
handleDeriveAddresses(index: number, numNeeded: number): Promise<void>;
|
||||
onSelectAddress(address: string): void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
index: number;
|
||||
isLoading: boolean;
|
||||
error: null | string;
|
||||
}
|
||||
|
||||
const ADDRESSES_PER_PAGE = 6;
|
||||
|
||||
export default class ChooseAddress extends React.PureComponent<Props, State> {
|
||||
state: State = {
|
||||
index: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.deriveAddresses();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
// Detect resets of the array, kick off derive
|
||||
if (prevProps.addresses !== this.props.addresses && !this.props.addresses.length) {
|
||||
this.setState({ index: 0 }, () => {
|
||||
this.deriveAddresses();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { addresses } = this.props;
|
||||
const { index, isLoading, error } = this.state;
|
||||
|
||||
let content;
|
||||
if (error) {
|
||||
content = (
|
||||
<div className="ChooseAddress-error">
|
||||
<Alert
|
||||
type="error"
|
||||
message="Something went wrong"
|
||||
description={error}
|
||||
showIcon
|
||||
/>
|
||||
<Button size="large" onClick={this.deriveAddresses}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
if (isLoading) {
|
||||
content = (
|
||||
<Spin size="large">
|
||||
<div className="ChooseAddress-loading">
|
||||
{new Array(ADDRESSES_PER_PAGE).fill(null).map((_, idx) => (
|
||||
<AddressChoice key={idx} isFake={true} name="Loading" address="0x0" />
|
||||
))}
|
||||
</div>
|
||||
</Spin>
|
||||
);
|
||||
} else {
|
||||
const pageAddresses = addresses.slice(index, index + ADDRESSES_PER_PAGE);
|
||||
content = (
|
||||
<div className="ChooseAddress-addresses">
|
||||
{pageAddresses.map(address => (
|
||||
<AddressChoice
|
||||
key={address}
|
||||
address={address}
|
||||
name={`Address #${addresses.indexOf(address) + 1}`}
|
||||
onClick={this.props.onSelectAddress}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
content = (
|
||||
<>
|
||||
{content}
|
||||
<div className="ChooseAddress-buttons">
|
||||
<Button
|
||||
className="ChooseAddress-buttons-button"
|
||||
disabled={index <= 0}
|
||||
onClick={this.prev}
|
||||
>
|
||||
<Icon type="arrow-left" />
|
||||
</Button>
|
||||
<Button className="ChooseAddress-buttons-button" onClick={this.next}>
|
||||
<Icon type="arrow-right" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="ChooseAddress">{content}</div>;
|
||||
}
|
||||
|
||||
private deriveAddresses = () => {
|
||||
this.setState(
|
||||
{
|
||||
isLoading: true,
|
||||
error: null,
|
||||
},
|
||||
() => {
|
||||
this.props
|
||||
.handleDeriveAddresses(this.state.index, ADDRESSES_PER_PAGE)
|
||||
.then(() => this.setState({ isLoading: false }))
|
||||
.catch(err => this.setState({ isLoading: false, error: err.message }));
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
private next = () => {
|
||||
this.setState({ index: this.state.index + ADDRESSES_PER_PAGE }, () => {
|
||||
if (!this.props.addresses[this.state.index + ADDRESSES_PER_PAGE]) {
|
||||
this.deriveAddresses();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private prev = () => {
|
||||
this.setState({ index: Math.max(0, this.state.index - ADDRESSES_PER_PAGE) });
|
||||
};
|
||||
}
|
||||
|
||||
interface AddressChoiceProps {
|
||||
address: string;
|
||||
name: string;
|
||||
isFake?: boolean;
|
||||
onClick?(address: string): void;
|
||||
}
|
||||
|
||||
const AddressChoice: React.SFC<AddressChoiceProps> = props => (
|
||||
<button
|
||||
className={classnames('AddressChoice', props.isFake && 'is-fake')}
|
||||
onClick={props.onClick ? () => props.onClick(props.address) : undefined}
|
||||
>
|
||||
{/* TODO: Use user avatar + name if they have an account */}
|
||||
{props.isFake ? (
|
||||
<div className="AddressChoice-avatar" />
|
||||
) : (
|
||||
<Identicon className="AddressChoice-avatar" address={props.address} />
|
||||
)}
|
||||
<div className="AddressChoice-name">{props.name}</div>
|
||||
<div className="AddressChoice-address">
|
||||
<ShortAddress address={props.address} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
|
@ -0,0 +1,25 @@
|
|||
.LedgerProvider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
&-type {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: -0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
.ant-radio-button-wrapper {
|
||||
min-width: 5rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&-hint {
|
||||
opacity: 0.7;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: -1rem;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
import React from 'react';
|
||||
import TransportU2F from '@ledgerhq/hw-transport-u2f';
|
||||
import LedgerEth from '@ledgerhq/hw-app-eth';
|
||||
import { Radio } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib/radio';
|
||||
import ChooseAddress from './ChooseAddress';
|
||||
import { deriveAddressesFromPubKey, parseLedgerError } from 'utils/wallet';
|
||||
import './Ledger.less';
|
||||
|
||||
enum ADDRESS_TYPE {
|
||||
LEGACY = 'LEGACY',
|
||||
LIVE = 'LIVE',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSelectAddress(addr: string): void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
publicKey: null | string;
|
||||
chainCode: null | string;
|
||||
addresses: string[];
|
||||
addressType: ADDRESS_TYPE;
|
||||
}
|
||||
|
||||
const DPATHS = {
|
||||
LEGACY: `m/44'/60'/0'/0`,
|
||||
LIVE: `m/44'/60'/$index'/0/0`,
|
||||
};
|
||||
|
||||
export default class LedgerProvider extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
publicKey: null,
|
||||
chainCode: null,
|
||||
addresses: [],
|
||||
addressType: ADDRESS_TYPE.LIVE,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { addresses, addressType } = this.state;
|
||||
return (
|
||||
<div className="LedgerProvider">
|
||||
<div className="LedgerProvider-type">
|
||||
<Radio.Group onChange={this.changeAddressType} value={addressType} size="large">
|
||||
<Radio.Button value={ADDRESS_TYPE.LIVE}>Live</Radio.Button>
|
||||
<Radio.Button value={ADDRESS_TYPE.LEGACY}>Legacy</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<ChooseAddress
|
||||
addresses={addresses}
|
||||
loadingMessage="Waiting for Ledger..."
|
||||
handleDeriveAddresses={this.deriveAddresses}
|
||||
onSelectAddress={this.props.onSelectAddress}
|
||||
/>
|
||||
|
||||
<div className="LedgerProvider-hint">
|
||||
Don't see your address? Try changing between Live and Legacy addresses.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private deriveAddresses = async (index: number, numAddresses: number) => {
|
||||
const { addressType } = this.state;
|
||||
let addresses = [...this.state.addresses];
|
||||
|
||||
try {
|
||||
if (addressType === ADDRESS_TYPE.LIVE) {
|
||||
const app = await this.getEthApp();
|
||||
for (let i = index; i < index + numAddresses; i++) {
|
||||
const res = await app.getAddress(DPATHS.LIVE.replace('$index', i.toString()));
|
||||
addresses.push(res.address);
|
||||
}
|
||||
} else {
|
||||
let { chainCode, publicKey } = this.state;
|
||||
if (!chainCode || !publicKey) {
|
||||
const app = await this.getEthApp();
|
||||
const res = await app.getAddress(DPATHS.LEGACY, false, true);
|
||||
chainCode = res.chainCode;
|
||||
publicKey = res.publicKey;
|
||||
this.setState({ chainCode, publicKey });
|
||||
}
|
||||
|
||||
addresses = addresses.concat(
|
||||
deriveAddressesFromPubKey({
|
||||
chainCode,
|
||||
publicKey,
|
||||
index,
|
||||
numAddresses,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = parseLedgerError(err);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
this.setState({ addresses });
|
||||
};
|
||||
|
||||
private getEthApp = async () => {
|
||||
const transport = await TransportU2F.create();
|
||||
return new LedgerEth(transport);
|
||||
};
|
||||
|
||||
private changeAddressType = (ev: RadioChangeEvent) => {
|
||||
const addressType = ev.target.value as ADDRESS_TYPE;
|
||||
if (addressType === this.state.addressType) {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
addresses: [],
|
||||
addressType,
|
||||
});
|
||||
};
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import React from 'react';
|
||||
import TrezorConnect from 'trezor-connect';
|
||||
import ChooseAddress from './ChooseAddress';
|
||||
import { deriveAddressesFromPubKey } from 'utils/wallet';
|
||||
|
||||
interface Props {
|
||||
onSelectAddress(addr: string): void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
publicKey: null | string;
|
||||
chainCode: null | string;
|
||||
addresses: string[];
|
||||
}
|
||||
|
||||
const DPATHS = {
|
||||
MAINNET: `m/44'/60'/0'/0`,
|
||||
TESTNET: `m/44'/1'/0'/0`,
|
||||
};
|
||||
|
||||
export default class TrezorProvider extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
publicKey: null,
|
||||
chainCode: null,
|
||||
addresses: [],
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ChooseAddress
|
||||
addresses={this.state.addresses}
|
||||
loadingMessage="Waiting for TREZOR..."
|
||||
handleDeriveAddresses={this.deriveAddresses}
|
||||
onSelectAddress={this.props.onSelectAddress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private deriveAddresses = async (index: number, numAddresses: number) => {
|
||||
let { chainCode, publicKey } = this.state;
|
||||
if (!chainCode || !publicKey) {
|
||||
const res = await this.getPublicKey();
|
||||
chainCode = res.chainCode;
|
||||
publicKey = res.publicKey;
|
||||
this.setState({ chainCode, publicKey });
|
||||
}
|
||||
|
||||
const addresses = this.state.addresses.concat(
|
||||
deriveAddressesFromPubKey({
|
||||
chainCode,
|
||||
publicKey,
|
||||
index,
|
||||
numAddresses,
|
||||
}),
|
||||
);
|
||||
this.setState({ addresses });
|
||||
};
|
||||
|
||||
private getPublicKey = async () => {
|
||||
const res = await TrezorConnect.getPublicKey({ path: DPATHS.TESTNET });
|
||||
if (res.success === false) {
|
||||
throw new Error(res.payload.error);
|
||||
}
|
||||
return res.payload;
|
||||
};
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
.Web3Provider {
|
||||
max-width: 360px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
|
||||
&-logo {
|
||||
display: block;
|
||||
max-width: 120px;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
&-description {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Button, Alert } from 'antd';
|
||||
import { web3Actions } from 'modules/web3';
|
||||
import { AppState } from 'store/reducers';
|
||||
import MetamaskIcon from 'static/images/metamask.png';
|
||||
import './Web3.less';
|
||||
|
||||
interface StateProps {
|
||||
accounts: AppState['web3']['accounts'];
|
||||
isWeb3Locked: AppState['web3']['isWeb3Locked'];
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
setWeb3: typeof web3Actions['setWeb3'];
|
||||
setAccounts: typeof web3Actions['setAccounts'];
|
||||
}
|
||||
|
||||
interface OwnProps {
|
||||
onSelectAddress(addr: string): void;
|
||||
}
|
||||
|
||||
type Props = StateProps & DispatchProps & OwnProps;
|
||||
|
||||
class Web3Provider extends React.Component<Props> {
|
||||
componentDidUpdate() {
|
||||
const { accounts } = this.props;
|
||||
if (accounts && accounts[0]) {
|
||||
this.props.onSelectAddress(accounts[0]);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isWeb3Locked } = this.props;
|
||||
return (
|
||||
<div className="Web3Provider">
|
||||
<img className="Web3Provider-logo" src={MetamaskIcon} />
|
||||
<p className="Web3Provider-description">
|
||||
Make sure you have MetaMask or another web3 provider installed and unlocked,
|
||||
then click below.
|
||||
</p>
|
||||
{isWeb3Locked && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
message="It looks like MetaMask is locked"
|
||||
style={{ margin: '1rem auto' }}
|
||||
/>
|
||||
)}
|
||||
<Button type="primary" size="large" onClick={this.connect}>
|
||||
Connect to Web3
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private connect = () => {
|
||||
this.props.setWeb3();
|
||||
this.props.setAccounts();
|
||||
};
|
||||
}
|
||||
|
||||
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||
state => ({
|
||||
accounts: state.web3.accounts,
|
||||
isWeb3Locked: state.web3.isWeb3Locked,
|
||||
}),
|
||||
{
|
||||
setWeb3: web3Actions.setWeb3,
|
||||
setAccounts: web3Actions.setAccounts,
|
||||
},
|
||||
)(Web3Provider);
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Spin } from 'antd';
|
||||
import { Route, Redirect, RouteProps } from 'react-router-dom';
|
||||
import { AppState } from 'store/reducers';
|
||||
|
||||
interface StateProps {
|
||||
user: AppState['auth']['user'];
|
||||
isAuthingUser: AppState['auth']['isAuthingUser'];
|
||||
}
|
||||
|
||||
interface OwnProps {
|
||||
onlyLoggedOut?: boolean;
|
||||
}
|
||||
|
||||
type Props = RouteProps & StateProps & OwnProps;
|
||||
|
||||
class AuthRoute extends React.Component<Props> {
|
||||
public render() {
|
||||
const { user, isAuthingUser, onlyLoggedOut, ...routeProps } = this.props;
|
||||
|
||||
if (isAuthingUser) {
|
||||
return <Spin size="large" />;
|
||||
} else if ((user && !onlyLoggedOut) || (!user && onlyLoggedOut)) {
|
||||
return <Route {...routeProps} />;
|
||||
} else {
|
||||
// TODO: redirect to desired destination after auth
|
||||
// TODO: Show alert that claims they need to be logged in
|
||||
return <Redirect to={onlyLoggedOut ? '/profile' : '/auth'} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default connect((state: AppState) => ({
|
||||
user: state.auth.user,
|
||||
isAuthingUser: state.auth.isAuthingUser,
|
||||
}))(AuthRoute);
|
|
@ -6,7 +6,7 @@ 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 { Comment as IComment, Proposal } from 'types';
|
||||
import { AppState } from 'store/reducers';
|
||||
import './style.less';
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { Proposal, ProposalComments } from 'modules/proposals/reducers';
|
||||
import { Proposal, ProposalComments } from 'types';
|
||||
import Comment from 'components/Comment';
|
||||
|
||||
interface Props {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
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 { CreateFormState } from 'types';
|
||||
import { getCreateErrors } from 'modules/create/utils';
|
||||
|
||||
interface State {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Form } from 'antd';
|
||||
import MarkdownEditor from 'components/MarkdownEditor';
|
||||
import { CreateFormState } from 'modules/create/types';
|
||||
import { CreateFormState } from 'types';
|
||||
|
||||
interface State {
|
||||
details: string;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
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 { CreateFormState } from 'types';
|
||||
import { getCreateErrors } from 'modules/create/utils';
|
||||
import { ONE_DAY } from 'utils/time';
|
||||
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
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 { CreateFormState, CreateMilestone } from 'types';
|
||||
import { getCreateErrors } from 'modules/create/utils';
|
||||
|
||||
interface State {
|
||||
milestones: Milestone[];
|
||||
milestones: CreateMilestone[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
@ -42,7 +42,7 @@ export default class CreateFlowMilestones extends React.Component<Props, State>
|
|||
}
|
||||
}
|
||||
|
||||
handleMilestoneChange = (index: number, milestone: Milestone) => {
|
||||
handleMilestoneChange = (index: number, milestone: CreateMilestone) => {
|
||||
const milestones = [...this.state.milestones];
|
||||
milestones[index] = milestone;
|
||||
this.setState({ milestones }, () => {
|
||||
|
@ -107,9 +107,9 @@ export default class CreateFlowMilestones extends React.Component<Props, State>
|
|||
|
||||
interface MilestoneFieldsProps {
|
||||
index: number;
|
||||
milestone: Milestone;
|
||||
milestone: CreateMilestone;
|
||||
error: null | false | string;
|
||||
onChange(index: number, milestone: Milestone): void;
|
||||
onChange(index: number, milestone: CreateMilestone): void;
|
||||
onRemove(index: number): void;
|
||||
}
|
||||
|
||||
|
|
|
@ -81,3 +81,30 @@
|
|||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ReviewTeam {
|
||||
&-member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&-avatar {
|
||||
height: 4.2rem;
|
||||
width: 4.2rem;
|
||||
margin-right: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&-info {
|
||||
&-name {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,13 +2,13 @@ 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';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
|
||||
interface OwnProps {
|
||||
setStep(step: CREATE_STEP): void;
|
||||
|
@ -68,11 +68,17 @@ class CreateReview extends React.Component<Props> {
|
|||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// step: CREATE_STEP.TEAM,
|
||||
// name: 'Team',
|
||||
// fields: [],
|
||||
// },
|
||||
{
|
||||
step: CREATE_STEP.TEAM,
|
||||
name: 'Team',
|
||||
fields: [
|
||||
{
|
||||
key: 'team',
|
||||
content: <ReviewTeam team={form.team} />,
|
||||
error: errors.team && errors.team.join(' '),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
step: CREATE_STEP.DETAILS,
|
||||
name: 'Details',
|
||||
|
@ -132,7 +138,7 @@ class CreateReview extends React.Component<Props> {
|
|||
return (
|
||||
<div className="CreateReview">
|
||||
{sections.map(s => (
|
||||
<div className="CreateReview-section">
|
||||
<div className="CreateReview-section" key={s.step}>
|
||||
{s.fields.map(f => (
|
||||
<div className="ReviewField" key={f.key}>
|
||||
<div className="ReviewField-label">
|
||||
|
@ -178,10 +184,14 @@ export default connect<StateProps, {}, OwnProps, AppState>(state => ({
|
|||
form: state.create.form,
|
||||
}))(CreateReview);
|
||||
|
||||
const ReviewMilestones = ({ milestones }: { milestones: Milestone[] }) => (
|
||||
const ReviewMilestones = ({
|
||||
milestones,
|
||||
}: {
|
||||
milestones: AppState['create']['form']['milestones'];
|
||||
}) => (
|
||||
<Timeline>
|
||||
{milestones.map(m => (
|
||||
<Timeline.Item>
|
||||
<Timeline.Item key={m.title}>
|
||||
<div className="ReviewMilestone">
|
||||
<div className="ReviewMilestone-title">{m.title}</div>
|
||||
<div className="ReviewMilestone-info">
|
||||
|
@ -195,3 +205,17 @@ const ReviewMilestones = ({ milestones }: { milestones: Milestone[] }) => (
|
|||
))}
|
||||
</Timeline>
|
||||
);
|
||||
|
||||
const ReviewTeam = ({ team }: { team: AppState['create']['form']['team'] }) => (
|
||||
<div className="ReviewTeam">
|
||||
{team.map((u, idx) => (
|
||||
<div className="ReviewTeam-member" key={idx}>
|
||||
<UserAvatar className="ReviewTeam-member-avatar" user={u} />
|
||||
<div className="ReviewTeam-member-info">
|
||||
<div className="ReviewTeam-member-info-name">{u.name}</div>
|
||||
<div className="ReviewTeam-member-info-title">{u.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
.TeamForm {
|
||||
max-width: 660px;
|
||||
padding: 0 1rem;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
&-add {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 80ms ease, transform 80ms ease;
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
&:active {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
&-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 1.25rem;
|
||||
width: 7.4rem;
|
||||
height: 7.4rem;
|
||||
border: 2px dashed #2ecc71;
|
||||
color: #2ecc71;
|
||||
border-radius: 8px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
&-text {
|
||||
text-align: left;
|
||||
|
||||
&-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 300;
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
&-subtitle {
|
||||
opacity: 0.7;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,29 +1,120 @@
|
|||
import React from 'react';
|
||||
import Placeholder from 'components/Placeholder';
|
||||
import { CreateFormState } from 'modules/create/types';
|
||||
import { connect } from 'react-redux';
|
||||
import { Icon } from 'antd';
|
||||
import { CreateFormState, TeamMember } from 'types';
|
||||
import TeamMemberComponent from './TeamMember';
|
||||
import './Team.less';
|
||||
import { AppState } from 'store/reducers';
|
||||
|
||||
type State = object;
|
||||
interface State {
|
||||
team: TeamMember[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
interface StateProps {
|
||||
authUser: AppState['auth']['user'];
|
||||
}
|
||||
|
||||
interface OwnProps {
|
||||
initialState?: Partial<State>;
|
||||
updateForm(form: Partial<CreateFormState>): void;
|
||||
}
|
||||
|
||||
export default class CreateFlowTeam extends React.Component<Props, State> {
|
||||
type Props = OwnProps & StateProps;
|
||||
|
||||
const MAX_TEAM_SIZE = 6;
|
||||
const DEFAULT_STATE: State = {
|
||||
team: [
|
||||
{
|
||||
name: '',
|
||||
title: '',
|
||||
avatarUrl: '',
|
||||
ethAddress: '',
|
||||
emailAddress: '',
|
||||
socialAccounts: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
class CreateFlowTeam extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
...DEFAULT_STATE,
|
||||
...(props.initialState || {}),
|
||||
};
|
||||
|
||||
// Don't allow for empty team array
|
||||
if (!this.state.team.length) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
team: [...DEFAULT_STATE.team],
|
||||
};
|
||||
}
|
||||
|
||||
// Auth'd user is always first member of a team
|
||||
if (props.authUser) {
|
||||
this.state.team[0] = {
|
||||
...props.authUser,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { team } = this.state;
|
||||
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."
|
||||
<div className="TeamForm">
|
||||
{team.map((user, idx) => (
|
||||
<TeamMemberComponent
|
||||
key={idx}
|
||||
index={idx}
|
||||
user={user}
|
||||
initialEditingState={!user.name}
|
||||
onChange={this.handleChange}
|
||||
onRemove={this.removeMember}
|
||||
/>
|
||||
))}
|
||||
{team.length < MAX_TEAM_SIZE && (
|
||||
<button className="TeamForm-add" onClick={this.addMember}>
|
||||
<div className="TeamForm-add-icon">
|
||||
<Icon type="plus" />
|
||||
</div>
|
||||
<div className="TeamForm-add-text">
|
||||
<div className="TeamForm-add-text-title">Add a team member</div>
|
||||
<div className="TeamForm-add-text-subtitle">
|
||||
Find an existing user, or fill out their info yourself
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private handleChange = (user: TeamMember, idx: number) => {
|
||||
const team = [...this.state.team];
|
||||
team[idx] = user;
|
||||
this.setState({ team });
|
||||
this.props.updateForm({ team });
|
||||
};
|
||||
|
||||
private addMember = () => {
|
||||
const team = [...this.state.team, { ...DEFAULT_STATE.team[0] }];
|
||||
this.setState({ team });
|
||||
this.props.updateForm({ team });
|
||||
};
|
||||
|
||||
private removeMember = (index: number) => {
|
||||
const team = [
|
||||
...this.state.team.slice(0, index),
|
||||
...this.state.team.slice(index + 1),
|
||||
];
|
||||
this.setState({ team });
|
||||
this.props.updateForm({ team });
|
||||
};
|
||||
}
|
||||
|
||||
const withConnect = connect<StateProps>((state: AppState) => ({
|
||||
authUser: state.auth.user,
|
||||
}));
|
||||
|
||||
export default withConnect(CreateFlowTeam);
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
.TeamMember {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
margin: 0 auto 1rem;
|
||||
background: #FFF;
|
||||
box-shadow: 0 1px 2px rgba(#000, 0.2);
|
||||
|
||||
&.is-editing {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
position: relative;
|
||||
height: 7.5rem;
|
||||
width: 7.5rem;
|
||||
margin-right: 1.25rem;
|
||||
|
||||
&-img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&-change {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateY(1rem) translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&-info {
|
||||
flex: 1;
|
||||
|
||||
// Read only view
|
||||
&-name {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 1rem;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&-social {
|
||||
display: flex;
|
||||
|
||||
&-icon {
|
||||
position: relative;
|
||||
height: 1.3rem;
|
||||
font-size: 1.3rem;
|
||||
margin-right: 1rem;
|
||||
opacity: 0.2;
|
||||
|
||||
&.is-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&-check {
|
||||
position: absolute;
|
||||
font-size: 0.8rem;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
transform: translate(50%, 50%);
|
||||
color: #2ecc71;
|
||||
background: #FFF;
|
||||
border: 1px solid #FFF;
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-edit {
|
||||
position: absolute;
|
||||
bottom: 1.5rem;
|
||||
right: 1rem;
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-remove {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
font-size: 1rem;
|
||||
opacity: 0.3;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Edit form
|
||||
.ant-form-item {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
margin-right: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,241 @@
|
|||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Input, Form, Col, Row, Button, Icon, Alert } from 'antd';
|
||||
import { SOCIAL_INFO } from 'utils/social';
|
||||
import { SOCIAL_TYPE, TeamMember } from 'types';
|
||||
import { getCreateTeamMemberError } from 'modules/create/utils';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import './TeamMember.less';
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
user: TeamMember;
|
||||
initialEditingState?: boolean;
|
||||
onChange(user: TeamMember, index: number): void;
|
||||
onRemove(index: number): void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
fields: TeamMember;
|
||||
isEditing: boolean;
|
||||
}
|
||||
|
||||
export default class CreateFlowTeamMember extends React.PureComponent<Props, State> {
|
||||
state: State = {
|
||||
fields: { ...this.props.user },
|
||||
isEditing: this.props.initialEditingState || false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { user, index } = this.props;
|
||||
const { fields, isEditing } = this.state;
|
||||
const error = getCreateTeamMemberError(fields);
|
||||
const isMissingField =
|
||||
!fields.name || !fields.title || !fields.emailAddress || !fields.ethAddress;
|
||||
const isDisabled = !!error || isMissingField;
|
||||
|
||||
return (
|
||||
<div className={classnames('TeamMember', isEditing && 'is-editing')}>
|
||||
<div className="TeamMember-avatar">
|
||||
<UserAvatar className="TeamMember-avatar-img" user={fields} />
|
||||
{isEditing && (
|
||||
<Button className="TeamMember-avatar-change" onClick={this.handleChangePhoto}>
|
||||
Change
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="TeamMember-info">
|
||||
{isEditing ? (
|
||||
<Form
|
||||
className="TeamMember-info-form"
|
||||
layout="vertical"
|
||||
onSubmit={this.toggleEditing}
|
||||
>
|
||||
<Form.Item>
|
||||
<Input
|
||||
name="name"
|
||||
autoComplete="off"
|
||||
placeholder="Display name (Required)"
|
||||
value={fields.name}
|
||||
onChange={this.handleChangeField}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Input
|
||||
name="title"
|
||||
autoComplete="off"
|
||||
placeholder="Title (Required)"
|
||||
value={fields.title}
|
||||
onChange={this.handleChangeField}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item>
|
||||
<Input
|
||||
name="ethAddress"
|
||||
autoComplete="ethAddress"
|
||||
placeholder="Ethereum address (Required)"
|
||||
value={fields.ethAddress}
|
||||
onChange={this.handleChangeField}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item>
|
||||
<Input
|
||||
name="emailAddress"
|
||||
placeholder="Email address (Required)"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={fields.emailAddress}
|
||||
onChange={this.handleChangeField}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={12}>
|
||||
{Object.values(SOCIAL_INFO).map(s => (
|
||||
<Col xs={24} sm={12} key={s.type}>
|
||||
<Form.Item>
|
||||
<Input
|
||||
placeholder={`${s.name} account`}
|
||||
autoComplete="off"
|
||||
value={fields.socialAccounts[s.type]}
|
||||
onChange={ev => this.handleSocialChange(ev, s.type)}
|
||||
addonBefore={s.icon}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{!isMissingField &&
|
||||
error && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={error}
|
||||
showIcon
|
||||
style={{ marginBottom: '0.75rem' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row>
|
||||
<Button type="primary" htmlType="submit" disabled={isDisabled}>
|
||||
Save changes
|
||||
</Button>
|
||||
<Button type="ghost" htmlType="button" onClick={this.cancelEditing}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Row>
|
||||
</Form>
|
||||
) : (
|
||||
<>
|
||||
<div className="TeamMember-info-name">{user.name || <em>No name</em>}</div>
|
||||
<div className="TeamMember-info-title">
|
||||
{user.title || <em>No title</em>}
|
||||
</div>
|
||||
<div className="TeamMember-info-social">
|
||||
{Object.values(SOCIAL_INFO).map(s => {
|
||||
const account = user.socialAccounts[s.type];
|
||||
const cn = classnames(
|
||||
'TeamMember-info-social-icon',
|
||||
account && 'is-active',
|
||||
);
|
||||
return (
|
||||
<div key={s.name} className={cn}>
|
||||
{s.icon}
|
||||
{account && (
|
||||
<Icon
|
||||
className="TeamMember-info-social-icon-check"
|
||||
type="check-circle"
|
||||
theme="filled"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{index !== 0 && (
|
||||
<>
|
||||
<button className="TeamMember-info-edit" onClick={this.toggleEditing}>
|
||||
<Icon type="form" /> Edit
|
||||
</button>
|
||||
<button className="TeamMember-info-remove" onClick={this.removeMember}>
|
||||
<Icon type="close-circle" theme="filled" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private toggleEditing = (ev?: React.SyntheticEvent<any>) => {
|
||||
if (ev) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
const { isEditing, fields } = this.state;
|
||||
if (isEditing) {
|
||||
// TODO: Check if valid first
|
||||
this.props.onChange(fields, this.props.index);
|
||||
}
|
||||
|
||||
this.setState({ isEditing: !isEditing });
|
||||
};
|
||||
|
||||
private cancelEditing = () => {
|
||||
this.setState({
|
||||
isEditing: false,
|
||||
fields: { ...this.props.user },
|
||||
});
|
||||
};
|
||||
|
||||
private handleChangeField = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = ev.currentTarget;
|
||||
this.setState({
|
||||
fields: {
|
||||
...this.state.fields,
|
||||
[name as any]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private handleSocialChange = (
|
||||
ev: React.ChangeEvent<HTMLInputElement>,
|
||||
type: SOCIAL_TYPE,
|
||||
) => {
|
||||
const { value } = ev.currentTarget;
|
||||
this.setState({
|
||||
fields: {
|
||||
...this.state.fields,
|
||||
socialAccounts: {
|
||||
...this.state.fields.socialAccounts,
|
||||
[type]: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private handleChangePhoto = () => {
|
||||
// TODO: Actual file uploading
|
||||
const gender = ['men', 'women'][Math.floor(Math.random() * 2)];
|
||||
const num = Math.floor(Math.random() * 80);
|
||||
this.setState({
|
||||
fields: {
|
||||
...this.state.fields,
|
||||
avatarUrl: `https://randomuser.me/api/portraits/${gender}/${num}.jpg`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private removeMember = () => {
|
||||
this.props.onRemove(this.props.index);
|
||||
};
|
||||
}
|
|
@ -1,10 +1,42 @@
|
|||
import { PROPOSAL_CATEGORY } from 'api/constants';
|
||||
import { SOCIAL_TYPE, CreateFormState } from 'types';
|
||||
|
||||
const createExampleProposal = (payOutAddress: string, trustees: string[]) => {
|
||||
const createExampleProposal = (
|
||||
payOutAddress: string,
|
||||
trustees: string[],
|
||||
): CreateFormState => {
|
||||
return {
|
||||
title: 'Grant.io T-Shirts',
|
||||
brief: "The most stylish wear, sporting your favorite brand's logo",
|
||||
category: PROPOSAL_CATEGORY.COMMUNITY,
|
||||
team: [
|
||||
{
|
||||
name: 'John Smith',
|
||||
title: 'CEO of Grant.io',
|
||||
avatarUrl: `https://randomuser.me/api/portraits/men/${Math.floor(
|
||||
Math.random() * 80,
|
||||
)}.jpg`,
|
||||
ethAddress: payOutAddress,
|
||||
emailAddress: 'test@grant.io',
|
||||
socialAccounts: {
|
||||
[SOCIAL_TYPE.GITHUB]: 'dternyak',
|
||||
[SOCIAL_TYPE.LINKEDIN]: 'dternyak',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Jane Smith',
|
||||
title: 'T-Shirt Designer',
|
||||
avatarUrl: `https://randomuser.me/api/portraits/women/${Math.floor(
|
||||
Math.random() * 80,
|
||||
)}.jpg`,
|
||||
ethAddress: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520',
|
||||
emailAddress: 'designer@tshirt.com',
|
||||
socialAccounts: {
|
||||
[SOCIAL_TYPE.KEYBASE]: 'willo',
|
||||
[SOCIAL_TYPE.TWITTER]: 'wbobeirne',
|
||||
},
|
||||
},
|
||||
],
|
||||
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',
|
||||
|
|
|
@ -6,7 +6,7 @@ import qs from 'query-string';
|
|||
import { withRouter, RouteComponentProps } from 'react-router';
|
||||
import { debounce } from 'underscore';
|
||||
import Basics from './Basics';
|
||||
// import Team from './Team';
|
||||
import Team from './Team';
|
||||
import Details from './Details';
|
||||
import Milestones from './Milestones';
|
||||
import Governance from './Governance';
|
||||
|
@ -15,7 +15,7 @@ import Preview from './Preview';
|
|||
import Final from './Final';
|
||||
import createExampleProposal from './example';
|
||||
import { createActions } from 'modules/create';
|
||||
import { CreateFormState } from 'modules/create/types';
|
||||
import { CreateFormState } from 'types';
|
||||
import { getCreateErrors } from 'modules/create/utils';
|
||||
import { web3Actions } from 'modules/web3';
|
||||
import { AppState } from 'store/reducers';
|
||||
|
@ -25,7 +25,7 @@ import './index.less';
|
|||
|
||||
export enum CREATE_STEP {
|
||||
BASICS = 'BASICS',
|
||||
// TEAM = 'TEAM',
|
||||
TEAM = 'TEAM',
|
||||
DETAILS = 'DETAILS',
|
||||
MILESTONES = 'MILESTONES',
|
||||
GOVERNANCE = 'GOVERNANCE',
|
||||
|
@ -34,7 +34,7 @@ export enum CREATE_STEP {
|
|||
|
||||
const STEP_ORDER = [
|
||||
CREATE_STEP.BASICS,
|
||||
// CREATE_STEP.TEAM,
|
||||
CREATE_STEP.TEAM,
|
||||
CREATE_STEP.DETAILS,
|
||||
CREATE_STEP.MILESTONES,
|
||||
CREATE_STEP.GOVERNANCE,
|
||||
|
@ -57,14 +57,14 @@ const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = {
|
|||
'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.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',
|
||||
|
@ -198,7 +198,7 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
<div className="CreateFlow">
|
||||
<div className="CreateFlow-header">
|
||||
<Steps current={currentIndex}>
|
||||
{STEP_ORDER.slice(0, 4).map(s => (
|
||||
{STEP_ORDER.slice(0, 5).map(s => (
|
||||
<Steps.Step
|
||||
key={s}
|
||||
title={STEP_INFO[s].short}
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
.AuthButton {
|
||||
// Needed to override inherited styles
|
||||
&.Header-links-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: -0.2rem;
|
||||
transform: none;
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
|
||||
&.is-loading {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.anticon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
position: relative;
|
||||
height: 2.4rem;
|
||||
width: 2.4rem;
|
||||
margin-left: 0.6rem;
|
||||
border-radius: 100%;
|
||||
overflow: hidden;
|
||||
background: rgba(#FFF, 0.2);
|
||||
box-shadow: 0 0.5px 2px rgba(#000, 0.3);
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.ant-spin {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
&-locked {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(#000, 0.2);
|
||||
|
||||
.anticon {
|
||||
color: #FFF;
|
||||
font-size: 1.4rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
import React from 'react';
|
||||
import { Icon, Dropdown, Menu } from 'antd';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import Identicon from 'components/Identicon';
|
||||
import { AppState } from 'store/reducers';
|
||||
import './Auth.less';
|
||||
|
||||
interface StateProps {
|
||||
user: AppState['auth']['user'];
|
||||
isAuthingUser: AppState['auth']['isAuthingUser'];
|
||||
accounts: AppState['web3']['accounts'];
|
||||
accountsLoading: AppState['web3']['accountsLoading'];
|
||||
}
|
||||
|
||||
type Props = StateProps;
|
||||
|
||||
interface State {
|
||||
isMenuOpen: boolean;
|
||||
}
|
||||
|
||||
class HeaderAuth extends React.Component<Props> {
|
||||
state: State = {
|
||||
isMenuOpen: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { accounts, accountsLoading, user, isAuthingUser } = this.props;
|
||||
const { isMenuOpen } = this.state;
|
||||
const isAuthed = !!user;
|
||||
|
||||
let avatar;
|
||||
let isLoading;
|
||||
if (user) {
|
||||
avatar = <UserAvatar user={user} />;
|
||||
} else if (accounts && accounts[0]) {
|
||||
avatar = <Identicon address={accounts[0]} />;
|
||||
} else if (accountsLoading || isAuthingUser) {
|
||||
avatar = '';
|
||||
isLoading = true;
|
||||
}
|
||||
|
||||
const link = (
|
||||
<Link
|
||||
to={isAuthed ? '/profile' : '/auth'}
|
||||
className={classnames('AuthButton Header-links-link', isLoading && 'is-loading')}
|
||||
onClick={this.toggleMenu}
|
||||
>
|
||||
{isAuthed ? '' : 'Sign in'}
|
||||
{avatar && (
|
||||
<div className="AuthButton-avatar">
|
||||
{avatar}
|
||||
{!isAuthed && (
|
||||
<div className="AuthButton-avatar-locked">
|
||||
<Icon type="lock" theme="filled" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
// If they're not authed, don't render the dropdown menu
|
||||
if (!isAuthed) {
|
||||
return link;
|
||||
}
|
||||
|
||||
const menu = (
|
||||
<Menu style={{ minWidth: '100px' }} onClick={this.closeMenu}>
|
||||
<Menu.Item>
|
||||
<Link to="/profile">Profile</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<Link to="/profile/settings">Settings</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item>
|
||||
<Link to="/auth/sign-out">Sign out</Link>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlay={menu}
|
||||
visible={isMenuOpen}
|
||||
placement="bottomRight"
|
||||
onVisibleChange={this.handleVisibilityChange}
|
||||
trigger={['click']}
|
||||
>
|
||||
{link}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
private toggleMenu = (ev?: React.MouseEvent<HTMLElement>) => {
|
||||
if (!this.props.user) {
|
||||
return;
|
||||
}
|
||||
if (ev) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
this.setState({ isMenuOpen: !this.state.isMenuOpen });
|
||||
};
|
||||
|
||||
private closeMenu = () => this.setState({ isMenuOpen: false });
|
||||
|
||||
private handleVisibilityChange = (visibility: boolean) => {
|
||||
// Handle the dropdown component's built in close events
|
||||
this.setState({ isMenuOpen: visibility });
|
||||
};
|
||||
}
|
||||
|
||||
export default connect<StateProps, {}, {}, AppState>(state => ({
|
||||
user: state.auth.user,
|
||||
isAuthingUser: state.auth.isAuthingUser,
|
||||
accounts: state.web3.accounts,
|
||||
accountsLoading: state.web3.accountsLoading,
|
||||
}))(HeaderAuth);
|
|
@ -0,0 +1,28 @@
|
|||
.HeaderDrawer {
|
||||
.ant-drawer-body {
|
||||
padding: 0.8rem 0;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 2rem;
|
||||
padding-left: 1.25rem;
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(#000, 0.08);
|
||||
}
|
||||
|
||||
&-user {
|
||||
.ant-menu-item-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin-right: 0.75rem;
|
||||
border-radius: 100%;
|
||||
box-shadow: 0 0.5px 2px rgba(#000, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Drawer, Menu } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import Identicon from 'components/Identicon';
|
||||
import { AppState } from 'store/reducers';
|
||||
import './Drawer.less';
|
||||
|
||||
interface StateProps {
|
||||
user: AppState['auth']['user'];
|
||||
accounts: AppState['web3']['accounts'];
|
||||
}
|
||||
|
||||
interface OwnProps {
|
||||
isOpen: boolean;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
type Props = StateProps & OwnProps;
|
||||
|
||||
class HeaderDrawer extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
window.addEventListener('resize', this.props.onClose);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.props.onClose);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isOpen, onClose, user, accounts } = this.props;
|
||||
|
||||
let userTitle: React.ReactNode = 'Account';
|
||||
if (user) {
|
||||
userTitle = (
|
||||
<>
|
||||
<UserAvatar className="HeaderDrawer-user-avatar" user={user} />
|
||||
My account
|
||||
</>
|
||||
);
|
||||
} else if (accounts && accounts[0]) {
|
||||
userTitle = (
|
||||
<>
|
||||
<Identicon className="HeaderDrawer-user-avatar" address={accounts[0]} />
|
||||
Account
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
className="HeaderDrawer"
|
||||
visible={isOpen}
|
||||
onClose={onClose}
|
||||
placement="left"
|
||||
>
|
||||
<div className="HeaderDrawer-title">Grant.io</div>
|
||||
<Menu mode="inline" style={{ borderRight: 0 }}>
|
||||
<Menu.ItemGroup className="HeaderDrawer-user" title={userTitle}>
|
||||
{user ? (
|
||||
[
|
||||
<Menu.Item key="profile">
|
||||
<Link to="/profile">Profile</Link>
|
||||
</Menu.Item>,
|
||||
<Menu.Item key="settings">
|
||||
<Link to="/profile/settings">Settings</Link>
|
||||
</Menu.Item>,
|
||||
<Menu.Item key="sign-out">
|
||||
<Link to="/auth/sign-out">Sign out</Link>
|
||||
</Menu.Item>,
|
||||
]
|
||||
) : (
|
||||
<Menu.Item>
|
||||
<Link to="/auth">Sign in</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.ItemGroup>
|
||||
<Menu.ItemGroup title="Proposals">
|
||||
<Menu.Item>
|
||||
<Link to="/proposals">Browse proposals</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<Link to="/create">Start a proposal</Link>
|
||||
</Menu.Item>
|
||||
</Menu.ItemGroup>
|
||||
</Menu>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect<StateProps, {}, OwnProps, AppState>(state => ({
|
||||
user: state.auth.user,
|
||||
accounts: state.web3.accounts,
|
||||
}))(HeaderDrawer);
|
|
@ -1,18 +1,28 @@
|
|||
import React from 'react';
|
||||
import { Icon } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
import HeaderAuth from './Auth';
|
||||
import HeaderDrawer from './Drawer';
|
||||
import MenuIcon from 'static/images/menu.svg';
|
||||
import './style.less';
|
||||
|
||||
interface OwnProps {
|
||||
interface Props {
|
||||
isTransparent?: boolean;
|
||||
}
|
||||
|
||||
type Props = OwnProps;
|
||||
interface State {
|
||||
isDrawerOpen: boolean;
|
||||
}
|
||||
|
||||
export default class Header extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
isDrawerOpen: false,
|
||||
};
|
||||
|
||||
export default class Header extends React.Component<Props> {
|
||||
render() {
|
||||
const { isTransparent } = this.props;
|
||||
const { isDrawerOpen } = this.state;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames({
|
||||
|
@ -20,26 +30,35 @@ export default class Header extends React.Component<Props> {
|
|||
['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>
|
||||
<div className="Header-links is-left is-desktop">
|
||||
<Link to="/proposals" className="Header-links-link">
|
||||
Browse
|
||||
</Link>
|
||||
<Link to="/create" className="Header-links-link">
|
||||
Start a Proposal
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="Header-links is-left is-mobile">
|
||||
<button className="Header-links-link is-menu" onClick={this.openDrawer}>
|
||||
<MenuIcon className="Header-links-link-icon" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Link className="Header-title" to="/">
|
||||
Grant.io
|
||||
</Link>
|
||||
|
||||
<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>
|
||||
<div className="Header-links is-right">
|
||||
<HeaderAuth />
|
||||
</div>
|
||||
|
||||
{!isTransparent && <div className="Header-alphaBanner">Alpha</div>}
|
||||
<HeaderDrawer isOpen={isDrawerOpen} onClose={this.closeDrawer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private openDrawer = () => this.setState({ isDrawerOpen: true });
|
||||
private closeDrawer = () => this.setState({ isDrawerOpen: false });
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
@header-height: 78px;
|
||||
@small-query: ~'(max-width: 520px)';
|
||||
@header-height: 62px;
|
||||
@small-query: ~'(max-width: 660px)';
|
||||
@big-query: ~'(min-width: 661px)';
|
||||
|
||||
.Header {
|
||||
top: 0;
|
||||
|
@ -15,7 +16,7 @@
|
|||
color: #333;
|
||||
background: #fff;
|
||||
text-shadow: none;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 1px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&.is-transparent {
|
||||
position: absolute;
|
||||
|
@ -23,14 +24,22 @@
|
|||
background: transparent;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: none;
|
||||
|
||||
svg {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.Header-title {
|
||||
transform: translateY(0px) translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 2.2rem;
|
||||
transform: translateY(-2px) translate(-50%, -50%);
|
||||
font-size: 1.8rem;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
letter-spacing: 0.08rem;
|
||||
|
@ -43,21 +52,47 @@
|
|||
&:focus,
|
||||
&:active {
|
||||
color: inherit;
|
||||
transform: translateY(-2px) translate(-50%, -50%);
|
||||
transform: translateY(-4px) translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
&-button {
|
||||
&-links {
|
||||
display: flex;
|
||||
|
||||
&.is-left {
|
||||
justify-self: flex-start;
|
||||
margin-left: -0.75rem;
|
||||
}
|
||||
|
||||
&.is-right {
|
||||
justify-self: flex-end;
|
||||
margin-right: -0.75rem;
|
||||
}
|
||||
|
||||
&.is-desktop {
|
||||
@media @small-query {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-mobile {
|
||||
@media @big-query {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&-link {
|
||||
display: block;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 1.2rem;
|
||||
padding: 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 300;
|
||||
color: inherit;
|
||||
letter-spacing: 0.05rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: transform 100ms ease, opacity 100ms ease;
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
|
@ -68,21 +103,10 @@
|
|||
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;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -94,14 +118,14 @@
|
|||
transform: translate(-50%, 50%);
|
||||
background: linear-gradient(to right, #8e2de2, #4a00e0);
|
||||
color: #fff;
|
||||
width: 80px;
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
line-height: 22px;
|
||||
width: 70px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2rem;
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
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,11 +1,10 @@
|
|||
import React from 'react';
|
||||
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';
|
||||
import './style.less';
|
||||
|
||||
const introBlobs = [
|
||||
{
|
||||
|
@ -25,7 +24,6 @@ const introBlobs = [
|
|||
export default class Home extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<AntWrap title="Home" isHeaderTransparent isFullScreen>
|
||||
<div className="Home">
|
||||
<div className="Home-hero">
|
||||
<h1 className="Home-hero-title">
|
||||
|
@ -63,7 +61,6 @@ export default class Home extends React.Component {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AntWrap>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,11 +22,19 @@
|
|||
|
||||
&-title {
|
||||
color: #fff;
|
||||
font-size: 3.4rem;
|
||||
font-size: 3.2rem;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
|
||||
letter-spacing: 0.06rem;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
@media (max-width: 980px) {
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-buttons {
|
||||
|
|
|
@ -4,17 +4,19 @@ import defaultUserImg from 'static/images/default-user.jpg';
|
|||
|
||||
interface Props {
|
||||
address?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default class Identicon extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const blockie = this.props.address ? makeBlockie(this.props.address) : defaultUserImg;
|
||||
const { address, className } = this.props;
|
||||
const blockie = address ? makeBlockie(address) : defaultUserImg;
|
||||
const style = {
|
||||
display: 'block',
|
||||
...(this.props.style || {}),
|
||||
};
|
||||
|
||||
return <img style={style} src={blockie} />;
|
||||
return <img className={className} style={style} src={blockie} />;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import { withRouter, RouteComponentProps } from 'react-router';
|
||||
import { Button } from 'antd';
|
||||
import { BaseButtonProps } from 'antd/lib/button/button';
|
||||
|
||||
interface OwnProps {
|
||||
to: string;
|
||||
}
|
||||
|
||||
type Props = OwnProps & BaseButtonProps & RouteComponentProps<any>;
|
||||
|
||||
class LinkButton extends React.Component<Props> {
|
||||
render() {
|
||||
const { history, to, staticContext, ...rest } = this.props;
|
||||
return (
|
||||
<Button
|
||||
{...rest}
|
||||
onClick={(_: React.MouseEvent<any>) => {
|
||||
history.push(to);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(LinkButton);
|
|
@ -0,0 +1,27 @@
|
|||
.ProfileComment {
|
||||
padding-bottom: 1.2rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: none;
|
||||
}
|
||||
|
||||
&-head {
|
||||
color: #989898;
|
||||
margin-bottom: 0.2rem;
|
||||
font-size: 0.8rem;
|
||||
|
||||
&-name {
|
||||
color: #4c4c4c;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&-proposal {
|
||||
color: #4c4c4c;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import { UserComment } from 'types';
|
||||
import './ProfileComment.less';
|
||||
|
||||
interface OwnProps {
|
||||
comment: UserComment;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
export default class Profile extends React.Component<OwnProps> {
|
||||
render() {
|
||||
const {
|
||||
userName,
|
||||
comment: { body, proposal, dateCreated },
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="ProfileComment">
|
||||
<div className="ProfileComment-head">
|
||||
<span className="ProfileComment-head-name">{userName}</span> commented on{' '}
|
||||
<Link
|
||||
to={`/proposals/${proposal.proposalId}`}
|
||||
className="ProfileComment-head-proposal"
|
||||
>
|
||||
{proposal.title}
|
||||
</Link>{' '}
|
||||
{moment(dateCreated).from(Date.now())}
|
||||
</div>
|
||||
<div className="ProfileComment-body">{body}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
@small-query: ~'(max-width: 640px)';
|
||||
|
||||
.ProfileProposal {
|
||||
display: flex;
|
||||
padding-bottom: 1.2rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: none;
|
||||
}
|
||||
|
||||
@media @small-query {
|
||||
flex-direction: column;
|
||||
padding-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&-block {
|
||||
flex: 1 0 0%;
|
||||
|
||||
&:last-child {
|
||||
margin-left: 1.2rem;
|
||||
flex: 0 0 0%;
|
||||
min-width: 15rem;
|
||||
|
||||
@media @small-query {
|
||||
margin-left: 0;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-team {
|
||||
@media @small-query {
|
||||
display: flex;
|
||||
flex-flow: wrap;
|
||||
}
|
||||
|
||||
& .UserRow {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-raised {
|
||||
margin-top: 0.6rem;
|
||||
|
||||
& small {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { UserProposal } from 'types';
|
||||
import './ProfileProposal.less';
|
||||
import UserRow from 'components/UserRow';
|
||||
import UnitDisplay from 'components/UnitDisplay';
|
||||
|
||||
interface OwnProps {
|
||||
proposal: UserProposal;
|
||||
}
|
||||
|
||||
export default class Profile extends React.Component<OwnProps> {
|
||||
render() {
|
||||
const { title, brief, team, funded, target, proposalId } = this.props.proposal;
|
||||
|
||||
return (
|
||||
<div className="ProfileProposal">
|
||||
<div className="ProfileProposal-block">
|
||||
<Link to={`/proposals/${proposalId}`} className="ProfileProposal-title">
|
||||
{title}
|
||||
</Link>
|
||||
<div className="ProfileProposal-brief">{brief}</div>
|
||||
<div className="ProfileProposal-raised">
|
||||
<UnitDisplay value={funded} symbol="ETH" displayShortBalance={4} />{' '}
|
||||
<small>raised</small> of{' '}
|
||||
<UnitDisplay value={target} symbol="ETH" displayShortBalance={4} /> goal
|
||||
</div>
|
||||
</div>
|
||||
<div className="ProfileProposal-block">
|
||||
<h3>Team</h3>
|
||||
<div className="ProfileProposal-block-team">
|
||||
{team.map(user => (
|
||||
<UserRow key={user.ethAddress || user.emailAddress} user={user} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
.ProfileUser {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
&-avatar {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
height: 10.5rem;
|
||||
width: 10.5rem;
|
||||
margin-right: 1.25rem;
|
||||
|
||||
&-img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-info {
|
||||
// no overflow of flexbox
|
||||
min-width: 0;
|
||||
|
||||
&-name {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 1rem;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
&-address {
|
||||
position: relative;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.7rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
& > span {
|
||||
position: absolute;
|
||||
top: 1.2rem;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
&-social {
|
||||
display: flex;
|
||||
|
||||
& a {
|
||||
display: block;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
height: 1.3rem;
|
||||
font-size: 1.3rem;
|
||||
margin-right: 0.5rem;
|
||||
transition: transform 100ms;
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import React from 'react';
|
||||
import { SocialInfo, TeamMember } from 'types';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import './ProfileUser.less';
|
||||
import { SOCIAL_INFO, socialAccountToUrl } from 'utils/social';
|
||||
import ShortAddress from 'components/ShortAddress';
|
||||
|
||||
interface OwnProps {
|
||||
user: TeamMember;
|
||||
}
|
||||
|
||||
export default class Profile extends React.Component<OwnProps> {
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
user: { socialAccounts },
|
||||
} = this.props;
|
||||
return (
|
||||
<div className="ProfileUser">
|
||||
<div className="ProfileUser-avatar">
|
||||
<UserAvatar className="ProfileUser-avatar-img" user={user} />
|
||||
</div>
|
||||
<div className="ProfileUser-info">
|
||||
<div className="ProfileUser-info-name">{user.name}</div>
|
||||
<div className="ProfileUser-info-title">{user.title}</div>
|
||||
<div>
|
||||
{user.emailAddress && (
|
||||
<div className="ProfileUser-info-address">
|
||||
<span>email address</span>
|
||||
{user.emailAddress}
|
||||
</div>
|
||||
)}
|
||||
{user.ethAddress && (
|
||||
<div className="ProfileUser-info-address">
|
||||
<span>ethereum address</span>
|
||||
<ShortAddress address={user.ethAddress} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ProfileUser-info-social">
|
||||
{Object.values(SOCIAL_INFO).map(
|
||||
s =>
|
||||
(socialAccounts[s.type] && (
|
||||
<Social key={s.type} account={socialAccounts[s.type]} info={s} />
|
||||
)) ||
|
||||
null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Social = ({ account, info }: { account: string; info: SocialInfo }) => {
|
||||
return (
|
||||
<a href={socialAccountToUrl(account, info.type)}>
|
||||
<div className="ProfileUser-info-social-icon">{info.icon}</div>
|
||||
</a>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,153 @@
|
|||
import React from 'react';
|
||||
import { UsersState } from 'modules/users/reducers';
|
||||
import { withRouter, RouteComponentProps, Redirect } from 'react-router-dom';
|
||||
import { usersActions } from 'modules/users';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { connect } from 'react-redux';
|
||||
import { compose } from 'recompose';
|
||||
import { Spin, Tabs, Badge } from 'antd';
|
||||
import ProfileUser from './ProfileUser';
|
||||
import ProfileProposal from './ProfileProposal';
|
||||
import ProfileComment from './ProfileComment';
|
||||
import PlaceHolder from 'components/Placeholder';
|
||||
import Exception from 'pages/exception';
|
||||
import './style.less';
|
||||
|
||||
interface StateProps {
|
||||
usersMap: UsersState['map'];
|
||||
authUser: AppState['auth']['user'];
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
fetchUser: typeof usersActions['fetchUser'];
|
||||
fetchUserCreated: typeof usersActions['fetchUserCreated'];
|
||||
fetchUserFunded: typeof usersActions['fetchUserFunded'];
|
||||
fetchUserComments: typeof usersActions['fetchUserComments'];
|
||||
}
|
||||
|
||||
type Props = RouteComponentProps<any> & StateProps & DispatchProps;
|
||||
|
||||
class Profile extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.fetchData();
|
||||
}
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
const userLookupId = this.props.match.params.id;
|
||||
const prevUserLookupId = prevProps.match.params.id;
|
||||
if (userLookupId !== prevUserLookupId) {
|
||||
window.scrollTo(0, 0);
|
||||
this.fetchData();
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const userLookupParam = this.props.match.params.id;
|
||||
const { authUser } = this.props;
|
||||
if (!userLookupParam) {
|
||||
if (authUser.ethAddress) {
|
||||
return <Redirect to={`/profile/${authUser.ethAddress}`} />;
|
||||
} else {
|
||||
return <Redirect to="auth" />;
|
||||
}
|
||||
}
|
||||
|
||||
const user = this.props.usersMap[userLookupParam];
|
||||
const waiting = !user || !user.hasFetched;
|
||||
|
||||
if (waiting) {
|
||||
return <Spin />;
|
||||
}
|
||||
|
||||
if (user.fetchError) {
|
||||
return <Exception type="404" />;
|
||||
}
|
||||
|
||||
const { createdProposals, fundedProposals, comments } = user;
|
||||
const noneCreated = user.hasFetchedCreated && createdProposals.length === 0;
|
||||
const noneFunded = user.hasFetchedFunded && fundedProposals.length === 0;
|
||||
const noneCommented = user.hasFetchedComments && comments.length === 0;
|
||||
|
||||
return (
|
||||
<div className="Profile">
|
||||
<ProfileUser user={user} />
|
||||
<Tabs>
|
||||
<Tabs.TabPane
|
||||
tab={TabTitle('Created', createdProposals.length)}
|
||||
key="created"
|
||||
disabled={!user.hasFetchedCreated}
|
||||
>
|
||||
<div>
|
||||
{noneCreated && (
|
||||
<PlaceHolder subtitle="Has not created any proposals yet" />
|
||||
)}
|
||||
{createdProposals.map(p => (
|
||||
<ProfileProposal key={p.proposalId} proposal={p} />
|
||||
))}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane
|
||||
tab={TabTitle('Funded', fundedProposals.length)}
|
||||
key="funded"
|
||||
disabled={!user.hasFetchedFunded}
|
||||
>
|
||||
<div>
|
||||
{noneFunded && <PlaceHolder subtitle="Has not funded any proposals yet" />}
|
||||
{createdProposals.map(p => (
|
||||
<ProfileProposal key={p.proposalId} proposal={p} />
|
||||
))}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane
|
||||
tab={TabTitle('Comments', comments.length)}
|
||||
key="comments"
|
||||
disabled={!user.hasFetchedComments}
|
||||
>
|
||||
<div>
|
||||
{noneCommented && <PlaceHolder subtitle="Has not made any comments yet" />}
|
||||
{comments.map(c => (
|
||||
<ProfileComment key={c.commentId} userName={user.name} comment={c} />
|
||||
))}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
private fetchData() {
|
||||
const userLookupId = this.props.match.params.id;
|
||||
if (userLookupId) {
|
||||
this.props.fetchUser(userLookupId);
|
||||
this.props.fetchUserCreated(userLookupId);
|
||||
this.props.fetchUserFunded(userLookupId);
|
||||
this.props.fetchUserComments(userLookupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TabTitle = (disp: string, count: number) => (
|
||||
<div>
|
||||
{disp}
|
||||
<Badge
|
||||
className={`Profile-tabBadge ${count > 0 ? 'is-not-zero' : 'is-zero'}`}
|
||||
showZero={true}
|
||||
count={count}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const withConnect = connect<StateProps, DispatchProps>(
|
||||
(state: AppState) => ({
|
||||
usersMap: state.users.map,
|
||||
authUser: state.auth.user,
|
||||
}),
|
||||
{
|
||||
fetchUser: usersActions.fetchUser,
|
||||
fetchUserCreated: usersActions.fetchUserCreated,
|
||||
fetchUserFunded: usersActions.fetchUserFunded,
|
||||
fetchUserComments: usersActions.fetchUserComments,
|
||||
},
|
||||
);
|
||||
|
||||
export default compose<Props, any>(
|
||||
withRouter,
|
||||
withConnect,
|
||||
)(Profile);
|
|
@ -0,0 +1,37 @@
|
|||
@small-query: ~'(max-width: 640px)';
|
||||
|
||||
.Profile {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
||||
& .ant-tabs-nav .ant-tabs-tab {
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
@media @small-query {
|
||||
& .ant-tabs-nav .ant-tabs-tab {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-tabBadge {
|
||||
transform: scale(0.8) translate(-0.4rem);
|
||||
|
||||
& .ant-badge-count {
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.is-zero {
|
||||
& .ant-badge-count {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-not-zero {
|
||||
& .ant-badge-count {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import { Spin, Form, Input, Button, Icon } from 'antd';
|
||||
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
|
||||
import { ProposalWithCrowdFund } from 'types';
|
||||
import './style.less';
|
||||
import classnames from 'classnames';
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Modal, Alert } from 'antd';
|
||||
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
|
||||
import { ProposalWithCrowdFund } from 'types';
|
||||
import { web3Actions } from 'modules/web3';
|
||||
import { AppState } from 'store/reducers';
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { connect } from 'react-redux';
|
||||
import { Spin, Button } from 'antd';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
|
||||
import { ProposalWithCrowdFund } from 'types';
|
||||
import { fetchProposalComments, postProposalComment } from 'modules/proposals/actions';
|
||||
import {
|
||||
getProposalComments,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
|
||||
import { ProposalWithCrowdFund } from 'types';
|
||||
|
||||
interface Props {
|
||||
proposalId: ProposalWithCrowdFund['proposalId'];
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import { CrowdFund } from 'modules/proposals/reducers';
|
||||
import UserRow from 'components/UserRow';
|
||||
import { CrowdFund } from 'types';
|
||||
import AddressRow from 'components/AddressRow';
|
||||
import Placeholder from 'components/Placeholder';
|
||||
import UnitDisplay from 'components/UnitDisplay';
|
||||
|
||||
|
@ -14,7 +14,7 @@ const ContributorsBlock = ({ crowdFund }: Props) => {
|
|||
if (crowdFund) {
|
||||
if (crowdFund.contributors.length) {
|
||||
content = crowdFund.contributors.map(contributor => (
|
||||
<UserRow
|
||||
<AddressRow
|
||||
key={contributor.address}
|
||||
address={contributor.address}
|
||||
secondary={<UnitDisplay value={contributor.contributionAmount} symbol="ETH" />}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Spin, Progress, Button, Alert } from 'antd';
|
||||
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
|
||||
import { ProposalWithCrowdFund } from 'types';
|
||||
import Web3Container, { Web3RenderProps } from 'lib/Web3Container';
|
||||
import { web3Actions } from 'modules/web3';
|
||||
import { AppState } from 'store/reducers';
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import GovernanceMilestones from './Milestones';
|
||||
import GovernanceRefunds from './Refunds';
|
||||
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
|
||||
import { ProposalWithCrowdFund } from 'types';
|
||||
import './style.less';
|
||||
|
||||
interface Props {
|
||||
|
@ -13,13 +12,7 @@ export default class ProposalGovernance extends React.Component<Props> {
|
|||
const { proposal } = this.props;
|
||||
return (
|
||||
<div className="ProposalGovernance">
|
||||
<div className="ProposalGovernance-section">
|
||||
<h2 className="ProposalGovernance-section-title">Milestone Voting</h2>
|
||||
<GovernanceMilestones proposal={proposal} />
|
||||
</div>
|
||||
<div className="ProposalGovernance-divider" />
|
||||
<div className="ProposalGovernance-section">
|
||||
<h2 className="ProposalGovernance-section-title">Refunds</h2>
|
||||
<div className="ProposalGovernance-content">
|
||||
<GovernanceRefunds proposal={proposal} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -2,35 +2,11 @@
|
|||
|
||||
.ProposalGovernance {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 1rem;
|
||||
|
||||
@media (max-width: @small-screen) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&-section {
|
||||
flex: 1;
|
||||
|
||||
&-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-divider {
|
||||
width: 1px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
margin: 0 2rem;
|
||||
|
||||
@media (max-width: @small-screen) {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-milestoneActionText {
|
||||
font-size: 1rem;
|
||||
&-content {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
&-progress {
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
@small-screen: 1080px;
|
||||
|
||||
.MilestoneAction {
|
||||
&-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: 0 2rem 0.5rem 0;
|
||||
|
||||
&-text {
|
||||
white-space: nowrap;
|
||||
opacity: 0.6;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
// Ant progress overrides
|
||||
.ant-progress-text {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
&.is-starting {
|
||||
.ant-progress-circle-path {
|
||||
stroke: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-started {
|
||||
.ant-progress-circle-path {
|
||||
stroke: #faad14;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-finishing {
|
||||
.ant-progress-circle-path {
|
||||
stroke: #f5222d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,25 +1,23 @@
|
|||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import { connect } from 'react-redux';
|
||||
import { Button, Progress, Spin, Alert } from 'antd';
|
||||
import { ProposalWithCrowdFund, MILESTONE_STATE } from 'modules/proposals/reducers';
|
||||
import { Button, Progress, Alert } from 'antd';
|
||||
import { ProposalWithCrowdFund, MILESTONE_STATE } from 'types';
|
||||
import { web3Actions } from 'modules/web3';
|
||||
import { AppState } from 'store/reducers';
|
||||
import Web3Container, { Web3RenderProps } from 'lib/Web3Container';
|
||||
import UnitDisplay from 'components/UnitDisplay';
|
||||
import Placeholder from 'components/Placeholder';
|
||||
import './MilestoneAction.less';
|
||||
import { REJECTED } from 'redux-promise-middleware';
|
||||
|
||||
interface OwnProps {
|
||||
proposal: ProposalWithCrowdFund;
|
||||
}
|
||||
|
||||
interface Web3Props {
|
||||
accounts: Web3RenderProps['accounts'];
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
isMilestoneActionPending: AppState['web3']['isMilestoneActionPending'];
|
||||
milestoneActionError: AppState['web3']['milestoneActionError'];
|
||||
accounts: AppState['web3']['accounts'];
|
||||
}
|
||||
|
||||
interface ActionProps {
|
||||
|
@ -28,7 +26,7 @@ interface ActionProps {
|
|||
voteMilestonePayout: typeof web3Actions['voteMilestonePayout'];
|
||||
}
|
||||
|
||||
type Props = OwnProps & Web3Props & StateProps & ActionProps;
|
||||
type Props = OwnProps & StateProps & ActionProps;
|
||||
|
||||
export class Milestones extends React.Component<Props> {
|
||||
render() {
|
||||
|
@ -39,7 +37,6 @@ export class Milestones extends React.Component<Props> {
|
|||
milestoneActionError,
|
||||
} = this.props;
|
||||
const { crowdFund } = proposal;
|
||||
|
||||
if (!crowdFund.isRaiseGoalReached) {
|
||||
return (
|
||||
<Placeholder
|
||||
|
@ -51,22 +48,21 @@ export class Milestones extends React.Component<Props> {
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const contributor = crowdFund.contributors.find(c => c.address === accounts[0]);
|
||||
const isTrustee = crowdFund.trustees.includes(accounts[0]);
|
||||
const firstMilestone = crowdFund.milestones[0];
|
||||
const firstMilestone = proposal.milestones[0];
|
||||
const isImmediatePayout = crowdFund.immediateFirstMilestonePayout;
|
||||
// TODO: Should this information be abstracted to a lib or redux?
|
||||
const hasImmediatePayoutStarted =
|
||||
isImmediatePayout && firstMilestone.payoutRequestVoteDeadline;
|
||||
const hasImmediatePayoutBeenPaid = isImmediatePayout && firstMilestone.isPaid;
|
||||
const activeVoteMilestone = crowdFund.milestones.find(
|
||||
const activeVoteMilestone = proposal.milestones.find(
|
||||
m => m.state === MILESTONE_STATE.ACTIVE,
|
||||
);
|
||||
const uncollectedMilestone = crowdFund.milestones.find(
|
||||
const uncollectedMilestone = proposal.milestones.find(
|
||||
m => m.state === MILESTONE_STATE.PAID && !m.isPaid,
|
||||
);
|
||||
const nextUnpaidMilestone = crowdFund.milestones.find(
|
||||
const nextUnpaidMilestone = proposal.milestones.find(
|
||||
m => m.state !== MILESTONE_STATE.PAID,
|
||||
);
|
||||
|
||||
|
@ -78,7 +74,7 @@ export class Milestones extends React.Component<Props> {
|
|||
if (isImmediatePayout && !hasImmediatePayoutBeenPaid) {
|
||||
if (!hasImmediatePayoutStarted) {
|
||||
content = (
|
||||
<p className="ProposalGovernance-milestoneActionText">
|
||||
<p className="MilestoneAction-text">
|
||||
Congratulations on getting funded! You can now begin the process of
|
||||
receiving your initial payment. Click below to begin a milestone payout
|
||||
request. It will instantly be approved, and you’ll be able to request the
|
||||
|
@ -92,7 +88,7 @@ export class Milestones extends React.Component<Props> {
|
|||
};
|
||||
} else {
|
||||
content = (
|
||||
<p className="ProposalGovernance-milestoneActionText">
|
||||
<p className="MilestoneAction-text">
|
||||
Your initial payout is ready! Click below to claim it.
|
||||
</p>
|
||||
);
|
||||
|
@ -104,7 +100,7 @@ export class Milestones extends React.Component<Props> {
|
|||
}
|
||||
} else if (activeVoteMilestone) {
|
||||
content = (
|
||||
<p className="ProposalGovernance-milestoneActionText">
|
||||
<p className="MilestoneAction-text">
|
||||
The vote for your payout is in progress. If payout rejection votes don’t
|
||||
exceed 50% before{' '}
|
||||
{moment(activeVoteMilestone.payoutRequestVoteDeadline).format(
|
||||
|
@ -116,7 +112,7 @@ export class Milestones extends React.Component<Props> {
|
|||
showVoteProgress = true;
|
||||
} else if (uncollectedMilestone) {
|
||||
content = (
|
||||
<p className="ProposalGovernance-milestoneActionText">
|
||||
<p className="MilestoneAction-text">
|
||||
Congratulations! Your milestone payout request was succesful. Click below to
|
||||
receive your payment of{' '}
|
||||
<strong>
|
||||
|
@ -132,9 +128,11 @@ export class Milestones extends React.Component<Props> {
|
|||
};
|
||||
} else if (nextUnpaidMilestone) {
|
||||
content = (
|
||||
<p className="ProposalGovernance-milestoneActionText">
|
||||
You can request a payout for your next milestone, "Milestone Title". If fewer
|
||||
than 50% of funders vote against it before{' '}
|
||||
<p className="MilestoneAction-text">
|
||||
{nextUnpaidMilestone.state === REJECTED
|
||||
? 'You can make another request for this milestone payout. '
|
||||
: 'You can request a payout for this milestone. '}
|
||||
If fewer than 50% of funders vote against it before{' '}
|
||||
{moment(Date.now() + crowdFund.milestoneVotingPeriod).format('MMM Do h:mm a')}
|
||||
, you will be able to collect your payout here.
|
||||
</p>
|
||||
|
@ -169,7 +167,7 @@ export class Milestones extends React.Component<Props> {
|
|||
}
|
||||
|
||||
content = (
|
||||
<p className="ProposalGovernance-milestoneActionText">
|
||||
<p className="MilestoneAction-text">
|
||||
A milestone vote is currently in progress. If funders vote against paying out
|
||||
the milestone by over 50% before{' '}
|
||||
{moment(activeVoteMilestone.payoutRequestVoteDeadline).format(
|
||||
|
@ -182,31 +180,29 @@ export class Milestones extends React.Component<Props> {
|
|||
showVoteProgress = true;
|
||||
} else if (nextUnpaidMilestone) {
|
||||
content = (
|
||||
<p className="ProposalGovernance-milestoneActionText">
|
||||
<p className="MilestoneAction-text">
|
||||
There is no milestone vote currently active.
|
||||
</p>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<p className="ProposalGovernance-milestoneActionText">
|
||||
All milestones have been paid out.
|
||||
</p>
|
||||
<p className="MilestoneAction-text">All milestones have been paid out.</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div className="MilestonAction">
|
||||
<div className="MilestoneAction-top">
|
||||
{showVoteProgress && (
|
||||
<div className="ProposalGovernance-progress">
|
||||
<div className="MilestoneAction-progress">
|
||||
<Progress
|
||||
type="dashboard"
|
||||
percent={activeVoteMilestone.percentAgainstPayout}
|
||||
format={p => `${p}%`}
|
||||
status="exception"
|
||||
/>
|
||||
<div className="ProposalGovernance-progress-text">voted against payout</div>
|
||||
<div className="MilestoneAction-progress-text">voted against payout</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
|
@ -232,7 +228,7 @@ export class Milestones extends React.Component<Props> {
|
|||
showIcon
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -254,6 +250,7 @@ export class Milestones extends React.Component<Props> {
|
|||
|
||||
const ConnectedMilestones = connect(
|
||||
(state: AppState) => ({
|
||||
accounts: state.web3.accounts,
|
||||
isMilestoneActionPending: state.web3.isMilestoneActionPending,
|
||||
milestoneActionError: state.web3.milestoneActionError,
|
||||
}),
|
||||
|
@ -264,11 +261,4 @@ const ConnectedMilestones = connect(
|
|||
},
|
||||
)(Milestones);
|
||||
|
||||
export default (props: OwnProps) => (
|
||||
<Web3Container
|
||||
renderLoading={() => <Spin />}
|
||||
render={({ accounts }: Web3RenderProps) => (
|
||||
<ConnectedMilestones accounts={accounts} {...props} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
export default (props: OwnProps) => <ConnectedMilestones {...props} />;
|
|
@ -1,107 +1,290 @@
|
|||
import lodash from 'lodash';
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import { Timeline, Spin, Icon } from 'antd';
|
||||
import { ProposalWithCrowdFund, MILESTONE_STATE } from 'modules/proposals/reducers';
|
||||
import { Alert, Steps, Spin } from 'antd';
|
||||
import { ProposalWithCrowdFund, MILESTONE_STATE } from 'types';
|
||||
import UnitDisplay from 'components/UnitDisplay';
|
||||
import MilestoneAction from './MilestoneAction';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { connect } from 'react-redux';
|
||||
import classnames from 'classnames';
|
||||
import './style.less';
|
||||
|
||||
const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE;
|
||||
|
||||
enum STEP_STATUS {
|
||||
WAIT = 'wait',
|
||||
PROCESS = 'process',
|
||||
FINISH = 'finish',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
const milestoneStateToStepState = {
|
||||
[WAITING]: STEP_STATUS.WAIT,
|
||||
[ACTIVE]: STEP_STATUS.PROCESS,
|
||||
[PAID]: STEP_STATUS.FINISH,
|
||||
[REJECTED]: STEP_STATUS.ERROR,
|
||||
};
|
||||
|
||||
interface OwnProps {
|
||||
proposal: ProposalWithCrowdFund;
|
||||
}
|
||||
|
||||
type Props = OwnProps;
|
||||
interface StateProps {
|
||||
accounts: AppState['web3']['accounts'];
|
||||
}
|
||||
|
||||
type Props = OwnProps & StateProps;
|
||||
|
||||
interface State {
|
||||
step: number;
|
||||
activeMilestoneIdx: number;
|
||||
doTitlesOverflow: boolean;
|
||||
}
|
||||
|
||||
class ProposalMilestones extends React.Component<Props, State> {
|
||||
stepTitleRefs: Array<React.RefObject<HTMLDivElement>>;
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
throttledUpdateDoTitlesOverflow: () => void;
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.stepTitleRefs = this.props.proposal.milestones.map(() => React.createRef());
|
||||
this.ref = React.createRef();
|
||||
this.throttledUpdateDoTitlesOverflow = lodash.throttle(
|
||||
this.updateDoTitlesOverflow,
|
||||
500,
|
||||
);
|
||||
this.state = {
|
||||
step: 0,
|
||||
activeMilestoneIdx: 0,
|
||||
doTitlesOverflow: true,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.proposal) {
|
||||
const activeMilestoneIdx = this.getActiveMilestoneIdx();
|
||||
this.setState({ step: activeMilestoneIdx, activeMilestoneIdx });
|
||||
}
|
||||
this.updateDoTitlesOverflow();
|
||||
window.addEventListener('resize', this.throttledUpdateDoTitlesOverflow);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.throttledUpdateDoTitlesOverflow);
|
||||
}
|
||||
|
||||
componentDidUpdate(_: Props, prevState: State) {
|
||||
const activeMilestoneIdx = this.getActiveMilestoneIdx();
|
||||
if (prevState.activeMilestoneIdx !== activeMilestoneIdx) {
|
||||
this.setState({ step: activeMilestoneIdx, activeMilestoneIdx });
|
||||
}
|
||||
}
|
||||
|
||||
export default class ProposalMilestones extends React.Component<Props> {
|
||||
render() {
|
||||
const { proposal } = this.props;
|
||||
|
||||
if (!proposal) {
|
||||
return <Spin />;
|
||||
}
|
||||
const {
|
||||
milestones,
|
||||
crowdFund,
|
||||
crowdFund: { milestoneVotingPeriod, percentVotingForRefund },
|
||||
} = proposal;
|
||||
const { accounts } = this.props;
|
||||
|
||||
const wasRefunded = percentVotingForRefund > 50;
|
||||
const isTrustee = crowdFund.trustees.includes(accounts[0]);
|
||||
const milestoneCount = milestones.length;
|
||||
|
||||
const milestoneSteps = milestones.map((milestone, i) => {
|
||||
const status =
|
||||
this.state.activeMilestoneIdx === i && milestone.state === WAITING
|
||||
? STEP_STATUS.PROCESS
|
||||
: milestoneStateToStepState[milestone.state];
|
||||
|
||||
const className = this.state.step === i ? 'is-active' : 'is-inactive';
|
||||
const estimatedDate = moment(milestone.dateEstimated).format('MMMM YYYY');
|
||||
const reward = (
|
||||
<UnitDisplay value={milestone.amount} symbol="ETH" displayShortBalance={4} />
|
||||
);
|
||||
const approvalPeriod = milestone.isImmediatePayout
|
||||
? 'Immediate'
|
||||
: moment.duration(milestoneVotingPeriod).humanize();
|
||||
const alertStyle = { width: 'fit-content', margin: '0 0 1rem 0' };
|
||||
|
||||
const stepProps = {
|
||||
title: <div ref={this.stepTitleRefs[i]}>{milestone.title}</div>,
|
||||
status,
|
||||
className,
|
||||
onClick: () => this.setState({ step: i }),
|
||||
};
|
||||
|
||||
let notification;
|
||||
|
||||
const { milestones } = proposal;
|
||||
return (
|
||||
<Timeline className="ProposalMilestones" style={{ maxWidth: '800px' }}>
|
||||
{milestones.map((milestone, i) => {
|
||||
let paymentInfo;
|
||||
let icon;
|
||||
let color = 'blue';
|
||||
switch (milestone.state) {
|
||||
case MILESTONE_STATE.PAID:
|
||||
color = 'green';
|
||||
paymentInfo = (
|
||||
<div className="ProposalMilestones-milestone-payoutAmount">
|
||||
The team was awarded{' '}
|
||||
<strong>
|
||||
<UnitDisplay value={milestone.amount} symbol="ETH" />
|
||||
</strong>{' '}
|
||||
case PAID:
|
||||
notification = (
|
||||
<Alert
|
||||
type="success"
|
||||
message={
|
||||
<span>
|
||||
The team was awarded <strong>{reward}</strong>{' '}
|
||||
{milestone.isImmediatePayout
|
||||
? 'as an initial payout'
|
||||
: `on ${moment(milestone.payoutRequestVoteDeadline).format(
|
||||
'MMM Do, YYYY',
|
||||
)}`}
|
||||
</div>
|
||||
.
|
||||
</span>
|
||||
}
|
||||
style={alertStyle}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case MILESTONE_STATE.ACTIVE:
|
||||
icon = <Icon type="exclamation-circle-o" />;
|
||||
paymentInfo = (
|
||||
<div className="ProposalMilestones-milestone-payoutAmount">
|
||||
Payout vote is in progress! Go to the Governance tab to see more.
|
||||
</div>
|
||||
case ACTIVE:
|
||||
notification = (
|
||||
<Alert
|
||||
type="info"
|
||||
message={
|
||||
<span>
|
||||
Payout vote is in progress! The approval period ends{' '}
|
||||
{moment(milestone.payoutRequestVoteDeadline).from(new Date())}.
|
||||
</span>
|
||||
}
|
||||
style={alertStyle}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case MILESTONE_STATE.REJECTED:
|
||||
color = 'red';
|
||||
paymentInfo = (
|
||||
<>
|
||||
<div className="ProposalMilestones-milestone-payoutAmount">
|
||||
case REJECTED:
|
||||
notification = (
|
||||
<Alert
|
||||
type="warning"
|
||||
message={
|
||||
<span>
|
||||
Payout was voted against on{' '}
|
||||
{moment(milestone.payoutRequestVoteDeadline).format('MMM Do, YYYY')}
|
||||
</div>
|
||||
<div className="ProposalMilestones-milestone-payoutInfo">
|
||||
They can request another payout vote at any time
|
||||
</div>
|
||||
</>
|
||||
{moment(milestone.payoutRequestVoteDeadline).format('MMM Do, YYYY')}.
|
||||
{isTrustee ? ' You ' : ' The team '} can request another payout vote at
|
||||
any time.
|
||||
</span>
|
||||
}
|
||||
style={alertStyle}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
paymentInfo = (
|
||||
<>
|
||||
<div className="ProposalMilestones-milestone-payoutAmount">
|
||||
Rewards team with{' '}
|
||||
<strong>
|
||||
<UnitDisplay value={milestone.amount} symbol="ETH" />
|
||||
</strong>
|
||||
</div>
|
||||
<div className="ProposalMilestones-milestone-payoutInfo">
|
||||
{milestone.isImmediatePayout
|
||||
? 'Paid immediately upon funding completion'
|
||||
: 'Paid only on approval after 7 day voting period'}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
if (wasRefunded) {
|
||||
notification = (
|
||||
<Alert
|
||||
type="error"
|
||||
message={
|
||||
<span>A majority of the funders of this project voted for a refund.</span>
|
||||
}
|
||||
style={alertStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Timeline.Item color={color} dot={icon} key={i}>
|
||||
<div className="ProposalMilestones-milestone">
|
||||
{/* TODO: Real data from backend */}
|
||||
<h3 className="ProposalMilestones-milestone-title">{milestone.title}</h3>
|
||||
const statuses = (
|
||||
<div className="ProposalMilestones-milestone-status">
|
||||
{!milestone.isImmediatePayout && (
|
||||
<div className="ProposalMilestones-milestone-estimate">
|
||||
Estimate: {moment(milestone.dateEstimated).format('MMMM YYYY')}
|
||||
<div>
|
||||
Estimate: <strong>{estimatedDate}</strong>
|
||||
</div>
|
||||
)}
|
||||
<p className="ProposalMilestones-milestone-description">
|
||||
{milestone.body}
|
||||
</p>
|
||||
{paymentInfo}
|
||||
<div>
|
||||
Reward: <strong>{reward}</strong>
|
||||
</div>
|
||||
<div>
|
||||
Approval period: <strong>{approvalPeriod}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
);
|
||||
|
||||
const Content = (
|
||||
<div className="ProposalMilestones-milestone">
|
||||
<div className="ProposalMilestones-milestone-body">
|
||||
<div className="ProposalMilestones-milestone-description">
|
||||
<h3 className="ProposalMilestones-milestone-title">{milestone.title}</h3>
|
||||
{statuses}
|
||||
{notification}
|
||||
{milestone.body}
|
||||
</div>
|
||||
{this.state.activeMilestoneIdx === i &&
|
||||
!wasRefunded && (
|
||||
<>
|
||||
<div className="ProposalMilestones-milestone-divider" />
|
||||
<div className="ProposalMilestones-milestone-action">
|
||||
<MilestoneAction proposal={proposal} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return { key: i, stepProps, Content };
|
||||
});
|
||||
|
||||
const stepSize = milestoneCount > 5 ? 'small' : 'default';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this.ref}
|
||||
className={classnames({
|
||||
['ProposalMilestones']: true,
|
||||
['do-titles-overflow']: this.state.doTitlesOverflow,
|
||||
[`is-count-${milestoneCount}`]: true,
|
||||
})}
|
||||
</Timeline>
|
||||
>
|
||||
<Steps current={this.state.step} size={stepSize}>
|
||||
{milestoneSteps.map(mss => (
|
||||
<Steps.Step key={mss.key} {...mss.stepProps} />
|
||||
))}
|
||||
</Steps>
|
||||
{milestoneSteps[this.state.step].Content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private getActiveMilestoneIdx = () => {
|
||||
const { milestones } = this.props.proposal;
|
||||
const activeMilestone =
|
||||
milestones.find(
|
||||
m =>
|
||||
m.state === WAITING ||
|
||||
m.state === ACTIVE ||
|
||||
(m.state === PAID && !m.isPaid) ||
|
||||
m.state === REJECTED,
|
||||
) || milestones[0];
|
||||
return activeMilestone.index;
|
||||
};
|
||||
|
||||
private updateDoTitlesOverflow = () => {
|
||||
// hmr can sometimes muck up refs, let's make sure they all exist
|
||||
if (!this.stepTitleRefs.reduce((a, r) => !!r.current && a)) return;
|
||||
let doTitlesOverflow = false;
|
||||
const stepCount = this.stepTitleRefs.length;
|
||||
if (stepCount > 1) {
|
||||
// avoiding style calculation here by hardcoding antd icon width + padding + margin
|
||||
const iconWidths = stepCount * 56;
|
||||
const totalWidth = this.ref.current.clientWidth;
|
||||
const last = this.stepTitleRefs.slice(stepCount - 1).pop().current;
|
||||
// last title gets full space
|
||||
const lastWidth = last.clientWidth;
|
||||
const remainingWidth = totalWidth - (lastWidth + iconWidths);
|
||||
const remainingWidthSingle = remainingWidth / (stepCount - 1);
|
||||
// first titles have to share remaining space
|
||||
this.stepTitleRefs.slice(0, stepCount - 1).forEach(r => {
|
||||
doTitlesOverflow =
|
||||
doTitlesOverflow || r.current.clientWidth > remainingWidthSingle;
|
||||
});
|
||||
}
|
||||
this.setState({ doTitlesOverflow });
|
||||
};
|
||||
}
|
||||
|
||||
const ConnectedProposalMilestones = connect((state: AppState) => ({
|
||||
accounts: state.web3.accounts,
|
||||
}))(ProposalMilestones);
|
||||
|
||||
export default ConnectedProposalMilestones;
|
||||
|
|
|
@ -1,35 +1,164 @@
|
|||
@medium-query: ~'(max-width: 920px)';
|
||||
@small-query: ~'(max-width: 480px)';
|
||||
|
||||
.ProposalMilestones {
|
||||
width: 100%;
|
||||
|
||||
.ant-steps-customization();
|
||||
|
||||
.ant-steps-item-title > div {
|
||||
position: relative;
|
||||
}
|
||||
&.do-titles-overflow {
|
||||
.ant-steps-item,
|
||||
.ant-steps-item-icon {
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
.ant-steps-item-title {
|
||||
width: 0;
|
||||
padding-right: 0;
|
||||
color: rgba(0, 0, 0, 0) !important;
|
||||
|
||||
& > div {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.ProposalMilestones-milestone-title {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&-milestone {
|
||||
margin-left: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
min-height: 15rem;
|
||||
margin-top: 2rem;
|
||||
|
||||
@media @small-query {
|
||||
margin-top: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&-title {
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0;
|
||||
transform: translateY(-0.5rem);
|
||||
@media @small-query {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-estimate {
|
||||
margin-top: -0.5rem;
|
||||
&-status {
|
||||
white-space: nowrap;
|
||||
@media @small-query {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
& > div {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.5;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
display: inline-block;
|
||||
@media @small-query {
|
||||
margin-bottom: 0;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
& > div + div {
|
||||
padding-left: 0.5em;
|
||||
margin-left: 0.5em;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.35);
|
||||
@media @small-query {
|
||||
padding-left: 0;
|
||||
margin-left: 0;
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-body {
|
||||
display: flex;
|
||||
|
||||
@media @medium-query {
|
||||
flex-direction: column;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-description {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
&-payoutAmount {
|
||||
margin-bottom: 0.2rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
&-description,
|
||||
&-action {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&-payoutInfo {
|
||||
opacity: 0.5;
|
||||
font-size: 0.7rem;
|
||||
&-divider {
|
||||
width: 1px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
margin: 0 2rem;
|
||||
|
||||
@media @medium-query {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-steps-customization() {
|
||||
@media @small-query {
|
||||
display: flex;
|
||||
.ant-steps {
|
||||
width: 50px;
|
||||
margin-left: -2rem;
|
||||
.ant-steps-item {
|
||||
margin-right: 0;
|
||||
}
|
||||
.ant-steps-item-title {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-steps-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ant-steps-item-override(@status, @color, @title-color) {
|
||||
.ant-steps-item-@{status} {
|
||||
&.is-active {
|
||||
.ant-steps-item-icon {
|
||||
background: @color;
|
||||
border-color: @color;
|
||||
& > .ant-steps-icon {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.ant-steps-item-title {
|
||||
color: @title-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.ant-steps-item-override(wait, #949191, #949191);
|
||||
.ant-steps-item-override(finish, #1890ff, #4c4c4c);
|
||||
.ant-steps-item-override(error, #f5222d, #f5222d);
|
||||
|
||||
.ant-steps-item-process {
|
||||
&.is-inactive {
|
||||
.ant-steps-item-icon {
|
||||
background: #fff;
|
||||
border-color: #1890ff;
|
||||
& > .ant-steps-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,18 +1,16 @@
|
|||
import React from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import { CrowdFund } from 'modules/proposals/reducers';
|
||||
import { Proposal } from 'types';
|
||||
import UserRow from 'components/UserRow';
|
||||
|
||||
interface Props {
|
||||
crowdFund: CrowdFund;
|
||||
proposal: Proposal;
|
||||
}
|
||||
|
||||
const TeamBlock = ({ crowdFund }: Props) => {
|
||||
const TeamBlock = ({ proposal }: Props) => {
|
||||
let content;
|
||||
if (crowdFund) {
|
||||
content = crowdFund.trustees.map(trustee => (
|
||||
<UserRow key={trustee} address={trustee} />
|
||||
));
|
||||
if (proposal) {
|
||||
content = proposal.team.map(user => <UserRow key={user.name} user={user} />);
|
||||
} else {
|
||||
content = <Spin />;
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Spin } from 'antd';
|
|||
import Markdown from 'components/Markdown';
|
||||
import moment from 'moment';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
|
||||
import { ProposalWithCrowdFund } from 'types';
|
||||
import { fetchProposalUpdates } from 'modules/proposals/actions';
|
||||
import {
|
||||
getProposalUpdates,
|
||||
|
|
|
@ -5,7 +5,7 @@ import Markdown from 'components/Markdown';
|
|||
import { proposalActions } from 'modules/proposals';
|
||||
import { bindActionCreators, Dispatch } from 'redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
|
||||
import { ProposalWithCrowdFund } from 'types';
|
||||
import { getProposal } from 'modules/proposals/selectors';
|
||||
import { Spin, Tabs, Icon, Dropdown, Menu, Button } from 'antd';
|
||||
import CampaignBlock from './CampaignBlock';
|
||||
|
@ -22,6 +22,7 @@ import classnames from 'classnames';
|
|||
import { withRouter } from 'react-router';
|
||||
import Web3Container from 'lib/Web3Container';
|
||||
import { web3Actions } from 'modules/web3';
|
||||
import SocialShare from 'components/SocialShare';
|
||||
|
||||
interface OwnProps {
|
||||
proposalId: string;
|
||||
|
@ -86,6 +87,7 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
} else {
|
||||
const { crowdFund } = proposal;
|
||||
const isTrustee = crowdFund.trustees.includes(account);
|
||||
const isContributor = !!crowdFund.contributors.find(c => c.address === account);
|
||||
const hasBeenFunded = crowdFund.isRaiseGoalReached;
|
||||
const isProposalActive = !hasBeenFunded && crowdFund.deadline > Date.now();
|
||||
const canRefund = (hasBeenFunded || isProposalActive) && !crowdFund.isFrozen;
|
||||
|
@ -111,6 +113,15 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
return (
|
||||
<div className="Proposal">
|
||||
<div className="Proposal-top">
|
||||
<div className="Proposal-top-social">
|
||||
<SocialShare
|
||||
url={window.location.href}
|
||||
title={`${proposal.title} needs funding on Grant-io!`}
|
||||
text={`${
|
||||
proposal.title
|
||||
} needs funding on Grant.io! Come help make this proposal a reality by funding it.`}
|
||||
/>
|
||||
</div>
|
||||
<div className="Proposal-top-main">
|
||||
<h1 className="Proposal-top-main-title">
|
||||
{proposal ? proposal.title : <span> </span>}
|
||||
|
@ -151,7 +162,7 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
</div>
|
||||
<div className="Proposal-top-side">
|
||||
<CampaignBlock proposal={proposal} isPreview={isPreview} />
|
||||
<TeamBlock crowdFund={crowdFund} />
|
||||
<TeamBlock proposal={proposal} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -169,9 +180,11 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
<div style={{ marginTop: '1.5rem' }} />
|
||||
<UpdatesTab proposalId={proposal.proposalId} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="Governance" key="governance">
|
||||
{isContributor && (
|
||||
<Tabs.TabPane tab="Refund" key="refund">
|
||||
<GovernanceTab proposal={proposal} />
|
||||
</Tabs.TabPane>
|
||||
)}
|
||||
<Tabs.TabPane tab="Contributors" key="contributors">
|
||||
<ContributorsTab crowdFund={proposal.crowdFund} />
|
||||
</Tabs.TabPane>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
@collapse-width: 1100px;
|
||||
@single-col-width: 600px;
|
||||
@block-title-space: 3.75rem;
|
||||
|
||||
.Proposal {
|
||||
max-width: 1280px;
|
||||
|
@ -14,6 +15,21 @@
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
&-social {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: @block-title-space;
|
||||
margin-right: 0.5em;
|
||||
margin-left: -1rem;
|
||||
font-size: 1.1rem;
|
||||
|
||||
@media (max-width: @collapse-width) {
|
||||
position: absolute;
|
||||
font-size: 1rem;
|
||||
margin-left: -1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
|
|
@ -4,14 +4,14 @@ import { Progress, Icon, Spin } from 'antd';
|
|||
import moment from 'moment';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { CATEGORY_UI } from 'api/constants';
|
||||
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
|
||||
import { ProposalWithCrowdFund } from 'types';
|
||||
import './style.less';
|
||||
import { Dispatch, bindActionCreators } from 'redux';
|
||||
import * as web3Actions from 'modules/web3/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { connect } from 'react-redux';
|
||||
import { compose } from 'recompose';
|
||||
import Identicon from 'components/Identicon';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import UnitDisplay from 'components/UnitDisplay';
|
||||
|
||||
interface Props extends ProposalWithCrowdFund {
|
||||
|
@ -24,8 +24,15 @@ export class ProposalCard extends React.Component<Props> {
|
|||
if (this.state.redirect) {
|
||||
return <Redirect push to={this.state.redirect} />;
|
||||
}
|
||||
const { title, proposalId, category, dateCreated, web3, crowdFund } = this.props;
|
||||
const team = [...this.props.team].reverse();
|
||||
const {
|
||||
title,
|
||||
proposalId,
|
||||
category,
|
||||
dateCreated,
|
||||
web3,
|
||||
crowdFund,
|
||||
team,
|
||||
} = this.props;
|
||||
|
||||
if (!web3) {
|
||||
return <Spin />;
|
||||
|
@ -58,12 +65,15 @@ export class ProposalCard extends React.Component<Props> {
|
|||
|
||||
<div className="ProposalCard-team">
|
||||
<div className="ProposalCard-team-name">
|
||||
{team[0].accountAddress}{' '}
|
||||
{team.length > 1 && <small>+{team.length - 1} other</small>}
|
||||
{team[0].name} {team.length > 1 && <small>+{team.length - 1} other</small>}
|
||||
</div>
|
||||
<div className="ProposalCard-team-avatars">
|
||||
{team.reverse().map(u => (
|
||||
<Identicon key={u.userid} address={u.accountAddress} />
|
||||
{[...team].reverse().map((u, idx) => (
|
||||
<UserAvatar
|
||||
key={idx}
|
||||
className="ProposalCard-team-avatars-avatar"
|
||||
user={u}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -51,11 +51,12 @@
|
|||
flex-direction: row-reverse;
|
||||
margin-left: 1.25rem;
|
||||
|
||||
img {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
&-avatar {
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
margin-left: -0.75rem;
|
||||
border-radius: 100%;
|
||||
border: 2px solid #FFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { compose } from 'recompose';
|
|||
import { connect } from 'react-redux';
|
||||
import { proposalActions } from 'modules/proposals';
|
||||
import { getProposals } from 'modules/proposals/selectors';
|
||||
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
|
||||
import { ProposalWithCrowdFund } from 'types';
|
||||
import { bindActionCreators, Dispatch } from 'redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Input, Divider, Spin, Drawer, Icon, Button } from 'antd';
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import './ShortAddress.less';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ShortAddress = ({ address }: Props) => (
|
||||
<div className="ShortAddress">
|
||||
const ShortAddress = ({ address, className }: Props) => (
|
||||
<div className={classnames('ShortAddress', className)}>
|
||||
<div className="ShortAddress-bookend">{address.substr(0, 7)}</div>
|
||||
<div className="ShortAddress-middle">{address.substr(7, address.length - 5)}</div>
|
||||
<div className="ShortAddress-bookend">{address.substr(address.length - 5)}</div>
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
@twitter-color: #009cf7;
|
||||
@reddit-color: #ff4500;
|
||||
@facebook-color: #4063b6;
|
||||
@linkedin-color: #0074b8;
|
||||
|
||||
.SocialShare {
|
||||
&-button {
|
||||
display: block;
|
||||
opacity: 0.8;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.social-mixin(@name, @color) {
|
||||
&.is-@{name} {
|
||||
color: desaturate(@color, 80%);
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
color: @color;
|
||||
}
|
||||
}
|
||||
}
|
||||
.social-mixin(twitter, @twitter-color);
|
||||
.social-mixin(reddit, @reddit-color);
|
||||
.social-mixin(facebook, @facebook-color);
|
||||
.social-mixin(linkedin, @linkedin-color);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
import React from 'react';
|
||||
import './SocialShare.less';
|
||||
|
||||
interface TypeOptions {
|
||||
className: string;
|
||||
url: (url: string, title: string, text: string) => string;
|
||||
}
|
||||
|
||||
const types: { [index: string]: TypeOptions } = {
|
||||
twitter: {
|
||||
className: 'fab fa-twitter-square',
|
||||
url: (url: string, _: string, text: string) =>
|
||||
`https://twitter.com/intent/tweet?url=${url}&text=${text}`,
|
||||
},
|
||||
reddit: {
|
||||
className: 'fab fa-reddit-square',
|
||||
url: (url: string, title: string) =>
|
||||
`https://reddit.com/submit?url=${url}&title=${title}`,
|
||||
},
|
||||
facebook: {
|
||||
className: 'fab fa-facebook-square',
|
||||
url: (url: string) => `http://www.facebook.com/sharer.php?u=${url}`,
|
||||
},
|
||||
linkedin: {
|
||||
className: 'fab fa-linkedin-square',
|
||||
url: (url: string, title: string, text: string) =>
|
||||
`https://www.linkedin.com/shareArticle?mini=true&url=${url}&title=${title}&summary=${text}`,
|
||||
},
|
||||
};
|
||||
|
||||
interface OwnProps {
|
||||
url: string;
|
||||
text: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
type Props = OwnProps;
|
||||
|
||||
export default class SocialShare extends React.Component<Props> {
|
||||
render() {
|
||||
let { url, title, text } = this.props;
|
||||
url = url.replace(`localhost:${process.env.PORT}`, 'demo.grant.io');
|
||||
url = encodeURIComponent(url);
|
||||
title = encodeURIComponent(title);
|
||||
text = encodeURIComponent(text);
|
||||
return (
|
||||
<div className="SocialShare">
|
||||
{Object.keys(types).map(key => {
|
||||
const opts = types[key];
|
||||
return (
|
||||
<a
|
||||
target="popup"
|
||||
onClick={() => windowOpen(opts.url(url, title, text))}
|
||||
key={key}
|
||||
className={`SocialShare-button is-${key}`}
|
||||
>
|
||||
<i className={opts.className} />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function windowOpen(url: string, name = 'Share', width = 550, height = 500) {
|
||||
const left =
|
||||
window.outerWidth / 2 + (window.screenX || window.screenLeft || 0) - width / 2;
|
||||
const top =
|
||||
window.outerHeight / 2 + (window.screenY || window.screenTop || 0) - height / 2;
|
||||
|
||||
const config: { [index: string]: any } = {
|
||||
height,
|
||||
width,
|
||||
left,
|
||||
top,
|
||||
location: 'no',
|
||||
toolbar: 'no',
|
||||
status: 'no',
|
||||
directories: 'no',
|
||||
menubar: 'no',
|
||||
scrollbars: 'yes',
|
||||
resizable: 'no',
|
||||
centerscreen: 'yes',
|
||||
chrome: 'yes',
|
||||
};
|
||||
|
||||
const shareDialog = window.open(
|
||||
url,
|
||||
name,
|
||||
Object.keys(config)
|
||||
.map(key => `${key}=${config[key]}`)
|
||||
.join(', '),
|
||||
);
|
||||
|
||||
return shareDialog;
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
@keyframes fade-in {
|
||||
from {
|
||||
transform: translateY(1rem);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0rem);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.Web3Error {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
margin: 0 auto;
|
||||
animation: fade-in 500ms ease;
|
||||
|
||||
&-icon {
|
||||
display: block;
|
||||
height: 120px;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
&-message {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
&-button {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
padding: 0;
|
||||
height: 3rem;
|
||||
line-height: 3rem;
|
||||
max-width: 220px;
|
||||
font-size: 1.2rem;
|
||||
color: #fff;
|
||||
background: #f88500;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import './Web3Error.less';
|
||||
|
||||
interface Props {
|
||||
icon?: string;
|
||||
message: React.ReactNode;
|
||||
button?: {
|
||||
text: React.ReactNode;
|
||||
href?: string;
|
||||
onClick?: (ev: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const Web3Error: React.SFC<Props> = ({ icon, message, button }) => (
|
||||
<div className="Web3Error">
|
||||
{icon && <img className="Web3Error-icon" src={icon} />}
|
||||
<p className="Web3Error-message">{message}</p>
|
||||
{button && (
|
||||
<a
|
||||
className="Web3Error-button"
|
||||
onClick={button.onClick}
|
||||
href={button.href}
|
||||
target="_blank"
|
||||
rel="noopener nofollow"
|
||||
>
|
||||
{button.text}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Web3Error;
|
|
@ -0,0 +1,39 @@
|
|||
.Template {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
|
||||
&-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: 0 2.5rem;
|
||||
|
||||
.is-fullscreen & {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&-inner {
|
||||
width: 100%;
|
||||
padding-top: 2.5rem;
|
||||
padding-bottom: 2.5rem;
|
||||
min-height: 280px;
|
||||
|
||||
.is-fullscreen & {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.is-centered & {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
&-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 280px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Layout, Spin } from 'antd';
|
||||
import classnames from 'classnames';
|
||||
import BasicHead from 'components/BasicHead';
|
||||
import Header from 'components/Header';
|
||||
import Footer from 'components/Footer';
|
||||
import Web3Container from 'lib/Web3Container';
|
||||
import Web3Error from './Web3Error';
|
||||
import { web3Actions } from 'modules/web3';
|
||||
import { AppState } from 'store/reducers';
|
||||
import MetamaskIcon from 'static/images/metamask.png';
|
||||
import WrongNetworkIcon from 'static/images/wrong-network.png';
|
||||
import './index.less';
|
||||
|
||||
interface StateProps {
|
||||
isMissingWeb3: boolean;
|
||||
isWeb3Locked: boolean;
|
||||
isWrongNetwork: boolean;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
setAccounts: typeof web3Actions['setAccounts'];
|
||||
}
|
||||
|
||||
export interface TemplateProps {
|
||||
title: string;
|
||||
isHeaderTransparent?: boolean;
|
||||
isFullScreen?: boolean;
|
||||
hideFooter?: boolean;
|
||||
requiresWeb3?: boolean;
|
||||
}
|
||||
|
||||
type Props = StateProps & DispatchProps & TemplateProps;
|
||||
|
||||
class Template extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const {
|
||||
children,
|
||||
title,
|
||||
isHeaderTransparent,
|
||||
isFullScreen,
|
||||
hideFooter,
|
||||
requiresWeb3,
|
||||
isMissingWeb3,
|
||||
isWeb3Locked,
|
||||
isWrongNetwork,
|
||||
} = this.props;
|
||||
|
||||
let content = children;
|
||||
let isCentered = false;
|
||||
if (requiresWeb3) {
|
||||
if (isMissingWeb3) {
|
||||
isCentered = true;
|
||||
content = (
|
||||
<Web3Error
|
||||
icon={MetamaskIcon}
|
||||
message={`
|
||||
This page requires a web3 client to use. Either unlock or install the
|
||||
MetaMask browser extension and refresh to continue.
|
||||
`}
|
||||
button={{
|
||||
text: 'Get MetaMask',
|
||||
href: 'https://metamask.io/',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (isWeb3Locked) {
|
||||
isCentered = true;
|
||||
content = (
|
||||
<Web3Error
|
||||
icon={MetamaskIcon}
|
||||
message={`
|
||||
It looks like your MetaMask account is locked. Please unlock it and click the
|
||||
button below to continue.
|
||||
`}
|
||||
button={{
|
||||
text: 'Try again',
|
||||
onClick: this.props.setAccounts,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (isWrongNetwork) {
|
||||
isCentered = true;
|
||||
content = (
|
||||
<Web3Error
|
||||
icon={WrongNetworkIcon}
|
||||
message={
|
||||
<>
|
||||
The Grant.io smart contract is currently only supported on the{' '}
|
||||
<strong>Ropsten</strong> network. Please change your network to continue.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Web3Container
|
||||
render={() => children}
|
||||
renderLoading={() => (
|
||||
<div className="Template-content-inner-loading">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const className = classnames(
|
||||
'Template',
|
||||
isFullScreen && 'is-fullscreen',
|
||||
isCentered && 'is-centered',
|
||||
);
|
||||
return (
|
||||
<BasicHead title={title}>
|
||||
<div className={className}>
|
||||
<Header isTransparent={isHeaderTransparent} />
|
||||
<Layout.Content className="Template-content">
|
||||
<div className="Template-content-inner">{content}</div>
|
||||
</Layout.Content>
|
||||
{!hideFooter && <Footer />}
|
||||
</div>
|
||||
</BasicHead>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect<StateProps, DispatchProps, TemplateProps, AppState>(
|
||||
state => ({
|
||||
isMissingWeb3: state.web3.isMissingWeb3,
|
||||
isWeb3Locked: state.web3.isWeb3Locked,
|
||||
isWrongNetwork: state.web3.isWrongNetwork,
|
||||
}),
|
||||
{
|
||||
setAccounts: web3Actions.setAccounts,
|
||||
},
|
||||
)(Template);
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import Identicon from 'components/Identicon';
|
||||
import { TeamMember } from 'types';
|
||||
import defaultUserImg from 'static/images/default-user.jpg';
|
||||
|
||||
interface Props {
|
||||
user: TeamMember;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const UserAvatar: React.SFC<Props> = ({ user, className }) => {
|
||||
if (user.avatarUrl) {
|
||||
return <img className={className} src={user.avatarUrl} />;
|
||||
} else if (user.ethAddress) {
|
||||
return <Identicon className={className} address={user.ethAddress} />;
|
||||
} else {
|
||||
return <img className={className} src={defaultUserImg} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default UserAvatar;
|
|
@ -1,25 +1,23 @@
|
|||
import React from 'react';
|
||||
import ShortAddress from 'components/ShortAddress';
|
||||
import Identicon from 'components/Identicon';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import { TeamMember } from 'types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import './style.less';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
secondary?: React.ReactNode;
|
||||
user: TeamMember;
|
||||
}
|
||||
|
||||
const UserRow = ({ address, secondary }: Props) => (
|
||||
<div className="UserRow">
|
||||
const UserRow = ({ user }: Props) => (
|
||||
<Link to={`/profile/${user.ethAddress || user.emailAddress}`} className="UserRow">
|
||||
<div className="UserRow-avatar">
|
||||
<Identicon address={address} />
|
||||
<UserAvatar user={user} className="UserRow-avatar-img" />
|
||||
</div>
|
||||
<div className="UserRow-info">
|
||||
<div className="UserRow-info-main">
|
||||
<ShortAddress address={address} />
|
||||
</div>
|
||||
{secondary && <p className="UserRow-info-secondary">{secondary}</p>}
|
||||
</div>
|
||||
<div className="UserRow-info-main">{user.name}</div>
|
||||
<p className="UserRow-info-secondary">{user.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default UserRow;
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
display: flex;
|
||||
height: @height;
|
||||
margin-bottom: 1rem;
|
||||
color: inherit;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
|
@ -16,7 +17,7 @@
|
|||
width: @height;
|
||||
margin-right: 0.75rem;
|
||||
|
||||
img {
|
||||
&-img {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
|
|
@ -1,109 +0,0 @@
|
|||
import React from 'react';
|
||||
import Web3Container, { Web3RenderProps } from 'lib/Web3Container';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Spin } from 'antd';
|
||||
import AntWrap, { Props as AntWrapProps } from 'components/AntWrap';
|
||||
import { web3Actions } from 'modules/web3';
|
||||
import MetamaskIcon from 'static/images/metamask.png';
|
||||
import WrongNetworkIcon from 'static/images/wrong-network.png';
|
||||
import './style.less';
|
||||
|
||||
interface OwnProps extends AntWrapProps {
|
||||
render(props: Web3RenderProps & any): React.ReactNode;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
isMissingWeb3: boolean;
|
||||
isWeb3Locked: boolean;
|
||||
isWrongNetwork: boolean;
|
||||
}
|
||||
|
||||
interface ActionProps {
|
||||
setAccounts: typeof web3Actions['setAccounts'];
|
||||
}
|
||||
|
||||
type Props = OwnProps & StateProps & ActionProps;
|
||||
|
||||
const Web3Page = (props: Props) => {
|
||||
const { render, isMissingWeb3, isWeb3Locked, isWrongNetwork, ...rest } = props;
|
||||
let content;
|
||||
let centerContent = false;
|
||||
if (isMissingWeb3) {
|
||||
centerContent = true;
|
||||
content = (
|
||||
<div className="Web3Page-error">
|
||||
<img className="Web3Page-error-icon" src={MetamaskIcon} />
|
||||
<p className="Web3Page-error-message">
|
||||
This page requires a web3 client to use. Either unlock or install the MetaMask
|
||||
browser extension and refresh to continue.
|
||||
</p>
|
||||
<a
|
||||
className="Web3Page-error-metamaskButton"
|
||||
href="https://metamask.io/"
|
||||
target="_blank"
|
||||
rel="noopener nofollow"
|
||||
>
|
||||
Get MetaMask
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
} else if (isWeb3Locked) {
|
||||
centerContent = true;
|
||||
content = (
|
||||
<div className="Web3Page-error">
|
||||
<img className="Web3Page-error-icon" src={MetamaskIcon} />
|
||||
<p className="Web3Page-error-message">
|
||||
It looks like your MetaMask account is locked. Please unlock it and click the
|
||||
button below to continue.
|
||||
</p>
|
||||
<a className="Web3Page-error-metamaskButton" onClick={props.setAccounts}>
|
||||
Try again
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
} else if (isWrongNetwork) {
|
||||
centerContent = true;
|
||||
content = (
|
||||
<div className="Web3Page-error">
|
||||
<img className="Web3Page-error-icon" src={WrongNetworkIcon} />
|
||||
<p className="Web3Page-error-message">
|
||||
The Grant.io smart contract is currently only supported on the{' '}
|
||||
<strong>Ropsten</strong> network. Please change your network to continue.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Web3Container
|
||||
render={render}
|
||||
renderLoading={() => (
|
||||
<div className="Web3Page-loading">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AntWrap centerContent={centerContent} {...rest}>
|
||||
<div className="Web3Page">{content}</div>
|
||||
</AntWrap>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: AppState): StateProps {
|
||||
return {
|
||||
isMissingWeb3: state.web3.isMissingWeb3,
|
||||
isWeb3Locked: state.web3.isWeb3Locked,
|
||||
isWrongNetwork: state.web3.isWrongNetwork,
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{
|
||||
setAccounts: web3Actions.setAccounts,
|
||||
},
|
||||
)(Web3Page);
|
|
@ -1,56 +0,0 @@
|
|||
.Web3Page {
|
||||
&-error {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
margin: 0 auto;
|
||||
animation: fade-in 500ms ease;
|
||||
|
||||
&-icon {
|
||||
display: block;
|
||||
height: 120px;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
&-message {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
&-metamaskButton {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
padding: 0;
|
||||
height: 3rem;
|
||||
line-height: 3rem;
|
||||
max-width: 220px;
|
||||
font-size: 1.2rem;
|
||||
color: #fff;
|
||||
background: #f88500;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
transform: translateY(1rem);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0rem);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 280px;
|
||||
}
|
||||
}
|
|
@ -5,18 +5,20 @@ import { hydrate } from 'react-dom';
|
|||
import { loadComponents } from 'loadable-components';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
import { configureStore } from 'store/configure';
|
||||
import Routes from './Routes';
|
||||
|
||||
const initialState = window && (window as any).__PRELOADED_STATE__;
|
||||
const store = configureStore(initialState);
|
||||
const { store, persistor } = configureStore(initialState);
|
||||
|
||||
const App = hot(module)(() => (
|
||||
<Provider store={store}>
|
||||
<PersistGate persistor={persistor}>
|
||||
<Router>
|
||||
<Routes />
|
||||
</Router>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
));
|
||||
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
import React from 'react';
|
||||
import Web3 from 'web3';
|
||||
import { bindActionCreators, Dispatch } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { web3Actions } from 'modules/web3';
|
||||
/* tslint:disable no-var-requires --- TODO: find a better way to import json */
|
||||
const CrowdFundFactory = require('./contracts/CrowdFundFactory.json');
|
||||
|
||||
export interface Web3RenderProps {
|
||||
web3: Web3;
|
||||
|
@ -20,48 +16,13 @@ interface OwnProps {
|
|||
|
||||
interface StateProps {
|
||||
web3: Web3 | null;
|
||||
isWeb3Locked: boolean;
|
||||
contracts: any[];
|
||||
contractsLoading: boolean;
|
||||
contractsError: null | string;
|
||||
accounts: any[];
|
||||
accountsLoading: boolean;
|
||||
accountsError: null | string;
|
||||
}
|
||||
|
||||
interface ActionProps {
|
||||
setContract: typeof web3Actions['setContract'];
|
||||
setAccounts: typeof web3Actions['setAccounts'];
|
||||
setWeb3: typeof web3Actions['setWeb3'];
|
||||
}
|
||||
|
||||
type Props = OwnProps & StateProps & ActionProps;
|
||||
type Props = OwnProps & StateProps;
|
||||
|
||||
class Web3Container extends React.Component<Props> {
|
||||
componentDidUpdate() {
|
||||
const {
|
||||
web3,
|
||||
contracts,
|
||||
contractsLoading,
|
||||
contractsError,
|
||||
accounts,
|
||||
accountsLoading,
|
||||
accountsError,
|
||||
isWeb3Locked,
|
||||
} = this.props;
|
||||
if (web3 && !contracts.length && !contractsLoading && !contractsError) {
|
||||
this.props.setContract(CrowdFundFactory);
|
||||
}
|
||||
|
||||
if (web3 && !accounts.length && !accountsLoading && !accountsError && !isWeb3Locked) {
|
||||
this.props.setAccounts();
|
||||
}
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
this.props.setWeb3();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { web3, accounts, contracts } = this.props;
|
||||
|
||||
|
@ -74,21 +35,9 @@ class Web3Container extends React.Component<Props> {
|
|||
function mapStateToProps(state: AppState): StateProps {
|
||||
return {
|
||||
web3: state.web3.web3,
|
||||
isWeb3Locked: state.web3.isWeb3Locked,
|
||||
contracts: state.web3.contracts,
|
||||
contractsLoading: state.web3.contractsLoading,
|
||||
contractsError: state.web3.contractsError,
|
||||
accounts: state.web3.accounts,
|
||||
accountsLoading: state.web3.accountsLoading,
|
||||
accountsError: state.web3.accountsError,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return bindActionCreators(web3Actions, dispatch);
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
)(Web3Container);
|
||||
export default connect(mapStateToProps)(Web3Container);
|
||||
|
|
|
@ -0,0 +1,135 @@
|
|||
import types from './types';
|
||||
import { Dispatch } from 'redux';
|
||||
import { sleep } from 'utils/helpers';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { createUser as apiCreateUser, getUser as apiGetUser } from 'api/api';
|
||||
|
||||
type GetState = () => AppState;
|
||||
|
||||
export function authUser(address: string) {
|
||||
return async (dispatch: Dispatch<any>) => {
|
||||
dispatch({ type: types.AUTH_USER_PENDING });
|
||||
|
||||
// TODO: Actually auth using a signed token
|
||||
try {
|
||||
const res = await apiGetUser(address);
|
||||
dispatch({
|
||||
type: types.AUTH_USER_FULFILLED,
|
||||
payload: {
|
||||
user: res.data,
|
||||
token: '123fake', // TODO: Use real token
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
dispatch({
|
||||
type: types.AUTH_USER_REJECTED,
|
||||
payload: err.message || err.toString(),
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createUser(user: {
|
||||
address: string;
|
||||
email: string;
|
||||
name: string;
|
||||
title: string;
|
||||
}) {
|
||||
return async (dispatch: Dispatch<any>) => {
|
||||
dispatch({ type: types.CREATE_USER_PENDING });
|
||||
|
||||
try {
|
||||
// TODO: Pass real token
|
||||
const token = Math.random().toString();
|
||||
const res = await apiCreateUser({
|
||||
accountAddress: user.address,
|
||||
emailAddress: user.email,
|
||||
displayName: user.name,
|
||||
title: user.title,
|
||||
token,
|
||||
});
|
||||
dispatch({
|
||||
type: types.CREATE_USER_FULFILLED,
|
||||
payload: {
|
||||
user: res.data,
|
||||
token,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
dispatch({
|
||||
type: types.CREATE_USER_REJECTED,
|
||||
payload: err.message || err.toString(),
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function checkUser(address: string) {
|
||||
return async (dispatch: Dispatch<any>, getState: GetState) => {
|
||||
const checkedUsers = getState().auth.checkedUsers;
|
||||
if (checkedUsers[address] !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: types.CHECK_USER_PENDING });
|
||||
|
||||
try {
|
||||
const res = await apiGetUser(address);
|
||||
dispatch({
|
||||
type: types.CHECK_USER_FULFILLED,
|
||||
payload: {
|
||||
address,
|
||||
user: res.data,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status === 404) {
|
||||
dispatch({
|
||||
type: types.CHECK_USER_FULFILLED,
|
||||
payload: {
|
||||
address,
|
||||
user: false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: types.CHECK_USER_REJECTED,
|
||||
payload: err.message || err.toString(),
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function signToken(address: string) {
|
||||
return async (dispatch: Dispatch<any>) => {
|
||||
// TODO: Implement signing
|
||||
dispatch({ type: types.SIGN_TOKEN_PENDING });
|
||||
await sleep(500);
|
||||
dispatch({
|
||||
type: types.SIGN_TOKEN_FULFILLED,
|
||||
payload: {
|
||||
token: Math.random(),
|
||||
address,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function setToken(address: string, signedMessage: string) {
|
||||
// TODO: Check token for errors
|
||||
return {
|
||||
type: types.SIGN_TOKEN_FULFILLED,
|
||||
payload: {
|
||||
token: signedMessage,
|
||||
address,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return { type: types.LOGOUT };
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import reducers, { AuthState, INITIAL_STATE } from './reducers';
|
||||
import * as authActions from './actions';
|
||||
import * as authTypes from './types';
|
||||
import authSagas from './sagas';
|
||||
|
||||
export * from './persistence';
|
||||
|
||||
export { authActions, authTypes, authSagas, AuthState, INITIAL_STATE };
|
||||
|
||||
export default reducers;
|
|
@ -0,0 +1,9 @@
|
|||
import { PersistConfig } from 'redux-persist';
|
||||
import storage from 'redux-persist/lib/storage';
|
||||
|
||||
export const authPersistConfig: PersistConfig = {
|
||||
key: 'auth',
|
||||
storage,
|
||||
version: 1,
|
||||
whitelist: ['token', 'tokenAddress'],
|
||||
};
|
|
@ -0,0 +1,143 @@
|
|||
import types from './types';
|
||||
// TODO: Use a common User type instead of this
|
||||
import { TeamMember } from 'types';
|
||||
|
||||
export interface AuthState {
|
||||
user: TeamMember | null;
|
||||
isAuthingUser: boolean;
|
||||
authUserError: string | null;
|
||||
|
||||
checkedUsers: { [address: string]: TeamMember | false };
|
||||
isCheckingUser: boolean;
|
||||
|
||||
isCreatingUser: boolean;
|
||||
createUserError: string | null;
|
||||
|
||||
token: string | null;
|
||||
tokenAddress: string | null;
|
||||
isSigningToken: boolean;
|
||||
signTokenError: string | null;
|
||||
}
|
||||
|
||||
export const INITIAL_STATE: AuthState = {
|
||||
user: null,
|
||||
isAuthingUser: false,
|
||||
authUserError: null,
|
||||
|
||||
isCreatingUser: false,
|
||||
createUserError: null,
|
||||
|
||||
checkedUsers: {},
|
||||
isCheckingUser: false,
|
||||
|
||||
token: null,
|
||||
tokenAddress: null,
|
||||
isSigningToken: false,
|
||||
signTokenError: null,
|
||||
};
|
||||
|
||||
export default function createReducer(state: AuthState = INITIAL_STATE, action: any) {
|
||||
switch (action.type) {
|
||||
case types.AUTH_USER_PENDING:
|
||||
return {
|
||||
...state,
|
||||
user: null,
|
||||
isAuthingUser: true,
|
||||
authUserError: null,
|
||||
};
|
||||
case types.AUTH_USER_FULFILLED:
|
||||
return {
|
||||
...state,
|
||||
user: action.payload.user,
|
||||
token: action.payload.token, // TODO: Make this the real token
|
||||
tokenAddress: action.payload.user.ethAddress,
|
||||
isAuthingUser: false,
|
||||
};
|
||||
case types.AUTH_USER_REJECTED:
|
||||
return {
|
||||
...state,
|
||||
isAuthingUser: false,
|
||||
authUserError: action.payload,
|
||||
};
|
||||
|
||||
case types.CREATE_USER_PENDING:
|
||||
return {
|
||||
...state,
|
||||
isCreatingUser: true,
|
||||
createUserError: null,
|
||||
};
|
||||
case types.CREATE_USER_FULFILLED:
|
||||
return {
|
||||
...state,
|
||||
user: action.payload.user,
|
||||
token: action.payload.token,
|
||||
tokenAddress: action.payload.user.ethAddress,
|
||||
isCreatingUser: false,
|
||||
checkedUsers: {
|
||||
...state.checkedUsers,
|
||||
[action.payload.user.address]: action.payload.user,
|
||||
},
|
||||
};
|
||||
case types.CREATE_USER_REJECTED:
|
||||
return {
|
||||
...state,
|
||||
isCreatingUser: false,
|
||||
createUserError: action.payload,
|
||||
};
|
||||
|
||||
case types.CHECK_USER_PENDING:
|
||||
return {
|
||||
...state,
|
||||
isCheckingUser: true,
|
||||
};
|
||||
case types.CHECK_USER_FULFILLED:
|
||||
return {
|
||||
...state,
|
||||
isCheckingUser: false,
|
||||
checkedUsers: action.payload.user
|
||||
? {
|
||||
...state.checkedUsers,
|
||||
[action.payload.address]: action.payload.user,
|
||||
}
|
||||
: {
|
||||
...state.checkedUsers,
|
||||
[action.payload.address]: false,
|
||||
},
|
||||
};
|
||||
case types.CHECK_USER_REJECTED:
|
||||
return {
|
||||
...state,
|
||||
isCheckingUser: false,
|
||||
};
|
||||
|
||||
case types.SIGN_TOKEN_PENDING:
|
||||
return {
|
||||
...state,
|
||||
token: null,
|
||||
isSigningToken: true,
|
||||
signTokenError: null,
|
||||
};
|
||||
case types.SIGN_TOKEN_FULFILLED:
|
||||
return {
|
||||
...state,
|
||||
token: action.payload.token,
|
||||
tokenAddress: action.payload.address,
|
||||
isSigningToken: false,
|
||||
};
|
||||
case types.SIGN_TOKEN_REJECTED:
|
||||
return {
|
||||
...state,
|
||||
isSigningToken: false,
|
||||
signTokenError: action.payload,
|
||||
};
|
||||
|
||||
case types.LOGOUT:
|
||||
return {
|
||||
...state,
|
||||
user: null,
|
||||
token: null,
|
||||
tokenAddress: null,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue