feat: add AB test landing page top (#55659)

Co-authored-by: Naomi the Technomancer <accounts+github@nhcarrigan.com>
Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2024-08-16 12:33:48 +03:00
committed by GitHub
parent fa818eb8bb
commit b34291fc41
10 changed files with 1525 additions and 154 deletions

View File

@@ -0,0 +1,35 @@
{
"aa-test": {
"defaultValue": false
},
"aa-test-in-component": {
"defaultValue": false
},
"landing-page-redesign": {
"defaultValue": false,
"rules": [
{
"coverage": 1,
"hashAttribute": "id",
"seed": "landing-page-redesign",
"hashVersion": 2,
"variations": [false, true],
"weights": [0.5, 0.5],
"key": "landing-page-redesign",
"meta": [
{
"key": "0",
"name": "Control"
},
{
"key": "1",
"name": "Variation 1"
}
],
"phase": "0",
"name": "tests the conversion rate of the new design comparing to the old one"
}
]
}
}

View File

@@ -1,6 +1,7 @@
{
"buttons": {
"logged-in-cta-btn": "Get started (it's free)",
"get-started": "Get Started",
"logged-out-cta-btn": "Sign in to save your progress (it's free)",
"view-curriculum": "View the Curriculum",
"first-lesson": "Go to the first lesson",
@@ -106,11 +107,15 @@
},
"landing": {
"big-heading-1": "Learn to code — for free.",
"big-heading-1-b": "Learn to code.",
"big-heading-2": "Build projects.",
"big-heading-3": "Earn certifications.",
"big-heading-4": "All for free.",
"h2-heading": "Since 2014, more than 40,000 freeCodeCamp.org graduates have gotten jobs at tech companies including:",
"h2-heading-b": "More than 100,000 freeCodeCamp.org graduates have gotten jobs at tech companies including:",
"hero-img-description": "freeCodeCamp students at a local study group in South Korea.",
"hero-img-alt": "A group of people, including a White man, a Black woman, and an Asian woman, gathered around a laptop.",
"hero-img-uis": "A group of screenshots showing the freeCodeCamp editor interface on both a mobile and desktop device and a certification.",
"as-seen-in": "As seen in:",
"testimonials": {
"heading": "Here is what our alumni say about freeCodeCamp:",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -699,6 +699,30 @@ a.patreon-button:hover {
);
}
.light-palette .gradient-foreground {
background-image: linear-gradient(
-10deg,
rgb(0, 51, 133) 35%,
rgba(237, 202, 216, 0) 75%,
rgb(150, 15, 46) 100%
),
radial-gradient(circle, rgb(157, 1, 69) 0%, rgb(0, 89, 189) 100%);
color: transparent;
background-clip: text;
}
.dark-palette .gradient-foreground {
background-image: linear-gradient(
-10deg,
rgb(223 243 255) 35%,
rgba(237, 202, 216, 0) 75%,
rgb(255 215 224) 100%
),
radial-gradient(circle, rgb(255 139 189) 0%, rgb(187 219 255) 100%);
color: transparent;
background-clip: text;
}
.supporters-background {
background-repeat: repeat;
}

View File

@@ -12,6 +12,7 @@ import {
userFetchStateSelector
} from '../../redux/selectors';
import envData from '../../../config/env.json';
import defaultGrowthBookFeatures from '../../../config/growthbook-features-default.json';
import { User, UserFetchState } from '../../redux/prop-types';
import { getUUID } from '../../utils/growthbook-cookie';
import callGA from '../../analytics/call-ga';
@@ -77,17 +78,20 @@ const GrowthBookWrapper = ({
useEffect(() => {
async function setGrowthBookFeatures() {
if (!growthbookUri) return;
try {
const res = await fetch(growthbookUri);
const data = (await res.json()) as {
features: Record<string, FeatureDefinition>;
};
growthbook.setFeatures(data.features);
} catch (e) {
// TODO: report to sentry when it's enabled
console.error(e);
if (!growthbookUri) {
// Defaults are added to facilitate testing, and avoid passing the related env
growthbook.setFeatures(defaultGrowthBookFeatures);
} else {
try {
const res = await fetch(growthbookUri);
const data = (await res.json()) as {
features: Record<string, FeatureDefinition>;
};
growthbook.setFeatures(data.features);
} catch (e) {
// TODO: report to sentry when it's enabled
console.error(e);
}
}
}

View File

@@ -0,0 +1,110 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Container, Col, Row } from '@freecodecamp/ui';
import { clientLocale } from '../../../../config/env.json';
import {
AmazonLogo,
AppleLogo,
MicrosoftLogo,
SpotifyLogo,
GoogleLogo,
TencentLogo,
AlibabaLogo
} from '../../../assets/images/components';
import { Spacer } from '../../helpers';
import BigCallToAction from './big-call-to-action';
import UIImages from './ui-images';
const LogoRow = (): JSX.Element => {
const { t } = useTranslation();
const showChineseLogos = ['chinese', 'chinese-tradition'].includes(
clientLocale
);
return (
<>
<p
className='logo-row-title'
data-playwright-test-label='landing-h2-heading-b'
>
{t('landing.h2-heading-b')}
</p>
<Spacer size='small' />
<ul
className='logo-row'
data-playwright-test-label='brand-logo-container'
>
<AppleLogo />
<GoogleLogo />
<MicrosoftLogo />
{showChineseLogos ? (
<>
<TencentLogo />
<AlibabaLogo />
</>
) : (
<>
<SpotifyLogo />
<AmazonLogo />
</>
)}
</ul>
</>
);
};
function LandingTop(): JSX.Element {
const { t } = useTranslation();
return (
<Container fluid={true} className='gradient-container'>
<Container className='landing-top'>
<Row>
<Spacer size='medium' />
<Col lg={6} sm={12} xs={12}>
<h1
id='content-start'
className='mega-heading'
data-test-label='landing-header'
>
{t('landing.big-heading-1-b')}
</h1>
<p
className='mega-heading'
data-playwright-test-label='landing-big-heading-2'
>
{t('landing.big-heading-2')}
</p>
<p
className='mega-heading'
data-playwright-test-label='landing-big-heading-3'
>
{t('landing.big-heading-3')}
</p>
<p
className='mega-heading gradient-foreground'
data-playwright-test-label='landing-big-heading-4'
>
{t('landing.big-heading-4')}
</p>
<Spacer size='medium' />
<BigCallToAction text={t('buttons.get-started')} />
</Col>
<Col lg={6}>
<UIImages />
</Col>
</Row>
<Row>
<Spacer size='large' />
<Col xs={12}>
<LogoRow />
</Col>
</Row>
</Container>
</Container>
);
}
LandingTop.displayName = 'LandingTop';
export default LandingTop;

View File

@@ -0,0 +1,27 @@
// eslint-disable-next-line filenames-simple/naming-convention
import React from 'react';
import { useTranslation } from 'react-i18next';
import Media from 'react-responsive';
import landingPageb from '../../../assets/images/landing/landing-page-b.svg';
import { LazyImage, Spacer } from '../../helpers';
const LARGE_SCREEN_SIZE = 1200;
function UIImages(): JSX.Element {
const { t } = useTranslation();
return (
<Media minWidth={LARGE_SCREEN_SIZE}>
<figure
className='ui-images'
data-playwright-test-label='landing-page-figure'
>
<LazyImage alt={t('landing.hero-img-uis')} src={landingPageb} />
</figure>
<Spacer size='exLarge' />
</Media>
);
}
UIImages.displayName = 'UIImages';
export default UIImages;

View File

@@ -1,30 +1,58 @@
import React, { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { useGrowthBook } from '@growthbook/growthbook-react';
import SEO from '../seo';
import { Loader } from '../helpers';
import AsSeenIn from './components/as-seen-in';
import Certifications from './components/certifications';
import LandingTop from './components/landing-top';
import LandingTopB from './components/landing-top-b';
import Testimonials from './components/testimonials';
import Faq from './components/faq';
import './landing.css';
const LandingA = () => (
<main className='landing-page'>
<LandingTop />
<AsSeenIn />
<Testimonials />
<Certifications />
<Faq />
</main>
);
const LandingB = () => (
<main className='landing-page landing-page-b'>
<LandingTopB />
<Testimonials />
<Certifications />
<Faq />
</main>
);
function Landing(): ReactElement {
const { t } = useTranslation();
return (
<>
<SEO title={t('metaTags:title')} />
<main className='landing-page'>
<LandingTop />
<AsSeenIn />
<Testimonials />
<Certifications />
<Faq />
</main>
</>
);
const growthbook = useGrowthBook();
if (growthbook && growthbook.ready) {
const showLandingPageRedesign = growthbook.getFeatureValue(
'landing-page-redesign',
false
);
return (
<>
<SEO title={t('metaTags:title')} />
{showLandingPageRedesign === true ? <LandingB /> : <LandingA />}
</>
);
} else {
return (
<>
<SEO title={t('metaTags:title')} />
<Loader fullScreen={true} />
</>
);
}
}
Landing.displayName = 'Landing';

View File

@@ -214,3 +214,54 @@ figcaption.caption {
padding: 40px;
}
}
/* AB testing styles */
.landing-page-b .mega-heading {
font-size: 2rem;
margin: 0px 0px 1rem;
font-weight: 700;
line-height: 3rem;
}
@media (min-width: 500px) {
.landing-page-b .mega-heading {
font-size: 3rem;
}
}
.landing-page-b .landing-top .btn-cta-big {
margin: 0px;
min-width: 300px;
max-width: fit-content;
background-image: none;
color: var(--primary-background) !important;
background-color: var(--primary-color);
border-color: var(--primary-color);
padding: 12px;
}
.landing-page-b .landing-top .btn-cta-big:hover,
.landing-page-b .landing-top .btn-cta-big:focus {
background-color: var(--quaternary-color) !important;
}
.landing-page-b .logo-row-title {
font-weight: normal;
}
.landing-page-b {
overflow-x: hidden;
}
figure.ui-images {
position: absolute;
left: 50%;
height: auto;
width: 750px;
top: 3vw;
}
figure.ui-images img {
width: 100%;
height: auto;
}

View File

@@ -1,4 +1,4 @@
import { expect, test, type Page } from '@playwright/test';
import { BrowserContext, expect, Page, test } from '@playwright/test';
import intro from '../client/i18n/locales/english/intro.json';
import translations from '../client/i18n/locales/english/translations.json';
import { SuperBlocks } from '../shared/config/curriculum';
@@ -37,136 +37,236 @@ const superBlocks = [
intro[SuperBlocks.PythonForEverybody].title
];
let page: Page;
async function addDefaultCookies(context: BrowserContext, variation: string) {
await context.addCookies([
{
name: 'gbuuid',
value: variation,
domain: 'localhost',
path: '/',
expires: Math.floor(Date.now() / 1000) + 400 * 24 * 60 * 60 // 400 days from now
}
]);
}
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
async function goToLandingPage(page: Page) {
await page.goto('/');
});
}
test('The component Landing-top renders correctly', async () => {
const landingHeading1 = page.getByTestId('landing-big-heading-1');
await expect(landingHeading1).toHaveText(
translations.landing['big-heading-1']
);
const landingHeading2 = page.getByTestId('landing-big-heading-2');
await expect(landingHeading2).toHaveText(
translations.landing['big-heading-2']
);
const landingHeading3 = page.getByTestId('landing-big-heading-3');
await expect(landingHeading3).toHaveText(
translations.landing['big-heading-3']
);
const landingH2Heading = page.getByTestId('landing-h2-heading');
await expect(landingH2Heading).toHaveText(translations.landing['h2-heading']);
});
test('Has 5 brand logos', async () => {
const logos = page.getByTestId('brand-logo-container').locator('svg');
await expect(logos).toHaveCount(5);
for (const logo of await logos.all()) {
await expect(logo).toBeVisible();
}
});
test('The landing-top & testimonial sections should contain call-to-action, and have a descriptive text content', async () => {
const ctas = await page
.getByRole('link', {
name: translations.buttons['logged-in-cta-btn']
})
.all();
expect(ctas).toHaveLength(4);
for (const cta of ctas) {
await expect(cta).toBeVisible();
}
});
test("The landing-top should contain a descriptive text explaining the camper's image", async ({
isMobile
}) => {
const campersImage = page.getByAltText(translations.landing['hero-img-alt']);
const captionText = page.getByText(
translations.landing['hero-img-description']
);
if (isMobile) {
await expect(campersImage).toBeHidden();
await expect(captionText).toBeHidden();
} else {
await expect(campersImage).toBeVisible();
await expect(captionText).toBeVisible();
}
});
test('The campers landing page figure is visible on desktop and hidden on mobile view', async ({
isMobile
}) => {
const landingPageImage = page.getByTestId('landing-page-figure');
if (isMobile) {
await expect(landingPageImage).toBeHidden();
} else {
await expect(landingPageImage).toBeVisible();
}
});
test('The as seen in container is visible with featured logos', async () => {
const asSeenInContainer = page.getByTestId('landing-as-seen-in-text');
await expect(asSeenInContainer).toHaveText(
translations.landing['as-seen-in']
);
const featuredLogos = page.getByTestId('landing-as-seen-in-container-logos');
await expect(featuredLogos).toBeVisible();
});
test('Testimonial section has a header', async () => {
const testimonialsHeader = page.getByTestId('testimonials-section-header');
await expect(testimonialsHeader).toHaveText(
translations.landing.testimonials['heading']
);
});
test('Testimonial endorser people have images, occupation, location and testimony visible', async () => {
const cards = page.getByTestId('testimonial-card');
await expect(cards).toHaveCount(3);
for (const card of await cards.all()) {
await expect(card).toBeVisible();
await expect(
card.getByTestId('testimonials-endorser-image-container')
).toBeVisible();
await expect(
card.getByTestId('testimonials-endorser-location')
).toBeVisible();
await expect(
card.getByTestId('testimonials-endorser-occupation')
).toBeVisible();
await expect(
card.getByTestId('testimonials-endorser-testimony')
).toBeVisible();
}
});
test('Has links to all curriculum', async () => {
const curriculumBtns = page.getByTestId(landingPageElements.curriculumBtns);
await expect(curriculumBtns).toHaveCount(21);
for (let index = 0; index < superBlocks.length; index++) {
const btn = curriculumBtns.nth(index);
await expect(btn).toContainText(superBlocks[index]);
}
});
test('Has FAQ section', async () => {
const faqs = page.getByTestId(landingPageElements.faq);
await expect(faqs).toHaveCount(9);
});
test("Has CTA Get Started It's free buttons", async () => {
const ctaButtons = page.getByRole('link', {
name: "Get started (it's free)"
test.describe('Landing Page - Variation B', () => {
test.beforeEach(async ({ context, page }) => {
await addDefaultCookies(context, 'B');
await goToLandingPage(page);
});
test('The component Landing-top renders correctly', async ({ page }) => {
await expect(
page
.getByRole('heading', { level: 1 })
.filter({ hasText: `${translations.landing['big-heading-1-b']}` })
).toBeVisible();
const landingHeading2 = page.getByTestId('landing-big-heading-2');
await expect(landingHeading2).toHaveText(
translations.landing['big-heading-2']
);
const landingHeading3 = page.getByTestId('landing-big-heading-3');
await expect(landingHeading3).toHaveText(
translations.landing['big-heading-3']
);
const landingHeading4 = page.getByTestId('landing-big-heading-4');
await expect(landingHeading4).toHaveText(
translations.landing['big-heading-4']
);
const landingH2Heading = page.getByTestId('landing-h2-heading-b');
await expect(landingH2Heading).toHaveText(
translations.landing['h2-heading-b']
);
});
test('CTA buttons should render correctly', async ({ page }) => {
const mainCta = page.getByRole('link', {
name: translations.buttons['get-started'],
exact: true
});
await expect(mainCta).toHaveCount(1);
for (const cta of await mainCta.all()) {
await expect(cta).toBeVisible();
}
const ctas = page.getByRole('link', {
name: translations.buttons['logged-in-cta-btn'],
exact: true
});
await expect(ctas).toHaveCount(3);
for (const cta of await ctas.all()) {
await expect(cta).toBeVisible();
}
});
test('Hero image should have a descriptive alt', async ({
isMobile,
page
}) => {
const campersImage = page.getByAltText(
translations.landing['hero-img-uis']
);
if (isMobile) {
await expect(campersImage).toBeHidden();
} else {
await expect(campersImage).toBeVisible();
}
});
test('The as seen in container with featured logos should not exist', async ({
page
}) => {
const asSeenInContainer = page.getByTestId('landing-as-seen-in-text');
await expect(asSeenInContainer).toHaveCount(0);
});
});
test.describe('Landing Page - Variation A', () => {
test.beforeEach(async ({ context, page }) => {
await addDefaultCookies(context, 'A');
await goToLandingPage(page);
});
test('The component Landing-top renders correctly', async ({ page }) => {
const landingHeading1 = page.getByTestId('landing-big-heading-1');
await expect(landingHeading1).toHaveText(
translations.landing['big-heading-1']
);
const landingHeading2 = page.getByTestId('landing-big-heading-2');
await expect(landingHeading2).toHaveText(
translations.landing['big-heading-2']
);
const landingHeading3 = page.getByTestId('landing-big-heading-3');
await expect(landingHeading3).toHaveText(
translations.landing['big-heading-3']
);
const landingH2Heading = page.getByTestId('landing-h2-heading');
await expect(landingH2Heading).toHaveText(
translations.landing['h2-heading']
);
});
test('Call to action buttons should render correctly', async ({ page }) => {
const ctas = page.getByRole('link', {
name: translations.buttons['logged-in-cta-btn']
});
await expect(ctas).toHaveCount(4);
for (const cta of await ctas.all()) {
await expect(cta).toBeVisible();
}
});
test('Hero image should have an alt and a description', async ({
isMobile,
page
}) => {
const campersImage = page.getByAltText(
translations.landing['hero-img-alt']
);
const captionText = page.getByText(
translations.landing['hero-img-description']
);
if (isMobile) {
await expect(campersImage).toBeHidden();
await expect(captionText).toBeHidden();
} else {
await expect(campersImage).toBeVisible();
await expect(captionText).toBeVisible();
}
});
test('The as seen in container is visible with featured logos', async ({
page
}) => {
const asSeenInContainer = page.getByTestId('landing-as-seen-in-text');
await expect(asSeenInContainer).toHaveText(
translations.landing['as-seen-in']
);
const featuredLogos = page.getByTestId(
'landing-as-seen-in-container-logos'
);
await expect(featuredLogos).toBeVisible();
});
});
test.describe('Landing Page - common', () => {
test.beforeEach(async ({ page }) => {
await goToLandingPage(page);
});
test('Has 5 brand logos', async ({ page }) => {
const logos = page.getByTestId('brand-logo-container').locator('svg');
await expect(logos).toHaveCount(5);
for (const logo of await logos.all()) {
await expect(logo).toBeVisible();
}
});
test('The campers landing page figure is visible on desktop and hidden on mobile view', async ({
page,
isMobile
}) => {
const landingPageImage = page.getByTestId('landing-page-figure');
if (isMobile) {
await expect(landingPageImage).toBeHidden();
} else {
await expect(landingPageImage).toBeVisible();
}
});
test('Testimonial section has a header', async ({ page }) => {
const testimonialsHeader = page.getByTestId('testimonials-section-header');
await expect(testimonialsHeader).toHaveText(
translations.landing.testimonials['heading']
);
});
test('Testimonial endorser people have images, occupation, location and testimony visible', async ({
page
}) => {
const cards = page.getByTestId('testimonial-card');
await expect(cards).toHaveCount(3);
for (const card of await cards.all()) {
await expect(card).toBeVisible();
await expect(
card.getByTestId('testimonials-endorser-image-container')
).toBeVisible();
await expect(
card.getByTestId('testimonials-endorser-location')
).toBeVisible();
await expect(
card.getByTestId('testimonials-endorser-occupation')
).toBeVisible();
await expect(
card.getByTestId('testimonials-endorser-testimony')
).toBeVisible();
}
});
test('Has links to all curriculum', async ({ page }) => {
const curriculumBtns = page.getByTestId(landingPageElements.curriculumBtns);
await expect(curriculumBtns).toHaveCount(21);
for (let index = 0; index < superBlocks.length; index++) {
const btn = curriculumBtns.nth(index);
await expect(btn).toContainText(superBlocks[index]);
}
});
test('Has FAQ section', async ({ page }) => {
const faqs = page.getByTestId(landingPageElements.faq);
await expect(faqs).toHaveCount(9);
});
await expect(ctaButtons).toHaveCount(4);
});