diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..083ffc7 --- /dev/null +++ b/.babelrc @@ -0,0 +1,35 @@ +{ + "env": { + "development": { + "presets": [ + "next/babel" + ], + "plugins": [ + "inline-react-svg" + ] + }, + "production": { + "presets": [ + "next/babel" + ], + "plugins": [ + "inline-react-svg" + ] + }, + "test": { + "presets": [ + [ + "next/babel", + { + "preset-env": { + "modules": "commonjs" + } + } + ] + ], + "plugins": [ + "inline-react-svg" + ] + } + } +} diff --git a/.eslintignore b/.eslintignore index 5be912f..f1d08f7 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,5 +8,7 @@ /tests/fixtures/** /tests/performance/** /tmp/** +next.config.js + # Add any other files or folders that you want eslint to ignore diff --git a/.eslintrc.json b/.eslintrc.json index 73d11d8..103afe9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -8,7 +8,8 @@ "browser": true, "commonjs": true, "es6": true, - "node": true + "node": true, + "jest": true }, "parser": "babel-eslint", "parserOptions":{ @@ -78,12 +79,15 @@ "react/no-danger": "error", "react/jsx-pascal-case": "error", "react/jsx-indent": ["error", 4], + "react/jsx-indent-props": ["error", 4], "react/jsx-closing-bracket-location": "error", "react/void-dom-elements-no-children": "error", "react/jsx-tag-spacing": "error", "react/jsx-no-literals": "error", "react/jsx-wrap-multilines": "error", - "react/jsx-no-comment-textnodes": "error" + "react/jsx-no-comment-textnodes": "error", + "jsx-a11y/anchor-is-valid": 0, + "import/prefer-default-export": "off" }, "settings": { "react": { diff --git a/.gitignore b/.gitignore index 188d7f4..918b2e2 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,7 @@ build/Release node_modules/ jspm_packages/ package-lock.json - +.DS_Store # TypeScript v1 declaration files typings/ @@ -69,6 +69,7 @@ typings/ # next.js build output .next +next.config.js # nuxt.js build output .nuxt diff --git a/.hound.yml b/.hound.yml deleted file mode 100644 index cd9b2c9..0000000 --- a/.hound.yml +++ /dev/null @@ -1,4 +0,0 @@ -eslint: - enabled: true - config_file: .eslintrc - ignore_file: .eslintignore diff --git a/.travis.yml b/.travis.yml index 2197832..dff50cd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,6 @@ language: node_js node_js: - "node" +script: + - npm run lint + - npm test diff --git a/README.md b/README.md index 26f035b..ea6e429 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,14 @@ -# project-template -Document your applications API and general usage +# Project Help Me + +Help Me is an app that connects Depressed people to each other, essentially, it is a social network for depressed people where affected individuals can connect to seek help and therapy. + +![](static/helpme_landing.png) + +## Development + +- Clone this repo to your local machine using `git clone https://github.com/team-helpme/helpme` +- `cd helpme` +- `npm install` +- `npm run dev` + +Antdesign is also used for the components, check diff --git a/components/authentication/actionTypes.js b/components/authentication/actionTypes.js new file mode 100644 index 0000000..f0b5a7d --- /dev/null +++ b/components/authentication/actionTypes.js @@ -0,0 +1,7 @@ +const actionTypes = { + GET_USER_PROFILE: 'GET_USER_PROFILE', + LOGIN_FAILURE: 'LOGIN_FAILURE', + LOGIN_SUCCESS: 'LOGIN_SUCCESS', +}; + +export default actionTypes; diff --git a/components/authentication/actions.js b/components/authentication/actions.js new file mode 100644 index 0000000..0359690 --- /dev/null +++ b/components/authentication/actions.js @@ -0,0 +1,16 @@ +import actionTypes from './actionTypes'; + +const { LOGIN_FAILURE, LOGIN_SUCCESS, GET_USER_PROFILE } = actionTypes; + +export const loginFailure = () => ({ + type: LOGIN_FAILURE, +}); + +export const loginSuccess = () => ({ + type: LOGIN_SUCCESS, +}); + +export const getUserProfile = payload => ({ + payload, + type: GET_USER_PROFILE, +}); diff --git a/components/authentication/components/Authentication.css b/components/authentication/components/Authentication.css new file mode 100644 index 0000000..f6f897e --- /dev/null +++ b/components/authentication/components/Authentication.css @@ -0,0 +1,6 @@ +.loading_Div { + height: 100vh; + width: 100vw; + display: grid; + place-content: center; + } diff --git a/components/authentication/components/Authentication.jsx b/components/authentication/components/Authentication.jsx new file mode 100644 index 0000000..d3bc8e2 --- /dev/null +++ b/components/authentication/components/Authentication.jsx @@ -0,0 +1,59 @@ +/* eslint-disable no-shadow */ +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import React, { PureComponent } from 'react'; +import Router from 'next/router'; +import { Spin } from 'antd'; + +import { getUserProfile, loginFailure, loginSuccess } from '../actions'; +import { getIsAuthenticated, getUsersProfile } from '../selectors'; +import { handleAuthentication, isAuthenticated, login } from '../utils'; +import { SIGNING_IN_TEXT } from '../constants'; + +class Authentication extends PureComponent { + componentDidMount() { + const { loginSuccess, loginFailure } = this.props; + try { + handleAuthentication().then(() => { + if (isAuthenticated()) { + loginSuccess(); + Router.push('/timeline'); + } + }); + } catch (err) { + if (!isAuthenticated()) { + loginFailure(); + login(); + } + } + } + + render() { + return ( +
+ +
+ ); + } +} + +const mapStateToProps = state => ({ + isAuthenticated: getIsAuthenticated(state), + userProfile: getUsersProfile(state), +}); + +const authActions = { + getUserProfile, + loginFailure, + loginSuccess, +}; + +const mapDispatchToProps = dispatch => bindActionCreators(authActions, dispatch); + +export default connect(mapStateToProps, mapDispatchToProps)(Authentication); + +Authentication.propTypes = { + loginFailure: PropTypes.func.isRequired, + loginSuccess: PropTypes.func.isRequired, +}; diff --git a/components/authentication/components/index.js b/components/authentication/components/index.js new file mode 100644 index 0000000..d185e52 --- /dev/null +++ b/components/authentication/components/index.js @@ -0,0 +1,6 @@ +import 'antd/dist/antd.css'; + +import './Authentication.css'; +import Authentication from './Authentication'; + +export { Authentication }; diff --git a/components/authentication/constants.js b/components/authentication/constants.js new file mode 100644 index 0000000..94a3d9a --- /dev/null +++ b/components/authentication/constants.js @@ -0,0 +1,2 @@ +export const NAME = 'auth'; +export const SIGNING_IN_TEXT = 'Signing you in ...'; diff --git a/components/authentication/index.js b/components/authentication/index.js new file mode 100644 index 0000000..b225071 --- /dev/null +++ b/components/authentication/index.js @@ -0,0 +1,9 @@ +import * as actions from './actions'; +import * as components from './components'; +import * as utils from './utils'; +import authSagas from './sagas'; +import reducers from './reducers'; + +export { + actions, components, authSagas, reducers, utils +}; diff --git a/components/authentication/reducers.js b/components/authentication/reducers.js new file mode 100644 index 0000000..460bfa4 --- /dev/null +++ b/components/authentication/reducers.js @@ -0,0 +1,23 @@ +import actionTypes from './actionTypes'; + +const { GET_USER_PROFILE, LOGIN_FAILURE, LOGIN_SUCCESS } = actionTypes; + +const initialState = { + isAuthenticated: false, + usersProfile: null, +}; + +export default (state = initialState, action) => { + const { payload, type } = action; + + switch (type) { + case LOGIN_FAILURE: + return { ...state, isAuthenticated: false }; + case LOGIN_SUCCESS: + return { ...state, isAuthenticated: true }; + case GET_USER_PROFILE: + return { ...state, usersProfile: payload }; + default: + return state; + } +}; diff --git a/components/authentication/sagas.js b/components/authentication/sagas.js new file mode 100644 index 0000000..ff4c55e --- /dev/null +++ b/components/authentication/sagas.js @@ -0,0 +1,14 @@ +import { takeEvery, put } from 'redux-saga/effects'; + +import actionTypes from './actionTypes'; +import { getUserProfile } from './actions'; + +const { LOGIN_SUCCESS } = actionTypes; + +function* handleGetUserProfile(payload) { + yield put(getUserProfile(payload)); +} + +export default function* watchProfileDataLoad() { + yield takeEvery(LOGIN_SUCCESS, handleGetUserProfile); +} diff --git a/components/authentication/selectors.js b/components/authentication/selectors.js new file mode 100644 index 0000000..6deca90 --- /dev/null +++ b/components/authentication/selectors.js @@ -0,0 +1,4 @@ +import { NAME } from './constants'; + +export const getIsAuthenticated = state => state[NAME].isAuthenticated; +export const getUsersProfile = state => state[NAME].userProfile; diff --git a/components/authentication/utils.js b/components/authentication/utils.js new file mode 100644 index 0000000..3633095 --- /dev/null +++ b/components/authentication/utils.js @@ -0,0 +1,50 @@ +import auth0 from 'auth0-js'; + +export const authConfig = new auth0.WebAuth({ + clientID: `${process.env.clientID}`, + domain: 'teamhelpme.auth0.com', + redirectUri: 'http://localhost:3000/auth/signed-in', + responseType: 'token id_token', + scope: 'openid profile email', +}); + +export const setSession = authResult => { + // Set the time that the access token will expire at + const expiresAt = JSON.stringify((authResult.expiresIn * 1000) + new Date().getTime()); + localStorage.setItem('access_token', authResult.accessToken); + localStorage.setItem('id_token', authResult.idToken); + localStorage.setItem('expires_at', expiresAt); + localStorage.setItem('profile', JSON.stringify(authResult.idTokenPayload)); +}; + +export const login = () => { + authConfig.authorize(); +}; + +export const logout = () => { + // Clear access token and ID token from local storage + localStorage.removeItem('access_token'); + localStorage.removeItem('id_token'); + localStorage.removeItem('expires_at'); + localStorage.removeItem('profile'); +}; + +export const handleAuthentication = () => new Promise((resolve, reject) => { + authConfig.parseHash((err, authResult) => { + if (authResult && authResult.accessToken && authResult.idToken) { + setSession(authResult); + return resolve(); + } if (err) { + login(); + return reject(); + } + return reject(); + }); +}); + +export const isAuthenticated = () => { + // Check whether the current time is past the + // access token's expiry time + const expiresAt = JSON.parse(localStorage.getItem('expires_at')); + return new Date().getTime() < expiresAt; +}; diff --git a/components/forum/actionTypes.js b/components/forum/actionTypes.js new file mode 100644 index 0000000..e4db3b7 --- /dev/null +++ b/components/forum/actionTypes.js @@ -0,0 +1,7 @@ +const actionTypes = { + REQUEST_LOAD_FORUM_DATA: 'REQUEST_LOAD_FORUM_DATA', + REQUEST_SET_FORUM_DATA_ERROR: 'REQUEST_SET_FORUM_DATA_ERROR', + REQUEST_SET_FORUM_DATA_SUCCESS: 'REQUEST_SET_FORUM_DATA_SUCCESS', +}; + +export default actionTypes; diff --git a/components/forum/actions.js b/components/forum/actions.js new file mode 100644 index 0000000..3a5b0c1 --- /dev/null +++ b/components/forum/actions.js @@ -0,0 +1,21 @@ +import actionTypes from './actionTypes'; + +const { + REQUEST_LOAD_FORUM_DATA, + REQUEST_SET_FORUM_DATA_ERROR, + REQUEST_SET_FORUM_DATA_SUCCESS, +} = actionTypes; + +export const loadForumData = () => ({ + type: REQUEST_LOAD_FORUM_DATA, +}); + +export const setForumDataError = payload => ({ + payload, + type: REQUEST_SET_FORUM_DATA_ERROR, +}); + +export const setForumDataSuccess = payload => ({ + payload, + type: REQUEST_SET_FORUM_DATA_SUCCESS, +}); diff --git a/components/forum/components/Forum.css b/components/forum/components/Forum.css new file mode 100644 index 0000000..b92b14a --- /dev/null +++ b/components/forum/components/Forum.css @@ -0,0 +1,122 @@ +.forum_topusers { + display: none; +} + +.forum_user { + display: flex; + justify-content: space-between; + border-top: 1px dotted #e8e8e8; + padding: 1em +} + +.forum-item { + display: flex; + padding: 1em +} + +.forum-text>* { + margin: .5em 0; +} + +.forum-topic { + color: rgba(0, 0, 0, 0.65); +} + +.forum-reaction>* { + font-size: 12px; + margin-right: 10px; +} + +.forum-time { + font-size: 10px; + margin-top: 1em; +} + +.forum-time-tag { + flex-direction: row; + justify-content: space-between; + align-content: center; +} + +.user-avatar { + margin-right: 12px; + border-radius: 50%; + box-shadow: 1px 1px 2px 0px rgba(0, 0, 0, .1), -1px -1px 2px 0px rgba(0, 0, 0, .1); +} + +.ant-tabs-nav-scroll { + background: #ffffff; + margin-bottom: 2px; +} + +.user_interaction { + color: #FFD700 +} + +.tag-style { + font-size: 10px +} + +.anticon { + margin-right: 2px +} + +@media screen and (min-width: 451px) and (max-width: 767px) { + .forum { + max-width: 450px; + margin: 0 auto; + border: 1px solid #e8e8e8; + } +} + +@media screen and (min-width: 768px) { + .ant-tabs-nav-scroll { + padding: 0 1em; + } + + .forum_latest_tab { + display: grid; + grid-template-rows: minmax(300px, auto) auto; + grid-template-columns: auto minmax(300px, 286px); + grid-gap: 1em; + } + + .forum_latest_tab { + max-width: 768px; + margin: 0 auto; + margin-left: auto; + } + + .forum { + grid-row: 1/3; + margin-left: 1em; + } + + .forum_topusers { + display: block; + background-color: #ffffff; + grid-row: 1; + grid-column: 2; + margin-right: 1em; + padding: 1em; + } + + .forum-time { + align-self: center; + margin-top: 0px + } + + .forum-item { + background: white + } +} + +@media screen and (min-width: 1024px) { + .forum_latest_tab { + max-width: 1024px; + } + + .ant-tabs-nav-scroll { + padding: 0 6em; + } +} diff --git a/components/forum/components/Forum.jsx b/components/forum/components/Forum.jsx new file mode 100644 index 0000000..ed7b0e6 --- /dev/null +++ b/components/forum/components/Forum.jsx @@ -0,0 +1,97 @@ +/* eslint-disable no-shadow */ +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Tabs } from 'antd'; + +import { components } from '../../layout'; +import ForumLatestPost from './ForumLatestPost'; +import { ForumTopUsers } from './ForumTopUsers'; +import { getError, getIsForumDataLoading, getForumData } from '../selectors'; +import { loadForumData, setForumDataError, setForumDataSuccess } from '../actions'; +import { STRINGS, TabPanes } from '../constants'; + +const { PageLayout } = components; +const { PAGE_TITLE } = STRINGS; +const { TabPane } = Tabs; + +export class ForumComponent extends Component { + componentDidMount() { + const { loadForumData } = this.props; + loadForumData(); + } + + render() { + const { forumData, isForumDataLoading } = this.props; + return ( + +
+ + { + TabPanes().map(tabpane => { + const { key, tab } = tabpane; + let children; + if (key === '1') { + children = ( +
+ + +
+ ); + } + return ( + {children} + ); + }) + + } +
+
+
+ ); + } +} + +const mapStateToProps = state => ({ + error: getError(state), + forumData: getForumData(state), + isForumDataLoading: getIsForumDataLoading(state), +}); + +const forumActions = { + loadForumData, + setForumDataError, + setForumDataSuccess, +}; + +const mapDispatchToProps = dispatch => bindActionCreators(forumActions, dispatch); +const Forum = connect(mapStateToProps, mapDispatchToProps)(ForumComponent); +export default Forum; + +ForumComponent.propTypes = { + forumData: PropTypes.arrayOf(PropTypes.shape({ + answers: PropTypes.number.isRequired, + image: PropTypes.string.isRequired, + tag: PropTypes.string.isRequired, + time: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + views: PropTypes.number.isRequired, + votes: PropTypes.number.isRequired, + })), + isForumDataLoading: PropTypes.bool, + loadForumData: PropTypes.func, +}; + +ForumComponent.defaultProps = { + forumData: [], + isForumDataLoading: true, + loadForumData: () => null, +}; diff --git a/components/forum/components/Forum.test.jsx b/components/forum/components/Forum.test.jsx new file mode 100644 index 0000000..bc16465 --- /dev/null +++ b/components/forum/components/Forum.test.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { ForumComponent } from './Forum'; + +describe('Forum', () => { + it('Forum should renders without crashing', () => { + const div = document.createElement('div'); + ReactDOM.render(, div); + }); +}); diff --git a/components/forum/components/ForumLatestPost.jsx b/components/forum/components/ForumLatestPost.jsx new file mode 100644 index 0000000..e4f1c58 --- /dev/null +++ b/components/forum/components/ForumLatestPost.jsx @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { + Icon, Divider, Tag, Typography, Skeleton +} from 'antd'; +import Link from 'next/link'; +import uuid from 'uuid'; + +import { FORUM_REACTION, LOADING_SKELETON, STRINGS } from '../constants'; + +const { ASKED, BLOG_TOPIC_LINK } = STRINGS; +const { Text } = Typography; + +const ForumLatestPost = props => { + const { blogData } = props; + return ( +
+ { + blogData.length > 0 ? blogData.map(data => { + const { + image, title, answers, votes, views, tag, time, + } = data; + return ( +
+
+ user + +
+ + {title} + + +
+ { + FORUM_REACTION().map(reaction => { + const { + id, action, iconType, textType, + } = reaction; + let actionType; + + if (id === '1') { + actionType = answers; + } else if (id === '2') { + actionType = votes; + } else if (id === '3') { + actionType = views; + } + return ( + + + + {actionType} + {action} + + + ); + }) + } +
+ +
+
+ {(tag.split(' ') + .map(singleTag => ( + + {singleTag} + + )) + )} +
+ + {ASKED + time} + +
+
+
+ +
+ ); + }) + // data loading simulation + : LOADING_SKELETON.map(items => { + const { + paragraph, + title, + loading, + active, + avatar, + } = items; + return ( + + ); + }) + } +
+ ); +}; + +export default ForumLatestPost; + +ForumLatestPost.propTypes = { + blogData: PropTypes.arrayOf(PropTypes.shape({ + answers: PropTypes.number.isRequired, + image: PropTypes.string.isRequired, + tag: PropTypes.string.isRequired, + time: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + views: PropTypes.number.isRequired, + votes: PropTypes.number.isRequired, + })).isRequired, +}; diff --git a/components/forum/components/ForumTopUsers.jsx b/components/forum/components/ForumTopUsers.jsx new file mode 100644 index 0000000..a584852 --- /dev/null +++ b/components/forum/components/ForumTopUsers.jsx @@ -0,0 +1,71 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Typography, Icon, Skeleton } from 'antd'; +import uuid from 'uuid'; + +import { STRINGS, TOP_USERS, LOADING_SKELETON } from '../constants'; + +const { SINCE, TOP_USERS_HEADING } = STRINGS; +const { Title, Text } = Typography; + +const ForumTopUsers = props => { + const { isForumDataLoading } = props; + + return ( +
+ {TOP_USERS_HEADING} + { + !isForumDataLoading ? TOP_USERS.map(user => { + const { + name, avatar, dateJoined, postCount, commentCount, + } = user; + + return ( +
+ {name} + +
+

{name}

+ + {SINCE} + {dateJoined} + +
+ +

+ {postCount + commentCount} + +

+
+ ); + }) // data loading simulation + : LOADING_SKELETON.map(items => { + const { + paragraph, + title, + loading, + active, + avatar, + } = items; + return ( + + ); + }) + } +
+ ); +}; + +export { ForumTopUsers }; + +ForumTopUsers.propTypes = { + isForumDataLoading: PropTypes.bool.isRequired, +}; diff --git a/components/forum/components/index.js b/components/forum/components/index.js new file mode 100644 index 0000000..63e4d16 --- /dev/null +++ b/components/forum/components/index.js @@ -0,0 +1,6 @@ +import 'antd/dist/antd.css'; + +import './Forum.css'; +import Forum from './Forum'; + +export { Forum }; diff --git a/components/forum/constants.js b/components/forum/constants.js new file mode 100644 index 0000000..c7b8540 --- /dev/null +++ b/components/forum/constants.js @@ -0,0 +1,101 @@ +export const LOADING_SKELETON = [{ + active: true, + avatar: true, + id: 1, + loading: true, + paragraph: { rows: 3 }, + title: true, +}, +{ + active: true, + avatar: true, + id: 2, + loading: true, + paragraph: { rows: 3 }, + title: true, +}, +{ + active: true, + avatar: true, + id: 3, + loading: true, + paragraph: { rows: 3 }, + title: true, +}]; + +export const TabPanes = children => [ + { + children, + key: '1', + tab: 'Latest', + }, { + children, + key: '2', + tab: 'Trending', + }, { + children, + key: '3', + tab: 'Favourite', + }, { + children, + key: '4', + tab: 'Answered', + }, +]; + +export const FORUM_REACTION = () => [ + { + action: ' answers', + iconType: 'message', + id: '1', + textType: 'secondary', + }, { + action: ' votes', + iconType: 'arrow-up', + id: '2', + textType: 'secondary', + }, { + action: ' views', + iconType: 'eye', + id: '3', + textType: 'secondary', + }, +]; + +export const TOP_USERS = [{ + avatar: 'https://randomuser.me/api/portraits/men/68.jpg', + commentCount: 30, + dateJoined: '2017', + name: 'Macho', + postCount: 12, +}, +{ + avatar: 'https://randomuser.me/api/portraits/women/8.jpg', + commentCount: 30, + dateJoined: '2018', + name: 'Idris Kaine', + postCount: 67, +}, +{ + avatar: 'https://randomuser.me/api/portraits/men/28.jpg', + commentCount: 300, + dateJoined: '2019', + name: 'Justice', + postCount: 12, +}, +]; + +export const STRINGS = { + ANSWERED_CONTENT: 'Content of Tab Pane 4', + ANSWERS: ' answers', + ASKED: 'asked ', + BLOG_TOPIC_LINK: '/forum/post?title=', + FAVORITE_CONTENT: 'Content of Tab Pane 3', + NAME: 'forum', + PAGE_TITLE: 'Forum | talk about topics you like', + SINCE: 'since ', + TOP_USERS_HEADING: 'Top Users of The Week', + TRENDING_CONTENT: 'Content of Tab Pane 2', + VIEWS: ' views', + VOTES: ' votes', +}; diff --git a/components/forum/index.js b/components/forum/index.js new file mode 100644 index 0000000..4c729b9 --- /dev/null +++ b/components/forum/index.js @@ -0,0 +1,8 @@ +import * as components from './components'; +import * as actions from './actions'; +import reducers from './reducers'; +import forumSagas from './sagas'; + +export { + actions, components, reducers, forumSagas +}; diff --git a/components/forum/reducers.js b/components/forum/reducers.js new file mode 100644 index 0000000..a870734 --- /dev/null +++ b/components/forum/reducers.js @@ -0,0 +1,29 @@ +import actionTypes from './actionTypes'; + +const initialState = { + error: undefined, + forumData: [], + isForumDataLoading: false, +}; + +export default (state = initialState, action) => { + const { + REQUEST_LOAD_FORUM_DATA, + REQUEST_SET_FORUM_DATA_ERROR, + REQUEST_SET_FORUM_DATA_SUCCESS, + } = actionTypes; + const { type, error, payload } = action; + + switch (type) { + case REQUEST_LOAD_FORUM_DATA: + return { ...state, isForumDataLoading: true }; + + case REQUEST_SET_FORUM_DATA_ERROR: + return { ...state, error, isForumDataLoading: false }; + + case REQUEST_SET_FORUM_DATA_SUCCESS: + return { ...state, forumData: payload, isForumDataLoading: false }; + default: + return state; + } +}; diff --git a/components/forum/sagas.js b/components/forum/sagas.js new file mode 100644 index 0000000..cbde5d4 --- /dev/null +++ b/components/forum/sagas.js @@ -0,0 +1,21 @@ +import { takeEvery, call, put } from 'redux-saga/effects'; +import actionTypes from './actionTypes'; +import { setForumDataError, setForumDataSuccess } from './actions'; + +const FORUM_DATA_URL = '../../static/data/forumData.json'; +const { REQUEST_LOAD_FORUM_DATA } = actionTypes; + +function* handleForumDataLoad() { + const response = yield call(fetch, FORUM_DATA_URL); + if (response.ok) { + const data = yield response.json(); + yield put(setForumDataSuccess(data)); + } else { + yield put(setForumDataError(response)); + } +} + +// forum data watcher +export default function* watchForumDataLoad() { + yield takeEvery(REQUEST_LOAD_FORUM_DATA, handleForumDataLoad); +} diff --git a/components/forum/selectors.js b/components/forum/selectors.js new file mode 100644 index 0000000..891d04f --- /dev/null +++ b/components/forum/selectors.js @@ -0,0 +1,7 @@ +import { STRINGS } from './constants'; + +const { NAME } = STRINGS; + +export const getError = state => state[NAME].error; +export const getForumData = state => state[NAME].forumData; +export const getIsForumDataLoading = state => state[NAME].isForumDataLoading; diff --git a/components/landingPage/components/LandingPage.css b/components/landingPage/components/LandingPage.css new file mode 100644 index 0000000..208a02e --- /dev/null +++ b/components/landingPage/components/LandingPage.css @@ -0,0 +1,127 @@ +.LandingPage_footer { + background: #001529; + color: #ffffff; + text-align: center; +} + +.LandingPage_footer > img { + width: 50%; + height: 50%; + margin-bottom: 1em; +} + +.LandingPage_footer > ul { + list-style-type: none; + padding: 0; + display: flex; + flex-direction: column; + margin: 0; +} + +.LandingPage_hero { + padding: 24px; + background: #ffffff; + display: flex; + flex-direction: column; + justify-content: center; +} + +.LandingPage_hero > img { + width: 100%; + margin: 2em 0; +} +.LandingPage_content__text { + text-align: center; +} + +.LandingPage_button { + margin-bottom: 2em; +} + +.column-section > img { + width: 50%; + margin: 0 auto; +} + +@media screen and (min-width: 425px) and (max-width: 767px) { + .LandingPage_hero > img { + width: 65%; + margin: 2em auto; + } + + .column-section > img { + width: 30%; + margin-top: 0; + } +} + +@media screen and (min-width: 768px) { + + + .LandingPage_hero { + flex-direction: row; + align-items: center; + } + .LandingPage_content__text { + margin: 2em; + max-width: 30vw; + } + .LandingPage_hero > img { + width: 30%; + } + + .LandingPage_body > section { + margin: 0 6em; + } + + .reverse { + flex-direction: row-reverse; + } + + .column-section { + flex-direction: column; + } + + .column-section > div { + margin-bottom: 0; + } + + .column-section > img { + width: 20%; + margin-top: 0; + } + + + + .LandingPage_footer { + text-align: left; + display: flex; + justify-content: space-evenly; + padding: 5em 50px; + } + + .LandingPage_content { + height: 100%; + margin-top: 0; + } + + .LandingPage_footer > img { + width: 120px; + height: 30px; + margin-bottom: 0; + } + + +} + +@media screen and (min-width: 1024px) { + .LandingPage_body > section { + /* padding: 0 3em; */ + } + + .layout_header-desktop { + padding: 0 6em; + } + + +} diff --git a/components/landingPage/components/LandingPage.jsx b/components/landingPage/components/LandingPage.jsx new file mode 100644 index 0000000..67b0cbe --- /dev/null +++ b/components/landingPage/components/LandingPage.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +import Router from 'next/router'; +import uuid from 'uuid'; + +import { components } from '../../layout'; +import LandingPageContent from './LandingPageContent'; +import { LANDING_PAGE_CONTENTS, STRING } from '../constants'; +import { utils } from '../../authentication'; + +const { isAuthenticated } = utils; +const { PageLayout } = components; +const { PAGE_TITLE } = STRING; + +/** + * Function for displaying the landing page + * + * @function + * @return {Object} The landing page + */ + +class LandingPage extends React.Component { + state ={ + isUserAuthenticated: false, + } + + componentDidMount() { + if (isAuthenticated()) { + this.setState({ + isUserAuthenticated: true, + }); + Router.push('/timeline'); + } + } + + render() { + const { isUserAuthenticated } = this.state; + return ( + + { + LANDING_PAGE_CONTENTS.map(landingPageContent => { + const { + paragraphText, + isButtonPresent, + columnSection, + isImagePresent, + imageLink, + level, + title, + reverseSection, + buttonText, + buttonLink, + } = landingPageContent; + return ( + + ); + }) + } + + ); + } +} +export default LandingPage; diff --git a/components/landingPage/components/LandingPage.test.jsx b/components/landingPage/components/LandingPage.test.jsx new file mode 100644 index 0000000..6c1776b --- /dev/null +++ b/components/landingPage/components/LandingPage.test.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import LandingPage from './LandingPage'; + +describe('LandingPage', () => { + it('LandingPage should renders without crashing', () => { + const div = document.createElement('div'); + ReactDOM.render(, div); + }); +}); diff --git a/components/landingPage/components/LandingPageContent.jsx b/components/landingPage/components/LandingPageContent.jsx new file mode 100644 index 0000000..a74c6c6 --- /dev/null +++ b/components/landingPage/components/LandingPageContent.jsx @@ -0,0 +1,82 @@ +import { Button, Typography } from 'antd'; +import Link from 'next/link'; +import PropTypes from 'prop-types'; +import React from 'react'; + +const { Paragraph, Title } = Typography; + +/** + * Function used to generate section layout content for landing page + * @function + * @param {String} buttonLink- the link that the button leads to + * @param {String} buttonText- the text on the button + * @param {Boolean} columnSection - if true, the section will be stacked + * @param {Boolean} isButtonPresent- If true, a button is shown on that section + * @param {Boolean} isImagePresent - if true, the image is shown + * @param {String} imageLink- the link to an image in the section + * @param {Number} level - The Number from 1-5 representing the header level h1-h5 + *@param {String} paragraphText- The Text of that section + * @param {Boolean} reverseSection - if true, the image and section position is swapped + * @param {String} title- The Title of that Section + * @return {Object} The landing page content component which is used to populate the landing page + */ + +export default function LandingPageContent(props) { + const { + level, + title, + paragraphText, + isButtonPresent, + buttonText, + buttonLink, + imageLink, + isImagePresent, + reverseSection, + columnSection, + } = props; + + let className; + + // this helps to structure the section, the section can be normalize, reversed or columnized + if (!reverseSection && !columnSection) { + className = 'LandingPage_hero'; + } else if (reverseSection && !columnSection) { + className = 'LandingPage_hero reverse'; + } else if (columnSection) { + className = 'LandingPage_hero column-section'; + } + + return ( +
+
+ {title} + {paragraphText} + {/* displays button in a section */} + { + isButtonPresent ? ( + + ) : null + } +
+ + {/* displays image in a section */} + {isImagePresent ? {title} : null} +
+ ); +} +LandingPageContent.propTypes = { + buttonLink: PropTypes.string.isRequired, + buttonText: PropTypes.string.isRequired, + columnSection: PropTypes.bool.isRequired, + imageLink: PropTypes.string.isRequired, + isButtonPresent: PropTypes.bool.isRequired, + isImagePresent: PropTypes.bool.isRequired, + level: PropTypes.number.isRequired, + paragraphText: PropTypes.string.isRequired, + reverseSection: PropTypes.bool.isRequired, + title: PropTypes.string.isRequired, +}; diff --git a/components/landingPage/components/index.js b/components/landingPage/components/index.js new file mode 100644 index 0000000..36b4c65 --- /dev/null +++ b/components/landingPage/components/index.js @@ -0,0 +1,6 @@ +import 'antd/dist/antd.css'; + +import './LandingPage.css'; +import LandingPage from './LandingPage'; + +export { LandingPage }; diff --git a/components/landingPage/constants.js b/components/landingPage/constants.js new file mode 100644 index 0000000..f7ae089 --- /dev/null +++ b/components/landingPage/constants.js @@ -0,0 +1,104 @@ +const STRING = { + LANDING_PAGE_LEVEL_2_BUTTON_TEXT: 'Lets begin this Journey', + LANDING_PAGE_LEVEL_2_PARAGRAPH_TEXT: `Lorem ipsum dolor sit amet consectetur adipisicing elit. + Deleniti porro veroDeleniti porro vero`, + LANDING_PAGE_LEVEL_3_CONTENT_TITLE: 'Lorem Ipsum dolor sit a ', + LANDING_PAGE_LEVEL_3_PARAGRAPH_TEXT: `Lorem ipsum dolor sit amet consectetur adipisicin + elit. Deleniti porro vero`, + LANDING_PAGE_LEVEL_4_CONTENT_TITLE: 'Title 2', + LANDING_PAGE_LEVEL_4_PARAGRAPH_TEXT: `Lorem ipsum dolor sit amet consectetur adipisicing elit. + Deleniti Lorem ipsum dolor sit amet consectetur adipisicing elit. Deleniti Lorem ipsum dolor + consectetur adipisicing elit. Deleniti porro vero`, + LANDING_PAGE_LEVEL_5_BUTTON_TEXT: 'Create an Account', + LANDING_PAGE_LEVEL_5_CONTENT_TITLE: 'Lorem Ipsum dolor sit a ', + LANDING_PAGE_LEVEL_5_PARAGRAPH_TEXT: 'lorem dhjh jdijdj', + LANDING_PAGE_MAIN_CONTENT_BUTTON_TEXT: 'Lets begin this Journey', + LANDING_PAGE_MAIN_CONTENT_PARAGRAPH_TEXT: `Lorem ipsum dolor sit amet consectetur adipisicing + elit.Deleniti porro vero`, + LANDING_PAGE_MAIN_CONTENT_TITLE: 'Help me Title', + PAGE_TITLE: 'Home | Welcome to Help me', // the title of the landing page + SIGNUP: '/signup', +}; + +const { + LANDING_PAGE_MAIN_CONTENT_BUTTON_TEXT, + LANDING_PAGE_MAIN_CONTENT_PARAGRAPH_TEXT, + LANDING_PAGE_MAIN_CONTENT_TITLE, + LANDING_PAGE_LEVEL_2_PARAGRAPH_TEXT, + LANDING_PAGE_LEVEL_2_BUTTON_TEXT, + LANDING_PAGE_LEVEL_3_PARAGRAPH_TEXT, + LANDING_PAGE_LEVEL_3_CONTENT_TITLE, + LANDING_PAGE_LEVEL_4_PARAGRAPH_TEXT, + LANDING_PAGE_LEVEL_4_CONTENT_TITLE, + LANDING_PAGE_LEVEL_5_BUTTON_TEXT, + LANDING_PAGE_LEVEL_5_PARAGRAPH_TEXT, + LANDING_PAGE_LEVEL_5_CONTENT_TITLE, +} = STRING; + +const LANDING_PAGE_CONTENTS = [ + { + buttonLink: '/signup', + buttonText: LANDING_PAGE_MAIN_CONTENT_BUTTON_TEXT, + columnSection: false, + imageLink: '../../../static/connected.svg', + isButtonPresent: true, + isImagePresent: true, + level: 1, + paragraphText: LANDING_PAGE_MAIN_CONTENT_PARAGRAPH_TEXT, + reverseSection: false, + title: LANDING_PAGE_MAIN_CONTENT_TITLE, + }, + { + buttonLink: '', + buttonText: '', + columnSection: true, + imageLink: '../../../static/smile.svg', + isButtonPresent: false, + isImagePresent: true, + level: 2, + paragraphText: LANDING_PAGE_LEVEL_2_PARAGRAPH_TEXT, + reverseSection: false, + title: LANDING_PAGE_LEVEL_2_BUTTON_TEXT, + }, + { + buttonLink: '', + buttonText: '', + columnSection: false, + imageLink: '../../../static/community.svg', + isButtonPresent: false, + isImagePresent: true, + level: 3, + paragraphText: LANDING_PAGE_LEVEL_3_PARAGRAPH_TEXT, + reverseSection: true, + title: LANDING_PAGE_LEVEL_3_CONTENT_TITLE, + }, + { + buttonLink: '', + buttonText: '', + columnSection: false, + imageLink: '', + isButtonPresent: false, + isImagePresent: false, + level: 2, + paragraphText: LANDING_PAGE_LEVEL_4_PARAGRAPH_TEXT, + reverseSection: true, + title: LANDING_PAGE_LEVEL_4_CONTENT_TITLE, + }, + { + buttonLink: '/signup', + buttonText: LANDING_PAGE_LEVEL_5_BUTTON_TEXT, + columnSection: false, + imageLink: '../../../static/hangout.svg', + isButtonPresent: true, + isImagePresent: true, + level: 3, + paragraphText: LANDING_PAGE_LEVEL_5_PARAGRAPH_TEXT, + reverseSection: false, + title: LANDING_PAGE_LEVEL_5_CONTENT_TITLE, + }, +]; + +export { + STRING, + LANDING_PAGE_CONTENTS +}; diff --git a/components/landingPage/index.js b/components/landingPage/index.js new file mode 100644 index 0000000..a5feb39 --- /dev/null +++ b/components/landingPage/index.js @@ -0,0 +1,3 @@ +import * as components from './components'; + +export { components }; diff --git a/components/layout/components/FooterListCreator.jsx b/components/layout/components/FooterListCreator.jsx new file mode 100644 index 0000000..3479869 --- /dev/null +++ b/components/layout/components/FooterListCreator.jsx @@ -0,0 +1,29 @@ +import Link from 'next/link'; +import PropTypes from 'prop-types'; +import React from 'react'; +import uuid from 'uuid'; + +const FooterListCreator = props => { + const { list } = props; + return ( +
    + { + list.map(link => { + const { href, text } = link; + return ( + + {text} + + ); + }) + } +
+ ); +}; +export default FooterListCreator; +FooterListCreator.propTypes = { + list: PropTypes.arrayOf(PropTypes.shape({ + href: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + })).isRequired, +}; diff --git a/components/layout/components/NavHeader.jsx b/components/layout/components/NavHeader.jsx new file mode 100644 index 0000000..1b90436 --- /dev/null +++ b/components/layout/components/NavHeader.jsx @@ -0,0 +1,134 @@ +import { + Button, Input, Layout, Menu +} from 'antd'; +import Head from 'next/head'; +import Link from 'next/link'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import { + IMAGE_ALT, IMAGE_URLS, LINKS, MENU_ITEMS, STRINGS +} from '../constants'; + +const { Header } = Layout; +const { + HEADER_TITLE, LOGIN, LOGOUT, PLACEHOLDER, +} = STRINGS; +const { HELPME_LOGO } = IMAGE_URLS; +const { HELPME_LOGO_DESC } = IMAGE_ALT; +const { LOGIN_LINK } = LINKS; +const { Search } = Input; + +/** + * Head function that is infused into all pages and controls page's title + * @function + * @param {String} title - The title of the currently viewed page + * @return {Object} head metadata which is inserted in every page + */ +function NavHeader(props) { + const { + handleSearch, searchValue, title, selectedKey, isAuthenticated, handleLogin, handleLogOut, + } = props; + return ( + <> + {/* head parametes */} + + {!title ? HEADER_TITLE : title} + + {/* navheader for mobile */} +
+ + + {HELPME_LOGO_DESC} + + + {/* hide when authenticated */} + {isAuthenticated ? null : ( + + )} +
+ {/* header for desktop */} +
+ + + {HELPME_LOGO_DESC} + + + { + isAuthenticated + ? ( + + ) + : null + } + + {/* navbar for authenticated desktop */} + + { + MENU_ITEMS.map(menuItem => { + let { key, href, text } = menuItem; + if (key === 1) { + href = isAuthenticated ? '/timeline' : '/'; + text = isAuthenticated ? 'TimeLine' : text; + } + return ( + + + {text} + + + ); + }) + } + + +
+ + ); +} +export default NavHeader; +NavHeader.propTypes = { + handleLogOut: PropTypes.func.isRequired, + handleLogin: PropTypes.func.isRequired, + handleSearch: PropTypes.func, + isAuthenticated: PropTypes.bool, + searchValue: PropTypes.string, + selectedKey: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, +}; + +NavHeader.defaultProps = { + handleSearch: null, + isAuthenticated: false, + searchValue: '', +}; diff --git a/components/layout/components/PageFooter.jsx b/components/layout/components/PageFooter.jsx new file mode 100644 index 0000000..2d8afed --- /dev/null +++ b/components/layout/components/PageFooter.jsx @@ -0,0 +1,43 @@ +import { Layout } from 'antd'; +import Link from 'next/link'; +import React from 'react'; + +import { + FOOTER_FIRST_COLUMN, FOOTER_SECOND_COLUMN, IMAGE_ALT, IMAGE_URLS +} from '../constants'; +import FooterListCreator from './FooterListCreator'; + +const { Content, Footer } = Layout; +const { HELPME_LOGO_DESC } = IMAGE_ALT; +const { HELPME_LOGO_LIGHT } = IMAGE_URLS; + +/** + * footer function that is infused into all pages + * @function + * @return {Object} footer + */ +export default function PageFooter() { + return ( + + + + + + ); +} diff --git a/components/layout/components/PageLayout.css b/components/layout/components/PageLayout.css new file mode 100644 index 0000000..652f59f --- /dev/null +++ b/components/layout/components/PageLayout.css @@ -0,0 +1,99 @@ +.PageLayout_content { + height: 100%; + margin-top: 64px; +} +.PageLayout_content_sidebar{ + height: calc(100vh - 64px) +} + +.layout_header-mobile { + display: flex; + justify-content: space-between; + background-color: #ffffff; + border-bottom: 0.4px solid #e8e8e8; + position: fixed; + width: 100%; + z-index: 3; + align-items: center; +} + +.layout_header-desktop { + display: none; +} + +.layout_sider { + position: fixed; + height: calc(100vh - 64px); + z-index: 4; + margin-top: 64px; +} + +.search{ + width: 200px +} + +.ant-layout { + background: #ffffff; +} + +.ant-layout-sider-zero-width-trigger { + top: 12px; + position: fixed; + background-color: #1890ff; + left: 0; +} + +.ant-layout-sider-zero-width-trigger:hover { + background-color: #1890ff; +} + +a { + color: #ffffff; +} + +.ant-divider-horizontal { + margin: 0.2px; +} + +.logo { + width: 120px; + height: 30px; +} + +@media screen and (min-width: 768px) { + .PageLayout_body { + background: #E6ECF0; + } + .PageLayout_content { + margin-top: 63px; + background-color: #e8e8e8 +} +.layout_sider { + display: none; + } + +.layout_header-mobile { + display: none; + } + .layout_header-desktop { + background-color: #ffffff; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #e8e8e8; + position: fixed; + width: 100%; + z-index: 3; + padding: 0 1em; + } + + .layout_header-list { + line-height: 63px; + } +} + +@media screen and (min-width: 1024px) { + .layout_header-desktop { + padding: 0 6em; + } +} diff --git a/components/layout/components/PageLayout.jsx b/components/layout/components/PageLayout.jsx new file mode 100644 index 0000000..e0fcc1e --- /dev/null +++ b/components/layout/components/PageLayout.jsx @@ -0,0 +1,96 @@ +/* eslint-disable react/require-default-props */ +import { Layout } from 'antd'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import NavHeader from './NavHeader'; +import PageFooter from './PageFooter'; +import Sidebar from './Sidebar'; +import { STRINGS } from '../constants'; +import { utils } from '../../authentication'; + +const { Content } = Layout; +const { HEADER_TITLE } = STRINGS; +const { + authConfig, isAuthenticated, logout, +} = utils; + +/** + * Function for displaying the landing page + * @function + * @param {Function} title controls the title of the page + * @param {Function} isAuthenticated controls if user is authrnticated or not + * @param {Function} children other pages who are children of this layout + * @param {Function} isFooterPresent displays footer if true + * @param {Function} isSiderPresent displays side for mobile pages + * @return {Object} control the over all layout of the webpage + */ + +class PageLayout extends React.Component { + state= { + isUserAuthenticated: false, + } + + componentDidMount() { + this.setState({ + isUserAuthenticated: isAuthenticated(), + }); + } + + // cannot pass login but need to declare it so that it can be + // called when components mounts. localstorage is not available in the server + login = () => { + authConfig.authorize(); + } + + render() { + const { + children, + handleSearch, + isFooterPresent, + isSiderPresent, + searchValue, + selectedKey, + title, + } = this.props; + + const { isUserAuthenticated } = this.state; + + return ( + <> + + + + + + + {children} + {isFooterPresent ? : null} + + + + + + ); + } +} + +export default PageLayout; + +PageLayout.propTypes = { + children: PropTypes.node, + handleSearch: PropTypes.func, + isFooterPresent: PropTypes.bool, + isSiderPresent: PropTypes.bool, + searchValue: PropTypes.string, + selectedKey: PropTypes.string.isRequired, + title: PropTypes.string, +}; diff --git a/components/layout/components/Sidebar.jsx b/components/layout/components/Sidebar.jsx new file mode 100644 index 0000000..88f6936 --- /dev/null +++ b/components/layout/components/Sidebar.jsx @@ -0,0 +1,54 @@ +import { Icon, Layout, Menu } from 'antd'; +import Link from 'next/link'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import { SIDEBAR_MENU_ITEMS } from '../constants'; + +const { Sider } = Layout; + +/** + * Function that controls the sidebar which displays on mobile + * @function + * @param {boolean} isSiderPresent shows sidebar if true + * @return {Object} Side Bar + */ +export default function Sidebar(props) { + const { isSiderPresent, selectedKey } = props; + return ( + isSiderPresent + ? ( + +
+ + { + SIDEBAR_MENU_ITEMS.map(sideBarItem => { + const { + href, key, type, text, + } = sideBarItem; + return ( + + + + + {text} + + + + ); + }) + } + + + ) : null + ); +} + +Sidebar.propTypes = { + isSiderPresent: PropTypes.bool, + selectedKey: PropTypes.string.isRequired, +}; + +Sidebar.defaultProps = { + isSiderPresent: false, +}; diff --git a/components/layout/components/index.js b/components/layout/components/index.js new file mode 100644 index 0000000..30c0401 --- /dev/null +++ b/components/layout/components/index.js @@ -0,0 +1,6 @@ +import 'antd/dist/antd.css'; + +import './PageLayout.css'; +import PageLayout from './PageLayout'; + +export { PageLayout }; diff --git a/components/layout/constants.js b/components/layout/constants.js new file mode 100644 index 0000000..e48d2b9 --- /dev/null +++ b/components/layout/constants.js @@ -0,0 +1,69 @@ +export const FOOTER_FIRST_COLUMN = [ + { + href: '/', + text: 'Home', + }, { + href: '/contact', + text: 'Contact us', + }, { + href: '/about-us', + text: 'About Helpme', + }, +]; + +export const FOOTER_SECOND_COLUMN = [ + { + href: '/about-us', + text: 'Security & Privacy', + }, { + href: '/terms', + text: 'Terms Of Service', + }, +]; + +export const IMAGE_ALT = { + HELPME_LOGO_DESC: 'helpme logo', +}; + +export const IMAGE_URLS = { + HELPME_LOGO: '../../../static/logo.png', + HELPME_LOGO_LIGHT: '../../../static/logo-light.png', +}; + +export const LINKS = { + LOGIN_LINK: '/api/users/login', + LOGOUT_LINK: '/api/users/logout', +}; + +export const MENU_ITEMS = [ + { + href: '/timeline', + key: 1, + text: 'Home', + }, { + href: '/forum', + key: 2, + text: 'Forum', + }, +]; + +export const SIDEBAR_MENU_ITEMS = [ + { + href: '/#', + key: '1', + text: 'Home', + type: 'user', + }, { + href: '/forum', + key: '2', + text: 'Forum', + type: 'video-camera', + }, +]; + +export const STRINGS = { + HEADER_TITLE: 'Helpme | Connect with Friends', // title of the header + LOGIN: 'Login', + LOGOUT: 'Logout', + PLACEHOLDER: 'input search text', +}; diff --git a/components/layout/index.js b/components/layout/index.js new file mode 100644 index 0000000..fbb849c --- /dev/null +++ b/components/layout/index.js @@ -0,0 +1,3 @@ +import * as components from './components/index'; + +export { components }; diff --git a/components/signup/actionTypes.js b/components/signup/actionTypes.js new file mode 100644 index 0000000..806a4b9 --- /dev/null +++ b/components/signup/actionTypes.js @@ -0,0 +1,7 @@ +const actionTypes = { + REGISTRATION_REQUEST_ERROR: 'REGISTRATION_REQUEST_ERROR', + REGISTRATION_REQUEST_SUCCESS: 'REGISTRATION_REQUEST_SUCCESS', + SEND_REGISTRATION_REQUEST: 'SEND_REGISTRATION_REQUEST', +}; + +export default actionTypes; diff --git a/components/signup/actions.js b/components/signup/actions.js new file mode 100644 index 0000000..9ba4ef1 --- /dev/null +++ b/components/signup/actions.js @@ -0,0 +1,22 @@ +import actionTypes from './actionTypes'; + +const { + REGISTRATION_REQUEST_ERROR, + REGISTRATION_REQUEST_SUCCESS, + SEND_REGISTRATION_REQUEST, +} = actionTypes; + +export const sendRegistrationRequest = payload => ({ + payload, + type: SEND_REGISTRATION_REQUEST, +}); + +export const registrationRequestSuccess = payload => ({ + payload, + type: REGISTRATION_REQUEST_SUCCESS, +}); + +export const registrationRequestError = payload => ({ + payload, + type: REGISTRATION_REQUEST_ERROR, +}); diff --git a/components/signup/components/SignUp.jsx b/components/signup/components/SignUp.jsx new file mode 100644 index 0000000..6fb69a8 --- /dev/null +++ b/components/signup/components/SignUp.jsx @@ -0,0 +1,206 @@ +/* eslint-disable react/forbid-prop-types */ +/* eslint-disable no-shadow */ +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { Form, notification } from 'antd'; +import Router from 'next/router'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import { + getError, getIsRegistering, getPayload, getSuccess +} from '../selectors'; +import { + registrationRequestError, + registrationRequestSuccess, + sendRegistrationRequest +} from '../actions'; +import RegistrationImage from '../../../static/register.svg'; +import { SIGNUP_INPUTS } from '../constants'; +import SignupInputGenerator from './SignupInputItemGenerator'; + +/** + * function that is used to display the registration Page + * @function + * @return {Object} the registration page + */ +class RegistrationForm extends React.Component { + state = { + confirmDirty: false, + }; + + componentDidUpdate(prevProps) { + const { error, success } = this.props; + + // get error values and display in notification + const errorValue = error !== null ? Object.keys(error).map(key => error[key]) : null; + + if (prevProps.error !== error) { + this.openNotificationWithIcon('error', errorValue.toLocaleString()); + } else if (prevProps.success !== success) { + this.openNotificationWithIcon('success', + `Successfully created account for ${success.name}`); + Router.push('/api/users/login'); + } + } + + // notification function + openNotificationWithIcon = (type, text) => { + notification[type]({ + description: + text, + message: type, + }); + }; + + /** + * function that is used to handle submit + * @function + * @return {Object} returns the user values + */ + handleSubmit = e => { + e.preventDefault(); + const { form } = this.props; + const { validateFieldsAndScroll } = form; + /** + * function that is used to handle submit, This function helps to Validate the specified + * fields and get theirs values and errors., if the target field is not in visible + * area of form, form will be automatically scrolled to the target field area. + * @function + * @return {Object} returns the values of the form + */ + validateFieldsAndScroll((err, values) => { + const { + name, password, email, agreement, + } = values; + const { sendRegistrationRequest } = this.props; + if (!err && agreement) { + const body = { + confirmPassword: password, + email, + name, + password, + }; + // make a call to the api with the values + sendRegistrationRequest(body); + } + }); + } + + /** + * function that is used to handle password validation, this fires when the first password + * field has been filled and has lost focus. this will help in comparing the password in + * that field to the next input field; + * @function + * @return {Object} sets the state of isConfirmedDirty + */ + handleConfirmBlur = e => { + const { value } = e.target; + const { confirmDirty } = this.state; + this.setState({ confirmDirty: confirmDirty || !!value }); + } + + /** + * function that is used to also handle password validation, this compares the two password + * field; + * @function + * @param {Array} rule the validation rule for the input field + * @param {String} value the value passed on the input field + * @param {function} callback error message to display + * @return {function} error message to display + */ + compareToFirstPassword = (rule, value, callback) => { + const { form } = this.props; + if (value && value !== form.getFieldValue('password')) { + callback('The Two passwords that you enter is inconsistent!'); + } else { + callback(); + } + } + + validateToNextPassword = (rule, value, callback) => { + const { form } = this.props; + const { confirmDirty } = this.state; + if (value && confirmDirty) { + form.validateFields(['confirm'], { force: true }); + } + callback(); + }; + + checkPasswordLength = (rule, value, callback) => { + if (value && value.length <= 5) { + callback('Password must be at least 6 characters'); + } else { + callback(); + } + } + + render() { + const { form } = this.props; + const { + getFieldDecorator, + } = form; + + return ( +
+
+ +
+ +
+
+ { + + SIGNUP_INPUTS().map(input => { + const { actions = {}, items } = input; + + if (items.label === 'Password') { + items.rules = [...items.rules, + { validator: this.validateToNextPassword }, + { validator: this.checkPasswordLength }]; + } if (items.label === 'Confirm Password') { + items.rules = [...items.rules, + { validator: this.compareToFirstPassword }, + ]; + } + return SignupInputGenerator(actions, items, getFieldDecorator); + }) + } +
+
+
+ ); + } +} + +const Signup = Form.create({ name: 'register' })(RegistrationForm); + +const mapStateToProps = state => ({ + error: getError(state), + isRegistering: getIsRegistering(state), + payload: getPayload(state), + success: getSuccess(state), +}); + +const signupActions = { + registrationRequestError, + registrationRequestSuccess, + sendRegistrationRequest, +}; + +const mapDispatchToProps = dispatch => bindActionCreators(signupActions, dispatch); + +export default connect(mapStateToProps, mapDispatchToProps)(Signup); + +RegistrationForm.propTypes = { + error: PropTypes.node, + form: PropTypes.object.isRequired, + sendRegistrationRequest: PropTypes.func, + success: PropTypes.node, +}; + +RegistrationForm.defaultProps = { + error: null, + sendRegistrationRequest: null, + success: null, +}; diff --git a/components/signup/components/Signup.css b/components/signup/components/Signup.css new file mode 100644 index 0000000..ff54cf6 --- /dev/null +++ b/components/signup/components/Signup.css @@ -0,0 +1,114 @@ +.login-form , .password-form{ + max-width: 300px; + margin: 1em auto; + height: 60vh; + display: grid; + place-content: center; +} + +.login-form-forgot, +.login-form-register,.password-form-register { + color: #1890ff; +} + +.login-form-button, .password-form-button { + width: 100%; +} + +.login-image-section, +.registration-image-section, +.password-image-section { + background: #1890ff; + /**fallback for old browsers*/ + background: -webkit-linear-gradient(to right, #1890ff, #40a9ff); + /**Chrome 10-25, Safari 5.1-6*/ + background: linear-gradient(to top right, #1890ff, #f6f7f8fb); + height: 40vh; +} + +.login-image, +.registration-image, +.password-image { + width: 100%; + height: 80%; +} + +.registration-image-section { + height: 20vh; +} + +.registration-image { + width: 100%; + height: 120%; +} + +.registration-form-section { + height: 80vh; + margin: 0 auto; +} + +.registration-form { + width: 90%; + margin: 1em auto; +} + +@media screen and (max-width: 798px) { + .login-image-section, + .password-image-section { + position: relative; + } + + .login-image, + .password-image { + position: absolute; + bottom: -33px; + } + + .registration-form { + max-width: 300px; + margin: 0 auto + } +} + +@media screen and (min-width: 799px) { + .registration-form { + max-width: 300px; + margin: 0 auto + } + + .Login-Section, + .Registration-Section, + .password-Section { + display: flex; + } + + .Login-Form-section, + .password-Form-section { + display: grid; + place-items: center; + width: 50vw; + } + + section .login-form, + section .password-form { + max-width: 50vw; + place-content: center; + display: grid; + } + + .login-image, + .password-image, + .registration-image { + padding: 0 2em; + max-width: 35vw; + height: 80% + } + + .login-image-section,.password-image-section, + .registration-image-section { + height: 100vh; + width: 50vw; + display: grid; + place-items: center; + } +} diff --git a/components/signup/components/SignupInputItemGenerator.jsx b/components/signup/components/SignupInputItemGenerator.jsx new file mode 100644 index 0000000..37906fd --- /dev/null +++ b/components/signup/components/SignupInputItemGenerator.jsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { + Form, Input, Checkbox, Button +} from 'antd'; +import PropTypes from 'prop-types'; + +import { STRINGS } from '../constants'; + +const { + AGREEMENT, + ALREADY_HAVE_ACCOUNT, + LOGIN, + LOGIN_LINK, + READ_ACCEPTED_AGREEMENT, + REGISTER, +} = STRINGS; + +/** +* function that is used to also handle password validation, this compares the two password field; +* @function +* @param {function} actions actions attached to the input field for validation +* @param {Object} items the values passed on to the input field +* @param {function} decorator a Two-way binding for form +* @param {string} label for the input field +* @param {string} id id of the input field +* @param {Object[]} rules rules for input validation +* @param {Boolean} hasOnBlur check if the input has an onChange function attached ot it +* @param {Boolean} hasOnChange check if the input has an onChange function attached ot it +* @param {function} valuePropName Props of checkbox +* @param {Boolean} isButton check if the form element is a button +* @param {Boolean} hasFieldChildren check if the input children +* @param {function} FieldType type of html form element +* @return {function} input item of the form +*/ +const SignupInputGenerator = (actions, items, decorator) => { + const { + label, + id, + rules, + hasOnBlur, + hasOnChange, + valuePropName, + isButton, + hasFieldChildren, + FieldType, + } = items; + const { onBlur, onChange } = actions; + const actionProps = { + onBlur: hasOnBlur && onBlur, + onChange: hasOnChange && onChange, + }; + + let Field; + let fieldChildren; + + switch (FieldType) { + case 'input': + Field = Input; + break; + case 'checkbox': + Field = Checkbox; + break; + case 'button': + Field = Button; + break; + case 'password': + Field = Input.Password; + break; + default: + Field = null; + break; + } + + if (isButton && hasFieldChildren) { + fieldChildren = ( +
+ {ALREADY_HAVE_ACCOUNT} + {LOGIN} +
+ ); + } else if (FieldType === 'checkbox' && hasFieldChildren) { + fieldChildren = ( + + {READ_ACCEPTED_AGREEMENT} + + {AGREEMENT} + + + ); + } + + return ( + + { + isButton ? ( + <> + {REGISTER} + {fieldChildren} + + ) + : decorator(id, { rules }, { valuePropName })( + + {hasFieldChildren ? fieldChildren : null} + + ) + } + + ); +}; + +export default SignupInputGenerator; + +SignupInputGenerator.propTypes = { + FieldType: PropTypes.elementType.isRequired, + hasFieldChildren: PropTypes.bool.isRequired, + hasOnBlur: PropTypes.bool, + hasOnChange: PropTypes.bool, + id: PropTypes.string.isRequired, + isButton: PropTypes.bool, + label: PropTypes.string, + rules: PropTypes.arrayOf(PropTypes.shape({ + message: PropTypes.string.isRequired, + required: PropTypes.bool.isRequired, + type: PropTypes.string.isRequired, + whitespace: PropTypes.bool.isRequired, + })).isRequired, + valuePropName: PropTypes.string, +}; + +SignupInputGenerator.defaultProps = { + hasOnBlur: false, + hasOnChange: false, + isButton: false, + label: null, + valuePropName: null, +}; diff --git a/components/signup/components/index.js b/components/signup/components/index.js new file mode 100644 index 0000000..ac6a9ad --- /dev/null +++ b/components/signup/components/index.js @@ -0,0 +1,5 @@ +import 'antd/dist/antd.css'; +import './Signup.css'; +import SignUp from './SignUp'; + +export { SignUp }; diff --git a/components/signup/constants.js b/components/signup/constants.js new file mode 100644 index 0000000..88c57d8 --- /dev/null +++ b/components/signup/constants.js @@ -0,0 +1,112 @@ +export const STRINGS = { + AGREEMENT: 'agreement', + ALREADY_HAVE_ACCOUNT: 'already have an account, please ', + FORGET_PASSWORD_EMAIL_INPUT_ERROR: 'The input is not a valid E-mail !', + FORGET_PASSWORD_EMAIL_INPUT_INSTRUCTION: `Please input your E-mail so that we can send +your reset link!`, + FORGET_PASSWORD_NOTIFICATION_DESCRIPTION: 'The resent link has been sent to', + FORGET_PASSWORD_NOTIFICATION_TITLE: 'Link Sent', + FORGOT_PASSWORD: 'Forgot password', + LOGIN: 'login', + LOGIN_LINK: '/api/users/login', + LOGIN_TO_CONTINUE: 'Login to continue', + NAME: 'signup', + OR: 'or', + PASSWORD_CHANGE_TEXT: 'Request for Password Change', + READ_ACCEPTED_AGREEMENT: 'I have read and accepted the ', + REGISTER: 'Register', + REGISTER_NOW: ' register now!', + REGISTRATION_URL: '/api/users/register', + REMEMBER_PASSWORD_TEXT: 'remember your password? ', + WELCOME: 'Welcome', +}; + +export const LOGIN_INPUTS = [ + { + className: 'form_icon', + iconType: 'user', + id: 'username', + placeholder: 'Username', + rules: [{ + message: 'Please input your username!', + required: true, + }], + }, { + className: 'form_icon', + iconType: 'lock', + id: 'password', + inputType: 'password', + placeholder: 'Password', + rules: [{ + message: 'Please input your Password!', + required: true, + }], + }, +]; + +export const SIGNUP_INPUTS = validator => [ + { + items: { + FieldType: 'input', + hasFieldChildren: false, + id: 'name', + label: 'UserName', + rules: [{ message: 'Please input your name!', required: true, whitespace: true }], + }, + }, + + { + items: { + FieldType: 'input', + hasFieldChildren: false, + id: 'email', + label: 'E-mail', + rules: [{ message: 'The input is not a valid E-mail!', type: 'email' }, { + message: 'Please input your E-mail!', required: true, + }], + }, + }, + + { + items: { + FieldType: 'password', + hasFieldChildren: false, + id: 'password', + label: 'Password', + rules: [{ message: 'The input is not a valid E-mail!', required: true }, + ], + }, + }, + + { + items: { + FieldType: 'password', + hasFieldChildren: false, + id: 'confirm', + label: 'Confirm Password', + rules: [{ message: 'Please confirm your password!', required: true }, + { validator }, + ], + }, + }, + + { + items: { + FieldType: 'checkbox', + hasFieldChildren: true, + id: 'agreement', + rules: [{ message: 'Please accept the agreement ', required: true }, + ], + valuePropName: 'checked', + }, + }, + + { + items: { + FieldType: 'button', + hasFieldChildren: true, + id: 'submit', + isButton: true, + }, + }, +]; diff --git a/components/signup/index.js b/components/signup/index.js new file mode 100644 index 0000000..2f3b992 --- /dev/null +++ b/components/signup/index.js @@ -0,0 +1,8 @@ +import reducers from './reducers'; +import * as components from './components'; +import * as actions from './actions'; +import signupSagas from './sagas'; + +export { + actions, components, reducers, signupSagas +}; diff --git a/components/signup/reducers.js b/components/signup/reducers.js new file mode 100644 index 0000000..e8a1d8f --- /dev/null +++ b/components/signup/reducers.js @@ -0,0 +1,31 @@ +import actionTypes from './actionTypes'; + +const initialState = { + error: null, + isRegistering: false, + payload: null, + success: null, +}; + +export default (state = initialState, action) => { + const { + REGISTRATION_REQUEST_ERROR, + REGISTRATION_REQUEST_SUCCESS, + SEND_REGISTRATION_REQUEST, + } = actionTypes; + + const { type, payload } = action; + + switch (type) { + case SEND_REGISTRATION_REQUEST: + return { ...state, isRegistering: true }; + + case REGISTRATION_REQUEST_SUCCESS: + return { ...state, isRegistering: false, success: payload }; + + case REGISTRATION_REQUEST_ERROR: + return { ...state, error: payload, isRegistering: false }; + default: + return state; + } +}; diff --git a/components/signup/sagas.js b/components/signup/sagas.js new file mode 100644 index 0000000..2f6a122 --- /dev/null +++ b/components/signup/sagas.js @@ -0,0 +1,34 @@ +import { + takeEvery, call, put +} from 'redux-saga/effects'; +import actionTypes from './actionTypes'; +import { + registrationRequestSuccess, + registrationRequestError +} from './actions'; +import { STRINGS } from './constants'; + +const { REGISTRATION_URL } = STRINGS; +const { + SEND_REGISTRATION_REQUEST, +} = actionTypes; + +function* handleUserRegistration({ payload }) { + const response = yield call(fetch, REGISTRATION_URL, { + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }); + if (response.ok) { + const data = yield response.json(); + yield put(registrationRequestSuccess(data)); + } else { + yield put(registrationRequestError(response)); + } +} + +export default function* watchRegistrationAction() { + yield takeEvery(SEND_REGISTRATION_REQUEST, handleUserRegistration); +} diff --git a/components/signup/selectors.js b/components/signup/selectors.js new file mode 100644 index 0000000..4283374 --- /dev/null +++ b/components/signup/selectors.js @@ -0,0 +1,8 @@ +import { STRINGS } from './constants'; + +const { NAME } = STRINGS; + +export const getError = state => state[NAME].error; +export const getIsRegistering = state => state[NAME].isRegistering; +export const getPayload = state => state[NAME].payload; +export const getSuccess = state => state[NAME].success; diff --git a/components/timeLine/actionTypes.js b/components/timeLine/actionTypes.js new file mode 100644 index 0000000..fc14319 --- /dev/null +++ b/components/timeLine/actionTypes.js @@ -0,0 +1,19 @@ +const actionTypes = { + ADD_COMMENT_TO_POST: 'ADD_COMMENT_TO_POST', + ADD_POST_TO_TIMELINE: 'ADD_POST_TO_TIMELINE', + POST_PROFILE_DATA_TO_DATABASE: 'POST_PROFILE_DATA_TO_DATABASE', + POST_PROFILE_DATA_TO_DATABASE_ERROR: 'POST_PROFILE_DATA_TO_DATABASE_ERROR', + POST_PROFILE_DATA_TO_DATABASE_SUCCESS: 'POST_PROFILE_DATA_TO_DATABASE_SUCCESS', + REQUEST_LOAD_ONLINE_FRIENDS_DATA: 'REQUEST_LOAD_ONLINE_FRIENDS_DATA', + REQUEST_LOAD_TIMELINE_DATA: 'REQUEST_LOAD_TIMELINE_DATA', + REQUEST_SET_ONLINE_FRIENDS_DATA: 'REQUEST_SET_ONLINE_FRIENDS_DATA', + REQUEST_SET_ONLINE_FRIENDS_ERROR: 'REQUEST_SET_ONLINE_FRIENDS_ERROR', + REQUEST_SET_TIMELINE_DATA_SUCCESS: 'REQUEST_SET_TIMELINE_DATA_SUCCESS', + REQUEST_SET_TIMELINE_ERROR: 'REQUEST_SET_TIMELINE_ERROR', + TOGGLE_COMMENT_BUTTON_CLICKED: 'TOGGLE_COMMENT_BUTTON_CLICKED', + TOGGLE_FAV_BUTTON_CLICKED: 'TOGGLE_FAV_BUTTON_CLICKED', + TOGGLE_LIKE_BUTTON_CLICKED: 'TOGGLE_LIKE_BUTTON_CLICKED', + UPDATE_STATUS: 'UPDATE_STATUS', +}; + +export default actionTypes; diff --git a/components/timeLine/actions.js b/components/timeLine/actions.js new file mode 100644 index 0000000..61bf9e0 --- /dev/null +++ b/components/timeLine/actions.js @@ -0,0 +1,86 @@ +import actionTypes from './actionTypes'; + +const { + ADD_COMMENT_TO_POST, + ADD_POST_TO_TIMELINE, + POST_PROFILE_DATA_TO_DATABASE, + POST_PROFILE_DATA_TO_DATABASE_SUCCESS, + POST_PROFILE_DATA_TO_DATABASE_ERROR, + REQUEST_SET_ONLINE_FRIENDS_ERROR, + REQUEST_LOAD_ONLINE_FRIENDS_DATA, + REQUEST_SET_ONLINE_FRIENDS_DATA, + REQUEST_LOAD_TIMELINE_DATA, + REQUEST_SET_TIMELINE_ERROR, + REQUEST_SET_TIMELINE_DATA_SUCCESS, + TOGGLE_FAV_BUTTON_CLICKED, + TOGGLE_COMMENT_BUTTON_CLICKED, + TOGGLE_LIKE_BUTTON_CLICKED, +} = actionTypes; + +export const loadTimeLineData = () => ({ + type: REQUEST_LOAD_TIMELINE_DATA, +}); + +export const setTimeLineError = payload => ({ + payload, + type: REQUEST_SET_TIMELINE_ERROR, +}); + +export const setTimeLineData = payload => ({ + payload, + type: REQUEST_SET_TIMELINE_DATA_SUCCESS, +}); + +export const loadOnlineFriendsData = () => ({ + type: REQUEST_LOAD_ONLINE_FRIENDS_DATA, +}); + +export const setOnlineFriendsError = payload => ({ + payload, + type: REQUEST_SET_ONLINE_FRIENDS_ERROR, +}); + +export const setOnlineFriendsData = payload => ({ + payload, + type: REQUEST_SET_ONLINE_FRIENDS_DATA, +}); + +export const handlePostUpdate = payload => ({ + payload, + type: ADD_POST_TO_TIMELINE, +}); + +export const handlePostComment = payload => ({ + payload, + type: ADD_COMMENT_TO_POST, +}); + +export const likeButtonClicked = payload => ({ + payload, + type: TOGGLE_LIKE_BUTTON_CLICKED, +}); + +export const favButtonClicked = payload => ({ + payload, + type: TOGGLE_FAV_BUTTON_CLICKED, +}); + +export const commentButtonClicked = payload => ({ + payload, + type: TOGGLE_COMMENT_BUTTON_CLICKED, +}); + +export const postProfileDataToDatabase = payload => ({ + payload, + type: POST_PROFILE_DATA_TO_DATABASE, +}); + +export const postProfileDataToDatabaseSuccess = payload => ({ + payload, + type: POST_PROFILE_DATA_TO_DATABASE_SUCCESS, +}); + +export const postProfileDataToDatabaseError = payload => ({ + payload, + type: POST_PROFILE_DATA_TO_DATABASE_ERROR, +}); diff --git a/components/timeLine/components/CreatePostComponent.jsx b/components/timeLine/components/CreatePostComponent.jsx new file mode 100644 index 0000000..c904d53 --- /dev/null +++ b/components/timeLine/components/CreatePostComponent.jsx @@ -0,0 +1,119 @@ +import { + Button, Icon, Input, Upload +} from 'antd'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import { STRINGS } from '../constants'; + +const { POST } = STRINGS; +const { TextArea } = Input; + +/** + * Helper function that is used for creating the input part of the post *component + * @function + * @param {String} InputPlaceholder - the placeholder for the input component + * @param {integer} rowHeight - the minimum amount of rows that the input + * component will initially contain + * @return {Object} the input part of the createpost component + */ +const CreatePostInput = props => { + const { + InputPlaceholder, rowHeight, handleValueChange, value, + } = props; + return ( +