From 09dc696c29141eb00e3742d6ede302e47c5bc48f Mon Sep 17 00:00:00 2001 From: Ahmad Abdolsaheb Date: Thu, 11 Sep 2025 11:14:00 +0300 Subject: [PATCH] feat: add email sign up alert (#61218) Co-authored-by: Niraj Nandish Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com> Co-authored-by: Oliver Eyton-Williams Co-authored-by: Shaun Hamilton --- api/prisma/schema.prisma | 3 +- api/src/plugins/__fixtures__/user.ts | 2 +- api/src/plugins/auth0.test.ts | 20 --- api/src/plugins/auth0.ts | 28 +-- api/src/routes/protected/user.test.ts | 4 +- api/src/routes/protected/user.ts | 1 + api/src/routes/public/user.test.ts | 1 + api/src/schemas/user/get-session-user.ts | 2 +- api/src/utils/create-user.ts | 2 +- .../components/Intro/email-sign-up-alert.tsx | 56 ++++++ client/src/components/Intro/index.tsx | 2 + client/src/components/Intro/intro.css | 17 ++ client/src/components/Intro/intro.test.tsx | 21 ++- client/src/components/email-options.tsx | 101 +++++++++++ .../src/components/profile/profile.test.tsx | 1 - client/src/components/settings/email.tsx | 4 +- client/src/pages/email-sign-up.css | 20 --- client/src/pages/email-sign-up.tsx | 160 ++++-------------- client/src/redux/accept-terms-saga.js | 28 --- client/src/redux/action-types.js | 1 - client/src/redux/actions.ts | 6 - client/src/redux/index.js | 22 --- client/src/redux/prop-types.ts | 2 +- client/src/utils/ajax.ts | 12 +- e2e/email-sign-up-alert.spec.ts | 153 +++++++++++++++++ e2e/quincy-email-sign-up.spec.ts | 55 +----- tools/scripts/seed/user-data.js | 8 +- 27 files changed, 415 insertions(+), 317 deletions(-) create mode 100644 client/src/components/Intro/email-sign-up-alert.tsx create mode 100644 client/src/components/email-options.tsx delete mode 100644 client/src/pages/email-sign-up.css delete mode 100644 client/src/redux/accept-terms-saga.js create mode 100644 e2e/email-sign-up-alert.spec.ts diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 31aafe1f9ff..f193ac6bf2a 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -147,7 +147,8 @@ model user { /// Valuable for selectively performing random logic. rand Float? savedChallenges SavedChallenge[] // Undefined | SavedChallenge[] - sendQuincyEmail Boolean + // Nullable tri-state: null (likely new user), true (subscribed), false (unsubscribed) + sendQuincyEmail Boolean? theme String? // Undefined timezone String? // Undefined twitter String? // Null | Undefined diff --git a/api/src/plugins/__fixtures__/user.ts b/api/src/plugins/__fixtures__/user.ts index f61e11a3072..0d79851410c 100644 --- a/api/src/plugins/__fixtures__/user.ts +++ b/api/src/plugins/__fixtures__/user.ts @@ -79,7 +79,7 @@ export const newUser = (email: string) => ({ progressTimestamps: [expect.any(Number)], rand: null, // TODO(Post-MVP): delete from schema (it's not used or required). savedChallenges: [], - sendQuincyEmail: false, + sendQuincyEmail: null, theme: 'default', timezone: null, twitter: null, diff --git a/api/src/plugins/auth0.test.ts b/api/src/plugins/auth0.test.ts index 078468a2bdf..78946581c1c 100644 --- a/api/src/plugins/auth0.test.ts +++ b/api/src/plugins/auth0.test.ts @@ -332,26 +332,6 @@ describe('auth0 plugin', () => { 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 () => { mockAuthSuccess(); diff --git a/api/src/plugins/auth0.ts b/api/src/plugins/auth0.ts index 6051aa018d1..85250fb2955 100644 --- a/api/src/plugins/auth0.ts +++ b/api/src/plugins/auth0.ts @@ -15,10 +15,7 @@ import { } from '../utils/env'; import { findOrCreateUser } from '../routes/helpers/auth-helpers'; import { createAccessToken } from '../utils/tokens'; -import { - getLoginRedirectParams, - getPrefixedLandingPath -} from '../utils/redirection'; +import { getLoginRedirectParams } from '../utils/redirection'; declare module 'fastify' { interface FastifyInstance { @@ -108,8 +105,7 @@ export const auth0Client: FastifyPluginCallbackTypebox = fp( } } - const { returnTo, pathPrefix, origin } = getLoginRedirectParams(req); - const redirectBase = getPrefixedLandingPath(origin, pathPrefix); + const { returnTo } = getLoginRedirectParams(req); let token; try { @@ -166,24 +162,14 @@ export const auth0Client: FastifyPluginCallbackTypebox = fp( }); } - const { id, acceptedPrivacyTerms } = await findOrCreateUser( - fastify, - email - ); + const { id } = await findOrCreateUser(fastify, email); reply.setAccessTokenCookie(createAccessToken(id)); - if (acceptedPrivacyTerms) { - void reply.redirectWithMessage(returnTo, { - type: 'success', - content: 'flash.signin-success' - }); - } else { - void reply.redirectWithMessage(`${redirectBase}/email-sign-up`, { - type: 'success', - content: 'flash.signin-success' - }); - } + void reply.redirectWithMessage(returnTo, { + type: 'success', + content: 'flash.signin-success' + }); }); done(); diff --git a/api/src/routes/protected/user.test.ts b/api/src/routes/protected/user.test.ts index ff112fffe01..606896162e1 100644 --- a/api/src/routes/protected/user.test.ts +++ b/api/src/routes/protected/user.test.ts @@ -151,7 +151,8 @@ const testUserData: Prisma.userCreateInput = { ], yearsTopContributor: ['2018'], twitter: '@foobar', - linkedin: 'linkedin.com/foobar' + linkedin: 'linkedin.com/foobar', + sendQuincyEmail: false }; const minimalUserData: Prisma.userCreateInput = { @@ -301,6 +302,7 @@ const publicUserData = { profileUI: testUserData.profileUI, savedChallenges: testUserData.savedChallenges, twitter: 'https://twitter.com/foobar', + sendQuincyEmail: testUserData.sendQuincyEmail, username: testUserData.username, usernameDisplay: testUserData.usernameDisplay, website: testUserData.website, diff --git a/api/src/routes/protected/user.ts b/api/src/routes/protected/user.ts index a1b80ab50e7..c2daa636846 100644 --- a/api/src/routes/protected/user.ts +++ b/api/src/routes/protected/user.ts @@ -697,6 +697,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( user: { [username]: { ...removeNulls(publicUser), + sendQuincyEmail: publicUser.sendQuincyEmail, ...normalizeFlags(flags), picture: publicUser.picture ?? '', email: email ?? '', diff --git a/api/src/routes/public/user.test.ts b/api/src/routes/public/user.test.ts index c26ed7da066..96f7eeb803d 100644 --- a/api/src/routes/public/user.test.ts +++ b/api/src/routes/public/user.test.ts @@ -26,6 +26,7 @@ vi.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch); // This is used to build a test user. const testUserData: Prisma.userCreateInput = { ...createUserInput(defaultUserEmail), + sendQuincyEmail: true, username: 'foobar', usernameDisplay: 'Foo Bar', progressTimestamps: [1520002973119, 1520440323273], diff --git a/api/src/schemas/user/get-session-user.ts b/api/src/schemas/user/get-session-user.ts index b09d4dcd282..6e4623d2907 100644 --- a/api/src/schemas/user/get-session-user.ts +++ b/api/src/schemas/user/get-session-user.ts @@ -108,7 +108,7 @@ export const getSessionUser = { }) ), 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(), twitter: Type.Optional(Type.String()), website: Type.Optional(Type.String()), diff --git a/api/src/utils/create-user.ts b/api/src/utils/create-user.ts index 7d25c378a10..52c08e3d58e 100644 --- a/api/src/utils/create-user.ts +++ b/api/src/utils/create-user.ts @@ -82,7 +82,7 @@ export function createUserInput(email: string) { showPortfolio: false, showTimeLine: false }, - sendQuincyEmail: false, + sendQuincyEmail: null, theme: 'default', username, usernameDisplay: username, diff --git a/client/src/components/Intro/email-sign-up-alert.tsx b/client/src/components/Intro/email-sign-up-alert.tsx new file mode 100644 index 00000000000..09ae41d9db1 --- /dev/null +++ b/client/src/components/Intro/email-sign-up-alert.tsx @@ -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 ( + + + + ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(EmailSignUpAlert); diff --git a/client/src/components/Intro/index.tsx b/client/src/components/Intro/index.tsx index 6b850240a7e..6d0ed1e80be 100644 --- a/client/src/components/Intro/index.tsx +++ b/client/src/components/Intro/index.tsx @@ -6,6 +6,7 @@ import Login from '../Header/components/login'; import { Link, Loader } from '../helpers'; import './intro.css'; +import EmailSignUpAlert from './email-sign-up-alert'; import LearnAlert from './learn-alert'; interface IntroProps { @@ -64,6 +65,7 @@ const Intro = ({ onLearnDonationAlertClick={onLearnDonationAlertClick} isDonating={isDonating} /> + {completedChallengeCount && slug && completedChallengeCount < 15 ? (
diff --git a/client/src/components/Intro/intro.css b/client/src/components/Intro/intro.css index ea8b5c064b6..36901de3634 100644 --- a/client/src/components/Intro/intro.css +++ b/client/src/components/Intro/intro.css @@ -42,3 +42,20 @@ font-style: normal; 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; +} diff --git a/client/src/components/Intro/intro.test.tsx b/client/src/components/Intro/intro.test.tsx index 702a5c0e50f..da26fc0f546 100644 --- a/client/src/components/Intro/intro.test.tsx +++ b/client/src/components/Intro/intro.test.tsx @@ -7,8 +7,11 @@ import Intro from '.'; jest.mock('../../analytics'); -function renderWithRedux(ui: JSX.Element) { - return render({ui}); +function renderWithRedux( + ui: JSX.Element, + preloadedState: Record = {} +) { + return render({ui}); } describe('', () => { @@ -19,7 +22,19 @@ describe('', () => { }); it('has a blockquote when loggedIn', () => { - renderWithRedux(); + // 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(, preloadedState); expect(screen.getByTestId('quote-block')).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); }); diff --git a/client/src/components/email-options.tsx b/client/src/components/email-options.tsx new file mode 100644 index 00000000000..9669fc290cd --- /dev/null +++ b/client/src/components/email-options.tsx @@ -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 ( + + + + + + + + + + + ); + } else { + return ( + + + + + + + + ); + } +} + +interface EmailOptionsProps { + isSignedIn: boolean; + updateQuincyEmail: (isSendQuincyEmail: boolean) => void; + isPage?: boolean; +} + +function EmailOptions({ + isSignedIn, + updateQuincyEmail, + isPage +}: EmailOptionsProps) { + const { t } = useTranslation(); + + return ( + <> + + + {isPage ? ( +

{t('misc.email-signup')}

+ ) : ( +

{t('misc.email-signup')}

+ )} + + +
+ + + +

{t('misc.email-blast')}

+ {t('misc.quincy')} + + +
+ + + ); +} + +export default EmailOptions; diff --git a/client/src/components/profile/profile.test.tsx b/client/src/components/profile/profile.test.tsx index 55167362125..e178bfff5fa 100644 --- a/client/src/components/profile/profile.test.tsx +++ b/client/src/components/profile/profile.test.tsx @@ -11,7 +11,6 @@ window.___loader = { enqueue: () => {}, hovering: () => {} }; const userProps = { user: { - acceptedPrivacyTerms: true, currentChallengeId: 'string', email: 'string', emailVerified: true, diff --git a/client/src/components/settings/email.tsx b/client/src/components/settings/email.tsx index 854781ceeb7..485d75cae47 100644 --- a/client/src/components/settings/email.tsx +++ b/client/src/components/settings/email.tsx @@ -32,7 +32,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => type EmailProps = { email: string; isEmailVerified: boolean; - sendQuincyEmail: boolean; + sendQuincyEmail: boolean | null; t: TFunction; updateMyEmail: (email: string) => void; updateQuincyEmail: (sendQuincyEmail: boolean) => void; @@ -250,7 +250,7 @@ function EmailSettings({ void; - acceptedPrivacyTerms: boolean; +interface EmailSignUpProps { + updateQuincyEmail: (isSendQuincyEmail: boolean) => void; + sendQuincyEmail: boolean | null | undefined; isSignedIn: boolean; showLoading: boolean; - completedChallengeCount: number; } const mapStateToProps = createSelector( @@ -33,90 +28,28 @@ const mapStateToProps = createSelector( isSignedInSelector, signInLoadingSelector, (user: User | null, isSignedIn: boolean, showLoading: boolean) => ({ - acceptedPrivacyTerms: !!user?.acceptedPrivacyTerms, + sendQuincyEmail: user?.sendQuincyEmail, isSignedIn, - showLoading, - completedChallengeCount: user?.completedChallengeCount ?? 0 + showLoading }) ); -const mapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators({ acceptTerms }, dispatch); +const mapDispatchToProps = { + updateQuincyEmail: (sendQuincyEmail: boolean) => + updateMyQuincyEmail({ sendQuincyEmail }) +}; const RedirectToLearn = createRedirect('/learn'); -function EmailListOptIn({ +function EmailSignUp({ + updateQuincyEmail, + sendQuincyEmail, isSignedIn, - acceptTerms -}: { - isSignedIn: boolean; - acceptTerms: (accepted: boolean) => void; -}) { + showLoading +}: EmailSignUpProps) { const { t } = useTranslation(); - if (isSignedIn) { - return ( - - - - - - - - - - - - - ); - } else { - return ( - - - - - - ); - } -} -function AcceptPrivacyTerms({ - acceptTerms, - acceptedPrivacyTerms, - isSignedIn, - showLoading, - completedChallengeCount -}: AcceptPrivacyTermsProps) { - const { t } = useTranslation(); - const acceptedPrivacyRef = useRef(acceptedPrivacyTerms); - const acceptTermsRef = useRef(acceptTerms); - const newAccount = isSignedIn && completedChallengeCount < 1; + const userHasMadeSelection = isSignedIn && sendQuincyEmail !== null; - useEffect(() => { - acceptedPrivacyRef.current = acceptedPrivacyTerms; - acceptTermsRef.current = acceptTerms; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return acceptedPrivacyTerms ? ( + return userHasMadeSelection ? ( ) : ( <> @@ -124,47 +57,20 @@ function AcceptPrivacyTerms({ {t('misc.email-signup')} | freeCodeCamp.org - - - -

- {newAccount - ? t('misc.brand-new-account') - : t('misc.email-signup')} -

- - -
- {newAccount && ( - - -

- - - -

-
- -
+ + {showLoading ? ( + + ) : ( + )} - - - -

{t('misc.email-blast')}

- - - {showLoading ? ( - - ) : ( - - )} - - - -
+ ); } -export default connect(mapStateToProps, mapDispatchToProps)(AcceptPrivacyTerms); +export default connect(mapStateToProps, mapDispatchToProps)(EmailSignUp); diff --git a/client/src/redux/accept-terms-saga.js b/client/src/redux/accept-terms-saga.js deleted file mode 100644 index 8397de24416..00000000000 --- a/client/src/redux/accept-terms-saga.js +++ /dev/null @@ -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) - ]; -} diff --git a/client/src/redux/action-types.js b/client/src/redux/action-types.js index d242e461e26..dd85c8d1860 100644 --- a/client/src/redux/action-types.js +++ b/client/src/redux/action-types.js @@ -42,7 +42,6 @@ export const actionTypes = createTypes( ...createAsyncTypes('fetchUser'), ...createAsyncTypes('postCharge'), ...createAsyncTypes('fetchProfileForUser'), - ...createAsyncTypes('acceptTerms'), ...createAsyncTypes('showCert'), ...createAsyncTypes('reportUser'), ...createAsyncTypes('deleteUserToken'), diff --git a/client/src/redux/actions.ts b/client/src/redux/actions.ts index a6d2ed2f9ef..5f363fe7fb4 100644 --- a/client/src/redux/actions.ts +++ b/client/src/redux/actions.ts @@ -42,12 +42,6 @@ export const saveChallengeComplete = createAction( 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 fetchUserComplete = createAction(actionTypes.fetchUserComplete); export const fetchUserTimeout = createAction(actionTypes.fetchUserTimeout); diff --git a/client/src/redux/index.js b/client/src/redux/index.js index 2335cd15dfe..fa63d9226eb 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -7,7 +7,6 @@ import { CURRENT_CHALLENGE_KEY } from '../templates/Challenges/redux/action-types'; import { getIsDailyCodingChallenge } from '../../../shared/config/challenge-types'; -import { createAcceptTermsSaga } from './accept-terms-saga'; import { actionTypes, ns as MainApp } from './action-types'; import { createAppMountSaga } from './app-mount-saga'; import { createDonationSaga } from './donation-saga'; @@ -88,7 +87,6 @@ const initialState = { export const epics = [hardGoToEpic, failedUpdatesEpic, updateCompleteEpic]; export const sagas = [ - ...createAcceptTermsSaga(actionTypes), ...createThemeSaga(actionTypes), ...createAppMountSaga(actionTypes), ...createDonationSaga(actionTypes), @@ -117,26 +115,6 @@ function spreadThePayloadOnUser(state, payload) { 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 }) => { return { ...state, diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index 52406aefdd9..4b03493c677 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -393,7 +393,7 @@ export type User = { profileUI: ProfileUI; progressTimestamps: Array; savedChallenges: SavedChallenges; - sendQuincyEmail: boolean; + sendQuincyEmail: boolean | null; sound: boolean; theme: UserThemes; keyboardShortcuts: boolean; diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index b3d19785cb4..f2ae23c74a2 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -361,9 +361,9 @@ export function putUpdateMyHonesty( return put('/update-my-honesty', update); } -export function putUpdateMyQuincyEmail( - update: Record -): Promise> { +export function putUpdateMyQuincyEmail(update: { + sendQuincyEmail: boolean; +}): Promise> { return put('/update-my-quincy-email', update); } @@ -373,12 +373,6 @@ export function putUpdateMyPortfolio( return put('/update-my-portfolio', update); } -export function putUserAcceptsTerms( - quincyEmails: boolean -): Promise> { - return put('/update-privacy-terms', { quincyEmails }); -} - export function putUserUpdateEmail( email: string ): Promise> { diff --git a/e2e/email-sign-up-alert.spec.ts b/e2e/email-sign-up-alert.spec.ts new file mode 100644 index 00000000000..15a7fe61376 --- /dev/null +++ b/e2e/email-sign-up-alert.spec.ts @@ -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(); + }); +}); diff --git a/e2e/quincy-email-sign-up.spec.ts b/e2e/quincy-email-sign-up.spec.ts index 20e09fa3b68..add0b7514e5 100644 --- a/e2e/quincy-email-sign-up.spec.ts +++ b/e2e/quincy-email-sign-up.spec.ts @@ -11,6 +11,7 @@ test.describe('Email sign-up page when user is not signed in', () => { test.use({ storageState: { cookies: [], origins: [] } }); test.beforeEach(async ({ page }) => { + execSync('node ./tools/scripts/seed/seed-demo-user --certified-user'); 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.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 --set-false acceptedPrivacyTerms' - ); + // It's necessary to seed with a user that has not selected an email newsletter option. + execSync('node ./tools/scripts/seed/seed-demo-user --certified-user'); await page.goto('/email-sign-up'); }); 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( 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(); @@ -148,9 +148,7 @@ test.describe('Email sign-up page when user is signed in', () => { page.getByRole('heading', { name: 'Welcome back, Full Stack User' }) ).toBeVisible(); - // When the user clicks Yes, the /update-privacy-terms API is called - // to update both `acceptedPrivacyTerms` and `sendQuincyEmail`. - // But `sendQuincyEmail` is not set in the DB since the endpoint is mocked, + // `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. await page.route('*/**/user/get-session-user', async route => { const response = await route.fetch(); @@ -172,40 +170,3 @@ test.describe('Email sign-up page when user is signed in', () => { ).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(); - }); -}); diff --git a/tools/scripts/seed/user-data.js b/tools/scripts/seed/user-data.js index 3ea49b2cecb..7e9490b7fb8 100644 --- a/tools/scripts/seed/user-data.js +++ b/tools/scripts/seed/user-data.js @@ -29,7 +29,7 @@ module.exports.blankUser = { location: '', picture: '', acceptedPrivacyTerms: true, - sendQuincyEmail: false, + sendQuincyEmail: null, currentChallengeId: '', isHonest: false, isFrontEndCert: false, @@ -91,7 +91,7 @@ module.exports.publicUser = { location: '', picture: '', acceptedPrivacyTerms: true, - sendQuincyEmail: false, + sendQuincyEmail: null, currentChallengeId: '', isHonest: false, isFrontEndCert: false, @@ -153,7 +153,7 @@ module.exports.demoUser = { location: '', picture: '', acceptedPrivacyTerms: true, - sendQuincyEmail: false, + sendQuincyEmail: null, currentChallengeId: '', isHonest: false, isFrontEndCert: false, @@ -217,7 +217,7 @@ module.exports.fullyCertifiedUser = { location: '', picture: '', acceptedPrivacyTerms: true, - sendQuincyEmail: false, + sendQuincyEmail: null, currentChallengeId: '', isHonest: true, isFrontEndCert: true,