Skip to content

Commit

Permalink
feat(CookieConsent): add CookieConsent component
Browse files Browse the repository at this point in the history
  • Loading branch information
Lakate committed Nov 27, 2023
1 parent bc3514b commit 55bd842
Show file tree
Hide file tree
Showing 34 changed files with 1,773 additions and 8 deletions.
18 changes: 16 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"@gravity-ui/i18n": "^1.1.0",
"@gravity-ui/icons": "^2.4.0",
"lodash": "^4.17.21",
"resize-observer-polyfill": "^1.5.1"
"resize-observer-polyfill": "^1.5.1",
"universal-cookie": "^6.1.1"
},
"devDependencies": {
"@babel/preset-env": "^7.22.6",
Expand Down
164 changes: 164 additions & 0 deletions src/components/CookieConsent/ConsentManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import _ from 'lodash';
import Cookies from 'universal-cookie';
import type {CookieSetOptions} from 'universal-cookie/cjs/types';

export const COOKIE_NAME = 'analyticsConsents';
export const CONSENT_COOKIE_SETTINGS: CookieSettings = {
path: '/',
maxAge: 60 * 60 * 24 * 365,
secure: true,
sameSite: true,
};

export enum ConsentType {
Necessary = 'necessary',
Analytics = 'analytics',
Marketing = 'marketing',
}

export enum ConsentMode {
Notification = 'notification',
OptIn = 'opt-in',
Base = 'base',
}

export enum AdditionalConsentParams {
Closed = 'closed',
Edition = 'edition',
}

export type Consents = {
[k in ConsentType | AdditionalConsentParams]?: boolean | number;
};

export type CookieSettings = CookieSetOptions;

const cookies = new Cookies();

type Subscriber = (changedConsents: Consents, allConsents: Consents) => void;

export class ConsentManager {
readonly mode: ConsentMode;
private consentEdition: number | undefined;
private projectConsentEdition: number | undefined;

private closed = false;
private consents: Consents = {};
private cookiesTypes: Array<ConsentType> = Object.values(ConsentType);
private readonly subscribers: Subscriber[] = [];
private readonly cookieSettings: CookieSettings = CONSENT_COOKIE_SETTINGS;

constructor(mode: ConsentMode, edition?: number) {
this.mode = mode;
this.projectConsentEdition = edition;

this.setInitValues();
}

get cookies() {
return this.cookiesTypes;
}

get cookiesSettings() {
return this.cookieSettings;
}

get edition() {
return this.consentEdition;
}

get projectCEdition() {
return this.projectConsentEdition;
}

isClosed(): boolean {
return this.closed;
}

setInitValues() {
const value = cookies.get(COOKIE_NAME);

if (!(typeof value === 'object' && !Array.isArray(value) && value)) {
return;
}

this.consents = {
..._.pick(value, Object.values(ConsentType)),
};

if (value[AdditionalConsentParams.Closed]) {
this.closed = true;
}

if (value[AdditionalConsentParams.Edition]) {
this.consentEdition = value.edition;
}
}

subscribe(handler: Subscriber) {
this.subscribers.push(handler);

return () => {
const index = this.subscribers.findIndex((value) => value === handler);
if (index >= 0) {
this.subscribers.splice(index, 1);
}
};
}

setConsents(consents: Consents) {
const difference = Object.values(this.cookiesTypes).filter(
(type) => !consents[type] || consents[type] !== this.consents[type],
);
const differenceInVersion = this.consentEdition !== this.projectConsentEdition;
const shouldClose = this.mode === ConsentMode.Notification && !this.closed;

if (!difference.length && !differenceInVersion && !shouldClose) {
return;
}

Object.assign(this.consents, consents);

this.saveNewCookieValue();
this.handleConsentChange(_.pick(consents, difference));
}

saveNewCookieValue() {
let newValue: Consents = {
...this.consents,
[AdditionalConsentParams.Edition]: this.projectConsentEdition,
};

if (this.mode === ConsentMode.Notification) {
newValue = {
...newValue,
[AdditionalConsentParams.Closed]: true,
};
}

cookies.set(COOKIE_NAME, newValue, this.cookieSettings);
}

getConsents() {
return {...this.consents};
}

isAllConsentsDefined() {
return Object.values(this.cookiesTypes).every(
(type) => typeof this.consents[type] === 'boolean',
);
}

isAllConsentsAccepted() {
return Object.values(this.cookiesTypes).every((type) => this.consents[type]);
}

setCookieSettings(settings: Partial<CookieSettings>) {
Object.assign(this.cookieSettings, settings);
}

private handleConsentChange(changedConsents: Consents) {
const allConsents = this.getConsents();
this.subscribers.forEach((handler) => handler(changedConsents, allConsents));
}
}
100 changes: 100 additions & 0 deletions src/components/CookieConsent/CookieConsent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React, {useEffect} from 'react';

import {block} from '../utils/cn';

import {ConsentMode} from './ConsentManager';
import {ConsentNotification} from './components/ConsentNotification/ConsentNotification';
import {ConsentPopup} from './components/ConsentPopup/ConsentPopup';
import type {ConsentPopupProps} from './components/ConsentPopup/types';
import {SimpleConsent} from './components/SimpleConsent/SimpleConsent';
import {CookieConsentBaseProps, CookieConsentProps} from './types';

const b = block('analytics');

export const CookieConsent: React.FC<CookieConsentProps> = ({
consentManager,
onConsentPopupClose,
disableInitialOpen,
...popupProps
}) => {
const [isOpened, setIsOpened] = React.useState(false);
const isNotificationMode = consentManager.mode === ConsentMode.Notification;

useEffect(() => {
// Show banner after some timeout so that the user has time to see the service content
const timeoutId = setTimeout(() => {
const isConsentsDefined = consentManager.isAllConsentsDefined();
const shouldOpen = isNotificationMode
? !consentManager.isClosed() || !isConsentsDefined
: !isConsentsDefined;
const differentEdition = consentManager.projectCEdition !== consentManager.edition;

if (!disableInitialOpen && (shouldOpen || differentEdition)) {
setIsOpened(true);
}
}, 1000);

return () => clearTimeout(timeoutId);
}, [disableInitialOpen]);

useEffect(() => {
if (!isNotificationMode) {
consentManager.subscribe(() => {
setIsOpened(!consentManager.isAllConsentsDefined());
});
}
}, [consentManager, isNotificationMode]);

const onConsentPopupAction = React.useCallback<CookieConsentBaseProps['onAction']>(
(consents) => consentManager.setConsents(consents),
[consentManager],
);

const onClose = React.useCallback<CookieConsentBaseProps['onClose']>(() => {
setIsOpened(false);
onConsentPopupClose?.();
}, [setIsOpened, onConsentPopupClose]);

const forceOpen =
!isOpened &&
consentManager.mode === ConsentMode.OptIn &&
(popupProps as ConsentPopupProps).forceOpenManageStep;

if (isOpened || forceOpen) {
switch (consentManager.mode) {
case ConsentMode.OptIn:
return (
<ConsentPopup
{...popupProps}
className={b()}
onAction={onConsentPopupAction}
onClose={onClose}
consentManager={consentManager}
forceOpenManageStep={forceOpen}
/>
);
case ConsentMode.Notification:
return (
<ConsentNotification
{...popupProps}
className={b()}
onAction={onConsentPopupAction}
onClose={onClose}
consentManager={consentManager}
/>
);
case ConsentMode.Base:
return (
<SimpleConsent
{...popupProps}
className={b()}
onAction={onConsentPopupAction}
onClose={onClose}
consentManager={consentManager}
/>
);
}
}

return null;
};
Loading

0 comments on commit 55bd842

Please sign in to comment.