Skip to content

Commit

Permalink
feat(CookieConsent): add CookieConsent component (#123)
Browse files Browse the repository at this point in the history
  • Loading branch information
Lakate authored Dec 6, 2023
1 parent f4dbe00 commit 1572965
Show file tree
Hide file tree
Showing 28 changed files with 1,577 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 @@ -42,7 +42,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
176 changes: 176 additions & 0 deletions src/components/CookieConsent/ConsentManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import pick from 'lodash/pick';
import Cookies from 'universal-cookie';
import type {CookieSetOptions} from 'universal-cookie/cjs/types';

import type {IConsentManager, Subscriber} from './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',
Manage = 'manage',
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();

export class ConsentManager implements IConsentManager {
private consentMode: `${ConsentMode}`;
private consentEdition: number | undefined;
private projectConsentEdition: number | undefined;

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

constructor(
mode: `${ConsentMode}`,
edition?: number,
cookieSettings = CONSENT_COOKIE_SETTINGS,
) {
this.consentMode = mode;
this.projectConsentEdition = edition;
this.cookieSettings = cookieSettings;

this.setInitValues();
}

get mode() {
return this.consentMode;
}

get cookies() {
return this.cookiesTypes;
}

get cookiesSettings() {
return this.cookieSettings;
}

getConsents() {
if (Object.keys(this.consents).length) {
return this.consents;
}

return this.prepareConsent('OnlyNecessary');
}

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(values: Consents | 'All' | 'OnlyNecessary') {
const consents: Consents =
typeof values === 'string' ? this.prepareConsent(values) : values;

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));
}

isConsentNotDefined() {
if (this.mode === ConsentMode.Notification && !this.closed) {
return true;
}

return !this.isAllConsentsDefined() || this.projectConsentEdition !== this.consentEdition;
}

private prepareConsent(value: 'All' | 'OnlyNecessary') {
return this.cookiesTypes.reduce((acc: Consents, type: `${ConsentType}`) => {
acc[type] = value === 'All' ? true : type === ConsentType.Necessary;

return acc;
}, {});
}

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

private 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;
}
}

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

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

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

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

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

import {Consents} from './ConsentManager';
import {ConsentNotification} from './components/ConsentNotification/ConsentNotification';
import {ConsentPopup} from './components/ConsentPopup/ConsentPopup';
import {ConsentPopupStep} from './components/ConsentPopup/types';
import {SimpleConsent} from './components/SimpleConsent/SimpleConsent';
import {CookieConsentProps} from './types';

const b = block('analytics');

export const CookieConsent = ({
consentManager,
onConsentPopupClose,
manageCookies,
...popupProps
}: CookieConsentProps) => {
const [isOpened, setIsOpened] = React.useState(false);

React.useEffect(() => {
// Show banner after some timeout so that the user has time to see the service content
const timeoutId = setTimeout(() => {
if (consentManager.isConsentNotDefined()) {
setIsOpened(true);
}
}, 1000);

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

const onConsentPopupAction = (values: Consents | 'All' | 'OnlyNecessary') => {
consentManager.setConsents(values);
setIsOpened(false);
onConsentPopupClose?.();
};

const onClose = () => {
setIsOpened(false);
onConsentPopupClose?.();
};
const view = manageCookies ? 'manage' : consentManager.mode;

if (isOpened || manageCookies) {
switch (view) {
case 'manage':
return (
<ConsentPopup
{...popupProps}
className={b()}
step={manageCookies ? ConsentPopupStep.Manage : ConsentPopupStep.Main}
onAction={onConsentPopupAction}
onClose={onClose}
consentManager={consentManager}
/>
);
case 'notification':
return (
<ConsentNotification
{...popupProps}
className={b()}
onAction={onConsentPopupAction}
consentManager={consentManager}
/>
);
case 'base':
return (
<SimpleConsent
{...popupProps}
className={b()}
onAction={onConsentPopupAction}
consentManager={consentManager}
/>
);
}
}

return null;
};
Loading

0 comments on commit 1572965

Please sign in to comment.