diff --git a/package-lock.json b/package-lock.json index 952af052..5219a50e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,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", @@ -5873,6 +5874,11 @@ "@types/node": "*" } }, + "node_modules/@types/cookie": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.4.tgz", + "integrity": "sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA==" + }, "node_modules/@types/cross-spawn": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.2.tgz", @@ -9220,7 +9226,6 @@ "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -24148,6 +24153,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universal-cookie": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-6.1.1.tgz", + "integrity": "sha512-33S9x3CpdUnnjwTNs2Fgc41WGve2tdLtvaK2kPSbZRc5pGpz2vQFbRWMxlATsxNNe/Cy8SzmnmbuBM85jpZPtA==", + "dependencies": { + "@types/cookie": "^0.5.1", + "cookie": "^0.5.0" + } + }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", diff --git a/package.json b/package.json index ee952d92..d4a8d62b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/CookieConsent/ConsentManager.ts b/src/components/CookieConsent/ConsentManager.ts new file mode 100644 index 00000000..d3f031c1 --- /dev/null +++ b/src/components/CookieConsent/ConsentManager.ts @@ -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 = 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)); + } +} diff --git a/src/components/CookieConsent/CookieConsent.tsx b/src/components/CookieConsent/CookieConsent.tsx new file mode 100644 index 00000000..456e9bdb --- /dev/null +++ b/src/components/CookieConsent/CookieConsent.tsx @@ -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 ( + + ); + case 'notification': + return ( + + ); + case 'base': + return ( + + ); + } + } + + return null; +}; diff --git a/src/components/CookieConsent/README.md b/src/components/CookieConsent/README.md new file mode 100644 index 00000000..d1effb08 --- /dev/null +++ b/src/components/CookieConsent/README.md @@ -0,0 +1,132 @@ +# CookieConsent + +## Usage ConsentMode.Base + +```tsx +import React from 'react'; +import {CookieConsent} from '@gravity-ui/components'; + +const consentManager = new ConsentManager('base'); + +const Analytics = () => { + const onUpdateConsent = (consents: Consents) => { + // do something: e.g. sent events to analytics + }; + + React.useEffect(() => { + consentManager.subscribe(onUpdateConsent); + }, []); + + return ; +}; +``` + +## Usage ConsentMode.Manage + +```tsx +import React from 'react'; +import {CookieConsent} from '@gravity-ui/components'; + +const consentManager = new ConsentManager('manage'); + +const Analytics = () => { + const onUpdateConsent = (consents: Consents) => { + // do something: e.g. sent events to analytics + }; + + const cookieList = useMemo(() => { + return ['necessary', 'analytics'].map((type) => { + return { + type, + titleLabel: type === 'necessary' ? i18n('necessary_cookie_title_label') : undefined, + link: {href: 'https://google.com'}, + }; + }); + }, [i18n]); + + React.useEffect(() => { + consentManager.subscribe(onUpdateConsent); + }, []); + + return ( + + ); +}; +``` + +## Props + +```ts +type CookieConsentComponentProps = + | ConsentNotificationData + | ConsentPopupData + | SimpleConsentData; + +type CookieConsentProps = CookieConsentComponentProps & { + consentManager: IConsentManager; + onConsentPopupClose?: () => void; + /* To open popup for managing cookies settings */ + manageCookies?: boolean; +}; + +type ConsentNotificationData { + /* Content */ + text?: string; + /* Link to the privacy policy */ + policyLink?: string; + /* Text for the link to the privacy policy */ + policyLinkText?: string; + /* Text on the consent acceptance button */ + buttonOkText?: string; +} + +type ConsentPopupData { + /* Content */ + text?: string; + /* Link to the privacy policy */ + policyLink?: string; + /* Text for the link to the privacy policy */ + policyLinkText?: string; + /* Text on the consent acceptance button */ + buttonAcceptText?: string; + /* Text on the reject button of the consent */ + buttonDeclineText?: string; + /* Text on the button for accepting required cookies */ + buttonNecessaryText?: string; + /* Text on the button to confirm the choice */ + buttonConfirmText?: string; + /* Text about cookie management */ + manageLabelText?: string; + cookieList?: ConsentPopupCookieListItem[]; +} + +type SimpleConsentData { + /* Content */ + text?: string; + /* Text on the consent acceptance button */ + buttonAcceptText?: string; + /* Text on the reject button of the consent */ + buttonDeclineText?: string; +} + +interface IConsentManager { + /* Mode for managing cookies and showing component. 'notification' | 'base' | 'manage' */ + mode: `${ConsentMode}`; + /* Types of cookies */ + cookies: ConsentType[]; + /* See CookieSetOptions from universal-cookie */ + cookiesSettings: CookieSettings; + /* Get current consents */ + getConsents: () => Consents; + /* To subscribe a component to update consents; e.g, to send actual consents to ga */ + subscribe: (handler: Subscriber) => () => void; + /* Set new consent values */ + setConsents: (values: Consents | 'All' | 'OnlyNecessary') => void; + /* Check conditions for showing consent component */ + isConsentNotDefined: () => boolean; +} +``` diff --git a/src/components/CookieConsent/__stories__/CookieConsent.stories.tsx b/src/components/CookieConsent/__stories__/CookieConsent.stories.tsx new file mode 100644 index 00000000..6ce321c7 --- /dev/null +++ b/src/components/CookieConsent/__stories__/CookieConsent.stories.tsx @@ -0,0 +1,90 @@ +import React from 'react'; + +import {Button} from '@gravity-ui/uikit'; +import {Meta, StoryFn} from '@storybook/react'; + +import {ConsentManager, ConsentMode, ConsentType} from '../ConsentManager'; +import {ConsentPopupCookieListItem} from '../components/ConsentPopup/types'; +import {CookieConsent} from '../index'; +import type {CookieConsentProps} from '../types'; + +export default { + title: 'Components/CookieConsent', + component: CookieConsent, +} as Meta; + +const cookieList = Object.values(ConsentType).map((type) => { + const result: ConsentPopupCookieListItem = { + type, + link: {href: 'https://google.com'}, + }; + + if (type === ConsentType.Necessary) { + result.titleLabel = 'Always active'; + } + + return result; +}); + +type DefaultTemplateProps = Omit & { + consentMode: `${ConsentMode}`; +}; + +const DefaultTemplate: StoryFn = ({consentMode, manageCookies, ...args}) => { + const [edition, setEdition] = React.useState(1); + const consentManager = React.useMemo( + () => new ConsentManager(consentMode, edition), + [consentMode, edition], + ); + const [showResetButton, setShowResetButton] = React.useState( + !consentManager.isConsentNotDefined(), + ); + + const onResetButtonClick = () => { + setEdition(edition + 1); + setShowResetButton(false); + }; + + return ( + + {showResetButton && ( + + )} + setShowResetButton(true)} + /> + + ); +}; + +export const SimpleConsent = DefaultTemplate.bind({}); +SimpleConsent.args = { + consentMode: ConsentMode.Base, +} as DefaultTemplateProps; + +export const ConsentPopup = DefaultTemplate.bind({}); +ConsentPopup.args = { + consentMode: ConsentMode.Manage, + policyLink: 'https://google.com', + cookieList, +} as DefaultTemplateProps; + +export const ManageCookies = DefaultTemplate.bind({}); +ManageCookies.args = { + consentMode: ConsentMode.Manage, + manageCookies: true, + policyLink: 'https://google.com', + cookieList, +} as DefaultTemplateProps; + +export const ConsentNotification = DefaultTemplate.bind({}); +ConsentNotification.args = { + consentMode: ConsentMode.Notification, + policyLink: 'https://google.com', + policyLinkText: 'Cookie Policy', +} as DefaultTemplateProps; diff --git a/src/components/CookieConsent/components/ConsentNotification/ConsentNotification.scss b/src/components/CookieConsent/components/ConsentNotification/ConsentNotification.scss new file mode 100644 index 00000000..fdcfb450 --- /dev/null +++ b/src/components/CookieConsent/components/ConsentNotification/ConsentNotification.scss @@ -0,0 +1,53 @@ +@use '@gravity-ui/uikit/styles/mixins'; +@use '../../../variables'; + +$block: '.#{variables.$ns}consent-notification'; + +#{$block} { + --g-color-base-generic: var(--g-color-base-float-announcement); + + @include mixins.text-body-2; + + position: fixed; + box-sizing: border-box; + z-index: 1000; + + &__text { + display: block; + + & + & { + margin-top: variables.$regularOffset; + } + } + + &_type_default { + right: variables.$regularOffset; + bottom: variables.$regularOffset; + width: 480px; + border-radius: var(--g-border-radius-xl); + padding: variables.$doubleRegularOffset; + box-shadow: 0px variables.$microOffset variables.$normalOffset rgba(0, 0, 0, 0.15); + + #{$block}__text { + line-height: variables.$doubleInlineOffset; + } + + #{$block}__button { + margin-top: variables.$microOffset; + padding: 0 variables.$regularOffset; + align-self: flex-end; + } + } + + &_type_mobile { + left: 0; + bottom: 0; + width: 100%; + padding: variables.$normalOffset; + + #{$block}__button { + width: 100%; + margin-top: variables.$normalOffset; + } + } +} diff --git a/src/components/CookieConsent/components/ConsentNotification/ConsentNotification.tsx b/src/components/CookieConsent/components/ConsentNotification/ConsentNotification.tsx new file mode 100644 index 00000000..39d6b669 --- /dev/null +++ b/src/components/CookieConsent/components/ConsentNotification/ConsentNotification.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import {Alert, Button, Link, useMobile} from '@gravity-ui/uikit'; + +import {block} from '../../../utils/cn'; +import i18n from '../../i18n'; + +import {ConsentNotificationProps} from './types'; + +import './ConsentNotification.scss'; + +const b = block('consent-notification'); + +export const ConsentNotification = ({ + policyLink, + onAction, + className, + policyLinkText = i18n('label_policy'), + text = i18n('label_text'), + buttonOkText = i18n('button_OK'), +}: ConsentNotificationProps) => { + const [mobile] = useMobile(); + const onClick = () => { + onAction('All'); + }; + const message = ( + + {text} + {policyLink ? ( + + {i18n('details_text')}{' '} + + {policyLinkText} + + . + + ) : null} + + ); + const actions = ( + + ); + + return ( + + ); +}; diff --git a/src/components/CookieConsent/components/ConsentNotification/index.ts b/src/components/CookieConsent/components/ConsentNotification/index.ts new file mode 100644 index 00000000..fb289af7 --- /dev/null +++ b/src/components/CookieConsent/components/ConsentNotification/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './ConsentNotification'; diff --git a/src/components/CookieConsent/components/ConsentNotification/types.ts b/src/components/CookieConsent/components/ConsentNotification/types.ts new file mode 100644 index 00000000..83394787 --- /dev/null +++ b/src/components/CookieConsent/components/ConsentNotification/types.ts @@ -0,0 +1,14 @@ +import {CookieConsentBaseProps} from '../../types'; + +export interface ConsentNotificationData { + /* Content */ + text?: string; + /* Link to the privacy policy */ + policyLink?: string; + /* Text for the link to the privacy policy */ + policyLinkText?: string; + /* Text on the consent acceptance button */ + buttonOkText?: string; +} + +export type ConsentNotificationProps = ConsentNotificationData & CookieConsentBaseProps; diff --git a/src/components/CookieConsent/components/ConsentPopup/ConsentPopup.scss b/src/components/CookieConsent/components/ConsentPopup/ConsentPopup.scss new file mode 100644 index 00000000..7fee5298 --- /dev/null +++ b/src/components/CookieConsent/components/ConsentPopup/ConsentPopup.scss @@ -0,0 +1,128 @@ +@use '@gravity-ui/uikit/styles/mixins'; +@use '../../../variables'; + +$block: '.#{variables.$ns}consent-popup'; + +#{$block} { + @include mixins.text-body-2; + + padding: variables.$doubleRegularOffset; + border-radius: 14px; + max-width: calc(720px - #{variables.$doubleRegularOffset} * 2); + + &__header { + display: flex; + justify-content: space-between; + } + + &__title { + font-weight: var(--g-text-header-font-weight); + } + + &__body { + margin: variables.$doubleInlineOffset 0 48px; + line-height: variables.$doubleInlineOffset; + + &_step_manage { + margin-bottom: variables.$doubleRegularOffset; + } + } + + &__text { + & + & { + margin-top: variables.$regularOffset; + } + } + + &__manage-cookie-button { + @include mixins.text-body-2; + + color: var(--g-color-text-link); + + &:hover, + &:focus, + &:active { + color: var(--g-color-text-link-hover); + transform: scale(1); + } + + &:hover::before, + &:active::before { + background: none; + } + + .yc-button__text { + padding: 0; + } + } + + &__buttons { + display: flex; + justify-content: flex-end; + } + + &__button + &__button { + margin-left: variables.$regularOffset; + } + + &__close-button { + --yc-button-outline-color: var(--g-color-line-focus); + + position: relative; + top: 1px; + } + + &__arrow-button { + --yc-button-outline-color: var(--g-color-line-focus); + + position: relative; + top: -2px; + } + + &__cookie-list { + margin-top: variables.$doubleInlineOffset; + } + + &__modal-content_mobile { + #{$block} { + max-width: initial; + } + + #{$block}__body { + margin: variables.$microOffset 0 variables.$regularOffset; + line-height: 20px; + } + + #{$block}__buttons { + justify-content: flex-start; + } + + #{$block}__button + #{$block}__button { + margin-left: variables.$inlineOffset; + } + + #{$block}__arrow-button { + margin-right: variables.$regularOffset; + } + + &#{$block}__modal-content_step_manage { + position: fixed; + inset: 0; + margin: 0; + overflow-y: scroll; + border-radius: 0; + + #{$block} { + padding: variables.$doubleRegularOffset variables.$doubleInlineOffset; + } + + #{$block}__body { + margin: variables.$doubleRegularOffset 0; + } + + #{$block}__text + #{$block}__text { + margin-top: variables.$microOffset; + } + } + } +} diff --git a/src/components/CookieConsent/components/ConsentPopup/ConsentPopup.tsx b/src/components/CookieConsent/components/ConsentPopup/ConsentPopup.tsx new file mode 100644 index 00000000..d253db70 --- /dev/null +++ b/src/components/CookieConsent/components/ConsentPopup/ConsentPopup.tsx @@ -0,0 +1,236 @@ +import React from 'react'; + +import {ArrowLeft, Xmark} from '@gravity-ui/icons'; +import {Button, Icon, Link, Modal, Text, useMobile} from '@gravity-ui/uikit'; + +import {block} from '../../../utils/cn'; +import {ConsentType} from '../../ConsentManager'; +import type {Consents} from '../../ConsentManager'; +import i18n from '../../i18n'; +import {FoldableList} from '../FoldableList/FoldableList'; + +import { + ConsentPopupCookieListItem, + ConsentPopupProps, + ConsentPopupStep, + FooterProps, + HeaderProps, +} from './types'; + +import './ConsentPopup.scss'; + +const b = block('consent-popup'); + +const Header = ({currentStep, initialStep, onClose, onChangeStep, isMobile}: HeaderProps) => { + const buttonsEnabled = currentStep === ConsentPopupStep.Manage; + const isBackButtonVisible = buttonsEnabled && initialStep === ConsentPopupStep.Main; + + return ( +
+
+ {isBackButtonVisible ? ( + + ) : null} + + {i18n(buttonsEnabled ? 'label_title_manage' : 'label_title_main')} + +
+ {buttonsEnabled && !isBackButtonVisible ? ( + + ) : null} +
+ ); +}; + +const Footer = ({ + onAction, + currentStep, + currentConsents, + buttonAcceptText = i18n('button_accept_all'), + buttonNecessaryText = i18n('button_necessary'), + buttonConfirmText = i18n('button_confirm'), +}: FooterProps) => { + const isManageStep = currentStep === ConsentPopupStep.Manage; + const onButtonClick = (onlyNecessary?: boolean) => { + return () => { + onAction(onlyNecessary ? 'OnlyNecessary' : 'All'); + }; + }; + const confirmSelectedConsent = () => { + onAction(currentConsents); + }; + + return ( +
+ + +
+ ); +}; + +export const ConsentPopup = ({ + policyLink, + onAction, + className, + policyLinkText = i18n('label_policy_extended'), + text, + manageLabelText = i18n('manage_label_text_extended'), + step = ConsentPopupStep.Main, + cookieList, + onClose, + consentManager, + ...buttonsParams +}: ConsentPopupProps) => { + const [mobile] = useMobile(); + const [currentConsents, setCurrentConsents] = React.useState( + consentManager.getConsents(), + ); + const [currentStep, setCurrentStep] = React.useState<`${ConsentPopupStep}`>(step); + const onChangeStep = (newStep: `${ConsentPopupStep}`) => { + return () => setCurrentStep(newStep); + }; + const isManageStep = currentStep === ConsentPopupStep.Manage; + const preparedCookieList = React.useMemo(() => { + return cookieList?.map((item) => { + const isNecessaryItem = item.type === ConsentType.Necessary; + + return { + checked: Boolean(currentConsents[item.type]), + disabled: isNecessaryItem, + defaultExpand: isNecessaryItem, + title: item.title || i18n(`cookie_${item.type}_title`), + text: item.text || i18n(`cookie_${item.type}_text`), + link: item.link + ? { + href: item.link?.href, + title: item.link?.title || i18n(`cookie_link_text`), + } + : undefined, + titleLabel: item.titleLabel, + }; + }); + }, [cookieList, currentConsents]); + const onChoose = (checkedItems: number[]) => { + if (!cookieList) return; + + setCurrentConsents( + cookieList.reduce((acc: Consents, item: ConsentPopupCookieListItem, index: number) => { + acc[item.type] = checkedItems.includes(index); + + return acc; + }, {}), + ); + }; + + return ( + +
+
+
+ {isManageStep ? ( + + + {i18n('manage_subtitle_extended')} + +
+ {manageLabelText} + {policyLink && policyLinkText && ( + + {' '} + + {policyLinkText} + + + )} + . +
+ {preparedCookieList ? ( + + ) : null} +
+ ) : ( + +
+ +
+
+ {i18n('label_manage_cookie')}{' '} + + . +
+
+ )} +
+
+
+
+ ); +}; diff --git a/src/components/CookieConsent/components/ConsentPopup/index.ts b/src/components/CookieConsent/components/ConsentPopup/index.ts new file mode 100644 index 00000000..b6c305e2 --- /dev/null +++ b/src/components/CookieConsent/components/ConsentPopup/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './ConsentPopup'; diff --git a/src/components/CookieConsent/components/ConsentPopup/types.ts b/src/components/CookieConsent/components/ConsentPopup/types.ts new file mode 100644 index 00000000..06571594 --- /dev/null +++ b/src/components/CookieConsent/components/ConsentPopup/types.ts @@ -0,0 +1,68 @@ +import type {ConsentType, Consents} from '../../ConsentManager'; +import {CookieConsentBaseProps} from '../../types'; +import {FoldableListItem} from '../FoldableList/types'; + +export enum ConsentPopupStep { + /* Step with base info */ + Main = 'main', + /* Step with cookies settings */ + Manage = 'manage', +} + +export interface ConsentPopupCookieListItem extends Pick { + type: `${ConsentType}`; + title?: string; + text?: string; +} + +export interface ConsentPopupData { + /* Content */ + text?: string; + /* Link to the privacy policy */ + policyLink?: string; + /* Text for the link to the privacy policy */ + policyLinkText?: string; + /* Text on the consent acceptance button */ + buttonAcceptText?: string; + /* Text on the reject button of the consent */ + buttonDeclineText?: string; + /* Text on the button for accepting required cookies */ + buttonNecessaryText?: string; + /* Text on the button to confirm the choice */ + buttonConfirmText?: string; + /* Text about cookie management */ + manageLabelText?: string; + cookieList?: ConsentPopupCookieListItem[]; + onClose: () => void; +} + +export type ConsentPopupProps = ConsentPopupData & + CookieConsentBaseProps & { + /* Active step */ + step?: `${ConsentPopupStep}`; + }; + +export interface HeaderProps { + /* Active step */ + currentStep: `${ConsentPopupStep}`; + /* Initial step */ + initialStep: `${ConsentPopupStep}`; + onClose: () => void; + onChangeStep: (step: `${ConsentPopupStep}`) => () => void; + /* Is mobile view */ + isMobile?: boolean; +} + +export interface FooterProps { + /* Active step */ + currentStep: `${ConsentPopupStep}`; + /* Text on the consent acceptance button */ + buttonAcceptText?: string; + /* Text on the button for accepting required cookies */ + buttonNecessaryText?: string; + /* Text on the button to confirm the choice */ + buttonConfirmText?: string; + onAction: (consents: Consents | 'All' | 'OnlyNecessary') => void; + /* Current consent */ + currentConsents: Consents; +} diff --git a/src/components/CookieConsent/components/FoldableList/FoldableList.scss b/src/components/CookieConsent/components/FoldableList/FoldableList.scss new file mode 100644 index 00000000..fb682a12 --- /dev/null +++ b/src/components/CookieConsent/components/FoldableList/FoldableList.scss @@ -0,0 +1,128 @@ +@use '../../../mixins'; +@use '../../../variables'; + +$block: '.#{variables.$ns}foldable-list'; + +#{$block} { + $border-color: var(--g-color-private-cool-grey-100); + + @include mixins.text-body-2(); + + &__item { + border-top: 1px solid $border-color; + padding: variables.$regularOffset variables.$microOffset; + + &:last-child { + border-bottom: 1px solid $border-color; + } + } + + &__item-title-wrapper { + width: 100%; + display: flex; + align-items: center; + } + + &__item-button { + @include mixins.button-reset(); + + .yc-button__text { + padding: 0; + } + + &:hover, + &:focus, + &:active { + transform: scale(1); + } + + &:focus::before, + &:hover::before, + &:active::before { + background: none; + border-radius: var(--g-focus-border-radius); + } + } + + &__title { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__item-checkbox { + margin-right: variables.$inlineOffset; + } + + &__title-label { + margin-left: variables.$microOffset; + padding: 1px variables.$microOffset; + background: var(--g-color-private-cool-grey-50); + color: var(--g-color-private-cool-grey-800); + border-radius: 5px; + } + + &__content { + margin-top: 16px; + } + + &__content-text { + line-height: variables.$doubleInlineOffset; + } + + &__content-link { + display: flex; + align-items: center; + margin-top: variables.$regularOffset; + } + + &__item_mobile { + #{$block}__item-button { + height: auto; + } + + #{$block}__title { + display: flex; + align-items: center; + } + + #{$block}__title-text { + font-size: variables.$normalOffset; + line-height: variables.$doubleInlineOffset; + display: flex; + flex-direction: column-reverse; + } + + #{$block}__title-label { + align-self: flex-start; + margin-left: 0; + margin-bottom: variables.$microOffset; + } + + #{$block}__item-checkbox { + margin-right: variables.$regularOffset; + + &_multiline { + align-self: flex-start; + } + } + + #{$block}__item-title-wrapper { + align-items: center; + } + + #{$block}__title-arrow_multiline { + align-self: flex-start; + } + + #{$block}__content { + margin-left: variables.$doubleRegularOffset; + margin-top: 8px; + } + + &#{$block}__item { + padding: variables.$doubleRegularOffset variables.$regularOffset + variables.$doubleRegularOffset 0; + } + } +} diff --git a/src/components/CookieConsent/components/FoldableList/FoldableList.tsx b/src/components/CookieConsent/components/FoldableList/FoldableList.tsx new file mode 100644 index 00000000..2d7e5d43 --- /dev/null +++ b/src/components/CookieConsent/components/FoldableList/FoldableList.tsx @@ -0,0 +1,126 @@ +import React from 'react'; + +import {ChevronDown, ChevronRight, ChevronUp} from '@gravity-ui/icons'; +import {Button, Checkbox, Disclosure, Icon, Link, Text} from '@gravity-ui/uikit'; + +import {block} from '../../../utils/cn'; + +import {FoldableListItem, FoldableListProps} from './types'; + +import './FoldableList.scss'; + +const b = block('foldable-list'); + +export const FoldableList = ({items, className, isMobile, onChooseItem}: FoldableListProps) => { + const [checkedItems, setChecked] = React.useState(() => + items.reduce((acc: number[], item: FoldableListItem, index: number) => { + if (item.checked) { + acc.push(index); + } + + return acc; + }, []), + ); + + const onCheckItem = (index: number) => { + return () => { + let newState; + + if (checkedItems.includes(index)) { + newState = checkedItems.filter((intemIndex: number) => intemIndex !== index); + } else { + newState = [...checkedItems, index]; + } + + onChooseItem?.(newState); + setChecked(newState); + }; + }; + + return ( +
+ {items.map( + ({title, titleLabel, text, link, checked, disabled, defaultExpand}, index) => { + const isChecked = checkedItems.includes(index); + + return ( + + + {(props) => ( +
+ + +
+ )} +
+
+ + {text} + + {link ? ( + + {link.title} + + + ) : null} +
+
+ ); + }, + )} +
+ ); +}; diff --git a/src/components/CookieConsent/components/FoldableList/types.ts b/src/components/CookieConsent/components/FoldableList/types.ts new file mode 100644 index 00000000..ec50fed3 --- /dev/null +++ b/src/components/CookieConsent/components/FoldableList/types.ts @@ -0,0 +1,25 @@ +import type {LinkProps} from '@gravity-ui/uikit'; + +export interface FoldableListItem { + /* Title */ + title: string; + /* Text in the hidden part */ + text: string; + /* Label, it is locates near the title */ + titleLabel?: string; + /* Link is locates at the end of the hidden part */ + link?: Pick; + /* Inintial expand */ + defaultExpand?: boolean; + /* Inintial check */ + checked?: boolean; + /* Inintial disable */ + disabled?: boolean; +} + +export interface FoldableListProps { + items: FoldableListItem[]; + className?: string; + onChooseItem?: (checkedItems: number[]) => void; + isMobile?: boolean; +} diff --git a/src/components/CookieConsent/components/SimpleConsent/SimpleConsent.scss b/src/components/CookieConsent/components/SimpleConsent/SimpleConsent.scss new file mode 100644 index 00000000..e875b78a --- /dev/null +++ b/src/components/CookieConsent/components/SimpleConsent/SimpleConsent.scss @@ -0,0 +1,55 @@ +@use '@gravity-ui/uikit/styles/mixins'; +@use '../../../variables'; + +$block: '.#{variables.$ns}simple-consent'; + +#{$block} { + $breakpoint: 769px; + $indent: 24px; + + @include mixins.text-body-2; + + position: fixed; + bottom: $indent; + left: 0; + max-width: calc(1232px + variables.$bigOffset * 2); + padding: $indent 32px; + margin: 0 variables.$bigOffset; + box-shadow: 0px 8px 32px 0px rgba(0, 0, 0, 0.1); + + background-color: var(--g-color-base-float); + border-radius: var(--g-border-radius-xl); + + &__container { + display: flex; + justify-content: center; + align-items: center; + } + + &__buttons { + display: flex; + } + + &__button { + margin-left: variables.$regularOffset; + } + + @media (max-width: $breakpoint) { + &__buttons { + margin-top: $indent; + width: 100%; + } + + &__container { + flex-direction: column; + } + + &__button { + flex: 1 1 0; + + &:first-child { + margin-left: 0; + } + } + } +} diff --git a/src/components/CookieConsent/components/SimpleConsent/SimpleConsent.tsx b/src/components/CookieConsent/components/SimpleConsent/SimpleConsent.tsx new file mode 100644 index 00000000..7ba59aac --- /dev/null +++ b/src/components/CookieConsent/components/SimpleConsent/SimpleConsent.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import {Button, Portal} from '@gravity-ui/uikit'; + +import {block} from '../../../utils/cn'; +import i18n from '../../i18n'; + +import {SimpleConsentProps} from './types'; + +import './SimpleConsent.scss'; + +const b = block('simple-consent'); +const buttons = ['decline', 'accept'] as const; + +export const SimpleConsent = (props: SimpleConsentProps) => { + const { + className, + text = i18n('label_text'), + buttonAcceptText = i18n('button_accept'), + buttonDeclineText = i18n('button_decline'), + onAction, + } = props; + + const onClick = (isAll: boolean) => { + return () => { + onAction(isAll ? 'All' : 'OnlyNecessary'); + }; + }; + + return ( + +
+
+ {text} +
+ {buttons.map((button) => ( + + ))} +
+
+
+
+ ); +}; diff --git a/src/components/CookieConsent/components/SimpleConsent/index.ts b/src/components/CookieConsent/components/SimpleConsent/index.ts new file mode 100644 index 00000000..b64c31f2 --- /dev/null +++ b/src/components/CookieConsent/components/SimpleConsent/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './SimpleConsent'; diff --git a/src/components/CookieConsent/components/SimpleConsent/types.ts b/src/components/CookieConsent/components/SimpleConsent/types.ts new file mode 100644 index 00000000..10b51785 --- /dev/null +++ b/src/components/CookieConsent/components/SimpleConsent/types.ts @@ -0,0 +1,12 @@ +import {CookieConsentBaseProps} from '../../types'; + +export interface SimpleConsentData { + /* Content */ + text?: string; + /* Text on the consent acceptance button */ + buttonAcceptText?: string; + /* Text on the reject button of the consent */ + buttonDeclineText?: string; +} + +export type SimpleConsentProps = SimpleConsentData & CookieConsentBaseProps; diff --git a/src/components/CookieConsent/i18n/en.json b/src/components/CookieConsent/i18n/en.json new file mode 100644 index 00000000..2a5b0bfb --- /dev/null +++ b/src/components/CookieConsent/i18n/en.json @@ -0,0 +1,26 @@ +{ + "button_OK": "OK", + "button_accept": "Accept", + "button_decline": "Decline", + "button_accept_all": "Accept all", + "button_confirm": "Confirm my choices", + "button_necessary": "Required only", + "cookie_analytics_text": "These cookies allow us to count visits and traffic sources so we can measure and improve the performance of our website. They help us to know which pages are the most and least popular and see how visitors move around the site. If these cookies are disabled, we will not know when a user has visited our website or be able to monitor the website’s performance.", + "cookie_analytics_title": "Analytics cookies", + "cookie_link_text": "View Cookies", + "cookie_marketing_text": "Marketing cookies are necessary to track visitors across the website and display ads that are relevant.", + "cookie_marketing_title": "Marketing cookies", + "cookie_necessary_text": "These cookies are necessary for the website to function and cannot be switched off in our systems. They are usually only set in response to actions by visitors which amount to a request for services, such as setting privacy preferences, logging in or filling out forms. You can set your browser to block or alert you about these cookies, but this may cause parts of the website to not work properly.", + "cookie_necessary_title": "Strictly necessary cookies", + "details_text": "For details, please read our", + "label_manage_cookie": "You can also", + "label_manage_cookie_link_text": "set up cookies your way", + "label_policy": "Privacy Policy", + "label_policy_extended": "Cookie Policy", + "label_text": "By clicking Accept, you consent to our website’s use of Google Analytics cookies in order to give you the most relevant experience, and for analytics purposes.", + "label_text_extended": "Site uses cookies to make your browsing secure and fast. To accept all cookies, including analytical, personalization, and advertising, select Allow all. For required cookies only, select Required only. Check our Cookie Policy for details.", + "label_title_main": "This website uses cookies", + "label_title_manage": "General information", + "manage_label_text_extended": "Click on the different category headings to find out more and change our default settings. You can withdraw your consent or manage your consent preferences at any time via the “Manage cookies” link at the footer of our website or you can disable cookies in the settings of your web-browser or mobile device. For more information please see our", + "manage_subtitle_extended": "Cookie Preferences" +} diff --git a/src/components/CookieConsent/i18n/index.ts b/src/components/CookieConsent/i18n/index.ts new file mode 100644 index 00000000..947850d3 --- /dev/null +++ b/src/components/CookieConsent/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeyset} from '../../utils/registerKeyset'; + +import en from './en.json'; +import ru from './ru.json'; + +const COMPONENT = 'CookieConsent'; +export default registerKeyset({en, ru}, COMPONENT); diff --git a/src/components/CookieConsent/i18n/ru.json b/src/components/CookieConsent/i18n/ru.json new file mode 100644 index 00000000..c53226fe --- /dev/null +++ b/src/components/CookieConsent/i18n/ru.json @@ -0,0 +1,26 @@ +{ + "button_OK": "OK", + "button_accept": "Принять", + "button_decline": "Отклонить", + "button_accept_all": "Принять всё", + "button_confirm": "Принять выбранные изменения", + "button_necessary": "Принять только обязательные", + "cookie_analytics_text": "Эти файлы cookie позволяют нам подсчитывать посещения и источники трафика, чтобы мы могли измерять и улучшать производительность нашего веб-сайта. Они помогают нам знать, какие страницы наиболее и наименее популярны, и видеть, как посетители перемещаются по сайту. Если эти файлы cookie отключены, мы не будем знать, когда пользователь посещал наш веб-сайт, и не сможем отслеживать работу веб-сайта.", + "cookie_analytics_title": "Аналитические файлы cookie", + "cookie_link_text": "Посмотреть файлы cookie", + "cookie_marketing_text": "Маркетинговые файлы cookie необходимы для отслеживания посетителей на веб-сайте и показа релевантной рекламы.", + "cookie_marketing_title": "Маркетинговые файлы cookie", + "cookie_necessary_text": "Эти файлы cookie необходимы для функционирования веб-сайта и не могут быть отключены в наших системах. Обычно они устанавливаются только в ответ на действия посетителей, которые представляют собой запрос на услуги, такие как настройка параметров конфиденциальности, вход в систему или заполнение форм. Вы можете настроить свой браузер таким образом, чтобы он блокировал или предупреждал вас об этих файлах cookie, но это может привести к неправильной работе некоторых частей веб-сайта.", + "cookie_necessary_title": "Обязательные файлы cookie", + "details_text": "Детали в ", + "label_manage_cookie": "Вы также можете", + "label_manage_cookie_link_text": "настроить файлы cookie", + "label_policy": "Политике конфиденциальности", + "label_policy_extended": "Политикой конфиденциальности", + "label_text": "Нажав Принять, вы даёте согласие на использование нашим веб-сайтом файлов cookie Google Analytics для предоставления вам наиболее релевантных услуг и в аналитических целях.", + "label_text_extended": "Сайт использует файлы cookie, чтобы сделать ваш просмотр безопасным и быстрым. Чтобы принимать все файлы cookie, включая аналитические, персонализационные и рекламные, выберите Принять всё. Только для обязательных файлов cookie выберите Только обязательные. Ознакомьтесь с нашей Политикой использования файлов cookie для получения подробной информации.", + "label_title_main": "Этот сайт использует файлы cookie", + "label_title_manage": "Основная информация", + "manage_label_text_extended": "Нажмите на заголовки различных категорий, чтобы узнать больше и изменить наши настройки по умолчанию. Вы можете отозвать свое согласие или изменить настройки вашего согласия в любое время, перейдя по ссылке \"Управление файлами cookie\" в нижней части нашего веб-сайта, или вы можете отключить файлы cookie в настройках вашего веб-браузера или мобильного устройства. Для получения дополнительной информации, пожалуйста, ознакомьтесь с нашей", + "manage_subtitle_extended": "Настройки файлов cookie" +} diff --git a/src/components/CookieConsent/index.ts b/src/components/CookieConsent/index.ts new file mode 100644 index 00000000..1ec96dbd --- /dev/null +++ b/src/components/CookieConsent/index.ts @@ -0,0 +1,6 @@ +export {CookieConsent} from './CookieConsent'; +export * from './types'; +export * from './ConsentManager'; +export * from './components/ConsentNotification'; +export * from './components/ConsentPopup'; +export * from './components/SimpleConsent'; diff --git a/src/components/CookieConsent/types.ts b/src/components/CookieConsent/types.ts new file mode 100644 index 00000000..233863b8 --- /dev/null +++ b/src/components/CookieConsent/types.ts @@ -0,0 +1,41 @@ +import type {ConsentMode, ConsentType, Consents, CookieSettings} from './ConsentManager'; +import type {ConsentNotificationData} from './components/ConsentNotification'; +import type {ConsentPopupData} from './components/ConsentPopup'; +import type {SimpleConsentData} from './components/SimpleConsent'; + +export interface CookieConsentBaseProps { + onAction: (consents: Consents | 'All' | 'OnlyNecessary') => void; + className?: string; + consentManager: IConsentManager; +} + +export type CookieConsentComponentProps = + | ConsentNotificationData + | ConsentPopupData + | SimpleConsentData; + +export type Subscriber = (changedConsents: Consents, allConsents: Consents) => void; + +export interface IConsentManager { + /* Mode for managing cookies and showing component. 'notification' | 'base' | 'manage' */ + mode: `${ConsentMode}`; + /* Types of cookies */ + cookies: ConsentType[]; + /* See CookieSetOptions from universal-cookie */ + cookiesSettings: CookieSettings; + /* Get current consents */ + getConsents: () => Consents; + /* To subscribe a component to update consents; e.g, to send actual consents to ga */ + subscribe: (handler: Subscriber) => () => void; + /* Set new consent values */ + setConsents: (values: Consents | 'All' | 'OnlyNecessary') => void; + /* Check conditions for showing consent component */ + isConsentNotDefined: () => boolean; +} + +export type CookieConsentProps = CookieConsentComponentProps & { + consentManager: IConsentManager; + onConsentPopupClose?: () => void; + /* To open popup for managing cookies settings */ + manageCookies?: boolean; +}; diff --git a/src/components/index.ts b/src/components/index.ts index 9640a46b..709e02aa 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,5 @@ export * from './AdaptiveTabs'; +export * from './CookieConsent'; export * from './ChangelogDialog'; export * from './FilePreview'; export * from './FormRow'; diff --git a/src/components/mixins.scss b/src/components/mixins.scss index 1640e361..6c7af52e 100644 --- a/src/components/mixins.scss +++ b/src/components/mixins.scss @@ -1,10 +1,21 @@ @import '@gravity-ui/uikit/styles/mixins'; -@mixin focusable() { - &:focus { - outline: 2px solid var(--g-color-line-focus); +@mixin focusable($offset: 0, $mode: 'outline') { + @if $mode == 'outline' { + &:focus { + outline: 2px solid var(--g-color-line-focus); + } + &:focus:not(:focus-visible) { + outline: 0; + } } - &:focus:not(:focus-visible) { - outline: 0; + @if $mode == 'box-shadow' { + &:focus { + box-shadow: 0 0 0 2px var(--g-color-line-focus); + outline: 0; + } + &:focus:not(:focus-visible) { + box-shadow: none; + } } }