From 0df7ee430dc661c0806408eb1ac27d1d0a52dd75 Mon Sep 17 00:00:00 2001 From: Ahmad Abdolsaheb Date: Mon, 17 Oct 2022 15:53:25 +0300 Subject: [PATCH] feat(client): add growthbook (#48003) * feat: initial set up * feat: useFeature setup * feat: adjust attributes * chore(client): remove ts-disables in growth-book-wrapper * feat: pull growthbook uri from env * feat: adjust the staff atribute * feat: make linter happy * feat: update recruitment message * refactor: simplify types * chore: delete unused config * fix: update copy * fix: add growthbookUri to expected env vars Co-authored-by: Shaun Hamilton Co-authored-by: Oliver Eyton-Williams --- client/gatsby-browser.js | 5 +- client/gatsby-ssr.js | 5 +- client/package.json | 1 + client/src/components/Intro/index.tsx | 2 + .../src/components/Intro/research-banner.tsx | 31 +++++++++ .../growth-book/growth-book-wrapper.tsx | 68 +++++++++++++++++++ config/read-env.js | 9 ++- package-lock.json | 37 ++++++++++ sample.env | 3 + tools/scripts/build/ensure-env.ts | 4 +- 10 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 client/src/components/Intro/research-banner.tsx create mode 100644 client/src/components/growth-book/growth-book-wrapper.tsx diff --git a/client/gatsby-browser.js b/client/gatsby-browser.js index 11616f7cd30..513cc8d8a55 100644 --- a/client/gatsby-browser.js +++ b/client/gatsby-browser.js @@ -8,6 +8,7 @@ import i18n from './i18n/config'; import AppMountNotifier from './src/components/app-mount-notifier'; import { createStore } from './src/redux/createStore'; import layoutSelector from './utils/gatsby/layout-selector'; +import GrowthBookProvider from './src/components/growth-book/growth-book-wrapper'; const store = createStore(); @@ -15,7 +16,9 @@ export const wrapRootElement = ({ element }) => { return ( - element} /> + + element} /> + ); diff --git a/client/gatsby-ssr.js b/client/gatsby-ssr.js index c0c0fd3ccaf..e7995a365d0 100644 --- a/client/gatsby-ssr.js +++ b/client/gatsby-ssr.js @@ -7,13 +7,16 @@ import i18n from './i18n/config'; import { createStore } from './src/redux/createStore'; import layoutSelector from './utils/gatsby/layout-selector'; import { getheadTagComponents, getPostBodyComponents } from './utils/tags'; +import GrowthBookProvider from './src/components/growth-book/growth-book-wrapper'; const store = createStore(); export const wrapRootElement = ({ element }) => { return ( - {element} + + {element} + ); }; diff --git a/client/package.json b/client/package.json index 0d399fd50ea..b3099fdfd66 100644 --- a/client/package.json +++ b/client/package.json @@ -48,6 +48,7 @@ "@freecodecamp/react-bootstrap": "0.32.3", "@freecodecamp/react-calendar-heatmap": "1.0.0", "@freecodecamp/strip-comments": "3.0.1", + "@growthbook/growthbook-react": "0.9.1", "@loadable/component": "5.15.2", "@reach/router": "1.3.4", "@sentry/gatsby": "6.19.7", diff --git a/client/src/components/Intro/index.tsx b/client/src/components/Intro/index.tsx index f4bad7a7851..22e7bad8425 100644 --- a/client/src/components/Intro/index.tsx +++ b/client/src/components/Intro/index.tsx @@ -6,6 +6,7 @@ import { Link, Spacer, Loader } from '../helpers'; import IntroDescription from './components/IntroDescription'; import './intro.css'; +import ResearchBannerx from './research-banner'; interface IntroProps { complete?: boolean; @@ -55,6 +56,7 @@ const Intro = ({ + {completedChallengeCount && slug && completedChallengeCount < 15 ? (
diff --git a/client/src/components/Intro/research-banner.tsx b/client/src/components/Intro/research-banner.tsx new file mode 100644 index 00000000000..324b146bb4b --- /dev/null +++ b/client/src/components/Intro/research-banner.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Alert, Button } from '@freecodecamp/react-bootstrap'; +import { useFeature } from '@growthbook/growthbook-react'; + +const ResearchBannerx = (): JSX.Element | null => { + const feature = useFeature('show-research-recruitment-alert'); + return feature.on ? ( + +

+ Launching Oct 19: freeCodeCamp is teaming up with researchers + from Stanford and UPenn to study how to help people build strong coding + habits. +

+

+ Would you like to get involved? You’ll get free coaching from our + scientists. +

+
+ +
+
+ ) : null; +}; + +ResearchBannerx.displayName = 'ResearchBannerx'; + +export default ResearchBannerx; diff --git a/client/src/components/growth-book/growth-book-wrapper.tsx b/client/src/components/growth-book/growth-book-wrapper.tsx new file mode 100644 index 00000000000..3da37b977b1 --- /dev/null +++ b/client/src/components/growth-book/growth-book-wrapper.tsx @@ -0,0 +1,68 @@ +import React, { ReactNode, useEffect } from 'react'; +import sha1 from 'sha-1'; +import { + FeatureDefinition, + GrowthBook, + GrowthBookProvider +} from '@growthbook/growthbook-react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { isSignedInSelector, userSelector } from '../../redux/selectors'; +import envData from '../../../../config/env.json'; +import { User } from '../../redux/prop-types'; + +const { clientLocale, growthbookUri } = envData as { + clientLocale: string; + growthbookUri: string | null; +}; + +const growthbook = new GrowthBook(); + +const mapStateToProps = createSelector( + isSignedInSelector, + userSelector, + (isSignedIn, user: User) => ({ + isSignedIn, + user + }) +); + +type StateProps = ReturnType; +interface GrowthBookWrapper extends StateProps { + children: ReactNode; +} + +const GrowthBookWrapper = ({ + children, + isSignedIn, + user +}: GrowthBookWrapper) => { + if (growthbookUri) { + void (async () => { + const res = await fetch(growthbookUri); + const data = (await res.json()) as { + features: Record; + }; + growthbook.setFeatures(data.features); + })(); + } + useEffect(() => { + if (isSignedIn) { + const { joinDate, completedChallenges } = user; + growthbook.setAttributes({ + id: sha1(user.email), + staff: user.email.includes('@freecodecamp'), + clientLocal: clientLocale, + joinDateUnix: Date.parse(joinDate), + completedChallengesLength: completedChallenges.length + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSignedIn]); + + return ( + {children} + ); +}; + +export default connect(mapStateToProps)(GrowthBookWrapper); diff --git a/config/read-env.js b/config/read-env.js index 7dd78f9a90b..50d07fbb1e5 100644 --- a/config/read-env.js +++ b/config/read-env.js @@ -33,7 +33,8 @@ const { DEPLOYMENT_ENV: deploymentEnv, SENTRY_CLIENT_DSN: sentryClientDSN, SHOW_UPCOMING_CHANGES: showUpcomingChanges, - SHOW_NEW_CURRICULUM: showNewCurriculum + SHOW_NEW_CURRICULUM: showNewCurriculum, + GROWTHBOOK_URI: growthbookUri } = process.env; const locations = { @@ -77,5 +78,9 @@ module.exports = Object.assign(locations, { ? null : sentryClientDSN, showUpcomingChanges: showUpcomingChanges === 'true', - showNewCurriculum: showNewCurriculum === 'true' + showNewCurriculum: showNewCurriculum === 'true', + growthbookUri: + !growthbookUri || growthbookUri === 'api_URI_from_Growthbook_dashboard' + ? null + : growthbookUri }); diff --git a/package-lock.json b/package-lock.json index 9d3be1a5717..56af4535a09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -444,6 +444,7 @@ "@freecodecamp/react-bootstrap": "0.32.3", "@freecodecamp/react-calendar-heatmap": "1.0.0", "@freecodecamp/strip-comments": "3.0.1", + "@growthbook/growthbook-react": "0.9.1", "@loadable/component": "5.15.2", "@reach/router": "1.3.4", "@sentry/gatsby": "6.19.7", @@ -3799,6 +3800,28 @@ "version": "2.2.0", "license": "0BSD" }, + "node_modules/@growthbook/growthbook": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@growthbook/growthbook/-/growthbook-0.18.1.tgz", + "integrity": "sha512-hNNh515lleAUzTch+ezYYVHBteYTIAF1Xxh6+dLJk+BjbhPXUo6X9qNcryIN1b/IMLVnO1ZfE5zbAzVGEQai2w==", + "engines": { + "node": ">=10" + } + }, + "node_modules/@growthbook/growthbook-react": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@growthbook/growthbook-react/-/growthbook-react-0.9.1.tgz", + "integrity": "sha512-b4yaMkcIeYQ+j5wND+wsGHbeV33QJoG6vQc4r/7qTDKmq3QhAyMNuLvfy/bYQ/nWnh5TmZArzQ6IRUGTWKfOGg==", + "dependencies": { + "@growthbook/growthbook": "^0.18.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, "node_modules/@hapi/address": { "version": "2.1.4", "license": "BSD-3-Clause" @@ -56971,6 +56994,7 @@ "@freecodecamp/react-bootstrap": "0.32.3", "@freecodecamp/react-calendar-heatmap": "1.0.0", "@freecodecamp/strip-comments": "3.0.1", + "@growthbook/growthbook-react": "*", "@loadable/component": "5.15.2", "@reach/router": "1.3.4", "@sentry/gatsby": "6.19.7", @@ -57577,6 +57601,19 @@ } } }, + "@growthbook/growthbook": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@growthbook/growthbook/-/growthbook-0.18.1.tgz", + "integrity": "sha512-hNNh515lleAUzTch+ezYYVHBteYTIAF1Xxh6+dLJk+BjbhPXUo6X9qNcryIN1b/IMLVnO1ZfE5zbAzVGEQai2w==" + }, + "@growthbook/growthbook-react": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@growthbook/growthbook-react/-/growthbook-react-0.9.1.tgz", + "integrity": "sha512-b4yaMkcIeYQ+j5wND+wsGHbeV33QJoG6vQc4r/7qTDKmq3QhAyMNuLvfy/bYQ/nWnh5TmZArzQ6IRUGTWKfOGg==", + "requires": { + "@growthbook/growthbook": "^0.18.1" + } + }, "@hapi/address": { "version": "2.1.4" }, diff --git a/sample.env b/sample.env index 796deac589c..202fe68220a 100644 --- a/sample.env +++ b/sample.env @@ -78,3 +78,6 @@ CODESEE=false # Webhook proxy url from smee.io for PayPal WEBHOOK_PROXY_URL= + +# Analytics +GROWTHBOOK_URI=api_URI_from_Growthbook_dashboard diff --git a/tools/scripts/build/ensure-env.ts b/tools/scripts/build/ensure-env.ts index 301565a3580..1f296823581 100644 --- a/tools/scripts/build/ensure-env.ts +++ b/tools/scripts/build/ensure-env.ts @@ -52,12 +52,14 @@ if (FREECODECAMP_NODE_ENV !== 'development') { const searchKeys = ['algoliaAppId', 'algoliaAPIKey']; const donationKeys = ['stripePublicKey', 'paypalClientId', 'patreonClientId']; const loggingKeys = ['sentryClientDSN']; + const abTestingKeys = ['growthbookUri']; const expectedVariables = locationKeys.concat( deploymentKeys, searchKeys, donationKeys, - loggingKeys + loggingKeys, + abTestingKeys ); const actualVariables = Object.keys(env as Record); if (expectedVariables.length !== actualVariables.length) {