Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Configurable landing page #6655

Open
wants to merge 33 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions support-frontend/app/admin/settings/LandingPageTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package admin.settings

import com.gu.support.encoding.Codec
import com.gu.support.encoding.Codec.deriveCodec
import io.circe.generic.extras.semiauto.{deriveEnumerationDecoder, deriveEnumerationEncoder}
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
import io.circe.{Decoder, Encoder}

sealed trait Status
object Status {
case object Live extends Status
case object Draft extends Status

implicit val statusEncoder = deriveEnumerationEncoder[Status]
implicit val statusDecoder = deriveEnumerationDecoder[Status]
}

case class LandingPageTestTargeting(
countryGroups: List[String],
)

object LandingPageTestTargeting {
implicit val codec: Codec[LandingPageTestTargeting] = deriveCodec
charleycampbell marked this conversation as resolved.
Show resolved Hide resolved
}

case class LandingPageCopy(
heading: String,
subheading: String,
)

object LandingPageCopy {
implicit val codec: Codec[LandingPageCopy] = deriveCodec
}

case class LandingPageVariant(
name: String,
copy: LandingPageCopy,
)

object LandingPageVariant {
implicit val codec: Codec[LandingPageVariant] = deriveCodec
}

case class LandingPageTest(
name: String,
status: Status,
priority: Int,
targeting: LandingPageTestTargeting,
variants: List[LandingPageVariant],
)

object LandingPageTest {
implicit val encoder: Encoder[LandingPageTest] = deriveEncoder
implicit val decoder: Decoder[LandingPageTest] = deriveDecoder
}

// TODO - fetch config from dynamodb instead of hardcoding here
object LandingPageTestsProvider extends SettingsProvider[List[LandingPageTest]] {
def settings(): List[LandingPageTest] = List(
LandingPageTest(
name = "LP_DEFAULT_US",
status = Status.Live,
priority = 0,
targeting = LandingPageTestTargeting(countryGroups = List("UnitedStates")),
variants = List(
LandingPageVariant(
name = "CONTROL",
copy = LandingPageCopy(
heading = "Protect fearless, independent journalism",
subheading =
"We're not owned by a billionaire or profit-driven corporation: our fiercely independent journalism is funded by our readers. Monthly giving makes the most impact (and you can cancel anytime). Thank you.",
),
),
),
),
LandingPageTest(
name = "LP_DEFAULT",
status = Status.Live,
priority = 1,
targeting = LandingPageTestTargeting(countryGroups =
charleycampbell marked this conversation as resolved.
Show resolved Hide resolved
List("GBPCountries", "AUDCountries", "EURCountries", "International", "NZDCountries", "Canada"),
),
variants = List(
LandingPageVariant(
name = "CONTROL",
copy = LandingPageCopy(
heading = "Support fearless, independent journalism",
subheading =
"We're not owned by a billionaire or shareholders - our readers support us. Choose to join with one of the options below. <strong>Cancel anytime.</strong>",
),
),
),
),
)
}
1 change: 1 addition & 0 deletions support-frontend/app/admin/settings/Settings.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ case class AllSettings(
amounts: AmountsTests,
contributionTypes: ContributionTypes,
metricUrl: MetricUrl,
landingPageTests: List[LandingPageTest],
)

object AllSettings {
Expand Down
3 changes: 3 additions & 0 deletions support-frontend/app/admin/settings/SettingsProvider.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class AllSettingsProvider private (
amountsProvider: SettingsProvider[AmountsTests],
contributionTypesProvider: SettingsProvider[ContributionTypes],
metricUrl: MetricUrl,
landingPageTestsProvider: SettingsProvider[List[LandingPageTest]],
) {

def getAllSettings(): AllSettings = {
Expand All @@ -40,6 +41,7 @@ class AllSettingsProvider private (
amountsProvider.settings(),
contributionTypesProvider.settings(),
metricUrl,
landingPageTestsProvider.settings(),
)
}
}
Expand All @@ -58,6 +60,7 @@ object AllSettingsProvider {
amountsProvider,
contributionTypesProvider,
config.metricUrl,
LandingPageTestsProvider,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
AmountsVariant,
SelectedAmountsVariant,
} from '../../contributions';
import { emptySwitches } from '../../globalsAndSwitches/globals';
import { emptySwitches, getSettings } from '../../globalsAndSwitches/globals';
import type { Settings } from '../../globalsAndSwitches/settings';
import {
GBPCountries,
Expand Down Expand Up @@ -45,6 +45,7 @@ describe('init', () => {
countryId: country,
countryGroupId,
mvt,
settings: getSettings(),
};

afterEach(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { LandingPageTest } from '../../globalsAndSwitches/landingPageSettings';
import {
fallBackLandingPageSelection,
getLandingPageParticipations,
getLandingPageVariant,
} from '../landingPageAbTests';
import { LANDING_PAGE_PARTICIPATIONS_KEY } from '../sessionStorage';

const nonUsTest: LandingPageTest = {
name: 'LP_DEFAULT',
status: 'Live',
targeting: {
countryGroups: [
'GBPCountries',
'AUDCountries',
'EURCountries',
'International',
'NZDCountries',
'Canada',
],
},
variants: [
{
name: 'CONTROL',
copy: {
heading: 'Support fearless, independent journalism',
subheading:
"We're not owned by a billionaire or shareholders - our readers support us. Choose to join with one of the options below. <strong>Cancel anytime.</strong>",
},
},
],
};
const usTest: LandingPageTest = {
name: 'LP_DEFAULT_US',
status: 'Live',
targeting: { countryGroups: ['UnitedStates'] },
variants: [
{
name: 'CONTROL',
copy: {
heading: 'Support fearless, independent journalism',
subheading:
"We're not owned by a billionaire or profit-driven corporation: our fiercely independent journalism is funded by our readers. Monthly giving makes the most impact (and you can cancel anytime). Thank you.",
},
},
],
};
const tests = [usTest, nonUsTest];

const mvtId = 0;

describe('getLandingPageParticipations', () => {
afterEach(() => {
window.sessionStorage.clear();
});

it('assigns a user to the UK test on UK landing page', () => {
const result = getLandingPageParticipations(
'GBPCountries',
'/uk/contribute',
tests,
mvtId,
);
expect(result).toEqual({ [nonUsTest.name]: 'CONTROL' });
});

it('assigns a user to the US test on US landing page', () => {
const result = getLandingPageParticipations(
'UnitedStates',
'/us/contribute',
tests,
mvtId,
);
expect(result).toEqual({ [usTest.name]: 'CONTROL' });
});

it('assigns a user to the UK test on a checkout page if it is in session storage', () => {
window.sessionStorage.setItem(
LANDING_PAGE_PARTICIPATIONS_KEY,
JSON.stringify({ [nonUsTest.name]: 'CONTROL' }),
);

const result = getLandingPageParticipations(
'GBPCountries',
'/uk/one-time-checkout',
tests,
mvtId,
);
expect(result).toEqual({ [nonUsTest.name]: 'CONTROL' });
});

it('does not assign a user to the UK test on a checkout page if it is *not* in session storage', () => {
const result = getLandingPageParticipations(
'GBPCountries',
'/uk/one-time-checkout',
tests,
mvtId,
);
expect(result).toBeUndefined();
});
});

describe('getLandingPageVariant', () => {
it('finds variant for participation', () => {
const participations = {
TEST_A: 'V1',
TEST_B: 'V2',
[nonUsTest.name]: 'CONTROL',
};
const result = getLandingPageVariant(participations, tests);
expect(result).toEqual({
...nonUsTest.variants[0],
testName: nonUsTest.name,
});
});

it('falls back on default settings if no landing page test matches', () => {
const participations = {
TEST_A: 'V1',
TEST_B: 'V2',
};
const result = getLandingPageVariant(participations, tests);
expect(result).toEqual(fallBackLandingPageSelection);
});
});
38 changes: 19 additions & 19 deletions support-frontend/assets/helpers/abTests/abtest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { Settings } from 'helpers/globalsAndSwitches/settings';
import type { IsoCountry } from 'helpers/internationalisation/country';
import type { CountryGroupId } from 'helpers/internationalisation/countryGroup';
import * as cookie from 'helpers/storage/cookie';
import * as storage from 'helpers/storage/storage';
import { getQueryParameter } from 'helpers/urls/url';
import type {
AmountsTest,
Expand All @@ -13,6 +12,7 @@ import type {
} from '../contributions';
import { tests } from './abtestDefinitions';
import { getFallbackAmounts } from './helpers';
import { getLandingPageParticipations } from './landingPageAbTests';
import type {
AcquisitionABTest,
Audience,
Expand All @@ -21,6 +21,11 @@ import type {
Tests,
} from './models';
import { breakpoints } from './models';
import {
getSessionParticipations,
PARTICIPATIONS_KEY,
setSessionParticipations,
} from './sessionStorage';

export const testIsActive = (
value: [string, string | undefined],
Expand All @@ -36,6 +41,7 @@ type ABtestInitalizerData = {
mvt?: number;
acquisitionDataTests?: AcquisitionABTest[];
path?: string;
settings: Settings;
};

function init({
Expand All @@ -46,8 +52,9 @@ function init({
mvt = getMvtId(),
acquisitionDataTests = getTestFromAcquisitionData() ?? [],
path = window.location.pathname,
settings,
}: ABtestInitalizerData): Participations {
const sessionParticipations = getParticipationsFromSession();
const sessionParticipations = getSessionParticipations(PARTICIPATIONS_KEY);
const participations = getParticipations(
abTests,
mvt,
Expand All @@ -59,10 +66,19 @@ function init({
sessionParticipations,
);

// A landing page test config may be passed through from the server, so we handle this separately
const landingPageParticipations = getLandingPageParticipations(
countryGroupId,
path,
settings.landingPageTests,
mvt,
);

const urlParticipations = getParticipationsFromUrl();
const serverSideParticipations = getServerSideParticipations();
return {
...participations,
...landingPageParticipations,
...serverSideParticipations,
...urlParticipations,
};
Expand Down Expand Up @@ -201,7 +217,7 @@ function getParticipations(
sessionParticipations[testId] = participations[testId];
}
});
storage.setSession('abParticipations', JSON.stringify(sessionParticipations));
setSessionParticipations(sessionParticipations, PARTICIPATIONS_KEY);

return participations;
}
Expand All @@ -219,22 +235,6 @@ function getParticipationsFromUrl(): Participations | undefined {
return;
}

function getParticipationsFromSession(): Participations | undefined {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved to sessionStorage.ts for reuse

const participations = storage.getSession('abParticipations');
if (participations) {
try {
return JSON.parse(participations) as Participations;
} catch (error) {
console.error(
'Failed to parse abParticipations from session storage',
error,
);
return undefined;
}
}
return undefined;
}

function getServerSideParticipations(): Participations | null | undefined {
if (window.guardian.serversideTests) {
return window.guardian.serversideTests;
Expand Down
Loading
Loading