Skip to content

Commit

Permalink
feat: cookie banner and defer gtm load
Browse files Browse the repository at this point in the history
  • Loading branch information
ChristiaanScheermeijer committed Feb 25, 2025
1 parent 5b83938 commit afa916d
Show file tree
Hide file tree
Showing 25 changed files with 361 additions and 28 deletions.
16 changes: 16 additions & 0 deletions packages/common/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<Env>) => {
Expand All @@ -33,11 +42,18 @@ export const configureEnv = (options: Partial<Env>) => {
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;
2 changes: 2 additions & 0 deletions packages/common/src/stores/UIStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ type UIState = {
searchActive: boolean;
userMenuOpen: boolean;
sideBarOpen: boolean;
cookieWallOpen: boolean;
languageMenuOpen: boolean;
preSearchPage?: string;
};
Expand All @@ -15,4 +16,5 @@ export const useUIStore = createStore<UIState>('UIStore', () => ({
userMenuOpen: false,
sideBarOpen: false,
languageMenuOpen: false,
cookieWallOpen: false,
}));
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import { axe } from 'vitest-axe';
import { render } from '@testing-library/react';

import CookieBanner from './CookieBanner';

describe('<CookieBanner>', () => {
test('WCAG 2.2 (AA) compliant', async () => {
const { container } = render(<CookieBanner onAccept={vi.fn()} onDecline={vi.fn()} />);

expect(await axe(container, { runOnly: ['wcag21a', 'wcag21aa', 'wcag22aa'] })).toHaveNoViolations();
});
});
83 changes: 83 additions & 0 deletions packages/ui-react/src/components/CookieBanner/CookieBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ 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</0> and <1>cookie policy</1>"
const linkComponents = (env.APP_CONSENT_COOKIE_POLICY_URL || '#').split('|').map((link) => <a key={link} href={link} />);

return (
<Modal
open={true}
AnimationComponent={Slide}
className={styles.overlay}
role="alertdialog"
aria-labelledby="cookie-banner-title"
aria-describedby="cookie-banner-description"
>
<div className={styles.banner}>
<h1 className="hidden" id="cookie-banner-title">
{t('title')}
</h1>
<p id="cookie-banner-description">
<Trans
t={t}
components={linkComponents}
i18nKey="consent_text"
defaults="We use cookies to improve your experience. Do you accept the use of cookies?"
/>
</p>
<div className={styles.buttonBar}>
<Button label={t('accept')} onClick={handleAcceptClick} color="primary" data-testid={testId('accept')} />
<Button label={t('decline')} onClick={handleDeclineClick} data-testid={testId('decline')} />
</div>
</div>
</Modal>
);
};

export default createInjectableComponent(CookieBannerIdentifier, CookieBanner);
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
@use '@jwp/ott-ui-react/src/styles/mixins/responsive';
@use '@jwp/ott-ui-react/src/styles/theme';

.captcha {
display: flex;
justify-content: flex-end;
width: 100%;
margin-bottom: 8px;
}
}

: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;
}
}
}
22 changes: 18 additions & 4 deletions packages/ui-react/src/components/RecaptchaField/RecaptchaField.tsx
Original file line number Diff line number Diff line change
@@ -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<ReCaptcha, Props>(({ siteKey }, ref) => {
const RecaptchaField = forwardRef<ReCaptcha, Props>(({ 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 (
<div className={styles.captcha}>
<ReCaptcha ref={ref} sitekey={siteKey} size={'invisible'} badge="inline" theme="dark" />
<ReCaptcha ref={ref} sitekey={siteKey} size={size} badge="inline" theme="dark" />
<div id="grecaptcha-challenge-container" />
</div>
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -57,6 +58,7 @@ const RegistrationForm: React.FC<Props> = ({
}: Props) => {
const { t, i18n } = useTranslation('account');
const location = useLocation();
const { isActive, consent: cookieConsent } = useCookieConsent();

const ref = useRef<HTMLDivElement>(null);
const htmlLang = i18n.language !== env.APP_DEFAULT_LANGUAGE ? env.APP_DEFAULT_LANGUAGE : undefined;
Expand All @@ -79,6 +81,10 @@ const RegistrationForm: React.FC<Props> = ({
}
}, [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 (
<form onSubmit={onSubmit} data-testid={testId('registration-form')} noValidate>
<div ref={ref}>
Expand Down Expand Up @@ -137,7 +143,7 @@ const RegistrationForm: React.FC<Props> = ({
})}
</div>
)}
{!!captchaSiteKey && <RecaptchaField siteKey={captchaSiteKey} ref={recaptchaRef} />}
{loadRecaptcha && <RecaptchaField siteKey={captchaSiteKey} ref={recaptchaRef} size={cookieConsent === 'accepted' ? 'invisible' : 'normal'} />}
<Button
className={styles.continue}
type="submit"
Expand Down
5 changes: 3 additions & 2 deletions packages/ui-react/src/containers/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ const Layout = () => {
const { footerText: configFooterText } = styling || {};
const footerText = configFooterText || unicodeToChar(env.APP_FOOTER_TEXT);

const { sideBarOpen, searchActive } = useUIStore((state) => ({
const { sideBarOpen, searchActive, cookieWallOpen } = useUIStore((state) => ({
sideBarOpen: state.sideBarOpen,
searchActive: state.searchActive,
cookieWallOpen: state.cookieWallOpen,
}));
const banner = assets.banner;

Expand All @@ -56,7 +57,7 @@ const Layout = () => {
})),
];

const containerProps = { inert: sideBarOpen ? '' : undefined }; // inert is not yet officially supported in react
const containerProps = { inert: sideBarOpen || cookieWallOpen ? '' : undefined }; // inert is not yet officially supported in react

return (
<div className={styles.layout}>
Expand Down
51 changes: 51 additions & 0 deletions packages/ui-react/src/hooks/useCookieConsent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useState, useEffect, useCallback } from 'react';
import env from '@jwp/ott-common/src/env';
import { injectScript } from '@jwp/ott-ui-react/src/utils/dom';
import { setCookie, getCookie, removeCookie } from '@jwp/ott-ui-react/src/utils/cookies';

export type ConsentState = 'accepted' | 'declined';

export function useCookieConsent() {
const cookieName = env.APP_CONSENT_COOKIE_NAME || 'cookies.consent';
const [isGTMLoaded, setIsGTMLoaded] = useState(false);
const [consent, setConsent] = useState<ConsentState | null>(() => {
const value = getCookie(cookieName);
if (value === null) return null;
return value === 'true' ? 'accepted' : 'declined';
});

const accept = useCallback(() => {
setCookie(cookieName, 'true', 365);
setConsent('accepted');
}, [cookieName]);

const decline = useCallback(() => {
setCookie(cookieName, 'false', 365);
setConsent('declined');
}, [cookieName]);

const reset = useCallback(() => {
removeCookie(cookieName);
setConsent(null);
}, [cookieName]);

useEffect(() => {
// Wait for consent before loading GTM
if (!env.APP_GTM_LOAD_ON_ACCEPT) return;
if (consent === 'accepted' && !isGTMLoaded && env.APP_GTM_SCRIPT) {
injectScript(env.APP_GTM_SCRIPT);
setIsGTMLoaded(true);
}
}, [consent, isGTMLoaded]);

return {
isActive: !!env.APP_GTM_TAG_ID,
isGTMLoaded,
consent,
reset,
accept,
decline,
};
}

export default useCookieConsent;
9 changes: 8 additions & 1 deletion packages/ui-react/src/hooks/useRecaptcha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ import type { ReCAPTCHA } from 'react-google-recaptcha';
const useRecaptcha = () => {
const recaptchaRef = useRef<ReCAPTCHA>(null);
const captchaSiteKey = useConfigStore(({ config }) => (config.custom?.captchaSiteKey ? (config.custom?.captchaSiteKey as string) : undefined));
const getCaptchaValue = useEventCallback(async () => (captchaSiteKey ? (await recaptchaRef.current?.executeAsync()) || undefined : undefined));
const getCaptchaValue = useEventCallback(async () => {
if (!captchaSiteKey || !recaptchaRef.current) return;
if (recaptchaRef.current.props.size === 'invisible') {
return (await recaptchaRef.current?.executeAsync()) || undefined;
}

return recaptchaRef.current.getValue() || undefined;
});

return { recaptchaRef, captchaSiteKey, getCaptchaValue };
};
Expand Down
Loading

0 comments on commit afa916d

Please sign in to comment.