diff --git a/.circleci/config.yml b/.circleci/config.yml index 349c4c5d3c..e86c2aeada 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -363,7 +363,7 @@ workflows: filters: branches: only: - - free + - thrive-vulnerability-1 # This is stage env for production QA releases - "build-prod-staging": context : org-global diff --git a/src/server/routes/contentful.js b/src/server/routes/contentful.js index 638ff7bd02..1084af62ba 100644 --- a/src/server/routes/contentful.js +++ b/src/server/routes/contentful.js @@ -3,7 +3,9 @@ */ import express from 'express'; - +import { middleware } from 'tc-core-library-js'; +import config from 'config'; +import _ from 'lodash'; import { ASSETS_DOMAIN, IMAGES_DOMAIN, @@ -14,6 +16,8 @@ import { const cors = require('cors'); +const authenticator = middleware.jwtAuthenticator; +const authenticatorOptions = _.pick(config.SECRET.JWT_AUTH, ['AUTH_SECRET', 'VALID_ISSUERS']); const routes = express.Router(); // Enables CORS on those routes according config above @@ -124,7 +128,7 @@ routes.use('/:spaceName/:environment/published/entries', (req, res, next) => { }); /* Update votes on article. */ -routes.use('/:spaceName/:environment/votes', (req, res, next) => { +routes.use('/:spaceName/:environment/votes', (req, res, next) => authenticator(authenticatorOptions)(req, res, next), (req, res, next) => { articleVote(req.body) .then(res.send.bind(res), next); }); diff --git a/src/shared/components/Contentful/Article/Article.jsx b/src/shared/components/Contentful/Article/Article.jsx index 0017142c24..2e4476dab5 100644 --- a/src/shared/components/Contentful/Article/Article.jsx +++ b/src/shared/components/Contentful/Article/Article.jsx @@ -5,6 +5,7 @@ */ import _ from 'lodash'; import React from 'react'; +import { connect } from 'react-redux'; import PT from 'prop-types'; import { fixStyle } from 'utils/contentful'; import { getService } from 'services/contentful'; @@ -22,6 +23,8 @@ import { config, Link, isomorphy, } from 'topcoder-react-utils'; import qs from 'qs'; +import LoginModal from 'components/LoginModal'; +import modalStyle from 'components/LoginModal/modal.scss'; // SVGs and assets import GestureIcon from 'assets/images/icon-gesture.svg'; import ReadMoreArrow from 'assets/images/read-more-arrow.svg'; @@ -41,21 +44,30 @@ const DEFAULT_BANNER_IMAGE = 'https://images.ctfassets.net/piwi0eufbb2g/7v2hlDsV const RANDOM_BANNERS = ['6G8mjiTC1mzeSQ2YoUG1gB', '1DnDD02xX1liHfSTf5Vsn8', 'HQZ3mN0rR92CbNTkKTHJ5', '1OLoX8ZsvjAnn4TdGbZESD', '77jn01UGoQe2gqA7x0coQD']; const RANDOM_BANNER = RANDOM_BANNERS[_.random(0, 4)]; -export default class Article extends React.Component { +class Article extends React.Component { componentDidMount() { const { fields } = this.props; this.setState({ upvotes: fields.upvotes || 0, downvotes: fields.downvotes || 0, + showLogin: false, + voting: false, }); } + // eslint-disable-next-line consistent-return updateVote(type) { - let userVotes = localStorage.getItem(LOCAL_STORAGE_KEY); - userVotes = userVotes ? JSON.parse(userVotes) : {}; const { - id, spaceName, environment, preview, + id, spaceName, environment, preview, auth, } = this.props; + // check for auth? + if (!auth) { + return this.setState({ + showLogin: true, + }); + } + let userVotes = localStorage.getItem(LOCAL_STORAGE_KEY); + userVotes = userVotes ? JSON.parse(userVotes) : {}; const articleVote = userVotes[id]; let { upvotes, downvotes } = this.state; // Check if user alredy voted on this article? @@ -93,10 +105,13 @@ export default class Article extends React.Component { } } // Store user action + this.setState({ + voting: true, + }); getService({ spaceName, environment, preview }).articleVote(id, { upvotes, downvotes, - }) + }, auth.tokenV3) .then(() => { // Only when Contentful enntry was succesfully updated // then we update the local store and the state @@ -104,6 +119,7 @@ export default class Article extends React.Component { this.setState({ upvotes, downvotes, + voting: false, }); }); } @@ -115,7 +131,9 @@ export default class Article extends React.Component { const contentfulConfig = { spaceName, environment, preview, }; - const { upvotes, downvotes } = this.state || {}; + const { + upvotes, downvotes, showLogin, voting, + } = this.state || {}; let shareUrl; if (isomorphy.isClientSide()) { shareUrl = encodeURIComponent(window.location.href); @@ -283,7 +301,7 @@ export default class Article extends React.Component { {/* Voting */}
-
this.updateVote('up')} onKeyPress={() => this.updateVote('up')}> +
this.updateVote('up')} onKeyPress={() => this.updateVote('up')}>
@@ -293,7 +311,7 @@ export default class Article extends React.Component {
-
this.updateVote('down')} onKeyPress={() => this.updateVote('down')}> +
this.updateVote('down')} onKeyPress={() => this.updateVote('down')}>
{downvotes} @@ -380,6 +398,19 @@ export default class Article extends React.Component { ) : null }
+ { + showLogin && ( + this.setState({ showLogin: false })} + modalTitle="Want to vote?" + modalText="You must be a Topcoder member to do that." + utmSource="thrive_article" + infoNode={

Discover other features you can access by becoming a member.

} + /> + ) + } ); } @@ -388,6 +419,7 @@ export default class Article extends React.Component { Article.defaultProps = { spaceName: null, environment: null, + auth: null, }; Article.propTypes = { @@ -398,4 +430,16 @@ Article.propTypes = { preview: PT.bool.isRequired, spaceName: PT.string, environment: PT.string, + auth: PT.shape(), }; + +function mapStateToProps(state) { + const auth = state.auth && state.auth.profile ? { ...state.auth } : null; + return { + auth, + }; +} + +export default connect( + mapStateToProps, +)(Article); diff --git a/src/shared/components/Contentful/Article/themes/default.scss b/src/shared/components/Contentful/Article/themes/default.scss index 599dc5f363..7874e05796 100644 --- a/src/shared/components/Contentful/Article/themes/default.scss +++ b/src/shared/components/Contentful/Article/themes/default.scss @@ -419,7 +419,8 @@ padding: 8px 9px; margin-right: 10px; - .circleGreenIcon { + .circleGreenIcon, + .circleGreenIconDisabled { border-radius: 100%; width: 42px; height: 42px; @@ -431,7 +432,8 @@ background-color: #12c188; } - .circleRedIcon { + .circleRedIcon, + .circleRedIconDisabled { border-radius: 100%; width: 42px; height: 42px; @@ -444,6 +446,12 @@ transform: rotateX(-180deg); } + .circleGreenIconDisabled, + .circleRedIconDisabled { + pointer-events: none; + opacity: 0.5; + } + span { @include barlow-bold; diff --git a/src/shared/components/Gigs/LoginModal/index.jsx b/src/shared/components/Gigs/LoginModal/index.jsx index 673a390a7f..47c1124533 100644 --- a/src/shared/components/Gigs/LoginModal/index.jsx +++ b/src/shared/components/Gigs/LoginModal/index.jsx @@ -30,7 +30,7 @@ const progressBarMid = 'https://images.ctfassets.net/b5f1djy59z3a/517ZRt9geweW3Q const progressBarXS = 'https://images.ctfassets.net/b5f1djy59z3a/6QxH7uVKCngtzBaXDn3Od1/3e0222a1ce773cead3f3a45f291f43a6/progress-bar-mobile.svg'; const blobPurple = 'https://images.ctfassets.net/b5f1djy59z3a/1ZRCwp1uoShcES16lQmeu/ba084734120ffedebcb92b4e3fa2d667/blob-purple.svg'; -function LoginModal({ retUrl, onCancel }) { +function LoginModal({ retUrl, onCancel, utmSource }) { return ( { - window.location = `${config.URL.AUTH}/member/registration?retUrl=${encodeURIComponent(retUrl)}&mode=signUp&utm_source=gig_listing`; + window.location = `${config.URL.AUTH}/member/registration?retUrl=${encodeURIComponent(retUrl)}&mode=signUp&utm_source=${utmSource}`; }} theme={{ button: buttonThemes.tc['primary-green-md'], @@ -72,9 +72,14 @@ function LoginModal({ retUrl, onCancel }) { ); } +LoginModal.defaultProps = { + utmSource: 'gig_listing', +}; + LoginModal.propTypes = { retUrl: PT.string.isRequired, onCancel: PT.func.isRequired, + utmSource: PT.string, }; export default LoginModal; diff --git a/src/shared/components/LoginModal/index.jsx b/src/shared/components/LoginModal/index.jsx new file mode 100644 index 0000000000..b68d2170fc --- /dev/null +++ b/src/shared/components/LoginModal/index.jsx @@ -0,0 +1,70 @@ +/** + * Generic Login Modal Dialog + */ +/* global window */ + +import PT from 'prop-types'; +import React from 'react'; +import { Modal, PrimaryButton } from 'topcoder-react-ui-kit'; +import { config, Link } from 'topcoder-react-utils'; +import tc from 'components/buttons/themed/tc.scss'; +import modalStyle from './modal.scss'; + +/** Themes for buttons + * those overwrite PrimaryButton style to match achieve various styles. + * Should implement pattern of classes. + */ +const buttonThemes = { + tc, +}; + +function LoginModal({ + onCancel, + retUrl, + utmSource, + modalTitle, + modalText, + infoNode, +}) { + return ( + +
+

{modalTitle}

+

{modalText}

+
+ { + window.location = `${config.URL.AUTH}/member?retUrl=${encodeURIComponent(retUrl)}`; + }} + theme={{ + button: buttonThemes.tc['primary-green-md'], + }} + > + LOGIN + + REGISTER +
+ {infoNode} +
+
+ ); +} + +LoginModal.defaultProps = { + utmSource: null, + infoNode: null, +}; + +LoginModal.propTypes = { + onCancel: PT.func.isRequired, + retUrl: PT.string.isRequired, + utmSource: PT.string, + modalTitle: PT.string.isRequired, + modalText: PT.string.isRequired, + infoNode: PT.node, +}; + +export default LoginModal; diff --git a/src/shared/components/LoginModal/modal.scss b/src/shared/components/LoginModal/modal.scss new file mode 100644 index 0000000000..57bd7e2c8e --- /dev/null +++ b/src/shared/components/LoginModal/modal.scss @@ -0,0 +1,89 @@ +/* stylelint-disable no-descending-specificity */ +@import "~styles/mixins"; +@import "~components/Contentful/default"; + +.container { + padding: 0; + width: auto; + max-width: 95vw; + height: auto; + max-height: 95vh; + border-radius: 10px; + display: flex; + flex-direction: column; + justify-content: center; + + @include gui-kit-headers; + @include gui-kit-content; + + @include xs-to-sm { + width: 90vw; + max-width: 90vw; + } + + .title { + color: #1e94a3; + font-family: BarlowCondensed, sans-serif; + font-size: 34px; + line-height: 38px; + font-weight: 500; + margin: 0; + margin-bottom: 20px; + } + + .loginMsg { + color: #2a2a2a; + font-size: 24px; + line-height: 36px; + margin-bottom: 40px; + } + + .ctaButtons { + display: flex; + align-content: center; + justify-content: center; + + & > button:first-child { + margin-right: 10px !important; + } + + & > a:first-child { + margin-right: 10px !important; + } + } + + .loginRequired { + display: flex; + flex-direction: column; + padding: 100px 80px; + text-align: center; + + @include xs-to-sm { + padding: 20px 15px; + } + + .loginMsg { + font-size: 20px; + } + + .regTxt { + font-size: 14px; + margin: 10px 0 0; + } + + .ctaButtons { + @include xs-to-sm { + flex-direction: column; + + > button { + margin: 0 0 20px !important; + } + } + } + } +} + +.overlay { + background-color: #2a2a2a; + opacity: 0.95; +} diff --git a/src/shared/services/contentful.js b/src/shared/services/contentful.js index 289830d0a7..ded9dacaf7 100644 --- a/src/shared/services/contentful.js +++ b/src/shared/services/contentful.js @@ -258,15 +258,17 @@ class Service { * Vote on article * @param {String} id Entry ID. * @param {Array} data The updated data array + * @param {String} tokenV3 user's auth token * @returns {Promise} */ - async articleVote(id, votes) { + async articleVote(id, votes, tokenV3) { // eslint-disable-next-line prefer-template const url = this.private.baseUrl + '/votes'; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', + Authorization: `Bearer ${tokenV3}`, }, body: JSON.stringify({ id, votes,