Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(CookieConsent): add CookieConsent component #123

Merged
merged 1 commit into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not use enums, or at least let's not force users to use them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain please
I see that we already have enums in components and even export them

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

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() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think that this should return the same as getCurrentConsents.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onAction is always called with onClose

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add onConsentPopupClose in onConsentPopupAction

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
Loading