Return

All tests / src/components/CookieConsent index.tsx

97.43% Statements 38/39
100% Branches 3/3
100% Functions 9/9
100% Lines 29/29

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 1241x 1x 1x 1x 1x 1x             4x                   3x                                 1x 17x 17x   17x     8x 8x               17x 3x 3x       3x 3x     17x 17x       17x 17x 6x 1x   6x 6x     17x                                                                                     8x  
import React from 'react';
import { useIntl } from 'react-intl';
import Link from 'next/link';
import { BsCookie } from 'react-icons/bs';
import { clx } from '@/helpers';
import styles from './cookieConsent.module.css';
 
/**
 * Where the visitor's answer is kept. `_app` reads the same key from its inline
 * bootstrap so a returning visitor's grant is replayed into Consent Mode before
 * gtag.js ever runs — otherwise every visit would start denied.
 */
export const CONSENT_STORAGE_KEY = 'cookie-consent';
 
export type ConsentChoice = 'granted' | 'denied';
 
/**
 * @description The four Consent Mode v2 signals this site can move. Storage that
 * is not tied to identifying the visitor (`functionality_storage`,
 * `security_storage`) is granted by default in `_app` and never asked about,
 * because it is exempt from prior consent.
 */
export const consentSignals = (choice: ConsentChoice) => ({
    ad_storage: choice,
    ad_user_data: choice,
    ad_personalization: choice,
    analytics_storage: choice,
});
 
/**
 * @description Prior-consent gate for analytics storage. Until the visitor
 * answers, `_app` has already told gtag every identifying signal is denied, so
 * nothing is written; this component only ever moves that state.
 *
 * @example
 *     <CookieConsent />;
 *
 * @returns {JSX.Element | null}
 */
const CookieConsent = () => {
    const { formatMessage } = useIntl();
    const [needsAnswer, setNeedsAnswer] = React.useState(false);
 
    React.useEffect(() => {
        // Reading storage during render would make the server and client markup
        // disagree, so the banner is decided after hydration.
        try {
            setNeedsAnswer(window.localStorage.getItem(CONSENT_STORAGE_KEY) === null);
        } catch {
            // Storage blocked (private mode, cookie-blocking extension). With
            // nowhere to record an answer, asking on every page load is worse than
            // staying on the denied defaults.
        }
    }, []);
 
    const answer = React.useCallback((choice: ConsentChoice) => {
        try {
            window.localStorage.setItem(CONSENT_STORAGE_KEY, choice);
        } catch {
            // Same as above: the choice still applies to this page view.
        }
        window.gtag?.('consent', 'update', consentSignals(choice));
        setNeedsAnswer(false);
    }, []);
 
    const handleAccept = React.useCallback(() => answer('granted'), [answer]);
    const handleReject = React.useCallback(() => answer('denied'), [answer]);
 
    // Escape answers "no". The default state is already denied, so dismissing without choosing must
    // not be read as acceptance — and a notification the keyboard cannot dismiss is a trap.
    React.useEffect(() => {
        if (!needsAnswer) return undefined;
        const onKeyDown = (event: KeyboardEvent) => {
            if (event.key === 'Escape') handleReject();
        };
        window.addEventListener('keydown', onKeyDown);
        return () => window.removeEventListener('keydown', onKeyDown);
    }, [needsAnswer, handleReject]);
 
    if (!needsAnswer) return null;
 
    return (
        /**
         * `role="dialog"` without `aria-modal`, and no focus trap: this is a macOS-style notification
         * sitting beside the page, not a modal over it, so trapping focus would misdescribe it and
         * strand a keyboard user. The previous markup paired `role="dialog"` with `aria-live`, which is
         * invalid — a dialog is a window, not a live region — and made some screen readers announce the
         * whole subtree as an atomic update while also reporting a dialog.
         */
        <section
            className={styles.notification}
            role="dialog"
            aria-labelledby="cookie-consent-title"
            aria-describedby="cookie-consent-message"
        >
            <div className={styles.header}>
                <BsCookie className={styles.icon} aria-hidden="true" />
                <div className={styles.title} id="cookie-consent-title">
                    {formatMessage({ id: 'consent.title' })}
                </div>
            </div>
            <div className={styles.message} id="cookie-consent-message">
                {formatMessage({ id: 'consent.message' })}{' '}
                <Link href="/legal/cookies-policy">{formatMessage({ id: 'legal.cookies-policy' })}</Link>
            </div>
            <div className={styles.actions}>
                <button type="button" className={styles.button} onClick={handleReject} data-testid="consent-reject">
                    {formatMessage({ id: 'consent.reject' })}
                </button>
                <button
                    type="button"
                    className={clx(styles.button, styles.accept)}
                    onClick={handleAccept}
                    data-testid="consent-accept"
                >
                    {formatMessage({ id: 'consent.accept' })}
                </button>
            </div>
        </section>
    );
};
 
export default CookieConsent;