diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 520fb3d5..de638bd9 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -1,5 +1,7 @@ import axios from './axios'; import { Proposal } from 'modules/proposals/reducers'; +import { TeamMember } from 'modules/create/types'; +import { socialAccountsToUrls } from 'utils/social'; import { PROPOSAL_CATEGORY } from './constants'; export function getProposals(): Promise<{ data: Proposal[] }> { @@ -26,9 +28,20 @@ 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(u => ({ + displayName: u.name, + title: u.title, + accountAddress: u.ethAddress, + emailAddress: u.emailAddress, + avatar: { link: u.avatarUrl }, + socialMedias: socialAccountsToUrls(u.socialAccounts).map(url => ({ + link: url, + })), + })), }); } diff --git a/frontend/client/components/CreateFlow/Review.less b/frontend/client/components/CreateFlow/Review.less index 3082ebec..90bace9b 100644 --- a/frontend/client/components/CreateFlow/Review.less +++ b/frontend/client/components/CreateFlow/Review.less @@ -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; + } + } + } +} diff --git a/frontend/client/components/CreateFlow/Review.tsx b/frontend/client/components/CreateFlow/Review.tsx index 559f8545..262985f9 100644 --- a/frontend/client/components/CreateFlow/Review.tsx +++ b/frontend/client/components/CreateFlow/Review.tsx @@ -2,12 +2,12 @@ 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 defaultUserImg from 'static/images/default-user.jpg'; import './Review.less'; interface OwnProps { @@ -68,11 +68,17 @@ class CreateReview extends React.Component { }, ], }, - // { - // step: CREATE_STEP.TEAM, - // name: 'Team', - // fields: [], - // }, + { + step: CREATE_STEP.TEAM, + name: 'Team', + fields: [ + { + key: 'team', + content: , + error: errors.team && errors.team.join(' '), + }, + ], + }, { step: CREATE_STEP.DETAILS, name: 'Details', @@ -178,7 +184,11 @@ export default connect(state => ({ form: state.create.form, }))(CreateReview); -const ReviewMilestones = ({ milestones }: { milestones: Milestone[] }) => ( +const ReviewMilestones = ({ + milestones, +}: { + milestones: AppState['create']['form']['milestones']; +}) => ( {milestones.map(m => ( @@ -195,3 +205,17 @@ const ReviewMilestones = ({ milestones }: { milestones: Milestone[] }) => ( ))} ); + +const ReviewTeam = ({ team }: { team: AppState['create']['form']['team'] }) => ( +
+ {team.map((u, idx) => ( +
+ +
+
{u.name}
+
{u.title}
+
+
+ ))} +
+); diff --git a/frontend/client/components/CreateFlow/Team.less b/frontend/client/components/CreateFlow/Team.less new file mode 100644 index 00000000..16dbf8e5 --- /dev/null +++ b/frontend/client/components/CreateFlow/Team.less @@ -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; + } + } + } +} \ No newline at end of file diff --git a/frontend/client/components/CreateFlow/Team.tsx b/frontend/client/components/CreateFlow/Team.tsx index b8e5561a..386cb392 100644 --- a/frontend/client/components/CreateFlow/Team.tsx +++ b/frontend/client/components/CreateFlow/Team.tsx @@ -1,29 +1,101 @@ import React from 'react'; -import Placeholder from 'components/Placeholder'; -import { CreateFormState } from 'modules/create/types'; +import { Icon } from 'antd'; +import { CreateFormState, TeamMember } from 'modules/create/types'; +import TeamMemberComponent from './TeamMember'; +import './Team.less'; -type State = object; +interface State { + team: TeamMember[]; +} interface Props { initialState?: Partial; updateForm(form: Partial): void; } -export default class CreateFlowTeam extends React.Component { +const MAX_TEAM_SIZE = 6; +const DEFAULT_STATE: State = { + team: [ + { + name: '', + title: '', + avatarUrl: '', + ethAddress: '', + emailAddress: '', + socialAccounts: {}, + }, + ], +}; + +export default class CreateFlowTeam extends React.PureComponent { constructor(props: Props) { super(props); this.state = { + ...DEFAULT_STATE, ...(props.initialState || {}), }; + + // Don't allow for empty team array + // TODO: Default first user to auth'd user + if (!this.state.team.length) { + this.state = { + ...this.state, + team: [...DEFAULT_STATE.team], + }; + } } render() { + const { team } = this.state; + return ( - +
+ {team.map((user, idx) => ( + + ))} + {team.length < MAX_TEAM_SIZE && ( + + )} +
); } + + 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 }); + }; } diff --git a/frontend/client/components/CreateFlow/TeamMember.less b/frontend/client/components/CreateFlow/TeamMember.less new file mode 100644 index 00000000..547e4acc --- /dev/null +++ b/frontend/client/components/CreateFlow/TeamMember.less @@ -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; + } + } + } + + + +} \ No newline at end of file diff --git a/frontend/client/components/CreateFlow/TeamMember.tsx b/frontend/client/components/CreateFlow/TeamMember.tsx new file mode 100644 index 00000000..1ade41e8 --- /dev/null +++ b/frontend/client/components/CreateFlow/TeamMember.tsx @@ -0,0 +1,239 @@ +import React from 'react'; +import classnames from 'classnames'; +import { Input, Form, Col, Row, Button, Icon, Alert } from 'antd'; +import { SOCIAL_TYPE, SOCIAL_INFO } from 'utils/social'; +import { TeamMember } from 'modules/create/types'; +import { getCreateTeamMemberError } from 'modules/create/utils'; +import defaultUserImg from 'static/images/default-user.jpg'; +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 { + 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 ( +
+
+ + {isEditing && ( + + )} +
+
+ {isEditing ? ( +
+ + + + + + + + + + + + + + + + + + + + + + + {Object.values(SOCIAL_INFO).map(s => ( + + + this.handleSocialChange(ev, s.type)} + addonBefore={s.icon} + /> + + + ))} + + + {!isMissingField && + error && ( + + )} + + + + + + + ) : ( + <> +
{user.name || No name}
+
+ {user.title || No title} +
+
+ {Object.values(SOCIAL_INFO).map(s => { + const account = user.socialAccounts[s.type]; + const cn = classnames( + 'TeamMember-info-social-icon', + account && 'is-active', + ); + return ( +
+ {s.icon} + {account && ( + + )} +
+ ); + })} +
+ + {index !== 0 && ( + + )} + + )} +
+
+ ); + } + + private toggleEditing = (ev?: React.SyntheticEvent) => { + 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) => { + const { name, value } = ev.currentTarget; + this.setState({ + fields: { + ...this.state.fields, + [name as any]: value, + }, + }); + }; + + private handleSocialChange = ( + ev: React.ChangeEvent, + 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); + }; +} diff --git a/frontend/client/components/CreateFlow/example.ts b/frontend/client/components/CreateFlow/example.ts index f29370a5..86e87540 100644 --- a/frontend/client/components/CreateFlow/example.ts +++ b/frontend/client/components/CreateFlow/example.ts @@ -1,10 +1,36 @@ import { PROPOSAL_CATEGORY } from 'api/constants'; +import { CreateFormState } from 'modules/create/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: {}, + }, + { + 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: {}, + }, + ], 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', diff --git a/frontend/client/components/CreateFlow/index.tsx b/frontend/client/components/CreateFlow/index.tsx index c893b8d5..623a9db9 100644 --- a/frontend/client/components/CreateFlow/index.tsx +++ b/frontend/client/components/CreateFlow/index.tsx @@ -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'; @@ -25,7 +25,7 @@ import './index.less'; export enum CREATE_STEP { BASICS = 'BASICS', - // TEAM = 'TEAM', + TEAM = 'TEAM', DETAILS = 'DETAILS', MILESTONES = 'MILESTONES', GOVERNANCE = 'GOVERNANCE', @@ -34,7 +34,7 @@ export enum CREATE_STEP { const STEP_ORDER = [ CREATE_STEP.BASICS, - // CREATE_STEP.TEAM, + CREATE_STEP.TEAM, CREATE_STEP.DETAILS, CREATE_STEP.MILESTONES, CREATE_STEP.GOVERNANCE, @@ -57,14 +57,14 @@ const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = { 'You don’t have to fill out everything at once right now, you can come back later.', component: Basics, }, - // [CREATE_STEP.TEAM]: { - // short: 'Team', - // title: 'Assemble your team', - // subtitle: 'Let everyone know if you’re flying solo, or who you’re working with', - // help: - // 'More team members, real names, and linked social accounts adds legitimacy to your proposal', - // component: Team, - // }, + [CREATE_STEP.TEAM]: { + short: 'Team', + title: 'Assemble your team', + subtitle: 'Let everyone know if you’re flying solo, or who you’re working with', + help: + 'More team members, real names, and linked social accounts adds legitimacy to your proposal', + component: Team, + }, [CREATE_STEP.DETAILS]: { short: 'Details', title: 'Dive into the details', @@ -198,7 +198,7 @@ class CreateFlow extends React.Component {
- {STEP_ORDER.slice(0, 4).map(s => ( + {STEP_ORDER.slice(0, 5).map(s => ( { + if (!u.name || !u.title || !u.emailAddress || !u.ethAddress) { + didTeamError = true; + return ''; + } + + const err = getCreateTeamMemberError(u); + didTeamError = didTeamError || !!err; + return err; + }); + if (didTeamError) { + errors.team = teamErrors; + } + return errors; } +export function getCreateTeamMemberError(user: TeamMember) { + if (user.name.length > 30) { + return 'Display name can only be 30 characters maximum'; + } else if (user.title.length > 30) { + return 'Title can only be 30 characters maximum'; + } else if (!/.+\@.+\..+/.test(user.emailAddress)) { + return 'That doesn’t look like a valid email address'; + } else if (!isValidEthAddress(user.ethAddress)) { + return 'That doesn’t look like a valid ETH address'; + } + + return ''; +} + function milestoneToMilestoneAmount(milestone: Milestone, raiseGoal: Wei) { return raiseGoal.divn(100).mul(Wei(milestone.payoutPercent.toString())); } @@ -157,6 +192,7 @@ export function formToBackendData(form: CreateFormState): ProposalBackendData { title: form.title, category: form.category, content: form.details, + team: form.team, }; } diff --git a/frontend/client/modules/web3/actions.ts b/frontend/client/modules/web3/actions.ts index c92a653a..8ec0dc52 100644 --- a/frontend/client/modules/web3/actions.ts +++ b/frontend/client/modules/web3/actions.ts @@ -8,6 +8,7 @@ import { fetchProposal, fetchProposals } from 'modules/proposals/actions'; import { PROPOSAL_CATEGORY } from 'api/constants'; import { AppState } from 'store/reducers'; import { Wei } from 'utils/units'; +import { TeamMember } from 'modules/create/types'; type GetState = () => AppState; @@ -112,6 +113,7 @@ export interface ProposalBackendData { title: string; content: string; category: PROPOSAL_CATEGORY; + team: TeamMember[]; } export type TCreateCrowdFund = typeof createCrowdFund; @@ -136,7 +138,7 @@ export function createCrowdFund( immediateFirstMilestonePayout, } = contractData; - const { content, title, category } = backendData; + const { content, title, category, team } = backendData; const state = getState(); const accounts = state.web3.accounts; @@ -163,6 +165,7 @@ export function createCrowdFund( title, milestones, category, + team, }); dispatch({ type: types.CROWD_FUND_CREATED, diff --git a/frontend/client/static/images/keybase.svg b/frontend/client/static/images/keybase.svg new file mode 100644 index 00000000..98649b70 --- /dev/null +++ b/frontend/client/static/images/keybase.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/client/typings/images.d.ts b/frontend/client/typings/images.d.ts index cf741001..359abcd1 100644 --- a/frontend/client/typings/images.d.ts +++ b/frontend/client/typings/images.d.ts @@ -1,5 +1,6 @@ declare module '*.svg' { - const content: string; + import React from 'react'; + const content: React.ReactComponent>; export default content; } diff --git a/frontend/client/utils/social.tsx b/frontend/client/utils/social.tsx new file mode 100644 index 00000000..34881a58 --- /dev/null +++ b/frontend/client/utils/social.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Icon } from 'antd'; +import keybaseIcon from 'static/images/keybase.svg'; + +export enum SOCIAL_TYPE { + GITHUB = 'GITHUB', + TWITTER = 'TWITTER', + LINKEDIN = 'LINKEDIN', + KEYBASE = 'KEYBASE', +} + +export interface SocialInfo { + type: SOCIAL_TYPE; + name: string; + format: string; + icon: React.ReactNode; +} + +const accountNameRegex = '([a-zA-Z0-9-_]*)'; +export const SOCIAL_INFO: { [key in SOCIAL_TYPE]: SocialInfo } = { + [SOCIAL_TYPE.GITHUB]: { + type: SOCIAL_TYPE.GITHUB, + name: 'Github', + format: `https://github.com/${accountNameRegex}`, + icon: , + }, + [SOCIAL_TYPE.TWITTER]: { + type: SOCIAL_TYPE.TWITTER, + name: 'Twitter', + format: `https://twitter.com/${accountNameRegex}`, + icon: , + }, + [SOCIAL_TYPE.LINKEDIN]: { + type: SOCIAL_TYPE.LINKEDIN, + name: 'LinkedIn', + format: `https://linkedin.com/in/${accountNameRegex}`, + icon: , + }, + [SOCIAL_TYPE.KEYBASE]: { + type: SOCIAL_TYPE.KEYBASE, + name: 'KeyBase', + format: `https://keybase.io/${accountNameRegex}`, + icon: , + }, +}; + +export type SocialAccountMap = Partial<{ [key in SOCIAL_TYPE]: string }>; + +function urlToAccount(format: string, url: string): string | false { + const matches = url.match(new RegExp(format)); + return matches && matches[1] ? matches[1] : false; +} + +export function socialAccountToUrl(account: string, type: SOCIAL_TYPE): string { + return SOCIAL_INFO[type].format.replace(accountNameRegex, account); +} + +export function socialUrlsToAccounts(urls: string[]): SocialAccountMap { + const accounts: SocialAccountMap = {}; + urls.forEach(url => { + Object.values(SOCIAL_INFO).forEach(s => { + const account = urlToAccount(s.format, url); + if (account) { + accounts[s.type] = account; + } + }); + }); + return accounts; +} + +export function socialAccountsToUrls(accounts: SocialAccountMap): string[] { + return Object.keys(accounts).map((key: SOCIAL_TYPE) => { + return socialAccountToUrl(accounts[key], key); + }); +}