feat: add email sign up alert (#61218)

Co-authored-by: Niraj Nandish <nirajnandish@icloud.com>
Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2025-09-11 11:14:00 +03:00
committed by GitHub
parent 29513a4d6d
commit 09dc696c29
27 changed files with 415 additions and 317 deletions
+2 -1
View File
@@ -147,7 +147,8 @@ model user {
/// Valuable for selectively performing random logic. /// Valuable for selectively performing random logic.
rand Float? rand Float?
savedChallenges SavedChallenge[] // Undefined | SavedChallenge[] savedChallenges SavedChallenge[] // Undefined | SavedChallenge[]
sendQuincyEmail Boolean // Nullable tri-state: null (likely new user), true (subscribed), false (unsubscribed)
sendQuincyEmail Boolean?
theme String? // Undefined theme String? // Undefined
timezone String? // Undefined timezone String? // Undefined
twitter String? // Null | Undefined twitter String? // Null | Undefined
+1 -1
View File
@@ -79,7 +79,7 @@ export const newUser = (email: string) => ({
progressTimestamps: [expect.any(Number)], progressTimestamps: [expect.any(Number)],
rand: null, // TODO(Post-MVP): delete from schema (it's not used or required). rand: null, // TODO(Post-MVP): delete from schema (it's not used or required).
savedChallenges: [], savedChallenges: [],
sendQuincyEmail: false, sendQuincyEmail: null,
theme: 'default', theme: 'default',
timezone: null, timezone: null,
twitter: null, twitter: null,
-20
View File
@@ -332,26 +332,6 @@ describe('auth0 plugin', () => {
expect(res.headers.location).toMatch(HOME_LOCATION); expect(res.headers.location).toMatch(HOME_LOCATION);
}); });
test('should redirect to email-sign-up if the user has not acceptedPrivacyTerms', async () => {
mockAuthSuccess();
// Using an italian path to make sure redirection works.
const italianReturnTo = 'https://www.freecodecamp.org/italian/settings';
const res = await fastify.inject({
method: 'GET',
url: '/auth/auth0/callback?state=valid',
cookies: {
'login-returnto': sign(italianReturnTo)
}
});
expect(res.headers.location).toEqual(
expect.stringContaining(
'https://www.freecodecamp.org/italian/email-sign-up?'
)
);
});
test('should populate the user with the correct data', async () => { test('should populate the user with the correct data', async () => {
mockAuthSuccess(); mockAuthSuccess();
+7 -21
View File
@@ -15,10 +15,7 @@ import {
} from '../utils/env'; } from '../utils/env';
import { findOrCreateUser } from '../routes/helpers/auth-helpers'; import { findOrCreateUser } from '../routes/helpers/auth-helpers';
import { createAccessToken } from '../utils/tokens'; import { createAccessToken } from '../utils/tokens';
import { import { getLoginRedirectParams } from '../utils/redirection';
getLoginRedirectParams,
getPrefixedLandingPath
} from '../utils/redirection';
declare module 'fastify' { declare module 'fastify' {
interface FastifyInstance { interface FastifyInstance {
@@ -108,8 +105,7 @@ export const auth0Client: FastifyPluginCallbackTypebox = fp(
} }
} }
const { returnTo, pathPrefix, origin } = getLoginRedirectParams(req); const { returnTo } = getLoginRedirectParams(req);
const redirectBase = getPrefixedLandingPath(origin, pathPrefix);
let token; let token;
try { try {
@@ -166,24 +162,14 @@ export const auth0Client: FastifyPluginCallbackTypebox = fp(
}); });
} }
const { id, acceptedPrivacyTerms } = await findOrCreateUser( const { id } = await findOrCreateUser(fastify, email);
fastify,
email
);
reply.setAccessTokenCookie(createAccessToken(id)); reply.setAccessTokenCookie(createAccessToken(id));
if (acceptedPrivacyTerms) { void reply.redirectWithMessage(returnTo, {
void reply.redirectWithMessage(returnTo, { type: 'success',
type: 'success', content: 'flash.signin-success'
content: 'flash.signin-success' });
});
} else {
void reply.redirectWithMessage(`${redirectBase}/email-sign-up`, {
type: 'success',
content: 'flash.signin-success'
});
}
}); });
done(); done();
+3 -1
View File
@@ -151,7 +151,8 @@ const testUserData: Prisma.userCreateInput = {
], ],
yearsTopContributor: ['2018'], yearsTopContributor: ['2018'],
twitter: '@foobar', twitter: '@foobar',
linkedin: 'linkedin.com/foobar' linkedin: 'linkedin.com/foobar',
sendQuincyEmail: false
}; };
const minimalUserData: Prisma.userCreateInput = { const minimalUserData: Prisma.userCreateInput = {
@@ -301,6 +302,7 @@ const publicUserData = {
profileUI: testUserData.profileUI, profileUI: testUserData.profileUI,
savedChallenges: testUserData.savedChallenges, savedChallenges: testUserData.savedChallenges,
twitter: 'https://twitter.com/foobar', twitter: 'https://twitter.com/foobar',
sendQuincyEmail: testUserData.sendQuincyEmail,
username: testUserData.username, username: testUserData.username,
usernameDisplay: testUserData.usernameDisplay, usernameDisplay: testUserData.usernameDisplay,
website: testUserData.website, website: testUserData.website,
+1
View File
@@ -697,6 +697,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
user: { user: {
[username]: { [username]: {
...removeNulls(publicUser), ...removeNulls(publicUser),
sendQuincyEmail: publicUser.sendQuincyEmail,
...normalizeFlags(flags), ...normalizeFlags(flags),
picture: publicUser.picture ?? '', picture: publicUser.picture ?? '',
email: email ?? '', email: email ?? '',
+1
View File
@@ -26,6 +26,7 @@ vi.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch);
// This is used to build a test user. // This is used to build a test user.
const testUserData: Prisma.userCreateInput = { const testUserData: Prisma.userCreateInput = {
...createUserInput(defaultUserEmail), ...createUserInput(defaultUserEmail),
sendQuincyEmail: true,
username: 'foobar', username: 'foobar',
usernameDisplay: 'Foo Bar', usernameDisplay: 'Foo Bar',
progressTimestamps: [1520002973119, 1520440323273], progressTimestamps: [1520002973119, 1520440323273],
+1 -1
View File
@@ -108,7 +108,7 @@ export const getSessionUser = {
}) })
), ),
profileUI: Type.Optional(profileUI), profileUI: Type.Optional(profileUI),
sendQuincyEmail: Type.Boolean(), sendQuincyEmail: Type.Union([Type.Null(), Type.Boolean()]), // // Tri-state: null (likely new user), true (subscribed), false (unsubscribed)
theme: Type.String(), theme: Type.String(),
twitter: Type.Optional(Type.String()), twitter: Type.Optional(Type.String()),
website: Type.Optional(Type.String()), website: Type.Optional(Type.String()),
+1 -1
View File
@@ -82,7 +82,7 @@ export function createUserInput(email: string) {
showPortfolio: false, showPortfolio: false,
showTimeLine: false showTimeLine: false
}, },
sendQuincyEmail: false, sendQuincyEmail: null,
theme: 'default', theme: 'default',
username, username,
usernameDisplay: username, usernameDisplay: username,
@@ -0,0 +1,56 @@
import React from 'react';
import { connect } from 'react-redux';
import { Container } from '@freecodecamp/ui';
import EmailOptions from '../email-options';
import { updateMyQuincyEmail } from '../../redux/settings/actions';
import { userSelector, isSignedInSelector } from '../../redux/selectors';
import { CompletedChallenge } from '../../redux/prop-types';
interface EmailSignUpAlertProps {
updateQuincyEmail: (isSendQuincyEmail: boolean) => void;
sendQuincyEmail: boolean | null;
isSignedIn: boolean;
completedChallengesCount: number;
}
const mapStateToProps = (state: unknown) => {
const user = userSelector(state) as {
sendQuincyEmail: boolean | null;
completedChallenges: CompletedChallenge[];
};
return {
sendQuincyEmail: user.sendQuincyEmail,
isSignedIn: isSignedInSelector(state),
completedChallengesCount: user.completedChallenges.length
};
};
const mapDispatchToProps = {
updateQuincyEmail: (sendQuincyEmail: boolean) =>
updateMyQuincyEmail({ sendQuincyEmail })
};
function EmailSignUpAlert({
updateQuincyEmail,
sendQuincyEmail,
isSignedIn,
completedChallengesCount = 0
}: EmailSignUpAlertProps) {
const newAccount = isSignedIn && completedChallengesCount < 1;
const userHasMadeEmailSelection = sendQuincyEmail !== null;
if (userHasMadeEmailSelection || newAccount) {
return null;
}
return (
<Container fluid={true} className='email-sign-up-alert'>
<EmailOptions
isSignedIn={isSignedIn}
updateQuincyEmail={updateQuincyEmail}
/>
</Container>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(EmailSignUpAlert);
+2
View File
@@ -6,6 +6,7 @@ import Login from '../Header/components/login';
import { Link, Loader } from '../helpers'; import { Link, Loader } from '../helpers';
import './intro.css'; import './intro.css';
import EmailSignUpAlert from './email-sign-up-alert';
import LearnAlert from './learn-alert'; import LearnAlert from './learn-alert';
interface IntroProps { interface IntroProps {
@@ -64,6 +65,7 @@ const Intro = ({
onLearnDonationAlertClick={onLearnDonationAlertClick} onLearnDonationAlertClick={onLearnDonationAlertClick}
isDonating={isDonating} isDonating={isDonating}
/> />
<EmailSignUpAlert />
{completedChallengeCount && slug && completedChallengeCount < 15 ? ( {completedChallengeCount && slug && completedChallengeCount < 15 ? (
<div className='intro-description'> <div className='intro-description'>
<Spacer size='m' /> <Spacer size='m' />
+17
View File
@@ -42,3 +42,20 @@
font-style: normal; font-style: normal;
color: var(--secondary-color); color: var(--secondary-color);
} }
.email-sign-up-alert {
padding: 20px;
border: 1px solid var(--quaternary-color);
margin-bottom: 1.5rem;
}
.email-list-opt {
display: flex;
flex-wrap: wrap;
}
.message-author {
display: block;
text-align: center;
font-style: italic;
}
+18 -3
View File
@@ -7,8 +7,11 @@ import Intro from '.';
jest.mock('../../analytics'); jest.mock('../../analytics');
function renderWithRedux(ui: JSX.Element) { function renderWithRedux(
return render(<Provider store={createStore()}>{ui}</Provider>); ui: JSX.Element,
preloadedState: Record<string, unknown> = {}
) {
return render(<Provider store={createStore(preloadedState)}>{ui}</Provider>);
} }
describe('<Intro />', () => { describe('<Intro />', () => {
@@ -19,7 +22,19 @@ describe('<Intro />', () => {
}); });
it('has a blockquote when loggedIn', () => { it('has a blockquote when loggedIn', () => {
renderWithRedux(<Intro {...loggedInProps} />); // Provide a minimal preloaded state so connected components expecting a
// sessionUser (e.g. EmailSignUpAlert) do not receive null.
const preloadedState = {
app: {
user: {
sessionUser: {
completedChallenges: [{}],
sendQuincyEmail: null
}
}
}
};
renderWithRedux(<Intro {...loggedInProps} />, preloadedState);
expect(screen.getByTestId('quote-block')).toBeInTheDocument(); expect(screen.getByTestId('quote-block')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
}); });
+101
View File
@@ -0,0 +1,101 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Col, Row, Button, Spacer } from '@freecodecamp/ui';
import { apiLocation } from '../../config/env.json';
interface EmailListOptInProps {
isSignedIn: boolean;
updateQuincyEmail: (isSendQuincyEmail: boolean) => void;
}
export function EmailListOptIn({
isSignedIn,
updateQuincyEmail
}: EmailListOptInProps) {
const { t } = useTranslation();
if (isSignedIn) {
return (
<Row className='email-list-opt'>
<Col md={4} mdOffset={2} sm={5} smOffset={1} xs={12}>
<Button
block={true}
variant='primary'
onClick={() => updateQuincyEmail(true)}
>
{t('buttons.yes-please')}
</Button>
<Spacer size='xs' />
</Col>
<Col md={4} sm={5} xs={12}>
<Button
block={true}
variant='primary'
onClick={() => updateQuincyEmail(false)}
>
{t('buttons.no-thanks')}
</Button>
<Spacer size='xs' />
</Col>
</Row>
);
} else {
return (
<Row>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer size='xs' />
<Button block={true} variant='primary' href={`${apiLocation}/signin`}>
{t('buttons.sign-up-email-list')}
</Button>
<Spacer size='xs' />
</Col>
</Row>
);
}
}
interface EmailOptionsProps {
isSignedIn: boolean;
updateQuincyEmail: (isSendQuincyEmail: boolean) => void;
isPage?: boolean;
}
function EmailOptions({
isSignedIn,
updateQuincyEmail,
isPage
}: EmailOptionsProps) {
const { t } = useTranslation();
return (
<>
<Row>
<Col xs={12}>
{isPage ? (
<h1 className='text-center'>{t('misc.email-signup')}</h1>
) : (
<h2 className='text-center'>{t('misc.email-signup')}</h2>
)}
<Spacer size='xs' />
</Col>
</Row>
<Row>
<Col
{...(isPage ? { md: 8, mdOffset: 2, sm: 10, smOffset: 1 } : {})}
xs={12}
>
<p>{t('misc.email-blast')}</p>
<span className='message-author'>{t('misc.quincy')}</span>
<Spacer size='m' />
</Col>
</Row>
<EmailListOptIn
isSignedIn={isSignedIn}
updateQuincyEmail={updateQuincyEmail}
/>
</>
);
}
export default EmailOptions;
@@ -11,7 +11,6 @@ window.___loader = { enqueue: () => {}, hovering: () => {} };
const userProps = { const userProps = {
user: { user: {
acceptedPrivacyTerms: true,
currentChallengeId: 'string', currentChallengeId: 'string',
email: 'string', email: 'string',
emailVerified: true, emailVerified: true,
+2 -2
View File
@@ -32,7 +32,7 @@ const mapDispatchToProps = (dispatch: Dispatch) =>
type EmailProps = { type EmailProps = {
email: string; email: string;
isEmailVerified: boolean; isEmailVerified: boolean;
sendQuincyEmail: boolean; sendQuincyEmail: boolean | null;
t: TFunction; t: TFunction;
updateMyEmail: (email: string) => void; updateMyEmail: (email: string) => void;
updateQuincyEmail: (sendQuincyEmail: boolean) => void; updateQuincyEmail: (sendQuincyEmail: boolean) => void;
@@ -250,7 +250,7 @@ function EmailSettings({
<FullWidthRow> <FullWidthRow>
<ToggleButtonSetting <ToggleButtonSetting
action={t('settings.email.weekly')} action={t('settings.email.weekly')}
flag={sendQuincyEmail} flag={!!sendQuincyEmail}
flagName='sendQuincyEmail' flagName='sendQuincyEmail'
offLabel={t('buttons.no-thanks')} offLabel={t('buttons.no-thanks')}
onLabel={t('buttons.yes-please')} onLabel={t('buttons.yes-please')}
-20
View File
@@ -1,20 +0,0 @@
.email-sign-up strong,
.email-sign-up p {
font-size: 1rem;
}
/*
This is a temporary fix until the component library is revisited.
See https://github.com/freeCodeCamp/freeCodeCamp/issues/52131#issuecomment-1788840851.
*/
.email-list-opt {
display: flex;
flex-wrap: wrap;
}
@media (min-width: 500px) {
.email-sign-up strong,
.email-sign-up p {
font-size: 1.17rem;
}
}
+33 -127
View File
@@ -1,17 +1,14 @@
import React, { useEffect, useRef } from 'react'; import React from 'react';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
import { useTranslation, Trans } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import type { Dispatch } from 'redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { Container, Col, Row, Button, Spacer } from '@freecodecamp/ui'; import { Container, Spacer } from '@freecodecamp/ui';
import createRedirect from '../components/create-redirect'; import createRedirect from '../components/create-redirect';
import { Loader, Link } from '../components/helpers'; import { Loader } from '../components/helpers';
import { apiLocation } from '../../config/env.json'; import EmailOptions from '../components/email-options';
import { updateMyQuincyEmail } from '../redux/settings/actions';
import { acceptTerms } from '../redux/actions';
import { import {
signInLoadingSelector, signInLoadingSelector,
userSelector, userSelector,
@@ -19,13 +16,11 @@ import {
} from '../redux/selectors'; } from '../redux/selectors';
import type { User } from '../redux/prop-types'; import type { User } from '../redux/prop-types';
import './email-sign-up.css'; interface EmailSignUpProps {
interface AcceptPrivacyTermsProps { updateQuincyEmail: (isSendQuincyEmail: boolean) => void;
acceptTerms: (accept: boolean | null) => void; sendQuincyEmail: boolean | null | undefined;
acceptedPrivacyTerms: boolean;
isSignedIn: boolean; isSignedIn: boolean;
showLoading: boolean; showLoading: boolean;
completedChallengeCount: number;
} }
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
@@ -33,90 +28,28 @@ const mapStateToProps = createSelector(
isSignedInSelector, isSignedInSelector,
signInLoadingSelector, signInLoadingSelector,
(user: User | null, isSignedIn: boolean, showLoading: boolean) => ({ (user: User | null, isSignedIn: boolean, showLoading: boolean) => ({
acceptedPrivacyTerms: !!user?.acceptedPrivacyTerms, sendQuincyEmail: user?.sendQuincyEmail,
isSignedIn, isSignedIn,
showLoading, showLoading
completedChallengeCount: user?.completedChallengeCount ?? 0
}) })
); );
const mapDispatchToProps = (dispatch: Dispatch) => const mapDispatchToProps = {
bindActionCreators({ acceptTerms }, dispatch); updateQuincyEmail: (sendQuincyEmail: boolean) =>
updateMyQuincyEmail({ sendQuincyEmail })
};
const RedirectToLearn = createRedirect('/learn'); const RedirectToLearn = createRedirect('/learn');
function EmailListOptIn({ function EmailSignUp({
updateQuincyEmail,
sendQuincyEmail,
isSignedIn, isSignedIn,
acceptTerms showLoading
}: { }: EmailSignUpProps) {
isSignedIn: boolean;
acceptTerms: (accepted: boolean) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
if (isSignedIn) {
return (
<Container>
<Row className='email-list-opt'>
<Col md={4} mdOffset={2} sm={5} smOffset={1} xs={12}>
<Button
block={true}
size='large'
variant='primary'
onClick={() => acceptTerms(true)}
>
{t('buttons.yes-please')}
</Button>
<Spacer size='xs' />
</Col>
<Col md={4} sm={5} xs={12}>
<Button
block={true}
size='large'
variant='primary'
onClick={() => acceptTerms(false)}
>
{t('buttons.no-thanks')}
</Button>
<Spacer size='xs' />
</Col>
</Row>
</Container>
);
} else {
return (
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer size='xs' />
<Button
block={true}
size='large'
variant='primary'
href={`${apiLocation}/signin`}
>
{t('buttons.sign-up-email-list')}
</Button>
<Spacer size='xs' />
</Col>
);
}
}
function AcceptPrivacyTerms({ const userHasMadeSelection = isSignedIn && sendQuincyEmail !== null;
acceptTerms,
acceptedPrivacyTerms,
isSignedIn,
showLoading,
completedChallengeCount
}: AcceptPrivacyTermsProps) {
const { t } = useTranslation();
const acceptedPrivacyRef = useRef(acceptedPrivacyTerms);
const acceptTermsRef = useRef(acceptTerms);
const newAccount = isSignedIn && completedChallengeCount < 1;
useEffect(() => { return userHasMadeSelection ? (
acceptedPrivacyRef.current = acceptedPrivacyTerms;
acceptTermsRef.current = acceptTerms;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return acceptedPrivacyTerms ? (
<RedirectToLearn /> <RedirectToLearn />
) : ( ) : (
<> <>
@@ -124,47 +57,20 @@ function AcceptPrivacyTerms({
<title>{t('misc.email-signup')} | freeCodeCamp.org</title> <title>{t('misc.email-signup')} | freeCodeCamp.org</title>
</Helmet> </Helmet>
<Container> <Container>
<Row> <Spacer size='l' />
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}> {showLoading ? (
<Spacer size='l' /> <Loader fullScreen={true} />
<h1 className='text-center'> ) : (
{newAccount <EmailOptions
? t('misc.brand-new-account') isSignedIn={isSignedIn}
: t('misc.email-signup')} updateQuincyEmail={updateQuincyEmail}
</h1> isPage={true}
<Spacer size='xs' /> />
</Col>
</Row>
{newAccount && (
<Row>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<p>
<Trans i18nKey='misc.duplicate-account-warning'>
<Link className='inline' to='/settings#danger-zone' />
</Trans>
</p>
<hr />
</Col>
</Row>
)} )}
<Row className='email-sign-up'>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer size='xs' />
<p>{t('misc.email-blast')}</p>
<Spacer size='xs' />
</Col>
{showLoading ? (
<Loader fullScreen={true} />
) : (
<EmailListOptIn isSignedIn={isSignedIn} acceptTerms={acceptTerms} />
)}
<Col xs={12}>
<Spacer size='m' />
</Col>
</Row>
</Container> </Container>
<Spacer size='l' />
</> </>
); );
} }
export default connect(mapStateToProps, mapDispatchToProps)(AcceptPrivacyTerms); export default connect(mapStateToProps, mapDispatchToProps)(EmailSignUp);
-28
View File
@@ -1,28 +0,0 @@
import { navigate } from 'gatsby';
import { call, put, takeEvery } from 'redux-saga/effects';
import { createFlashMessage } from '../components/Flash/redux';
import { putUserAcceptsTerms } from '../utils/ajax';
import { acceptTermsComplete, acceptTermsError } from './actions';
function* acceptTermsSaga({ payload: quincyEmails }) {
try {
const { data } = yield call(putUserAcceptsTerms, quincyEmails);
yield put(acceptTermsComplete(quincyEmails));
yield put(createFlashMessage(data));
} catch (e) {
yield put(acceptTermsError(e));
}
}
function* acceptCompleteSaga() {
yield call(navigate, '/learn');
}
export function createAcceptTermsSaga(types) {
return [
takeEvery(types.acceptTerms, acceptTermsSaga),
takeEvery(types.acceptTermsComplete, acceptCompleteSaga)
];
}
-1
View File
@@ -42,7 +42,6 @@ export const actionTypes = createTypes(
...createAsyncTypes('fetchUser'), ...createAsyncTypes('fetchUser'),
...createAsyncTypes('postCharge'), ...createAsyncTypes('postCharge'),
...createAsyncTypes('fetchProfileForUser'), ...createAsyncTypes('fetchProfileForUser'),
...createAsyncTypes('acceptTerms'),
...createAsyncTypes('showCert'), ...createAsyncTypes('showCert'),
...createAsyncTypes('reportUser'), ...createAsyncTypes('reportUser'),
...createAsyncTypes('deleteUserToken'), ...createAsyncTypes('deleteUserToken'),
-6
View File
@@ -42,12 +42,6 @@ export const saveChallengeComplete = createAction(
actionTypes.saveChallengeComplete actionTypes.saveChallengeComplete
); );
export const acceptTerms = createAction(actionTypes.acceptTerms);
export const acceptTermsComplete = createAction(
actionTypes.acceptTermsComplete
);
export const acceptTermsError = createAction(actionTypes.acceptTermsError);
export const fetchUser = createAction(actionTypes.fetchUser); export const fetchUser = createAction(actionTypes.fetchUser);
export const fetchUserComplete = createAction(actionTypes.fetchUserComplete); export const fetchUserComplete = createAction(actionTypes.fetchUserComplete);
export const fetchUserTimeout = createAction(actionTypes.fetchUserTimeout); export const fetchUserTimeout = createAction(actionTypes.fetchUserTimeout);
-22
View File
@@ -7,7 +7,6 @@ import {
CURRENT_CHALLENGE_KEY CURRENT_CHALLENGE_KEY
} from '../templates/Challenges/redux/action-types'; } from '../templates/Challenges/redux/action-types';
import { getIsDailyCodingChallenge } from '../../../shared/config/challenge-types'; import { getIsDailyCodingChallenge } from '../../../shared/config/challenge-types';
import { createAcceptTermsSaga } from './accept-terms-saga';
import { actionTypes, ns as MainApp } from './action-types'; import { actionTypes, ns as MainApp } from './action-types';
import { createAppMountSaga } from './app-mount-saga'; import { createAppMountSaga } from './app-mount-saga';
import { createDonationSaga } from './donation-saga'; import { createDonationSaga } from './donation-saga';
@@ -88,7 +87,6 @@ const initialState = {
export const epics = [hardGoToEpic, failedUpdatesEpic, updateCompleteEpic]; export const epics = [hardGoToEpic, failedUpdatesEpic, updateCompleteEpic];
export const sagas = [ export const sagas = [
...createAcceptTermsSaga(actionTypes),
...createThemeSaga(actionTypes), ...createThemeSaga(actionTypes),
...createAppMountSaga(actionTypes), ...createAppMountSaga(actionTypes),
...createDonationSaga(actionTypes), ...createDonationSaga(actionTypes),
@@ -117,26 +115,6 @@ function spreadThePayloadOnUser(state, payload) {
export const reducer = handleActions( export const reducer = handleActions(
{ {
[actionTypes.acceptTermsComplete]: (state, { payload }) => {
return {
...state,
user: {
...state.user,
sessionUser: {
...state.user.sessionUser,
// TODO: the user accepts the privacy terms in practice during auth
// however, it's currently being used to track if they've accepted
// or rejected the newsletter. Ideally this should be migrated,
// since they can't sign up without accepting the terms.
acceptedPrivacyTerms: true,
sendQuincyEmail:
payload === null
? state.user.sessionUser.sendQuincyEmail
: payload
}
}
};
},
[actionTypes.allowSectionDonationRequests]: (state, { payload }) => { [actionTypes.allowSectionDonationRequests]: (state, { payload }) => {
return { return {
...state, ...state,
+1 -1
View File
@@ -393,7 +393,7 @@ export type User = {
profileUI: ProfileUI; profileUI: ProfileUI;
progressTimestamps: Array<unknown>; progressTimestamps: Array<unknown>;
savedChallenges: SavedChallenges; savedChallenges: SavedChallenges;
sendQuincyEmail: boolean; sendQuincyEmail: boolean | null;
sound: boolean; sound: boolean;
theme: UserThemes; theme: UserThemes;
keyboardShortcuts: boolean; keyboardShortcuts: boolean;
+3 -9
View File
@@ -361,9 +361,9 @@ export function putUpdateMyHonesty(
return put('/update-my-honesty', update); return put('/update-my-honesty', update);
} }
export function putUpdateMyQuincyEmail( export function putUpdateMyQuincyEmail(update: {
update: Record<string, string> sendQuincyEmail: boolean;
): Promise<ResponseWithData<void>> { }): Promise<ResponseWithData<void>> {
return put('/update-my-quincy-email', update); return put('/update-my-quincy-email', update);
} }
@@ -373,12 +373,6 @@ export function putUpdateMyPortfolio(
return put('/update-my-portfolio', update); return put('/update-my-portfolio', update);
} }
export function putUserAcceptsTerms(
quincyEmails: boolean
): Promise<ResponseWithData<void>> {
return put('/update-privacy-terms', { quincyEmails });
}
export function putUserUpdateEmail( export function putUserUpdateEmail(
email: string email: string
): Promise<ResponseWithData<void>> { ): Promise<ResponseWithData<void>> {
+153
View File
@@ -0,0 +1,153 @@
import { execSync } from 'child_process';
import { test, expect } from '@playwright/test';
import translations from '../client/i18n/locales/english/translations.json';
import { alertToBeVisible } from './utils/alerts';
test.describe('Email sign-up page when user is not signed in', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test.beforeEach(async ({ page }) => {
await page.goto('/learn');
});
test('should not display newsletter options', async ({ page }) => {
await expect(
page.getByText(translations.misc['email-blast'])
).not.toBeVisible();
await expect(
page.getByRole('button', { name: translations.buttons['yes-please'] })
).not.toBeVisible();
await expect(
page.getByRole('button', { name: translations.buttons['no-thanks'] })
).not.toBeVisible();
});
});
test.describe('Email sign-up page when user is signed in', () => {
test.beforeEach(async ({ page }) => {
// It's necessary to seed with a user that has not accepted the privacy
// terms, otherwise the user will be redirected away from the email sign-up
// page.
execSync('node ./tools/scripts/seed/seed-demo-user --certified-user');
await page.goto('/learn');
});
test('should display the newsletter options correctly', async ({ page }) => {
await expect(
page.getByText(translations.misc['email-signup'])
).toBeVisible();
await expect(
page.getByText(translations.misc['email-blast'])
).toBeVisible();
await expect(page.getByText(translations.misc['quincy'])).toBeVisible();
await expect(
page.getByRole('button', { name: translations.buttons['yes-please'] })
).toBeVisible();
await expect(
page.getByRole('button', { name: translations.buttons['no-thanks'] })
).toBeVisible();
});
test('should disable weekly newsletter if the user clicks No', async ({
page
}) => {
await expect(
page.getByText(translations.misc['email-blast'])
).toBeVisible();
const noThanksButton = page.getByRole('button', {
name: translations.buttons['no-thanks']
});
await expect(noThanksButton).toBeVisible();
await noThanksButton.click();
await alertToBeVisible(
page,
translations.flash['subscribe-to-quincy-updated']
);
await expect(
page.getByText(translations.misc['email-blast'])
).not.toBeVisible();
await page.goto('/settings');
await expect(
page.getByRole('button', { name: translations.buttons['no-thanks'] })
).toHaveAttribute('aria-pressed', 'true');
await expect(
page.getByRole('button', { name: translations.buttons['yes-please'] })
).toHaveAttribute('aria-pressed', 'false');
});
test('should enable weekly newsletter if the user clicks Yes', async ({
page
}) => {
await expect(
page.getByText(translations.misc['email-blast'])
).toBeVisible();
const yesPleaseButton = page.getByRole('button', {
name: translations.buttons['yes-please']
});
await expect(yesPleaseButton).toBeVisible();
await yesPleaseButton.click();
await alertToBeVisible(
page,
translations.flash['subscribe-to-quincy-updated']
);
await page.goto('/settings');
await expect(
page.getByRole('group', { name: translations.settings.email.weekly })
).toBeVisible();
await expect(
page.getByRole('button', { name: translations.buttons['yes-please'] })
).toHaveAttribute('aria-pressed', 'true');
await expect(
page.getByRole('button', { name: translations.buttons['no-thanks'] })
).toHaveAttribute('aria-pressed', 'false');
});
});
test.describe('Email sign-up page when the user is new', () => {
test.use({ storageState: 'playwright/.auth/development-user.json' });
test.beforeEach(async ({ page }) => {
execSync('node ./tools/scripts/seed/seed-demo-user');
await page.goto('/learn');
});
test('should not display newsletter options', async ({ page }) => {
await expect(
page.getByText(translations.misc['email-blast'])
).not.toBeVisible();
await expect(
page.getByRole('button', { name: translations.buttons['yes-please'] })
).not.toBeVisible();
await expect(
page.getByRole('button', { name: translations.buttons['no-thanks'] })
).not.toBeVisible();
});
});
test.describe('Email sign-up page when the user has made a selection', () => {
test.use({ storageState: 'playwright/.auth/development-user.json' });
test.beforeEach(async ({ page }) => {
execSync(
'node ./tools/scripts/seed/seed-demo-user --certified-user --set-false sendQuincyEmail'
);
await page.goto('/learn');
});
test('should not display newsletter options', async ({ page }) => {
await expect(
page.getByText(translations.misc['email-blast'])
).not.toBeVisible();
await expect(
page.getByRole('button', { name: translations.buttons['yes-please'] })
).not.toBeVisible();
await expect(
page.getByRole('button', { name: translations.buttons['no-thanks'] })
).not.toBeVisible();
});
});
+8 -47
View File
@@ -11,6 +11,7 @@ test.describe('Email sign-up page when user is not signed in', () => {
test.use({ storageState: { cookies: [], origins: [] } }); test.use({ storageState: { cookies: [], origins: [] } });
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
execSync('node ./tools/scripts/seed/seed-demo-user --certified-user');
await page.goto('/email-sign-up'); await page.goto('/email-sign-up');
}); });
@@ -69,21 +70,20 @@ test.describe('Email sign-up page when user is not signed in', () => {
test.describe('Email sign-up page when user is signed in', () => { test.describe('Email sign-up page when user is signed in', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// It's necessary to seed with a user that has not accepted the privacy // It's necessary to seed with a user that has not selected an email newsletter option.
// terms, otherwise the user will be redirected away from the email sign-up execSync('node ./tools/scripts/seed/seed-demo-user --certified-user');
// page.
execSync(
'node ./tools/scripts/seed/seed-demo-user --certified-user --set-false acceptedPrivacyTerms'
);
await page.goto('/email-sign-up'); await page.goto('/email-sign-up');
}); });
test('should display the content correctly', async ({ page }) => { test('should display the content correctly', async ({ page }) => {
await expect(page).toHaveTitle('Email Sign Up | freeCodeCamp.org'); await expect(
page.getByText(translations.misc['email-signup'])
).toBeVisible();
await expect( await expect(
page.getByText(translations.misc['email-blast']) page.getByText(translations.misc['email-blast'])
).toBeVisible(); ).toBeVisible();
await expect(page.getByText(translations.misc['quincy'])).toBeVisible();
await expect( await expect(
page.getByRole('button', { name: translations.buttons['yes-please'] }) page.getByRole('button', { name: translations.buttons['yes-please'] })
).toBeVisible(); ).toBeVisible();
@@ -148,9 +148,7 @@ test.describe('Email sign-up page when user is signed in', () => {
page.getByRole('heading', { name: 'Welcome back, Full Stack User' }) page.getByRole('heading', { name: 'Welcome back, Full Stack User' })
).toBeVisible(); ).toBeVisible();
// When the user clicks Yes, the /update-privacy-terms API is called // `sendQuincyEmail` is not set in the DB since the endpoint is mocked,
// to update both `acceptedPrivacyTerms` and `sendQuincyEmail`.
// But `sendQuincyEmail` is not set in the DB since the endpoint is mocked,
// so we are overriding the user data once again to mimic the real behavior. // so we are overriding the user data once again to mimic the real behavior.
await page.route('*/**/user/get-session-user', async route => { await page.route('*/**/user/get-session-user', async route => {
const response = await route.fetch(); const response = await route.fetch();
@@ -172,40 +170,3 @@ test.describe('Email sign-up page when user is signed in', () => {
).toHaveAttribute('aria-pressed', 'false'); ).toHaveAttribute('aria-pressed', 'false');
}); });
}); });
test.describe('Email sign-up page when the user is new', () => {
test.use({ storageState: 'playwright/.auth/development-user.json' });
test.beforeEach(async ({ page }) => {
// It's necessary to seed with a user that has not accepted the privacy
// terms, otherwise the user will be redirected away from the email sign-up
// page.
execSync(
'node ./tools/scripts/seed/seed-demo-user --set-false acceptedPrivacyTerms'
);
await page.goto('/email-sign-up');
});
test.afterAll(() => {
execSync('node ./tools/scripts/seed/seed-demo-user --certified-user');
});
test('should display the content correctly', async ({ page }) => {
await expect(
page.getByRole('heading', {
level: 1,
name: translations.misc['brand-new-account']
})
).toBeVisible();
await expect(
page.getByText(translations.misc['email-blast'])
).toBeVisible();
await expect(
page.getByRole('button', { name: translations.buttons['yes-please'] })
).toBeVisible();
await expect(
page.getByRole('button', { name: translations.buttons['no-thanks'] })
).toBeVisible();
});
});
+4 -4
View File
@@ -29,7 +29,7 @@ module.exports.blankUser = {
location: '', location: '',
picture: '', picture: '',
acceptedPrivacyTerms: true, acceptedPrivacyTerms: true,
sendQuincyEmail: false, sendQuincyEmail: null,
currentChallengeId: '', currentChallengeId: '',
isHonest: false, isHonest: false,
isFrontEndCert: false, isFrontEndCert: false,
@@ -91,7 +91,7 @@ module.exports.publicUser = {
location: '', location: '',
picture: '', picture: '',
acceptedPrivacyTerms: true, acceptedPrivacyTerms: true,
sendQuincyEmail: false, sendQuincyEmail: null,
currentChallengeId: '', currentChallengeId: '',
isHonest: false, isHonest: false,
isFrontEndCert: false, isFrontEndCert: false,
@@ -153,7 +153,7 @@ module.exports.demoUser = {
location: '', location: '',
picture: '', picture: '',
acceptedPrivacyTerms: true, acceptedPrivacyTerms: true,
sendQuincyEmail: false, sendQuincyEmail: null,
currentChallengeId: '', currentChallengeId: '',
isHonest: false, isHonest: false,
isFrontEndCert: false, isFrontEndCert: false,
@@ -217,7 +217,7 @@ module.exports.fullyCertifiedUser = {
location: '', location: '',
picture: '', picture: '',
acceptedPrivacyTerms: true, acceptedPrivacyTerms: true,
sendQuincyEmail: false, sendQuincyEmail: null,
currentChallengeId: '', currentChallengeId: '',
isHonest: true, isHonest: true,
isFrontEndCert: true, isFrontEndCert: true,