feat(donate):add donation modal and certification message (#37822)
Co-Authored-By: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
38
client/src/assets/icons/Heart.js
Normal file
38
client/src/assets/icons/Heart.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import React, { Fragment } from 'react';
|
||||||
|
|
||||||
|
const propTypes = {};
|
||||||
|
|
||||||
|
function Heart(props) {
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<span className='sr-only'>Heart</span>
|
||||||
|
<svg
|
||||||
|
height={184}
|
||||||
|
version='1.1'
|
||||||
|
viewBox='0 0 200 184'
|
||||||
|
width={200}
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<g fill='none' fillRule='evenodd'>
|
||||||
|
<g fill='var(--love-color)'>
|
||||||
|
<ellipse cx='140.5' cy={59} id='a' rx='59.5' ry={59} />
|
||||||
|
<circle cx={59} cy={59} r={59} />
|
||||||
|
<rect
|
||||||
|
height={118}
|
||||||
|
transform='translate(100 100) rotate(-45) translate(-100 -100)'
|
||||||
|
width={118}
|
||||||
|
x={41}
|
||||||
|
y={41}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Heart.displayName = 'Heart';
|
||||||
|
Heart.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default Heart;
|
@ -8,7 +8,12 @@ import { Grid, Row, Col, Image } from '@freecodecamp/react-bootstrap';
|
|||||||
import {
|
import {
|
||||||
showCertSelector,
|
showCertSelector,
|
||||||
showCertFetchStateSelector,
|
showCertFetchStateSelector,
|
||||||
showCert
|
showCert,
|
||||||
|
userFetchStateSelector,
|
||||||
|
usernameSelector,
|
||||||
|
isDonatingSelector,
|
||||||
|
isDonationRequestedSelector,
|
||||||
|
preventDonationRequests
|
||||||
} from '../redux';
|
} from '../redux';
|
||||||
import validCertNames from '../../utils/validCertNames';
|
import validCertNames from '../../utils/validCertNames';
|
||||||
import { createFlashMessage } from '../components/Flash/redux';
|
import { createFlashMessage } from '../components/Flash/redux';
|
||||||
@ -16,7 +21,7 @@ import standardErrorMessage from '../utils/standardErrorMessage';
|
|||||||
import reallyWeirdErrorMessage from '../utils/reallyWeirdErrorMessage';
|
import reallyWeirdErrorMessage from '../utils/reallyWeirdErrorMessage';
|
||||||
|
|
||||||
import RedirectHome from '../components/RedirectHome';
|
import RedirectHome from '../components/RedirectHome';
|
||||||
import { Loader } from '../components/helpers';
|
import { Loader, Link } from '../components/helpers';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
cert: PropTypes.shape({
|
cert: PropTypes.shape({
|
||||||
@ -35,8 +40,15 @@ const propTypes = {
|
|||||||
complete: PropTypes.bool,
|
complete: PropTypes.bool,
|
||||||
errored: PropTypes.bool
|
errored: PropTypes.bool
|
||||||
}),
|
}),
|
||||||
|
isDonating: PropTypes.bool,
|
||||||
|
isDonationRequested: PropTypes.bool,
|
||||||
issueDate: PropTypes.string,
|
issueDate: PropTypes.string,
|
||||||
|
preventDonationRequests: PropTypes.func,
|
||||||
showCert: PropTypes.func.isRequired,
|
showCert: PropTypes.func.isRequired,
|
||||||
|
signedInUserName: PropTypes.string,
|
||||||
|
userFetchState: PropTypes.shape({
|
||||||
|
complete: PropTypes.bool
|
||||||
|
}),
|
||||||
userFullName: PropTypes.string,
|
userFullName: PropTypes.string,
|
||||||
username: PropTypes.string,
|
username: PropTypes.string,
|
||||||
validCertName: PropTypes.bool
|
validCertName: PropTypes.bool
|
||||||
@ -47,16 +59,34 @@ const mapStateToProps = (state, { certName }) => {
|
|||||||
return createSelector(
|
return createSelector(
|
||||||
showCertSelector,
|
showCertSelector,
|
||||||
showCertFetchStateSelector,
|
showCertFetchStateSelector,
|
||||||
(cert, fetchState) => ({
|
usernameSelector,
|
||||||
|
userFetchStateSelector,
|
||||||
|
isDonatingSelector,
|
||||||
|
isDonationRequestedSelector,
|
||||||
|
(
|
||||||
cert,
|
cert,
|
||||||
fetchState,
|
fetchState,
|
||||||
validCertName
|
signedInUserName,
|
||||||
|
userFetchState,
|
||||||
|
isDonating,
|
||||||
|
isDonationRequested
|
||||||
|
) => ({
|
||||||
|
cert,
|
||||||
|
fetchState,
|
||||||
|
validCertName,
|
||||||
|
signedInUserName,
|
||||||
|
userFetchState,
|
||||||
|
isDonating,
|
||||||
|
isDonationRequested
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch =>
|
const mapDispatchToProps = dispatch =>
|
||||||
bindActionCreators({ createFlashMessage, showCert }, dispatch);
|
bindActionCreators(
|
||||||
|
{ createFlashMessage, showCert, preventDonationRequests },
|
||||||
|
dispatch
|
||||||
|
);
|
||||||
|
|
||||||
class ShowCertification extends Component {
|
class ShowCertification extends Component {
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@ -72,7 +102,12 @@ class ShowCertification extends Component {
|
|||||||
fetchState,
|
fetchState,
|
||||||
validCertName,
|
validCertName,
|
||||||
createFlashMessage,
|
createFlashMessage,
|
||||||
certName
|
certName,
|
||||||
|
preventDonationRequests,
|
||||||
|
signedInUserName,
|
||||||
|
isDonating,
|
||||||
|
isDonationRequested,
|
||||||
|
userFetchState
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (!validCertName) {
|
if (!validCertName) {
|
||||||
@ -81,6 +116,7 @@ class ShowCertification extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { pending, complete, errored } = fetchState;
|
const { pending, complete, errored } = fetchState;
|
||||||
|
const { complete: userComplete } = userFetchState;
|
||||||
|
|
||||||
if (pending) {
|
if (pending) {
|
||||||
return <Loader fullScreen={true} />;
|
return <Loader fullScreen={true} />;
|
||||||
@ -103,8 +139,40 @@ class ShowCertification extends Component {
|
|||||||
certTitle,
|
certTitle,
|
||||||
completionTime
|
completionTime
|
||||||
} = cert;
|
} = cert;
|
||||||
|
|
||||||
|
let conditionalDonationMessage = '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
userComplete &&
|
||||||
|
signedInUserName === username &&
|
||||||
|
!isDonating &&
|
||||||
|
!isDonationRequested
|
||||||
|
) {
|
||||||
|
conditionalDonationMessage = (
|
||||||
|
<Grid>
|
||||||
|
<Row className='certification-donation text-center'>
|
||||||
|
<p>
|
||||||
|
Only you can see this message. Congratulations on earning this
|
||||||
|
certification. It’s no easy task. Running freeCodeCamp isn’t easy
|
||||||
|
either. Nor is it cheap. Help us help you and many other people
|
||||||
|
around the world. Make a tax-deductible supporting donation to our
|
||||||
|
nonprofit today.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
className={'btn'}
|
||||||
|
onClick={preventDonationRequests}
|
||||||
|
to={'/donate'}
|
||||||
|
>
|
||||||
|
Check out our donation dashboard
|
||||||
|
</Link>
|
||||||
|
</Row>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='certificate-outer-wrapper'>
|
<div className='certificate-outer-wrapper'>
|
||||||
|
{conditionalDonationMessage}
|
||||||
<Grid className='certificate-wrapper certification-namespace'>
|
<Grid className='certificate-wrapper certification-namespace'>
|
||||||
<Row>
|
<Row>
|
||||||
<header>
|
<header>
|
||||||
|
@ -162,3 +162,59 @@ li.disabled > a {
|
|||||||
.servicebot-embed-panel .panel {
|
.servicebot-embed-panel .panel {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heart-icon-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heart-icon {
|
||||||
|
width: 150px;
|
||||||
|
height: auto;
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
animation: heart-icon-animation 1s linear 100ms forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes heart-icon-animation {
|
||||||
|
33% {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
66% {
|
||||||
|
transform: scale(1.25);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.donation-modal p {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donation-modal .modal-title {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 991px) {
|
||||||
|
.heart-icon-container {
|
||||||
|
margin: 30px;
|
||||||
|
}
|
||||||
|
.donation-modal p {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.donation-modal .modal-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
98
client/src/components/Donation/components/DonationModal.js
Normal file
98
client/src/components/Donation/components/DonationModal.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { bindActionCreators } from 'redux';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { Modal, Button } from '@freecodecamp/react-bootstrap';
|
||||||
|
import { Link } from '../../../components/helpers';
|
||||||
|
import { blockNameify } from '../../../../utils/blockNameify';
|
||||||
|
import Heart from '../../../assets/icons/Heart';
|
||||||
|
|
||||||
|
import ga from '../../../analytics';
|
||||||
|
import {
|
||||||
|
closeDonationModal,
|
||||||
|
isDonationModalOpenSelector
|
||||||
|
} from '../../../redux';
|
||||||
|
|
||||||
|
import { challengeMetaSelector } from '../../../templates/Challenges/redux';
|
||||||
|
|
||||||
|
import '../Donation.css';
|
||||||
|
|
||||||
|
const mapStateToProps = createSelector(
|
||||||
|
isDonationModalOpenSelector,
|
||||||
|
challengeMetaSelector,
|
||||||
|
(show, { block }) => ({
|
||||||
|
show,
|
||||||
|
block
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapDispatchToProps = dispatch =>
|
||||||
|
bindActionCreators(
|
||||||
|
{
|
||||||
|
closeDonationModal
|
||||||
|
},
|
||||||
|
dispatch
|
||||||
|
);
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
block: PropTypes.string,
|
||||||
|
closeDonationModal: PropTypes.func.isRequired,
|
||||||
|
show: PropTypes.bool
|
||||||
|
};
|
||||||
|
|
||||||
|
class DonateModal extends Component {
|
||||||
|
render() {
|
||||||
|
const { show, block } = this.props;
|
||||||
|
if (show) {
|
||||||
|
ga.modalview('/donation-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal bsSize='lg' className='donation-modal' show={show}>
|
||||||
|
<Modal.Header className='fcc-modal'>
|
||||||
|
<Modal.Title className='modal-title text-center'>
|
||||||
|
<strong>Support freeCodeCamp.org</strong>
|
||||||
|
</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<p className='text-center'>
|
||||||
|
Nicely done. You just completed {blockNameify(block)}.
|
||||||
|
</p>
|
||||||
|
<div className='heart-icon-container'>
|
||||||
|
<Heart className='heart-icon' />
|
||||||
|
</div>
|
||||||
|
<p className='text-center'>
|
||||||
|
Help us create even more learning resources like this.
|
||||||
|
</p>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Link
|
||||||
|
className='btn-invert btn btn-lg btn-primary btn-block btn-cta'
|
||||||
|
onClick={this.props.closeDonationModal}
|
||||||
|
to={`/donate`}
|
||||||
|
>
|
||||||
|
Support our nonprofit
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
block={true}
|
||||||
|
bsSize='lg'
|
||||||
|
bsStyle='primary'
|
||||||
|
className='btn-invert'
|
||||||
|
onClick={this.props.closeDonationModal}
|
||||||
|
>
|
||||||
|
Ask me later
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DonateModal.displayName = 'DonateModal';
|
||||||
|
DonateModal.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(DonateModal);
|
@ -1,11 +1,42 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React, { Fragment, Component } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
function CertificationLayout({ children }) {
|
import ga from '../../analytics';
|
||||||
return <Fragment>{children}</Fragment>;
|
import { fetchUser, isSignedInSelector } from '../../redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
|
const mapStateToProps = createSelector(
|
||||||
|
isSignedInSelector,
|
||||||
|
isSignedIn => ({
|
||||||
|
isSignedIn
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapDispatchToProps = { fetchUser };
|
||||||
|
|
||||||
|
class CertificationLayout extends Component {
|
||||||
|
componentDidMount() {
|
||||||
|
const { isSignedIn, fetchUser, pathname } = this.props;
|
||||||
|
if (!isSignedIn) {
|
||||||
|
fetchUser();
|
||||||
|
}
|
||||||
|
ga.pageview(pathname);
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
return <Fragment>{this.props.children}</Fragment>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CertificationLayout.displayName = 'CertificationLayout';
|
CertificationLayout.displayName = 'CertificationLayout';
|
||||||
CertificationLayout.propTypes = { children: PropTypes.any };
|
CertificationLayout.propTypes = {
|
||||||
|
children: PropTypes.any,
|
||||||
|
fetchUser: PropTypes.func.isRequired,
|
||||||
|
isSignedIn: PropTypes.bool,
|
||||||
|
pathname: PropTypes.string.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
export default CertificationLayout;
|
export default connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(CertificationLayout);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React, { Fragment, Component } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
@ -7,9 +7,11 @@ import { Loader } from '../../components/helpers';
|
|||||||
import {
|
import {
|
||||||
userSelector,
|
userSelector,
|
||||||
userFetchStateSelector,
|
userFetchStateSelector,
|
||||||
isSignedInSelector
|
isSignedInSelector,
|
||||||
|
tryToShowDonationModal
|
||||||
} from '../../redux';
|
} from '../../redux';
|
||||||
import createRedirect from '../../components/createRedirect';
|
import createRedirect from '../../components/createRedirect';
|
||||||
|
import DonateModal from '../Donation/components/DonationModal';
|
||||||
|
|
||||||
import 'prismjs/themes/prism.css';
|
import 'prismjs/themes/prism.css';
|
||||||
import './prism.css';
|
import './prism.css';
|
||||||
@ -28,14 +30,25 @@ const mapStateToProps = createSelector(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
tryToShowDonationModal
|
||||||
|
};
|
||||||
|
|
||||||
const RedirectAcceptPrivacyTerm = createRedirect('/accept-privacy-terms');
|
const RedirectAcceptPrivacyTerm = createRedirect('/accept-privacy-terms');
|
||||||
|
|
||||||
function LearnLayout({
|
class LearnLayout extends Component {
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.tryToShowDonationModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
fetchState: { pending, complete },
|
fetchState: { pending, complete },
|
||||||
isSignedIn,
|
isSignedIn,
|
||||||
user: { acceptedPrivacyTerms },
|
user: { acceptedPrivacyTerms },
|
||||||
children
|
children
|
||||||
}) {
|
} = this.props;
|
||||||
|
|
||||||
if (pending && !complete) {
|
if (pending && !complete) {
|
||||||
return <Loader fullScreen={true} />;
|
return <Loader fullScreen={true} />;
|
||||||
}
|
}
|
||||||
@ -47,8 +60,10 @@ function LearnLayout({
|
|||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<main id='learn-app-wrapper'>{children}</main>
|
<main id='learn-app-wrapper'>{children}</main>
|
||||||
|
<DonateModal />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LearnLayout.displayName = 'LearnLayout';
|
LearnLayout.displayName = 'LearnLayout';
|
||||||
@ -60,9 +75,13 @@ LearnLayout.propTypes = {
|
|||||||
errored: PropTypes.bool
|
errored: PropTypes.bool
|
||||||
}),
|
}),
|
||||||
isSignedIn: PropTypes.bool,
|
isSignedIn: PropTypes.bool,
|
||||||
|
tryToShowDonationModal: PropTypes.func.isRequired,
|
||||||
user: PropTypes.shape({
|
user: PropTypes.shape({
|
||||||
acceptedPrivacyTerms: PropTypes.bool
|
acceptedPrivacyTerms: PropTypes.bool
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(mapStateToProps)(LearnLayout);
|
export default connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(LearnLayout);
|
||||||
|
@ -208,18 +208,19 @@ fieldset[disabled] .btn-primary.focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-cta {
|
.btn-cta {
|
||||||
background-color: #ffac33;
|
background-color: #feac32;
|
||||||
background-image: linear-gradient(#ffcc4d, #ffac33);
|
background-image: linear-gradient(#fecc4c, #ffac33);
|
||||||
border-color: #f1a02a;
|
border-width: 3px;
|
||||||
|
border-color: #feac32;
|
||||||
color: #0a0a23 !important;
|
color: #0a0a23 !important;
|
||||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
|
|
||||||
border: none;
|
|
||||||
}
|
}
|
||||||
.btn-cta:hover,
|
.btn-cta:hover,
|
||||||
.btn-cta:focus,
|
.btn-cta:focus,
|
||||||
.btn-cta:active:hover {
|
.btn-cta:active:hover {
|
||||||
background-color: #e99110;
|
background-color: #fecc4c;
|
||||||
background-image: linear-gradient(#ffcc4d, #e99110);
|
border-width: 3px;
|
||||||
|
border-color: #f1a02a;
|
||||||
|
background-image: none;
|
||||||
color: #0a0a23 !important;
|
color: #0a0a23 !important;
|
||||||
}
|
}
|
||||||
.btn-cta:active {
|
.btn-cta:active {
|
||||||
|
@ -19,6 +19,8 @@
|
|||||||
--green-dark: #00471b;
|
--green-dark: #00471b;
|
||||||
--red-light: #ffadad;
|
--red-light: #ffadad;
|
||||||
--red-dark: #850000;
|
--red-dark: #850000;
|
||||||
|
--love-light: #f8577c;
|
||||||
|
--love-dark: #f82153;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark-palette {
|
.dark-palette {
|
||||||
@ -36,6 +38,7 @@
|
|||||||
--success-background: var(--green-dark);
|
--success-background: var(--green-dark);
|
||||||
--danger-color: var(--red-light);
|
--danger-color: var(--red-light);
|
||||||
--danger-background: var(--red-dark);
|
--danger-background: var(--red-dark);
|
||||||
|
--love-color: var(--love-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.light-palette {
|
.light-palette {
|
||||||
@ -53,4 +56,5 @@
|
|||||||
--success-background: var(--green-light);
|
--success-background: var(--green-light);
|
||||||
--danger-color: var(--red-dark);
|
--danger-color: var(--red-dark);
|
||||||
--danger-background: var(--red-light);
|
--danger-background: var(--red-light);
|
||||||
|
--love-color: var(--love-dark);
|
||||||
}
|
}
|
||||||
|
@ -35,6 +35,23 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certificate-outer-wrapper .certification-donation {
|
||||||
|
padding: 15px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certificate-outer-wrapper .certification-donation .btn {
|
||||||
|
background-color: var(--gray-15);
|
||||||
|
border-color: var(--gray-85);
|
||||||
|
color: var(--gray-85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.certificate-outer-wrapper .certification-donation .btn:hover {
|
||||||
|
border-color: var(--gray-85);
|
||||||
|
background-color: var(--gray-85);
|
||||||
|
color: var(--gray-05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.certification-namespace header {
|
.certification-namespace header {
|
||||||
|
20
client/src/redux/donation-saga.js
Normal file
20
client/src/redux/donation-saga.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { put, select, takeEvery, delay } from 'redux-saga/effects';
|
||||||
|
|
||||||
|
import {
|
||||||
|
openDonationModal,
|
||||||
|
shouldRequestDonationSelector,
|
||||||
|
preventDonationRequests
|
||||||
|
} from './';
|
||||||
|
|
||||||
|
function* showDonateModalSaga() {
|
||||||
|
let shouldRequestDonation = yield select(shouldRequestDonationSelector);
|
||||||
|
if (shouldRequestDonation) {
|
||||||
|
yield delay(200);
|
||||||
|
yield put(openDonationModal());
|
||||||
|
yield put(preventDonationRequests());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDonationSaga(types) {
|
||||||
|
return [takeEvery(types.tryToShowDonationModal, showDonateModalSaga)];
|
||||||
|
}
|
@ -10,6 +10,7 @@ import { createAppMountSaga } from './app-mount-saga';
|
|||||||
import { createReportUserSaga } from './report-user-saga';
|
import { createReportUserSaga } from './report-user-saga';
|
||||||
import { createShowCertSaga } from './show-cert-saga';
|
import { createShowCertSaga } from './show-cert-saga';
|
||||||
import { createNightModeSaga } from './night-mode-saga';
|
import { createNightModeSaga } from './night-mode-saga';
|
||||||
|
import { createDonationSaga } from './donation-saga';
|
||||||
|
|
||||||
import hardGoToEpic from './hard-go-to-epic';
|
import hardGoToEpic from './hard-go-to-epic';
|
||||||
import failedUpdatesEpic from './failed-updates-epic';
|
import failedUpdatesEpic from './failed-updates-epic';
|
||||||
@ -31,6 +32,7 @@ export const defaultFetchState = {
|
|||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
appUsername: '',
|
appUsername: '',
|
||||||
|
canRequestDonation: false,
|
||||||
completionCount: 0,
|
completionCount: 0,
|
||||||
currentChallengeId: store.get(CURRENT_CHALLENGE_KEY),
|
currentChallengeId: store.get(CURRENT_CHALLENGE_KEY),
|
||||||
donationRequested: false,
|
donationRequested: false,
|
||||||
@ -53,12 +55,14 @@ const initialState = {
|
|||||||
export const types = createTypes(
|
export const types = createTypes(
|
||||||
[
|
[
|
||||||
'appMount',
|
'appMount',
|
||||||
'closeDonationModal',
|
|
||||||
'donationRequested',
|
|
||||||
'hardGoTo',
|
'hardGoTo',
|
||||||
|
'allowDonationRequests',
|
||||||
|
'closeDonationModal',
|
||||||
|
'preventDonationRequests',
|
||||||
'openDonationModal',
|
'openDonationModal',
|
||||||
'onlineStatusChange',
|
'onlineStatusChange',
|
||||||
'resetUserData',
|
'resetUserData',
|
||||||
|
'tryToShowDonationModal',
|
||||||
'submitComplete',
|
'submitComplete',
|
||||||
'updateComplete',
|
'updateComplete',
|
||||||
'updateCurrentChallengeId',
|
'updateCurrentChallengeId',
|
||||||
@ -77,6 +81,7 @@ export const epics = [hardGoToEpic, failedUpdatesEpic, updateCompleteEpic];
|
|||||||
export const sagas = [
|
export const sagas = [
|
||||||
...createAcceptTermsSaga(types),
|
...createAcceptTermsSaga(types),
|
||||||
...createAppMountSaga(types),
|
...createAppMountSaga(types),
|
||||||
|
...createDonationSaga(types),
|
||||||
...createFetchUserSaga(types),
|
...createFetchUserSaga(types),
|
||||||
...createShowCertSaga(types),
|
...createShowCertSaga(types),
|
||||||
...createReportUserSaga(types),
|
...createReportUserSaga(types),
|
||||||
@ -85,9 +90,15 @@ export const sagas = [
|
|||||||
|
|
||||||
export const appMount = createAction(types.appMount);
|
export const appMount = createAction(types.appMount);
|
||||||
|
|
||||||
|
export const tryToShowDonationModal = createAction(
|
||||||
|
types.tryToShowDonationModal
|
||||||
|
);
|
||||||
|
export const allowDonationRequests = createAction(types.allowDonationRequests);
|
||||||
export const closeDonationModal = createAction(types.closeDonationModal);
|
export const closeDonationModal = createAction(types.closeDonationModal);
|
||||||
export const openDonationModal = createAction(types.openDonationModal);
|
export const openDonationModal = createAction(types.openDonationModal);
|
||||||
export const donationRequested = createAction(types.donationRequested);
|
export const preventDonationRequests = createAction(
|
||||||
|
types.preventDonationRequests
|
||||||
|
);
|
||||||
|
|
||||||
export const onlineStatusChange = createAction(types.onlineStatusChange);
|
export const onlineStatusChange = createAction(types.onlineStatusChange);
|
||||||
|
|
||||||
@ -134,7 +145,8 @@ export const completedChallengesSelector = state =>
|
|||||||
userSelector(state).completedChallenges || [];
|
userSelector(state).completedChallenges || [];
|
||||||
export const completionCountSelector = state => state[ns].completionCount;
|
export const completionCountSelector = state => state[ns].completionCount;
|
||||||
export const currentChallengeIdSelector = state => state[ns].currentChallengeId;
|
export const currentChallengeIdSelector = state => state[ns].currentChallengeId;
|
||||||
export const donationRequestedSelector = state => state[ns].donationRequested;
|
export const isDonationRequestedSelector = state => state[ns].donationRequested;
|
||||||
|
export const isDonatingSelector = state => userSelector(state).isDonating;
|
||||||
|
|
||||||
export const isOnlineSelector = state => state[ns].isOnline;
|
export const isOnlineSelector = state => state[ns].isOnline;
|
||||||
export const isSignedInSelector = state => !!state[ns].appUsername;
|
export const isSignedInSelector = state => !!state[ns].appUsername;
|
||||||
@ -145,21 +157,14 @@ export const signInLoadingSelector = state =>
|
|||||||
export const showCertSelector = state => state[ns].showCert;
|
export const showCertSelector = state => state[ns].showCert;
|
||||||
export const showCertFetchStateSelector = state => state[ns].showCertFetchState;
|
export const showCertFetchStateSelector = state => state[ns].showCertFetchState;
|
||||||
|
|
||||||
export const showDonationSelector = state => {
|
export const shouldRequestDonationSelector = state => {
|
||||||
const completedChallenges = completedChallengesSelector(state);
|
const isDonationRequested = isDonationRequestedSelector(state);
|
||||||
const completionCount = completionCountSelector(state);
|
const isDonating = isDonatingSelector(state);
|
||||||
const currentCompletedLength = completedChallenges.length;
|
if (
|
||||||
const donationRequested = donationRequestedSelector(state);
|
isDonationRequested === false &&
|
||||||
// the user has not completed 9 challenges in total yet
|
isDonating === false &&
|
||||||
if (currentCompletedLength < 9) {
|
state[ns].canRequestDonation
|
||||||
return false;
|
) {
|
||||||
}
|
|
||||||
// this will mean we are on the 10th submission in total for the user
|
|
||||||
if (completedChallenges.length === 9 && donationRequested === false) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// this will mean we are on the 3rd submission for this browser session
|
|
||||||
if (completionCount === 2 && donationRequested === false) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@ -209,6 +214,10 @@ function spreadThePayloadOnUser(state, payload) {
|
|||||||
|
|
||||||
export const reducer = handleActions(
|
export const reducer = handleActions(
|
||||||
{
|
{
|
||||||
|
[types.allowDonationRequests]: state => ({
|
||||||
|
...state,
|
||||||
|
canRequestDonation: true
|
||||||
|
}),
|
||||||
[types.fetchUser]: state => ({
|
[types.fetchUser]: state => ({
|
||||||
...state,
|
...state,
|
||||||
userFetchState: { ...defaultFetchState }
|
userFetchState: { ...defaultFetchState }
|
||||||
@ -288,7 +297,7 @@ export const reducer = handleActions(
|
|||||||
...state,
|
...state,
|
||||||
showDonationModal: true
|
showDonationModal: true
|
||||||
}),
|
}),
|
||||||
[types.donationRequested]: state => ({
|
[types.preventDonationRequests]: state => ({
|
||||||
...state,
|
...state,
|
||||||
donationRequested: true
|
donationRequested: true
|
||||||
}),
|
}),
|
||||||
|
@ -21,7 +21,8 @@ import {
|
|||||||
isCompletionModalOpenSelector,
|
isCompletionModalOpenSelector,
|
||||||
successMessageSelector,
|
successMessageSelector,
|
||||||
challengeFilesSelector,
|
challengeFilesSelector,
|
||||||
challengeMetaSelector
|
challengeMetaSelector,
|
||||||
|
lastBlockChalSubmitted
|
||||||
} from '../redux';
|
} from '../redux';
|
||||||
|
|
||||||
import { isSignedInSelector } from '../../../redux';
|
import { isSignedInSelector } from '../../../redux';
|
||||||
@ -54,17 +55,11 @@ const mapStateToProps = createSelector(
|
|||||||
const mapDispatchToProps = function(dispatch) {
|
const mapDispatchToProps = function(dispatch) {
|
||||||
const dispatchers = {
|
const dispatchers = {
|
||||||
close: () => dispatch(closeModal('completion')),
|
close: () => dispatch(closeModal('completion')),
|
||||||
handleKeypress: e => {
|
|
||||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
// Since Hotkeys also listens to Ctrl + Enter we have to stop this event
|
|
||||||
// getting to it.
|
|
||||||
e.stopPropagation();
|
|
||||||
dispatch(submitChallenge());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
submitChallenge: () => {
|
submitChallenge: () => {
|
||||||
dispatch(submitChallenge());
|
dispatch(submitChallenge());
|
||||||
|
},
|
||||||
|
lastBlockChalSubmitted: () => {
|
||||||
|
dispatch(lastBlockChalSubmitted());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return () => dispatchers;
|
return () => dispatchers;
|
||||||
@ -76,18 +71,18 @@ const propTypes = {
|
|||||||
completedChallengesIds: PropTypes.array,
|
completedChallengesIds: PropTypes.array,
|
||||||
currentBlockIds: PropTypes.array,
|
currentBlockIds: PropTypes.array,
|
||||||
files: PropTypes.object.isRequired,
|
files: PropTypes.object.isRequired,
|
||||||
handleKeypress: PropTypes.func.isRequired,
|
|
||||||
id: PropTypes.string,
|
id: PropTypes.string,
|
||||||
isOpen: PropTypes.bool,
|
isOpen: PropTypes.bool,
|
||||||
isSignedIn: PropTypes.bool.isRequired,
|
isSignedIn: PropTypes.bool.isRequired,
|
||||||
|
lastBlockChalSubmitted: PropTypes.func,
|
||||||
message: PropTypes.string,
|
message: PropTypes.string,
|
||||||
submitChallenge: PropTypes.func.isRequired,
|
submitChallenge: PropTypes.func.isRequired,
|
||||||
title: PropTypes.string
|
title: PropTypes.string
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getCompletedPercent(
|
export function getCompletedPercent(
|
||||||
completedChallengesIds,
|
completedChallengesIds = [],
|
||||||
currentBlockIds,
|
currentBlockIds = [],
|
||||||
currentChallengeId
|
currentChallengeId
|
||||||
) {
|
) {
|
||||||
completedChallengesIds = completedChallengesIds.includes(currentChallengeId)
|
completedChallengesIds = completedChallengesIds.includes(currentChallengeId)
|
||||||
@ -106,8 +101,15 @@ export function getCompletedPercent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CompletionModalInner extends Component {
|
export class CompletionModalInner extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
this.handleKeypress = this.handleKeypress.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
downloadURL: null
|
downloadURL: null,
|
||||||
|
completedPercent: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
static getDerivedStateFromProps(props, state) {
|
static getDerivedStateFromProps(props, state) {
|
||||||
@ -135,7 +137,37 @@ export class CompletionModalInner extends Component {
|
|||||||
});
|
});
|
||||||
newURL = URL.createObjectURL(blob);
|
newURL = URL.createObjectURL(blob);
|
||||||
}
|
}
|
||||||
return { downloadURL: newURL };
|
|
||||||
|
const { completedChallengesIds, currentBlockIds, id, isSignedIn } = props;
|
||||||
|
let completedPercent = isSignedIn
|
||||||
|
? getCompletedPercent(completedChallengesIds, currentBlockIds, id)
|
||||||
|
: 0;
|
||||||
|
return { downloadURL: newURL, completedPercent: completedPercent };
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeypress(e) {
|
||||||
|
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Since Hotkeys also listens to Ctrl + Enter we have to stop this event
|
||||||
|
// getting to it.
|
||||||
|
e.stopPropagation();
|
||||||
|
this.handleSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSubmit() {
|
||||||
|
this.props.submitChallenge();
|
||||||
|
this.checkBlockCompletion();
|
||||||
|
}
|
||||||
|
|
||||||
|
// check block completion for donation
|
||||||
|
checkBlockCompletion() {
|
||||||
|
if (
|
||||||
|
this.state.completedPercent === 100 &&
|
||||||
|
!this.props.completedChallengesIds.includes(this.props.id)
|
||||||
|
) {
|
||||||
|
this.props.lastBlockChalSubmitted();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
@ -149,20 +181,13 @@ export class CompletionModalInner extends Component {
|
|||||||
const {
|
const {
|
||||||
blockName = '',
|
blockName = '',
|
||||||
close,
|
close,
|
||||||
completedChallengesIds = [],
|
|
||||||
currentBlockIds = [],
|
|
||||||
id = '',
|
|
||||||
isOpen,
|
isOpen,
|
||||||
isSignedIn,
|
|
||||||
submitChallenge,
|
|
||||||
handleKeypress,
|
|
||||||
message,
|
message,
|
||||||
title
|
title,
|
||||||
|
isSignedIn
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const completedPercent = !isSignedIn
|
const { completedPercent } = this.state;
|
||||||
? 0
|
|
||||||
: getCompletedPercent(completedChallengesIds, currentBlockIds, id);
|
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
ga.modalview('/completion-modal');
|
ga.modalview('/completion-modal');
|
||||||
@ -175,7 +200,7 @@ export class CompletionModalInner extends Component {
|
|||||||
dialogClassName='challenge-success-modal'
|
dialogClassName='challenge-success-modal'
|
||||||
keyboard={true}
|
keyboard={true}
|
||||||
onHide={close}
|
onHide={close}
|
||||||
onKeyDown={isOpen ? handleKeypress : noop}
|
onKeyDown={isOpen ? this.handleKeypress : noop}
|
||||||
show={isOpen}
|
show={isOpen}
|
||||||
>
|
>
|
||||||
<Modal.Header
|
<Modal.Header
|
||||||
@ -195,7 +220,7 @@ export class CompletionModalInner extends Component {
|
|||||||
block={true}
|
block={true}
|
||||||
bsSize='large'
|
bsSize='large'
|
||||||
bsStyle='primary'
|
bsStyle='primary'
|
||||||
onClick={submitChallenge}
|
onClick={this.handleSubmit}
|
||||||
>
|
>
|
||||||
{isSignedIn ? 'Submit and g' : 'G'}o to next challenge{' '}
|
{isSignedIn ? 'Submit and g' : 'G'}o to next challenge{' '}
|
||||||
<span className='hidden-xs'>(Ctrl + Enter)</span>
|
<span className='hidden-xs'>(Ctrl + Enter)</span>
|
||||||
|
@ -132,6 +132,7 @@ export default function completionEpic(action$, state$) {
|
|||||||
const meta = challengeMetaSelector(state);
|
const meta = challengeMetaSelector(state);
|
||||||
const { nextChallengePath, introPath, challengeType } = meta;
|
const { nextChallengePath, introPath, challengeType } = meta;
|
||||||
const closeChallengeModal = of(closeModal('completion'));
|
const closeChallengeModal = of(closeModal('completion'));
|
||||||
|
|
||||||
let submitter = () => of({ type: 'no-user-signed-in' });
|
let submitter = () => of({ type: 'no-user-signed-in' });
|
||||||
if (
|
if (
|
||||||
!(challengeType in submitTypes) ||
|
!(challengeType in submitTypes) ||
|
||||||
|
@ -4,7 +4,8 @@ import store from 'store';
|
|||||||
import {
|
import {
|
||||||
isSignedInSelector,
|
isSignedInSelector,
|
||||||
updateComplete,
|
updateComplete,
|
||||||
updateFailed
|
updateFailed,
|
||||||
|
allowDonationRequests
|
||||||
} from '../../../redux';
|
} from '../../../redux';
|
||||||
|
|
||||||
import { post } from '../../../utils/ajax';
|
import { post } from '../../../utils/ajax';
|
||||||
@ -33,13 +34,18 @@ export function* currentChallengeSaga({ payload: id }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function* updateSuccessMessageSaga() {
|
export function* updateSuccessMessageSaga() {
|
||||||
yield put(updateSuccessMessage(randomCompliment()));
|
yield put(updateSuccessMessage(randomCompliment()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function* allowDonationRequestsSaga() {
|
||||||
|
yield put(allowDonationRequests());
|
||||||
|
}
|
||||||
|
|
||||||
export function createCurrentChallengeSaga(types) {
|
export function createCurrentChallengeSaga(types) {
|
||||||
return [
|
return [
|
||||||
takeEvery(types.challengeMounted, currentChallengeSaga),
|
takeEvery(types.challengeMounted, currentChallengeSaga),
|
||||||
takeEvery(types.challengeMounted, updateSuccessMessageSaga)
|
takeEvery(types.challengeMounted, updateSuccessMessageSaga),
|
||||||
|
takeEvery(types.lastBlockChalSubmitted, allowDonationRequestsSaga)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
/* global expect */
|
||||||
|
import { allowDonationRequestsSaga } from './current-challenge-saga';
|
||||||
|
import { types as appTypes } from '../../../redux';
|
||||||
|
|
||||||
|
describe('allowDonationRequestsSaga', () => {
|
||||||
|
it('should call allowDonationRequests', () => {
|
||||||
|
const gen = allowDonationRequestsSaga();
|
||||||
|
expect(gen.next().value.payload.action.type).toEqual(
|
||||||
|
appTypes.allowDonationRequests
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
@ -30,6 +30,7 @@ const initialState = {
|
|||||||
},
|
},
|
||||||
challengeTests: [],
|
challengeTests: [],
|
||||||
consoleOut: '',
|
consoleOut: '',
|
||||||
|
hasCompletedBlock: false,
|
||||||
inAccessibilityMode: false,
|
inAccessibilityMode: false,
|
||||||
isCodeLocked: false,
|
isCodeLocked: false,
|
||||||
isBuildEnabled: true,
|
isBuildEnabled: true,
|
||||||
@ -81,7 +82,9 @@ export const types = createTypes(
|
|||||||
'moveToTab',
|
'moveToTab',
|
||||||
|
|
||||||
'setEditorFocusability',
|
'setEditorFocusability',
|
||||||
'setAccessibilityMode'
|
'setAccessibilityMode',
|
||||||
|
|
||||||
|
'lastBlockChalSubmitted'
|
||||||
],
|
],
|
||||||
ns
|
ns
|
||||||
);
|
);
|
||||||
@ -157,6 +160,10 @@ export const moveToTab = createAction(types.moveToTab);
|
|||||||
export const setEditorFocusability = createAction(types.setEditorFocusability);
|
export const setEditorFocusability = createAction(types.setEditorFocusability);
|
||||||
export const setAccessibilityMode = createAction(types.setAccessibilityMode);
|
export const setAccessibilityMode = createAction(types.setAccessibilityMode);
|
||||||
|
|
||||||
|
export const lastBlockChalSubmitted = createAction(
|
||||||
|
types.lastBlockChalSubmitted
|
||||||
|
);
|
||||||
|
|
||||||
export const currentTabSelector = state => state[ns].currentTab;
|
export const currentTabSelector = state => state[ns].currentTab;
|
||||||
export const challengeFilesSelector = state => state[ns].challengeFiles;
|
export const challengeFilesSelector = state => state[ns].challengeFiles;
|
||||||
export const challengeMetaSelector = state => state[ns].challengeMeta;
|
export const challengeMetaSelector = state => state[ns].challengeMeta;
|
||||||
|
@ -15,7 +15,9 @@ export default function layoutSelector({ element, props }) {
|
|||||||
return <DefaultLayout pathname={pathname}>{element}</DefaultLayout>;
|
return <DefaultLayout pathname={pathname}>{element}</DefaultLayout>;
|
||||||
}
|
}
|
||||||
if (/^\/certification(\/.*)*/.test(pathname)) {
|
if (/^\/certification(\/.*)*/.test(pathname)) {
|
||||||
return <CertificationLayout>{element}</CertificationLayout>;
|
return (
|
||||||
|
<CertificationLayout pathname={pathname}>{element}</CertificationLayout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (/^\/guide(\/.*)*/.test(pathname)) {
|
if (/^\/guide(\/.*)*/.test(pathname)) {
|
||||||
console.log('Hitting guide for some reason. Need a redirect.');
|
console.log('Hitting guide for some reason. Need a redirect.');
|
||||||
|
Reference in New Issue
Block a user