From afa916d5196e3dae91b2f9e33ed6d3a62b21212b Mon Sep 17 00:00:00 2001 From: Christiaan Scheermeijer Date: Mon, 17 Feb 2025 17:06:14 +0100 Subject: [PATCH] feat: cookie banner and defer gtm load --- packages/common/src/env.ts | 16 ++++ packages/common/src/stores/UIStore.ts | 2 + .../CookieBanner/CookieBanner.module.scss | 41 +++++++++ .../CookieBanner/CookieBanner.test.tsx | 13 +++ .../components/CookieBanner/CookieBanner.tsx | 83 +++++++++++++++++++ .../RecaptchaField/RecaptchaField.module.scss | 18 +++- .../RecaptchaField/RecaptchaField.tsx | 22 ++++- .../RegistrationForm/RegistrationForm.tsx | 8 +- .../ui-react/src/containers/Layout/Layout.tsx | 5 +- .../ui-react/src/hooks/useCookieConsent.tsx | 51 ++++++++++++ packages/ui-react/src/hooks/useRecaptcha.ts | 9 +- packages/ui-react/src/styles/_theme.scss | 10 ++- packages/ui-react/src/utils/cookies.ts | 14 ++++ packages/ui-react/src/utils/dom.ts | 10 +++ packages/ui-react/src/utils/theming.ts | 4 + packages/ui-react/vitest.setup.ts | 4 + .../web/public/locales/en/cookiebanner.json | 6 ++ .../web/public/locales/es/cookiebanner.json | 6 ++ .../web/scripts/build-tools/buildTools.ts | 25 ++++-- platforms/web/scripts/gtm.js | 10 +++ platforms/web/src/containers/Root/Root.tsx | 3 + platforms/web/src/i18n/resources.ts | 2 +- platforms/web/src/index.tsx | 8 ++ platforms/web/src/styles/main.scss | 14 ++-- platforms/web/vite.config.mts | 5 +- 25 files changed, 361 insertions(+), 28 deletions(-) create mode 100644 packages/ui-react/src/components/CookieBanner/CookieBanner.module.scss create mode 100644 packages/ui-react/src/components/CookieBanner/CookieBanner.test.tsx create mode 100644 packages/ui-react/src/components/CookieBanner/CookieBanner.tsx create mode 100644 packages/ui-react/src/hooks/useCookieConsent.tsx create mode 100644 packages/ui-react/src/utils/cookies.ts create mode 100644 platforms/web/public/locales/en/cookiebanner.json create mode 100644 platforms/web/public/locales/es/cookiebanner.json create mode 100644 platforms/web/scripts/gtm.js diff --git a/packages/common/src/env.ts b/packages/common/src/env.ts index b58d89831..2a456d129 100644 --- a/packages/common/src/env.ts +++ b/packages/common/src/env.ts @@ -7,11 +7,19 @@ export type Env = { APP_DEFAULT_LANGUAGE: string; APP_PUBLIC_URL: string; + APP_CONSENT_COOKIE_NAME?: string; + APP_CONSENT_COOKIE_POLICY_URL?: string; + APP_DEFAULT_CONFIG_SOURCE?: string; APP_PLAYER_LICENSE_KEY?: string; APP_BODY_FONT?: string; APP_BODY_ALT_FONT?: string; + + APP_GTM_TAG_ID?: string; + APP_GTM_TAG_SERVER?: string; + APP_GTM_SCRIPT?: string; + APP_GTM_LOAD_ON_ACCEPT: boolean; }; const env: Env = { @@ -22,6 +30,7 @@ const env: Env = { APP_FOOTER_TEXT: '', APP_DEFAULT_LANGUAGE: 'en', APP_PUBLIC_URL: '', + APP_GTM_LOAD_ON_ACCEPT: false, }; export const configureEnv = (options: Partial) => { @@ -33,11 +42,18 @@ export const configureEnv = (options: Partial) => { env.APP_DEFAULT_LANGUAGE = options.APP_DEFAULT_LANGUAGE || env.APP_DEFAULT_LANGUAGE; env.APP_PUBLIC_URL = options.APP_PUBLIC_URL || env.APP_PUBLIC_URL; + env.APP_CONSENT_COOKIE_NAME ||= options.APP_CONSENT_COOKIE_NAME; + env.APP_CONSENT_COOKIE_POLICY_URL ||= options.APP_CONSENT_COOKIE_POLICY_URL; env.APP_DEFAULT_CONFIG_SOURCE ||= options.APP_DEFAULT_CONFIG_SOURCE; env.APP_PLAYER_LICENSE_KEY ||= options.APP_PLAYER_LICENSE_KEY; env.APP_BODY_FONT = options.APP_BODY_FONT || env.APP_BODY_FONT; env.APP_BODY_ALT_FONT = options.APP_BODY_ALT_FONT || env.APP_BODY_ALT_FONT; + + env.APP_GTM_TAG_ID ||= options.APP_GTM_TAG_ID; + env.APP_GTM_TAG_SERVER ||= options.APP_GTM_TAG_SERVER; + env.APP_GTM_SCRIPT ||= options.APP_GTM_SCRIPT; + env.APP_GTM_LOAD_ON_ACCEPT = options.APP_GTM_LOAD_ON_ACCEPT || env.APP_GTM_LOAD_ON_ACCEPT; }; export default env; diff --git a/packages/common/src/stores/UIStore.ts b/packages/common/src/stores/UIStore.ts index a328ae7ed..bf67b422a 100644 --- a/packages/common/src/stores/UIStore.ts +++ b/packages/common/src/stores/UIStore.ts @@ -5,6 +5,7 @@ type UIState = { searchActive: boolean; userMenuOpen: boolean; sideBarOpen: boolean; + cookieWallOpen: boolean; languageMenuOpen: boolean; preSearchPage?: string; }; @@ -15,4 +16,5 @@ export const useUIStore = createStore('UIStore', () => ({ userMenuOpen: false, sideBarOpen: false, languageMenuOpen: false, + cookieWallOpen: false, })); diff --git a/packages/ui-react/src/components/CookieBanner/CookieBanner.module.scss b/packages/ui-react/src/components/CookieBanner/CookieBanner.module.scss new file mode 100644 index 000000000..490c9a7c2 --- /dev/null +++ b/packages/ui-react/src/components/CookieBanner/CookieBanner.module.scss @@ -0,0 +1,41 @@ +@use '@jwp/ott-ui-react/src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; + +.overlay { + display: flex; + align-items: flex-end; + + > div { + width: 100%; + color: var(--cookie-banner-color); + background-color: var(--cookie-banner-background-color); + } +} + +.banner { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + line-height: 1.5; + + a { + color: inherit; + } + + @include responsive.desktop-only() { + max-width: 960px; + margin: 0 auto; + } +} + +.buttonBar { + display: flex; + align-content: center; + align-items: center; + gap: 8px; + + @include responsive.mobile-only() { + flex-direction: column; + } +} diff --git a/packages/ui-react/src/components/CookieBanner/CookieBanner.test.tsx b/packages/ui-react/src/components/CookieBanner/CookieBanner.test.tsx new file mode 100644 index 000000000..879cb2887 --- /dev/null +++ b/packages/ui-react/src/components/CookieBanner/CookieBanner.test.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { axe } from 'vitest-axe'; +import { render } from '@testing-library/react'; + +import CookieBanner from './CookieBanner'; + +describe('', () => { + test('WCAG 2.2 (AA) compliant', async () => { + const { container } = render(); + + expect(await axe(container, { runOnly: ['wcag21a', 'wcag21aa', 'wcag22aa'] })).toHaveNoViolations(); + }); +}); diff --git a/packages/ui-react/src/components/CookieBanner/CookieBanner.tsx b/packages/ui-react/src/components/CookieBanner/CookieBanner.tsx new file mode 100644 index 000000000..8f42cacb6 --- /dev/null +++ b/packages/ui-react/src/components/CookieBanner/CookieBanner.tsx @@ -0,0 +1,83 @@ +import React, { useEffect } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useCookieConsent } from '@jwp/ott-ui-react/src/hooks/useCookieConsent'; +import env from '@jwp/ott-common/src/env'; +import { testId } from '@jwp/ott-common/src/utils/common'; + +import Button from '../Button/Button'; +import Modal from '../Modal/Modal'; +import Slide from '../Animation/Slide/Slide'; +import createInjectableComponent from '../../modules/createInjectableComponent'; + +import styles from './CookieBanner.module.scss'; + +export const CookieBannerIdentifier = Symbol(`COOKIE_WALL`); + +export type Props = { + onAccept?: () => void; + onDecline?: () => void; + onOpen?: () => void; + onClose?: () => void; +}; + +const CookieBanner: React.FC = ({ onAccept, onDecline, onOpen, onClose }) => { + const { t } = useTranslation('cookiebanner'); + const { isActive, consent, accept, decline } = useCookieConsent(); + + useEffect(() => { + if (!isActive) return; + if (!consent) { + onOpen?.(); + } else { + onClose?.(); + } + }, [consent, isActive, onOpen, onClose]); + + const handleAcceptClick = () => { + accept(); + onAccept?.(); + }; + + const handleDeclineClick = () => { + decline(); + onDecline?.(); + }; + + if (!isActive || consent !== null) { + return null; + } + + // Multiple links are supported, separated by a pipe character. Usage for `consent_text`: "Read our <0>privacy policy and <1>cookie policy" + const linkComponents = (env.APP_CONSENT_COOKIE_POLICY_URL || '#').split('|').map((link) => ); + + return ( + +
+

+ {t('title')} +

+ +
+
+
+
+ ); +}; + +export default createInjectableComponent(CookieBannerIdentifier, CookieBanner); diff --git a/packages/ui-react/src/components/RecaptchaField/RecaptchaField.module.scss b/packages/ui-react/src/components/RecaptchaField/RecaptchaField.module.scss index 4b7001782..4ea2d0bfe 100644 --- a/packages/ui-react/src/components/RecaptchaField/RecaptchaField.module.scss +++ b/packages/ui-react/src/components/RecaptchaField/RecaptchaField.module.scss @@ -1,3 +1,4 @@ +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; @use '@jwp/ott-ui-react/src/styles/theme'; .captcha { @@ -5,4 +6,19 @@ justify-content: flex-end; width: 100%; margin-bottom: 8px; -} \ No newline at end of file +} + +:global(#grecaptcha-challenge-container > div) { + @include responsive.tablet-and-larger() { + top: 5% !important; + left: 5% !important; + + > div:first-child { + opacity: 0.5 !important; + } + + :global(.g-recaptcha-bubble-arrow) { + display: none; + } + } +} diff --git a/packages/ui-react/src/components/RecaptchaField/RecaptchaField.tsx b/packages/ui-react/src/components/RecaptchaField/RecaptchaField.tsx index 88d076dd7..1b75cdf4a 100644 --- a/packages/ui-react/src/components/RecaptchaField/RecaptchaField.tsx +++ b/packages/ui-react/src/components/RecaptchaField/RecaptchaField.tsx @@ -1,16 +1,30 @@ -import ReCaptcha from 'react-google-recaptcha'; -import { forwardRef } from 'react'; +import ReCaptcha, { type ReCAPTCHAProps } from 'react-google-recaptcha'; +import { forwardRef, useEffect } from 'react'; import styles from './RecaptchaField.module.scss'; type Props = { siteKey: string; + size?: ReCAPTCHAProps['size']; }; -const RecaptchaField = forwardRef(({ siteKey }, ref) => { +const RecaptchaField = forwardRef(({ siteKey, size }, ref) => { + useEffect(() => { + const domObserver = new MutationObserver(() => { + const iframe = document.querySelector('iframe[src^="https://www.google.com/recaptcha"][src*="bframe"]'); + + if (iframe?.parentNode?.parentNode) { + domObserver.disconnect(); + document.querySelector('#grecaptcha-challenge-container')?.appendChild(iframe?.parentNode?.parentNode); + } + }); + + domObserver.observe(document.documentElement || document.body, { childList: true, subtree: true }); + }, []); return (
- + +
); }); diff --git a/packages/ui-react/src/components/RegistrationForm/RegistrationForm.tsx b/packages/ui-react/src/components/RegistrationForm/RegistrationForm.tsx index e9a9e2290..fc06c08ba 100644 --- a/packages/ui-react/src/components/RegistrationForm/RegistrationForm.tsx +++ b/packages/ui-react/src/components/RegistrationForm/RegistrationForm.tsx @@ -7,6 +7,7 @@ import type { CustomFormField, RegistrationFormData } from '@jwp/ott-common/type import { testId } from '@jwp/ott-common/src/utils/common'; import type { SocialLoginURLs } from '@jwp/ott-hooks-react/src/useSocialLoginUrls'; import env from '@jwp/ott-common/src/env'; +import { useCookieConsent } from '@jwp/ott-ui-react/src/hooks/useCookieConsent'; import type { ReCAPTCHA } from 'react-google-recaptcha'; import TextField from '../form-fields/TextField/TextField'; @@ -57,6 +58,7 @@ const RegistrationForm: React.FC = ({ }: Props) => { const { t, i18n } = useTranslation('account'); const location = useLocation(); + const { isActive, consent: cookieConsent } = useCookieConsent(); const ref = useRef(null); const htmlLang = i18n.language !== env.APP_DEFAULT_LANGUAGE ? env.APP_DEFAULT_LANGUAGE : undefined; @@ -79,6 +81,10 @@ const RegistrationForm: React.FC = ({ } }, [errors.form]); + const requiredConsentsChecked = publisherConsents?.filter(({ type, required }) => type === 'checkbox' && required).every(({ name }) => !!consentValues[name]); + const hasConsent = isActive ? cookieConsent === 'accepted' || requiredConsentsChecked : requiredConsentsChecked; + const loadRecaptcha = !!captchaSiteKey && hasConsent; + return (
@@ -137,7 +143,7 @@ const RegistrationForm: React.FC = ({ })}
)} - {!!captchaSiteKey && } + {loadRecaptcha && }