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 Dec 1, 2023
1 parent f4dbe00 commit 761b66f
Show file tree
Hide file tree
Showing 29 changed files with 1,613 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
169 changes: 169 additions & 0 deletions src/components/CookieConsent/ConsentManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import pick from 'lodash/pick';
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 {
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 projectEdition() {
return this.projectConsentEdition;
}

setMode(newMode: `${ConsentMode}`) {
this.mode = newMode;
}

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,
};
this.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));
}
}
106 changes: 106 additions & 0 deletions src/components/CookieConsent/CookieConsent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React from 'react';

import {useMobile} from '@gravity-ui/uikit';

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

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

const b = block('analytics');

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

const isNotificationMode = consentManager.mode === ConsentMode.Notification;

React.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.projectEdition !== consentManager.edition;

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

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

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

const onConsentPopupAction = React.useCallback<CookieConsentBaseProps['onAction']>(
(consents) => {
if (onAction) {
onAction(consents);
setIsOpened(false);
} else {
consentManager.setConsents(consents);
}
},
[consentManager, onAction, setIsOpened],
);

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

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

return null;
};
Loading

0 comments on commit 761b66f

Please sign in to comment.