Merge pull request #142 from grant-project/develop

Release 4
This commit is contained in:
Daniel Ternyak 2018-10-09 12:33:17 -07:00 committed by GitHub
commit a455f7d9b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
163 changed files with 6523 additions and 1146 deletions

View File

@ -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:

View File

@ -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)

View File

@ -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')
@ -14,7 +14,50 @@ def get_users():
if not proposal:
users = User.query.all()
else:
users = User.query.join(proposal_team).join(Proposal)\
users = User.query.join(proposal_team).join(Proposal) \
.filter(proposal_team.c.proposal_id == proposal.id).all()
result = users_schema.dump(users)
return JSONResponse(result)
@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)

View File

@ -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"])

View File

@ -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);
}
}
@ -299,4 +298,4 @@ contract CrowdFund {
_;
}
}
}

View File

@ -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);

View File

@ -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;
});
}

View File

@ -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;

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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>
);
};

View File

@ -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);
}
}
}

View File

@ -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>
);
}
}

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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);

View File

@ -0,0 +1,9 @@
.AddressProvider {
width: 100%;
max-width: 360px;
margin: -0.5rem auto 0;
&-address {
margin-bottom: 0.5rem;
}
}

View File

@ -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);
};
}

View File

@ -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%;
}
}
}

View File

@ -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>
);

View File

@ -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;
}
}

View File

@ -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,
});
};
}

View File

@ -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;
};
}

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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';

View File

@ -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 {

View File

@ -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 {

View File

@ -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;

View File

@ -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';

View File

@ -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;
}

View File

@ -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;
}
}
}
}

View File

@ -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>
);

View File

@ -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;
}
}
}
}

View File

@ -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 isnt implemented yet"
subtitle="We dont 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);

View File

@ -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;
}
}
}
}

View File

@ -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);
};
}

View File

@ -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',

View File

@ -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 dont have to fill out everything at once right now, you can come back later.',
component: Basics,
},
// [CREATE_STEP.TEAM]: {
// short: 'Team',
// title: 'Assemble your team',
// subtitle: 'Let everyone know if youre flying solo, or who youre working with',
// help:
// 'More team members, real names, and linked social accounts adds legitimacy to your proposal',
// component: Team,
// },
[CREATE_STEP.TEAM]: {
short: 'Team',
title: 'Assemble your team',
subtitle: 'Let everyone know if youre flying solo, or who youre working with',
help:
'More team members, real names, and linked social accounts adds legitimacy to your proposal',
component: Team,
},
[CREATE_STEP.DETAILS]: {
short: 'Details',
title: 'Dive into the details',
@ -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}

View File

@ -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;
}
}
}
}

View File

@ -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);

View File

@ -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);
}
}
}

View File

@ -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);

View File

@ -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>
</Link>
<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 });
}

View File

@ -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,46 +52,61 @@
&:focus,
&:active {
color: inherit;
transform: translateY(-2px) translate(-50%, -50%);
transform: translateY(-4px) translate(-50%, -50%);
}
}
&-button {
display: block;
background: none;
padding: 0;
font-size: 1.2rem;
font-weight: 300;
color: inherit;
letter-spacing: 0.05rem;
cursor: pointer;
opacity: 0.8;
transition: transform 100ms ease, opacity 100ms ease;
&-links {
display: flex;
&:hover,
&:focus,
&:active {
opacity: 1;
transform: translateY(-1px);
color: inherit;
text-decoration-color: transparent;
&.is-left {
justify-self: flex-start;
margin-left: -0.75rem;
}
&-text {
font-size: 1.1rem;
&.is-right {
justify-self: flex-end;
margin-right: -0.75rem;
}
&.is-desktop {
@media @small-query {
display: none;
}
}
&-icon {
padding-right: 10px;
&.is-mobile {
@media @big-query {
display: none;
}
}
@media @small-query {
padding: 0;
font-weight: 400;
font-size: 1.5rem;
&-link {
display: block;
background: none;
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,
&:active {
opacity: 1;
transform: translateY(-1px);
color: inherit;
text-decoration-color: transparent;
}
&-icon {
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);

View File

@ -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,45 +24,43 @@ 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">
Decentralized funding for <br /> Blockchain ecosystem improvements
</h1>
<div className="Home">
<div className="Home-hero">
<h1 className="Home-hero-title">
Decentralized funding for <br /> Blockchain ecosystem improvements
</h1>
<div className="Home-hero-buttons">
<Link className="Home-hero-buttons-button is-primary" to="/create">
Propose a Project
</Link>
<Link className="Home-hero-buttons-button" to="/proposals">
Explore Projects
</Link>
</div>
<button className="Home-hero-scroll">
Learn More
<Icon type="down" />
</button>
<div className="Home-hero-buttons">
<Link className="Home-hero-buttons-button is-primary" to="/create">
Propose a Project
</Link>
<Link className="Home-hero-buttons-button" to="/proposals">
Explore Projects
</Link>
</div>
<div className="Home-intro">
<h3 className="Home-intro-text">
Grant.io organizes creators and community members to incentivize ecosystem
improvements
</h3>
<button className="Home-hero-scroll">
Learn More
<Icon type="down" />
</button>
</div>
<div className="Home-intro-blobs">
{introBlobs.map((blob, i) => (
<div className="Home-intro-blobs-blob" key={i}>
<blob.Svg />
<p>{blob.text}</p>
</div>
))}
</div>
<div className="Home-intro">
<h3 className="Home-intro-text">
Grant.io organizes creators and community members to incentivize ecosystem
improvements
</h3>
<div className="Home-intro-blobs">
{introBlobs.map((blob, i) => (
<div className="Home-intro-blobs-blob" key={i}>
<blob.Svg />
<p>{blob.text}</p>
</div>
))}
</div>
</div>
</AntWrap>
</div>
);
}
}

View File

@ -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 {

View File

@ -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} />;
}
}

View File

@ -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);

View File

@ -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;
}
}
}

View File

@ -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>
);
}
}

View File

@ -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;
}
}
}

View File

@ -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>
);
}
}

View File

@ -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);
}
}
}
}
}

View File

@ -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>
);
};

View File

@ -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);

View File

@ -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;
}
}
}
}

View File

@ -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';

View File

@ -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';

View File

@ -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,

View File

@ -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'];

View File

@ -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" />}

View File

@ -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';

View File

@ -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>

View File

@ -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 {

View File

@ -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;
}
}
}
}

View File

@ -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 youll 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 dont
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} />;

View File

@ -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 { 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>{' '}
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;
switch (milestone.state) {
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>
);
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>
);
break;
case MILESTONE_STATE.REJECTED:
color = 'red';
paymentInfo = (
<>
<div className="ProposalMilestones-milestone-payoutAmount">
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>
</>
);
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>
</>
);
}
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>
{!milestone.isImmediatePayout && (
<div className="ProposalMilestones-milestone-estimate">
Estimate: {moment(milestone.dateEstimated).format('MMMM YYYY')}
</div>
)}
<p className="ProposalMilestones-milestone-description">
{milestone.body}
</p>
{paymentInfo}
</div>
</Timeline.Item>
.
</span>
}
style={alertStyle}
/>
);
break;
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 REJECTED:
notification = (
<Alert
type="warning"
message={
<span>
Payout was voted against on{' '}
{moment(milestone.payoutRequestVoteDeadline).format('MMM Do, YYYY')}.
{isTrustee ? ' You ' : ' The team '} can request another payout vote at
any time.
</span>
}
style={alertStyle}
/>
);
break;
}
if (wasRefunded) {
notification = (
<Alert
type="error"
message={
<span>A majority of the funders of this project voted for a refund.</span>
}
style={alertStyle}
/>
);
}
const statuses = (
<div className="ProposalMilestones-milestone-status">
{!milestone.isImmediatePayout && (
<div>
Estimate: <strong>{estimatedDate}</strong>
</div>
)}
<div>
Reward: <strong>{reward}</strong>
</div>
<div>
Approval period: <strong>{approvalPeriod}</strong>
</div>
</div>
);
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;

View File

@ -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;
margin-bottom: 1rem;
font-size: 0.9rem;
opacity: 0.5;
font-style: italic;
&-status {
white-space: nowrap;
@media @small-query {
margin-bottom: 0.6rem;
}
& > div {
margin-bottom: 1rem;
font-size: 0.9rem;
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;
}
}
}
}
}

View File

@ -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 />;
}

View File

@ -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,

View File

@ -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>&nbsp;</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">
<GovernanceTab proposal={proposal} />
</Tabs.TabPane>
{isContributor && (
<Tabs.TabPane tab="Refund" key="refund">
<GovernanceTab proposal={proposal} />
</Tabs.TabPane>
)}
<Tabs.TabPane tab="Contributors" key="contributors">
<ContributorsTab crowdFund={proposal.crowdFund} />
</Tabs.TabPane>

View File

@ -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;

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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';

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -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;
}
}
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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 className="UserRow-info-main">{user.name}</div>
<p className="UserRow-info-secondary">{user.title}</p>
</div>
</div>
</Link>
);
export default UserRow;

View File

@ -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;
}

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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}>
<Router>
<Routes />
</Router>
<PersistGate persistor={persistor}>
<Router>
<Routes />
</Router>
</PersistGate>
</Provider>
));

View File

@ -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);

View File

@ -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 };
}

View File

@ -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;

View File

@ -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'],
};

View File

@ -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