├── .nvmrc
├── .gitattributes
├── .storybook
├── addons.js
├── config.js
├── webpack.config.js
└── preview-head.html
├── .gitignore
├── test
└── setupTests.ts
├── src
├── styled.d.ts
├── consent-manager
│ ├── font-styles.tsx
│ ├── categories.ts
│ ├── cancel-dialog.tsx
│ ├── buttons.tsx
│ ├── banner.tsx
│ ├── dialog.tsx
│ ├── container.tsx
│ ├── index.tsx
│ └── preference-dialog.tsx
├── index.ts
├── __tests__
│ ├── index.test.ts
│ └── consent-manager-builder
│ │ ├── fetch-destinations.test.ts
│ │ ├── preferences.test.ts
│ │ ├── analytics.test.ts
│ │ └── index.todo.js
├── consent-manager-builder
│ ├── fetch-destinations.ts
│ ├── preferences.ts
│ ├── analytics.ts
│ └── index.tsx
├── standalone.tsx
└── types.ts
├── .editorconfig
├── tsconfig.json
├── .github
└── workflows
│ └── nodeJS.yml
├── GUIDESTYLES.md
├── .eslintrc
├── stories
├── 4-custom-consent.stories.tsx
├── 1-standalone.stories.tsx
├── components
│ ├── destination-tile.tsx
│ ├── CookieView.tsx
│ └── common-react.tsx
├── 1.1-standalone-custom.stories.tsx
├── ImplyConsentOnInteraction.tsx
├── 3-tool-based.stories.tsx
├── 2-category-based.stories.tsx
├── standalone.html
├── standalone-custom.html
├── 0.1-consent-manager-close-interaction.stories.tsx
├── 0.2-consent-manager-custom-cookie-name.stories.tsx
├── 8-consent-manager-actions-block.stories.tsx
├── 0.3-consent-manager-custom-cookie-attributes.stories.tsx
├── 9-consent-manager-as-modal.stories.tsx
├── 0-consent-manager.stories.tsx
├── 7-default-destination-behavior.stories.tsx
├── 5-custom-categories.stories.tsx
└── 6-ccpa-gdpr-example.stories.tsx
├── LICENSE
├── webpack.config.js
├── package.json
└── CHANGELOG.md
/.nvmrc:
--------------------------------------------------------------------------------
1 | 16
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
2 |
--------------------------------------------------------------------------------
/.storybook/addons.js:
--------------------------------------------------------------------------------
1 | import '@storybook/addon-options/register'
2 | import '@storybook/addon-storysource/register'
3 |
--------------------------------------------------------------------------------
/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import { configure } from '@storybook/react'
2 |
3 | configure(require.context('../stories', true, /\.stories\.tsx$/), module)
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | /commonjs/
3 | /esm/
4 | /standalone/
5 | /build
6 | /storybook-static
7 | /types
8 |
9 | # IDE
10 | .vscode/
11 |
--------------------------------------------------------------------------------
/test/setupTests.ts:
--------------------------------------------------------------------------------
1 | import { configure } from 'enzyme'
2 | import Adapter from 'enzyme-adapter-react-16'
3 |
4 | configure({ adapter: new Adapter() })
5 |
--------------------------------------------------------------------------------
/src/styled.d.ts:
--------------------------------------------------------------------------------
1 | import { CSSProp } from 'styled-components'
2 |
3 | declare module 'react' {
4 | interface HTMLAttributes extends DOMAttributes {
5 | css?: CSSProp
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | ; editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
--------------------------------------------------------------------------------
/src/consent-manager/font-styles.tsx:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react'
2 |
3 | export default css`
4 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
5 | sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
6 | -moz-osx-font-smoothing: grayscale;
7 | -webkit-font-smoothing: antialiased;
8 | font-smoothing: antialiased;
9 | color: #435a6f;
10 | font-size: 16px;
11 | font-weight: 400;
12 | line-height: 22px;
13 | letter-spacing: -0.05px;
14 | `
15 |
--------------------------------------------------------------------------------
/src/consent-manager/categories.ts:
--------------------------------------------------------------------------------
1 | export const MARKETING_AND_ANALYTICS_CATEGORIES = [
2 | 'A/B Testing',
3 | 'Analytics',
4 | 'Attribution',
5 | 'Email',
6 | 'Enrichment',
7 | 'Heatmaps & Recordings',
8 | 'Raw Data',
9 | 'Realtime Dashboards',
10 | 'Referrals',
11 | 'Surveys',
12 | 'Video'
13 | ]
14 |
15 | export const ADVERTISING_CATEGORIES = ['Advertising', 'Tag Managers']
16 |
17 | export const FUNCTIONAL_CATEGORIES = [
18 | 'CRM',
19 | 'Customer Success',
20 | 'Deep Linking',
21 | 'Helpdesk',
22 | 'Livechat',
23 | 'Performance Monitoring',
24 | 'Personalization',
25 | 'SMS & Push Notifications',
26 | 'Security & Fraud'
27 | ]
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "build",
4 | "module": "esnext",
5 | "target": "es5",
6 | "lib": ["es6", "dom", "es2016", "es2017"],
7 | "jsx": "react",
8 | "allowJs": true,
9 | "moduleResolution": "node",
10 | "forceConsistentCasingInFileNames": true,
11 | "allowSyntheticDefaultImports": true,
12 | "noImplicitReturns": true,
13 | "noImplicitThis": true,
14 | "noImplicitAny": false,
15 | "strictNullChecks": true,
16 | "noUnusedLocals": true,
17 | "noUnusedParameters": true,
18 | "esModuleInterop": true,
19 | "declaration": true,
20 | "declarationDir": "types"
21 | },
22 | "include": ["src"],
23 | "exclude": ["node_modules", "build"]
24 | }
25 |
--------------------------------------------------------------------------------
/.github/workflows/nodeJS.yml:
--------------------------------------------------------------------------------
1 | name: Consent Manager
2 |
3 | on:
4 | push:
5 | branches: ['master']
6 | pull_request:
7 | branches: ['master']
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | strategy:
14 | matrix:
15 | node-version: [14.x]
16 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
17 |
18 | steps:
19 | - uses: actions/checkout@v3
20 | - name: Use Node.js ${{ matrix.node-version }}
21 | uses: actions/setup-node@v3
22 | with:
23 | node-version: ${{ matrix.node-version }}
24 | cache: 'yarn'
25 | - run: yarn install --frozen-lockfile
26 | - run: yarn build
27 | - run: yarn lint
28 | - run: yarn test
29 | - run: yarn size-limit
30 |
--------------------------------------------------------------------------------
/GUIDESTYLES.md:
--------------------------------------------------------------------------------
1 | # Elements inside Consent Manager
2 |
3 | This is the guide with the list ids from the different components inside Consent-Manager, you can use the id to change the styles with css files inside your project.
4 |
5 | ## Banner
6 |
7 | - segmentio_fragmentBanner
8 | - segmentio_pContent
9 | - segmentio_pSubContent
10 | - segmentio_subContentBtn
11 | - segmentio_actionBlock
12 | - segmentio_allowBtn
13 | - segmentio_denyBtn
14 | - segmentio_closeButton
15 |
16 | ## Cancel Dialog
17 |
18 | - segmentio_backDialogBtn
19 | - segmentio_cancelDialogBtn
20 |
21 | ## Dialog
22 |
23 | - segmentio_overlayDialog
24 | - segmentio_rootDialog
25 | - segmentio_headerDialog
26 | - segmentio_headerCancelBtn
27 | - segmentio_contentDialog
28 | - segmentio_buttonsDialog
29 |
30 | ## Preference Dialog
31 |
32 | - segmentio_prefBtnCancel
33 | - segmentio_prefBtnSubmit
34 | - segmentio_prefTableScroll
35 | - segmentio_prefTable
36 | - segmentio_prefThead
37 | - segmentio_prefTbody
38 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "parserOptions": {
4 | "project": "./tsconfig.json"
5 | },
6 | "extends": [
7 | "eslint:recommended",
8 | "plugin:@typescript-eslint/recommended",
9 | "plugin:@typescript-eslint/recommended-requiring-type-checking",
10 | "plugin:@typescript-eslint/eslint-recommended",
11 | "prettier",
12 | "prettier/@typescript-eslint"
13 | ],
14 | "plugins": ["react", "@typescript-eslint", "prettier"],
15 | "env": {
16 | "browser": true,
17 | "jest": true
18 | },
19 | "rules": {
20 | "@typescript-eslint/no-unused-vars": "off",
21 | "@typescript-eslint/no-floating-promises": "error",
22 | "@typescript-eslint/explicit-function-return-type": "off",
23 | "@typescript-eslint/ban-ts-ignore": "warn",
24 | "no-global-assign": "off",
25 | "@typescript-eslint/unbound-method": "warn"
26 | },
27 | "settings": {
28 | "react": {
29 | "pragma": "React",
30 | "version": "detect"
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/stories/4-custom-consent.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Pane, Button } from 'evergreen-ui'
3 | import { storiesOf } from '@storybook/react'
4 | import ConsentManager from '../src/consent-manager'
5 | import * as common from './components/common-react'
6 | import { openConsentManager } from '../src'
7 | import CookieView from './components/CookieView'
8 |
9 | const initialPreferences = {
10 | advertising: false,
11 | marketingAndAnalytics: true,
12 | functional: true
13 | }
14 |
15 | const Custom = () => {
16 | return (
17 |
18 | true}
22 | {...common}
23 | />
24 |
25 |
26 | Change Cookie Preferences
27 |
28 |
29 |
30 |
31 | )
32 | }
33 |
34 | storiesOf('Advanced Use Cases', module).add(`Partial consent`, () => )
35 |
--------------------------------------------------------------------------------
/.storybook/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack')
2 | const pkg = require('../package.json')
3 |
4 | module.exports = {
5 | mode: 'development',
6 | devtool: 'source-map',
7 | resolve: {
8 | extensions: ['.tsx', '.ts', '.js']
9 | },
10 | module: {
11 | rules: [
12 | {
13 | test: /\.stories\.tsx?$/,
14 | loaders: [
15 | {
16 | loader: require.resolve('@storybook/source-loader'),
17 | options: {
18 | parser: 'typescript',
19 | prettierConfig: {
20 | printWidth: 100,
21 | singleQuote: false
22 | }
23 | }
24 | }
25 | ],
26 | enforce: 'pre'
27 | },
28 | {
29 | test: /\.tsx?$/,
30 | exclude: /node_modules/,
31 | loader: 'ts-loader'
32 | }
33 | ]
34 | },
35 | plugins: [
36 | new webpack.DefinePlugin({
37 | 'process.env': {
38 | NODE_ENV: JSON.stringify('production'),
39 | VERSION: JSON.stringify(pkg.version)
40 | }
41 | })
42 | ]
43 | }
44 |
--------------------------------------------------------------------------------
/stories/1-standalone.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { storiesOf } from '@storybook/react'
3 | import { Pane } from 'evergreen-ui'
4 | import SyntaxHighlighter from 'react-syntax-highlighter'
5 | import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs'
6 |
7 | // @ts-ignore
8 | import contents from 'raw-loader!./standalone.html'
9 | import CookieView from './components/CookieView'
10 |
11 | const StandaloneConsentManagerExample = () => {
12 | return (
13 | <>
14 |
15 |
22 |
23 | {contents}
24 |
25 |
26 |
27 |
28 | >
29 | )
30 | }
31 |
32 | storiesOf('Standalone / Tag', module).add(`Basic`, () => )
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018, Segment.io, Inc
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/stories/components/destination-tile.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import { Li, Text, Link, Checkbox, Card } from 'evergreen-ui'
4 |
5 | export default function Destination({ destination, preferences, setPreferences }) {
6 | return (
7 |
8 |
9 |
12 | {destination.name}
13 |
14 | }
15 | checked={Boolean(preferences[destination.id])}
16 | onChange={() =>
17 | setPreferences({
18 | [destination.id]: !preferences[destination.id]
19 | })
20 | }
21 | />
22 |
23 | {destination.description}
24 |
25 |
26 |
27 | )
28 | }
29 |
30 | Destination.propTypes = {
31 | destination: PropTypes.object.isRequired,
32 | preferences: PropTypes.object.isRequired,
33 | setPreferences: PropTypes.func.isRequired
34 | }
35 |
--------------------------------------------------------------------------------
/stories/1.1-standalone-custom.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { storiesOf } from '@storybook/react'
3 | import { Pane } from 'evergreen-ui'
4 | import SyntaxHighlighter from 'react-syntax-highlighter'
5 | import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs'
6 |
7 | // @ts-ignore
8 | import contents from 'raw-loader!./standalone-custom.html'
9 | import CookieView from './components/CookieView'
10 |
11 | const StandaloneConsentManagerExample = () => {
12 | return (
13 | <>
14 |
15 |
22 |
23 | {contents}
24 |
25 |
26 |
27 | >
28 | )
29 | }
30 |
31 | storiesOf('Standalone / Javascript', module).add(`with Customization`, () => (
32 |
33 | ))
34 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const webpack = require('webpack')
3 | const pkg = require('./package.json')
4 |
5 | module.exports = {
6 | mode: 'production',
7 | devtool: 'source-map',
8 | entry: './src/standalone.tsx',
9 | output: {
10 | path: path.join(__dirname, 'standalone'),
11 | filename: 'consent-manager.js',
12 | library: 'consentManager'
13 | },
14 | resolve: {
15 | alias: {
16 | react: 'preact/compat',
17 | 'react-dom': 'preact/compat'
18 | },
19 | extensions: ['.tsx', '.ts', '.js']
20 | },
21 | module: {
22 | rules: [
23 | {
24 | test: /\.tsx?$/,
25 | exclude: /node_modules/,
26 | loader: 'ts-loader'
27 | }
28 | ]
29 | },
30 | plugins: [
31 | new webpack.DefinePlugin({
32 | 'process.env': {
33 | NODE_ENV: JSON.stringify('production'),
34 | VERSION: JSON.stringify(pkg.version)
35 | }
36 | }),
37 | new webpack.BannerPlugin(
38 | `
39 | Consent Manager v${pkg.version}
40 | https://github.com/segmentio/consent-manager
41 | Released under the MIT license
42 | Copyright © 2021, Segment.io, Inc
43 | `.trim()
44 | )
45 | ]
46 | }
47 |
--------------------------------------------------------------------------------
/stories/components/CookieView.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react'
2 | import { Pane, Heading, Button } from 'evergreen-ui'
3 | import cookies from 'js-cookie'
4 | import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs'
5 | import SyntaxHighlighter from 'react-syntax-highlighter'
6 |
7 | const CookieView = () => {
8 | const [cookieVal, updateCookieVal] = useState(cookies.getJSON())
9 |
10 | useEffect(() => {
11 | const clear = setInterval(() => {
12 | updateCookieVal(cookies.getJSON())
13 | }, 1000)
14 | return () => clearInterval(clear)
15 | })
16 |
17 | return (
18 |
19 | Cookies:
20 |
21 | {JSON.stringify(cookieVal, null, 2)}
22 |
23 |
24 | {
26 | const allCookies = cookies.getJSON()
27 | Object.keys(allCookies).forEach(key => {
28 | cookies.remove(key)
29 | })
30 | window.location.reload()
31 | }}
32 | >
33 | Clear 🧹🍪
34 |
35 |
36 | )
37 | }
38 |
39 | export default CookieView
40 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import CMB from './consent-manager-builder'
2 | import CM from './consent-manager'
3 |
4 | export { openDialog as openConsentManager } from './consent-manager/container'
5 | export {
6 | loadPreferences,
7 | savePreferences,
8 | onPreferencesSaved
9 | } from './consent-manager-builder/preferences'
10 |
11 | export const ConsentManagerBuilder = CMB
12 | export const ConsentManager = CM
13 |
14 | type Nav = Navigator & {
15 | msDoNotTrack?: Navigator['doNotTrack']
16 | }
17 |
18 | export function doNotTrack(): boolean | null {
19 |
20 | if (typeof window !== 'undefined' && (window.navigator || navigator)) {
21 | const nav = navigator as Nav
22 |
23 | let doNotTrackValue = nav.doNotTrack || window.doNotTrack || nav.msDoNotTrack
24 |
25 | // Normalise Firefox < 32
26 | // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack
27 | if (doNotTrackValue === 'yes') {
28 | doNotTrackValue = '1'
29 | } else if (doNotTrackValue === 'no') {
30 | doNotTrackValue = '0'
31 | }
32 |
33 | if (doNotTrackValue === '1') {
34 | return true
35 | }
36 | if (doNotTrackValue === '0') {
37 | return false
38 | }
39 | }
40 |
41 | return null
42 | }
43 |
--------------------------------------------------------------------------------
/src/__tests__/index.test.ts:
--------------------------------------------------------------------------------
1 | import { doNotTrack } from '../'
2 |
3 | describe('doNotTrack', () => {
4 | beforeEach(() => {
5 | navigator = {} as Navigator
6 | window = {} as Window & typeof globalThis
7 | })
8 |
9 | test('doNotTrack() supports standard API', () => {
10 | // @ts-ignore
11 | navigator.doNotTrack = '1'
12 | expect(doNotTrack()).toBe(true)
13 |
14 | // @ts-ignore
15 | navigator.doNotTrack = '0'
16 | expect(doNotTrack()).toBe(false)
17 |
18 | // @ts-ignore
19 | navigator.doNotTrack = 'unspecified'
20 | expect(doNotTrack()).toBe(null)
21 | })
22 |
23 | test('doNotTrack() supports window', () => {
24 | // @ts-ignore
25 | navigator.doNotTrack = undefined
26 |
27 | // @ts-ignore
28 | window.doNotTrack = '1'
29 | expect(doNotTrack()).toBe(true)
30 |
31 | // @ts-ignore
32 | window.doNotTrack = '0'
33 | expect(doNotTrack()).toBe(false)
34 |
35 | // @ts-ignore
36 | window.doNotTrack = 'unspecified'
37 | expect(doNotTrack()).toBeNull()
38 | })
39 |
40 | test('doNotTrack() support yes/no', () => {
41 | // @ts-ignore
42 | navigator.doNotTrack = 'yes'
43 | expect(doNotTrack()).toBe(true)
44 |
45 | // @ts-ignore
46 | navigator.doNotTrack = 'no'
47 | expect(doNotTrack()).toBe(false)
48 | })
49 |
50 | test('doNotTrack() supports ms prefix', () => {
51 | // @ts-ignore
52 | navigator.doNotTrack = undefined
53 | // @ts-ignore
54 | window.doNotTrack = undefined
55 |
56 | // @ts-ignore
57 | navigator.msDoNotTrack = '1'
58 | expect(doNotTrack()).toBe(true)
59 |
60 | // @ts-ignore
61 | navigator.msDoNotTrack = '0'
62 | expect(doNotTrack()).toBe(false)
63 |
64 | // @ts-ignore
65 | navigator.msDoNotTrack = 'unspecified'
66 | expect(doNotTrack()).toBeNull()
67 | })
68 | })
69 |
--------------------------------------------------------------------------------
/src/consent-manager-builder/fetch-destinations.ts:
--------------------------------------------------------------------------------
1 | import fetch from 'isomorphic-fetch'
2 | import flatten from 'lodash/flatten'
3 | import sortedUniqBy from 'lodash/sortedUniqBy'
4 | import sortBy from 'lodash/sortBy'
5 | import { Destination } from '../types'
6 |
7 | async function fetchDestinationForWriteKey(
8 | cdnHost: string,
9 | writeKey: string
10 | ): Promise {
11 | const res = await fetch(`https://${cdnHost}/v1/projects/${writeKey}/integrations`)
12 |
13 | if (!res.ok) {
14 | throw new Error(
15 | `Failed to fetch integrations for write key ${writeKey}: HTTP ${res.status} ${res.statusText}`
16 | )
17 | }
18 |
19 | const destinations = await res.json()
20 |
21 | // Rename creationName to id to abstract the weird data model
22 | for (const destination of destinations) {
23 | // Because of the legacy Fullstory integration the creationName for this integration is the `name`
24 | if (destination.name === 'Fullstory') {
25 | destination.id = destination.name
26 | } else {
27 | destination.id = destination.creationName
28 | }
29 | delete destination.creationName
30 | }
31 |
32 | return destinations
33 | }
34 |
35 | export default async function fetchDestinations(
36 | cdnHost: string,
37 | writeKeys: string[]
38 | ): Promise {
39 | const destinationsRequests: Promise[] = []
40 | for (const writeKey of writeKeys) {
41 | destinationsRequests.push(fetchDestinationForWriteKey(cdnHost, writeKey))
42 | }
43 |
44 | let destinations = flatten(await Promise.all(destinationsRequests))
45 | // Remove the dummy Repeater destination
46 | destinations = destinations.filter(d => d.id !== 'Repeater')
47 | destinations = sortBy(destinations, ['id'])
48 | destinations = sortedUniqBy(destinations, 'id')
49 | return destinations
50 | }
51 |
--------------------------------------------------------------------------------
/.storybook/preview-head.html:
--------------------------------------------------------------------------------
1 |
57 |
--------------------------------------------------------------------------------
/src/consent-manager/cancel-dialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react'
2 | import Dialog from './dialog'
3 | import { DefaultButton, RedButton } from './buttons'
4 | import { PreferenceDialogTemplate } from '../types'
5 |
6 | interface Props {
7 | innerRef: (node: HTMLElement) => void
8 | onBack: () => void
9 | onConfirm: () => void
10 | title: React.ReactNode
11 | content: React.ReactNode
12 | preferencesDialogTemplate?: PreferenceDialogTemplate
13 | }
14 |
15 | export default class CancelDialog extends PureComponent {
16 | static displayName = 'CancelDialog'
17 |
18 | render() {
19 | const { innerRef, onBack, title, content, preferencesDialogTemplate } = this.props
20 |
21 | const buttons = (
22 |
23 |
24 | {preferencesDialogTemplate?.cancelDialogButtons!.backValue}
25 |
26 |
27 | {preferencesDialogTemplate?.cancelDialogButtons!.cancelValue}
28 |
29 |
30 | )
31 |
32 | return (
33 |
40 | {content}
41 |
42 | )
43 | }
44 |
45 | componentDidMount() {
46 | document.body.addEventListener('keydown', this.handleEsc, false)
47 | }
48 |
49 | componentWillUnmount() {
50 | document.body.removeEventListener('keydown', this.handleEsc, false)
51 | }
52 |
53 | handleSubmit = e => {
54 | const { onConfirm } = this.props
55 |
56 | e.preventDefault()
57 | onConfirm()
58 | }
59 |
60 | handleEsc = (e: KeyboardEvent) => {
61 | const { onConfirm } = this.props
62 |
63 | // Esc key
64 | if (e.keyCode === 27) {
65 | onConfirm()
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/stories/components/common-react.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export const bannerContent = (
4 |
5 | We use cookies (and other similar technologies) to collect data to improve your experience on
6 | our site. By using our website, you’re agreeing to the collection of data as described in our{' '}
7 |
12 | Website Data Collection Policy
13 |
14 | .
15 |
16 | )
17 | export const bannerSubContent = 'You can manage your preferences here!'
18 | export const preferencesDialogTitle = 'Website Data Collection Preferences'
19 | export const preferencesDialogContent = (
20 |
21 |
22 | Segment uses data collected by cookies and JavaScript libraries to improve your browsing
23 | experience, analyze site traffic, deliver personalized advertisements, and increase the
24 | overall performance of our site.
25 |
26 |
27 | By using our website, you’re agreeing to our{' '}
28 |
33 | Website Data Collection Policy
34 |
35 | .
36 |
37 |
38 | The table below outlines how we use this data by category. To opt out of a category of data
39 | collection, select “No” and save your preferences.
40 |
41 |
42 | )
43 | export const cancelDialogTitle = 'Are you sure you want to cancel?'
44 | export const cancelDialogContent = (
45 |
56 | )
57 |
--------------------------------------------------------------------------------
/stories/ImplyConsentOnInteraction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import cookies from 'js-cookie'
3 | import { Pane, Heading, Button, Paragraph } from 'evergreen-ui'
4 | import { ConsentManager, openConsentManager } from '../src'
5 | import {
6 | bannerContent,
7 | bannerSubContent,
8 | preferencesDialogContent,
9 | preferencesDialogTitle,
10 | cancelDialogContent,
11 | cancelDialogTitle
12 | } from './components/common-react'
13 |
14 | export const ImplyConsentOnInteraction = () => {
15 | return (
16 |
17 |
28 |
29 |
30 | Your website content
31 |
32 | Clicking anywhere on this page will cause the Consent Manager to imply consent.
33 |
34 |
35 |
36 |
42 |
43 |
44 |
45 | Data Collection and Cookie Preferences
46 |
47 |
48 |
49 | to see the banner again:
50 | {
52 | cookies.remove('tracking-preferences')
53 | window.location.reload()
54 | }}
55 | >
56 | Clear tracking preferences cookie
57 |
58 |
59 |
60 |
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/stories/3-tool-based.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Pane, Heading, SubHeading, Ul, Code, Button } from 'evergreen-ui'
3 | import { ConsentManagerBuilder } from '../src'
4 | import DestinationTile from './components/destination-tile'
5 | import { storiesOf } from '@storybook/react'
6 | import CookieView from './components/CookieView'
7 |
8 | function Section(props) {
9 | return
10 | }
11 |
12 | const ToolBased = () => {
13 | return (
14 |
15 |
19 | {({ destinations, preferences, setPreferences, saveConsent }) => {
20 | function handleSubmit(e) {
21 | e.preventDefault()
22 | saveConsent()
23 | }
24 |
25 | return (
26 |
58 | )
59 | }}
60 |
61 |
62 |
63 |
64 | )
65 | }
66 |
67 | storiesOf('ConsentManagerBuilder', module).add(`Tool Based`, () => )
68 |
--------------------------------------------------------------------------------
/src/standalone.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import inEU from '@segment/in-eu'
4 | import inRegions from '@segment/in-regions'
5 | import { ConsentManager, openConsentManager, doNotTrack } from '.'
6 | import { ConsentManagerProps, WindowWithConsentManagerConfig, ConsentManagerInput } from './types'
7 | import * as preferences from './consent-manager-builder/preferences'
8 |
9 | export const version = process.env.VERSION
10 | export { openConsentManager, doNotTrack, inEU, preferences }
11 |
12 | let props: Partial = {}
13 | let containerRef: string | undefined
14 |
15 | const localWindow = window as WindowWithConsentManagerConfig
16 |
17 | if (localWindow.consentManagerConfig && typeof localWindow.consentManagerConfig === 'function') {
18 | props = localWindow.consentManagerConfig({
19 | React,
20 | version,
21 | openConsentManager,
22 | doNotTrack,
23 | inEU,
24 | preferences,
25 | inRegions
26 | })
27 | containerRef = props.container
28 | } else {
29 | throw new Error(`window.consentManagerConfig should be a function`)
30 | }
31 |
32 | if (!containerRef) {
33 | throw new Error('ConsentManager: container is required')
34 | }
35 |
36 | if (!props.writeKey) {
37 | throw new Error('ConsentManager: writeKey is required')
38 | }
39 |
40 | if (!props.bannerContent) {
41 | throw new Error('ConsentManager: bannerContent is required')
42 | }
43 |
44 | if (!props.preferencesDialogContent) {
45 | throw new Error('ConsentManager: preferencesDialogContent is required')
46 | }
47 |
48 | if (!props.cancelDialogContent) {
49 | throw new Error('ConsentManager: cancelDialogContent is required')
50 | }
51 |
52 | if (typeof props.implyConsentOnInteraction === 'string') {
53 | props.implyConsentOnInteraction = props.implyConsentOnInteraction === 'true'
54 | }
55 |
56 | if (props.closeBehavior !== undefined && typeof props.closeBehavior === 'string') {
57 | const options = ['accept', 'deny', 'dismiss']
58 |
59 | if (!options.includes(props.closeBehavior)) {
60 | throw new Error(`ConsentManager: closeBehavior should be one of ${options}`)
61 | }
62 | }
63 |
64 | const container = document.querySelector(containerRef)
65 | if (!container) {
66 | throw new Error('ConsentManager: container not found')
67 | }
68 |
69 | ReactDOM.render( , container)
70 |
--------------------------------------------------------------------------------
/src/__tests__/consent-manager-builder/fetch-destinations.test.ts:
--------------------------------------------------------------------------------
1 | import nock from 'nock'
2 | import fetchDestinations from '../../consent-manager-builder/fetch-destinations'
3 |
4 | describe('fetchDestinations', () => {
5 | test('Returns destinations for a writekey', async () => {
6 | nock('https://cdn.segment.com')
7 | .get('/v1/projects/123/integrations')
8 | .reply(200, [
9 | {
10 | name: 'Google Analytics',
11 | creationName: 'Google Analytics'
12 | },
13 | {
14 | name: 'Amplitude',
15 | creationName: 'Amplitude'
16 | }
17 | ])
18 |
19 | expect(await fetchDestinations('cdn.segment.com', ['123'])).toMatchObject([
20 | {
21 | id: 'Amplitude',
22 | name: 'Amplitude'
23 | },
24 | {
25 | id: 'Google Analytics',
26 | name: 'Google Analytics'
27 | }
28 | ])
29 | })
30 |
31 | test('Renames creationName to id', async () => {
32 | nock('https://cdn.segment.com')
33 | .get('/v1/projects/123/integrations')
34 | .reply(200, [
35 | {
36 | name: 'New Amplitude',
37 | creationName: 'Old Amplitude'
38 | }
39 | ])
40 |
41 | expect(await fetchDestinations('cdn.segment.com', ['123'])).toMatchObject([
42 | {
43 | id: 'Old Amplitude',
44 | name: 'New Amplitude'
45 | }
46 | ])
47 | })
48 |
49 | test('Doesn՚t include duplicate destinations', async () => {
50 | nock('https://cdn.segment.com')
51 | .get('/v1/projects/123/integrations')
52 | .reply(200, [
53 | {
54 | name: 'Google Analytics',
55 | creationName: 'Google Analytics'
56 | },
57 | {
58 | name: 'Amplitude',
59 | creationName: 'Amplitude'
60 | }
61 | ])
62 | .get('/v1/projects/abc/integrations')
63 | .reply(200, [
64 | {
65 | name: 'Google Analytics',
66 | creationName: 'Google Analytics'
67 | },
68 | {
69 | name: 'FullStory',
70 | creationName: 'FullStory'
71 | }
72 | ])
73 |
74 | expect(await fetchDestinations('cdn.segment.com', ['123', 'abc'])).toMatchObject([
75 | {
76 | id: 'Amplitude',
77 | name: 'Amplitude'
78 | },
79 | {
80 | id: 'FullStory',
81 | name: 'FullStory'
82 | },
83 | {
84 | id: 'Google Analytics',
85 | name: 'Google Analytics'
86 | }
87 | ])
88 | })
89 | })
90 |
--------------------------------------------------------------------------------
/src/consent-manager-builder/preferences.ts:
--------------------------------------------------------------------------------
1 | // TODO: remove duplicate cookie library from bundle
2 | import cookies, { CookieAttributes } from 'js-cookie'
3 | import topDomain from '@segment/top-domain'
4 | import { WindowWithAJS, Preferences, CategoryPreferences } from '../types'
5 | import { EventEmitter } from 'events'
6 |
7 | const DEFAULT_COOKIE_NAME = 'tracking-preferences'
8 | const COOKIE_DEFAULT_EXPIRES = 365
9 |
10 | export interface PreferencesManager {
11 | loadPreferences(cookieName?: string): Preferences
12 | onPreferencesSaved(listener: (prefs: Preferences) => void): void
13 | savePreferences(prefs: SavePreferences): void
14 | }
15 |
16 | // TODO: harden against invalid cookies
17 | // TODO: harden against different versions of cookies
18 | export function loadPreferences(cookieName?: string): Preferences {
19 | const preferences = cookies.getJSON(cookieName || DEFAULT_COOKIE_NAME)
20 |
21 | if (!preferences) {
22 | return {}
23 | }
24 |
25 | return {
26 | destinationPreferences: preferences.destinations as CategoryPreferences,
27 | customPreferences: preferences.custom as CategoryPreferences
28 | }
29 | }
30 |
31 | type SavePreferences = Preferences & {
32 | cookieDomain?: string
33 | cookieName?: string
34 | cookieExpires?: number
35 | cookieAttributes?: CookieAttributes
36 | }
37 |
38 | const emitter = new EventEmitter()
39 |
40 | /**
41 | * Subscribes to consent preferences changing over time and returns
42 | * a cleanup function that can be invoked to remove the instantiated listener.
43 | *
44 | * @param listener a function to be invoked when ConsentPreferences are saved
45 | */
46 | export function onPreferencesSaved(listener: (prefs: Preferences) => void) {
47 | emitter.on('preferencesSaved', listener)
48 | return () => emitter.off('preferencesSaved', listener)
49 | }
50 |
51 | export function savePreferences({
52 | destinationPreferences,
53 | customPreferences,
54 | cookieDomain,
55 | cookieName,
56 | cookieExpires,
57 | cookieAttributes = {}
58 | }: SavePreferences) {
59 | const wd = window as WindowWithAJS
60 | if (wd.analytics) {
61 | wd.analytics.identify({
62 | destinationTrackingPreferences: destinationPreferences,
63 | customTrackingPreferences: customPreferences
64 | })
65 | }
66 |
67 | const domain = cookieDomain || topDomain(window.location.href)
68 | const expires = cookieExpires || COOKIE_DEFAULT_EXPIRES
69 | const value = {
70 | version: 1,
71 | destinations: destinationPreferences,
72 | custom: customPreferences
73 | }
74 |
75 | cookies.set(cookieName || DEFAULT_COOKIE_NAME, value, {
76 | expires,
77 | domain,
78 | ...cookieAttributes
79 | })
80 |
81 | emitter.emit('preferencesSaved', {
82 | destinationPreferences,
83 | customPreferences
84 | })
85 | }
86 |
--------------------------------------------------------------------------------
/src/consent-manager/buttons.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled'
2 | import { css } from '@emotion/react'
3 |
4 | const baseStyles = css`
5 | height: 32px;
6 | padding: 0 16px;
7 | border: none;
8 | border-radius: 4px;
9 | color: inherit;
10 | font: inherit;
11 | font-size: 12px;
12 | line-height: 1;
13 | cursor: pointer;
14 | outline: none;
15 | transition: box-shadow 80ms ease-in-out;
16 | `
17 |
18 | export const DefaultButton = styled('button')`
19 | ${baseStyles};
20 | margin-right: 8px;
21 | background-color: #fff;
22 | background-image: linear-gradient(to top, rgba(67, 90, 111, 0.041), rgba(255, 255, 255, 0.041));
23 | box-shadow: inset 0 0 0 1px rgba(67, 90, 111, 0.146), inset 0 -1px 1px 0 rgba(67, 90, 111, 0.079);
24 | &:hover {
25 | background-image: linear-gradient(to top, rgba(67, 90, 111, 0.057), rgba(67, 90, 111, 0.025));
26 | box-shadow: inset 0 0 0 1px rgba(67, 90, 111, 0.255),
27 | inset 0 -1px 1px 0 rgba(67, 90, 111, 0.114);
28 | }
29 | &:focus {
30 | box-shadow: 0 0 0 3px rgba(1, 108, 209, 0.146), inset 0 0 0 1px rgba(67, 90, 111, 0.38),
31 | inset 0 -1px 1px 0 rgba(67, 90, 111, 0.079);
32 | }
33 | &:active {
34 | background: rgba(1, 108, 209, 0.079);
35 | box-shadow: inset 0 0 0 1px rgba(67, 90, 111, 0.146),
36 | inset 0 -1px 1px 0 rgba(67, 90, 111, 0.079);
37 | }
38 | `
39 |
40 | export const GreenButton = styled('button')`
41 | ${baseStyles};
42 | background-color: #47b881;
43 | background-image: linear-gradient(to top, #3faf77, #47b881);
44 | box-shadow: inset 0 0 0 1px rgba(67, 90, 111, 0.204), inset 0 -1px 1px 0 rgba(67, 90, 111, 0.204);
45 | color: #fff;
46 | &:hover {
47 | background-image: linear-gradient(to top, #37a56d, #3faf77);
48 | }
49 | &:focus {
50 | box-shadow: 0 0 0 3px rgba(71, 184, 129, 0.477), inset 0 0 0 1px rgba(71, 184, 129, 0.204),
51 | inset 0 -1px 1px 0 rgba(71, 184, 129, 0.204);
52 | }
53 | &:active {
54 | background-image: linear-gradient(to top, #2d9760, #248953);
55 | box-shadow: inset 0 0 0 1px rgba(71, 184, 129, 0.204),
56 | inset 0 -1px 1px 0 rgba(71, 184, 129, 0.204);
57 | }
58 | `
59 |
60 | export const RedButton = styled('button')`
61 | ${baseStyles};
62 | background-color: #f36331;
63 | background-image: linear-gradient(to top, #f4541d, #f36331);
64 | box-shadow: inset 0 0 0 1px rgba(67, 90, 111, 0.204), inset 0 -1px 1px 0 rgba(67, 90, 111, 0.204);
65 | color: #fff;
66 | &:hover {
67 | background-image: linear-gradient(to top, #f4450a, #f4541d);
68 | }
69 | &:focus {
70 | box-shadow: 0 0 0 3px rgba(243, 99, 49, 0.477), inset 0 0 0 1px rgba(243, 99, 49, 0.204),
71 | inset 0 -1px 1px 0 rgba(243, 99, 49, 0.204);
72 | }
73 | &:active {
74 | background-image: linear-gradient(to top, #dd3c06, #c63403);
75 | box-shadow: inset 0 0 0 1px rgba(67, 90, 111, 0.204),
76 | inset 0 -1px 1px 0 rgba(67, 90, 111, 0.204);
77 | }
78 | `
79 |
--------------------------------------------------------------------------------
/stories/2-category-based.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import groupBy from 'lodash/groupBy'
3 | import { Pane, Heading, SubHeading, Ul, Code, Button } from 'evergreen-ui'
4 | import { ConsentManagerBuilder } from '../src'
5 | import DestinationTile from './components/destination-tile'
6 | import { storiesOf } from '@storybook/react'
7 | import CookieView from './components/CookieView'
8 |
9 | function Section(props) {
10 | return
11 | }
12 |
13 | function byCategory(destinations) {
14 | return groupBy(destinations, 'category')
15 | }
16 |
17 | const CategoryBased = () => {
18 | return (
19 |
20 | console.error('Error Handling', e)}
22 | writeKey="tYQQPcY78Hc3T1hXUYk0n4xcbEHnN7r0"
23 | otherWriteKeys={['vMRS7xbsjH97Bb2PeKbEKvYDvgMm5T3l']}
24 | >
25 | {({ destinations, preferences, setPreferences, saveConsent }) => {
26 | function handleSubmit(e) {
27 | e.preventDefault()
28 | saveConsent()
29 | }
30 |
31 | const categories = byCategory(destinations)
32 |
33 | return (
34 |
75 | )
76 | }}
77 |
78 |
79 |
80 |
81 | )
82 | }
83 |
84 | storiesOf('ConsentManagerBuilder', module).add(`Category Based`, () => )
85 |
--------------------------------------------------------------------------------
/src/consent-manager-builder/analytics.ts:
--------------------------------------------------------------------------------
1 | import {
2 | WindowWithAJS,
3 | Destination,
4 | DefaultDestinationBehavior,
5 | CategoryPreferences,
6 | Middleware
7 | } from '../types'
8 |
9 | interface AnalyticsParams {
10 | writeKey: string
11 | destinations: Destination[]
12 | destinationPreferences: CategoryPreferences | null | undefined
13 | isConsentRequired: boolean
14 | shouldReload?: boolean
15 | devMode?: boolean
16 | defaultDestinationBehavior?: DefaultDestinationBehavior
17 | categoryPreferences: CategoryPreferences | null | undefined
18 | }
19 |
20 | function getConsentMiddleware(
21 | destinationPreferences,
22 | categoryPreferences,
23 | defaultDestinationBehavior
24 | ): Middleware {
25 | return ({ payload, next }) => {
26 | payload.obj.context.consent = {
27 | defaultDestinationBehavior,
28 | categoryPreferences,
29 | destinationPreferences
30 | }
31 | next(payload)
32 | }
33 | }
34 |
35 | export default function conditionallyLoadAnalytics({
36 | writeKey,
37 | destinations,
38 | destinationPreferences,
39 | isConsentRequired,
40 | shouldReload = true,
41 | devMode = false,
42 | defaultDestinationBehavior,
43 | categoryPreferences
44 | }: AnalyticsParams) {
45 | const wd = window as WindowWithAJS
46 | const integrations = { All: false, 'Segment.io': true }
47 | let isAnythingEnabled = false
48 |
49 | if (!destinationPreferences) {
50 | if (isConsentRequired) {
51 | return
52 | }
53 |
54 | // Load a.js normally when consent isn't required and there's no preferences
55 | if (!wd.analytics.initialized) {
56 | wd.analytics.load(writeKey)
57 | }
58 | return
59 | }
60 |
61 | for (const destination of destinations) {
62 | // Was a preference explicitly set on this destination?
63 | const explicitPreference = destination.id in destinationPreferences
64 | if (!explicitPreference && defaultDestinationBehavior === 'enable') {
65 | integrations[destination.id] = true
66 | continue
67 | }
68 |
69 | const isEnabled = Boolean(destinationPreferences[destination.id])
70 | if (isEnabled) {
71 | isAnythingEnabled = true
72 | }
73 | integrations[destination.id] = isEnabled
74 | }
75 |
76 | // Reload the page if the trackers have already been initialised so that
77 | // the user's new preferences can take affect
78 | if (wd.analytics && wd.analytics.initialized) {
79 | if (shouldReload) {
80 | window.location.reload()
81 | }
82 | return
83 | }
84 |
85 | if (devMode) {
86 | return
87 | }
88 |
89 | // Don't load a.js at all if nothing has been enabled
90 | if (isAnythingEnabled) {
91 | const middleware = getConsentMiddleware(
92 | destinationPreferences,
93 | categoryPreferences,
94 | defaultDestinationBehavior
95 | )
96 | // @ts-ignore: Analytics.JS type should be updated with addSourceMiddleware
97 | wd.analytics.addSourceMiddleware(middleware)
98 |
99 | wd.analytics.load(writeKey, { integrations })
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/stories/standalone.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 | Data Collection and Cookie Preferences
11 |
12 |
13 |
31 |
32 |
33 |
34 |
35 |
36 |
93 |
94 |
95 |
--------------------------------------------------------------------------------
/stories/standalone-custom.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
42 | Data Collection and Cookie Preferences
43 |
44 |
45 |
46 |
47 |
48 |
105 |
106 |
107 |
--------------------------------------------------------------------------------
/stories/0.1-consent-manager-close-interaction.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import cookies from 'js-cookie'
3 | import { Pane, Heading, Button } from 'evergreen-ui'
4 | import { ConsentManager, openConsentManager } from '../src'
5 | import { storiesOf } from '@storybook/react'
6 | import { ImplyConsentOnInteraction } from './ImplyConsentOnInteraction'
7 | import CookieView from './components/CookieView'
8 |
9 | const bannerContent = (
10 |
11 | We use cookies (and other similar technologies) to collect data to improve your experience on
12 | our site. By using our website, you’re agreeing to the collection of data as described in our{' '}
13 |
18 | Website Data Collection Policy
19 |
20 | .
21 |
22 | )
23 | const bannerSubContent = 'You can manage your preferences here!'
24 | const preferencesDialogTitle = 'Website Data Collection Preferences'
25 | const preferencesDialogContent = (
26 |
27 |
28 | Segment uses data collected by cookies and JavaScript libraries to improve your browsing
29 | experience, analyze site traffic, deliver personalized advertisements, and increase the
30 | overall performance of our site.
31 |
32 |
33 | By using our website, you’re agreeing to our{' '}
34 |
39 | Website Data Collection Policy
40 |
41 | .
42 |
43 |
44 | The table below outlines how we use this data by category. To opt out of a category of data
45 | collection, select “No” and save your preferences.
46 |
47 |
48 | )
49 | const cancelDialogTitle = 'Are you sure you want to cancel?'
50 | const cancelDialogContent = (
51 |
62 | )
63 |
64 | const ConsentManagerExample = () => {
65 | return (
66 |
67 |
77 |
78 |
79 | Your website content
80 |
81 |
87 |
88 |
94 |
95 |
96 |
97 | Data Collection and Cookie Preferences
98 |
99 |
100 |
101 | to see the banner again:
102 | {
104 | cookies.remove('tracking-preferences')
105 | window.location.reload()
106 | }}
107 | >
108 | Clear tracking preferences cookie
109 |
110 |
111 |
112 |
113 |
114 |
115 | )
116 | }
117 |
118 | storiesOf('React Component / Basics', module)
119 | .add(`Basic React Component`, () => )
120 | .add(`Basic React Component with implied consent`, () => )
121 |
--------------------------------------------------------------------------------
/src/__tests__/consent-manager-builder/preferences.test.ts:
--------------------------------------------------------------------------------
1 | import { URL } from 'url'
2 | import sinon from 'sinon'
3 | import { loadPreferences, savePreferences } from '../../consent-manager-builder/preferences'
4 |
5 | describe('preferences', () => {
6 | beforeEach(() => {
7 | window = {
8 | location: {
9 | href: 'http://localhost/'
10 | }
11 | } as Window & typeof globalThis
12 |
13 | document = {
14 | createElement(type: string) {
15 | if (type === 'a') {
16 | return new URL('http://localhost/')
17 | }
18 |
19 | return
20 | }
21 | } as Document
22 | })
23 |
24 | test('loadPreferences() returns preferences when cookie exists', () => {
25 | document.cookie =
26 | 'tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Amplitude%22:true}%2C%22custom%22:{%22functional%22:true}}'
27 |
28 | expect(loadPreferences()).toMatchObject({
29 | destinationPreferences: {
30 | Amplitude: true
31 | },
32 | customPreferences: {
33 | functional: true
34 | }
35 | })
36 | })
37 |
38 | test('loadPreferences(cookieName) returns preferences when cookie exists', () => {
39 | document.cookie =
40 | 'custom-tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Amplitude%22:true}%2C%22custom%22:{%22functional%22:true}}'
41 |
42 | expect(loadPreferences('custom-tracking-preferences')).toMatchObject({
43 | destinationPreferences: {
44 | Amplitude: true
45 | },
46 | customPreferences: {
47 | functional: true
48 | }
49 | })
50 | })
51 |
52 | test('savePreferences() saves the preferences', () => {
53 | const ajsIdentify = sinon.spy()
54 |
55 | // @ts-ignore
56 | window.analytics = { identify: ajsIdentify }
57 | document.cookie = ''
58 |
59 | const destinationPreferences = {
60 | Amplitude: true
61 | }
62 | const customPreferences = {
63 | functional: true
64 | }
65 |
66 | savePreferences({
67 | destinationPreferences,
68 | customPreferences,
69 | cookieDomain: undefined
70 | })
71 |
72 | expect(ajsIdentify.calledOnce).toBe(true)
73 | expect(ajsIdentify.args[0][0]).toMatchObject({
74 | destinationTrackingPreferences: destinationPreferences,
75 | customTrackingPreferences: customPreferences
76 | })
77 |
78 | expect(
79 | document.cookie.includes(
80 | 'tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Amplitude%22:true}%2C%22custom%22:{%22functional%22:true}}'
81 | )
82 | ).toBe(true)
83 | })
84 |
85 | test('savePreferences() sets the cookie domain', () => {
86 | const ajsIdentify = sinon.spy()
87 | // @ts-ignore
88 | window.analytics = { identify: ajsIdentify }
89 | document.cookie = ''
90 |
91 | const destinationPreferences = {
92 | Amplitude: true
93 | }
94 |
95 | savePreferences({
96 | destinationPreferences,
97 | customPreferences: undefined,
98 | cookieDomain: 'example.com'
99 | })
100 |
101 | expect(ajsIdentify.calledOnce).toBe(true)
102 | expect(ajsIdentify.args[0][0]).toMatchObject({
103 | destinationTrackingPreferences: destinationPreferences,
104 | customTrackingPreferences: undefined
105 | })
106 |
107 | // TODO: actually check domain
108 | // expect(document.cookie.includes('domain=example.com')).toBe(true)
109 | })
110 |
111 | test('savePreferences() sets the cookie with custom key', () => {
112 | const ajsIdentify = sinon.spy()
113 | // @ts-ignore
114 | window.analytics = { identify: ajsIdentify }
115 | document.cookie = ''
116 |
117 | const destinationPreferences = {
118 | Amplitude: true
119 | }
120 |
121 | savePreferences({
122 | destinationPreferences,
123 | customPreferences: undefined,
124 | cookieDomain: undefined,
125 | cookieName: 'custom-tracking-preferences'
126 | })
127 |
128 | expect(ajsIdentify.calledOnce).toBe(true)
129 | expect(ajsIdentify.args[0][0]).toMatchObject({
130 | destinationTrackingPreferences: destinationPreferences,
131 | customTrackingPreferences: undefined
132 | })
133 |
134 | expect(document.cookie.includes('custom-tracking-preferences')).toBe(true)
135 | })
136 | })
137 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { CloseBehaviorFunction } from './consent-manager/container'
2 | import { PreferencesManager } from './consent-manager-builder/preferences'
3 | import { CookieAttributes } from 'js-cookie'
4 |
5 | type AJS = SegmentAnalytics.AnalyticsJS & {
6 | initialized: boolean
7 | track: (event: string, properties: { [key: string]: any }) => void
8 | addSourceMiddleware: (middleware: Middleware) => void
9 | }
10 |
11 | export type Middleware = (input: MiddlewareInput) => void
12 | interface MiddlewareInput {
13 | payload: {
14 | obj: Record
15 | [key: string]: any
16 | }
17 | integrations?: Record
18 | next: (payload: MiddlewareInput['payload']) => void
19 | }
20 |
21 | export type WindowWithAJS = Window &
22 | typeof globalThis & {
23 | analytics?: AJS
24 | }
25 |
26 | export type WindowWithConsentManagerConfig = Window &
27 | typeof globalThis & {
28 | consentManagerConfig?: (
29 | args: StandaloneConsentManagerParams
30 | ) => ConsentManagerInput | ConsentManagerInput
31 | }
32 |
33 | export type ConsentManagerInput = ConsentManagerProps & {
34 | container: string
35 | }
36 |
37 | export type DefaultDestinationBehavior = 'enable' | 'disable' | 'imply' | 'ask'
38 |
39 | export type CloseBehavior = 'accept' | 'deny' | 'dismiss'
40 |
41 | interface StandaloneConsentManagerParams {
42 | React: unknown
43 | version?: string
44 | openConsentManager: () => void
45 | doNotTrack: () => boolean | null
46 | inEU: () => boolean
47 | preferences: PreferencesManager
48 | inRegions: (regions: string[]) => () => boolean
49 | }
50 |
51 | export interface Preferences {
52 | destinationPreferences?: CategoryPreferences
53 | customPreferences?: CategoryPreferences
54 | }
55 |
56 | export interface Destination {
57 | id: string
58 | name: string
59 | creationName: string
60 | description: string
61 | website: string
62 | category: string
63 | }
64 |
65 | export interface CategoryPreferences {
66 | functional?: boolean | null | undefined
67 | marketingAndAnalytics?: boolean | null | undefined
68 | advertising?: boolean | null | undefined
69 | [category: string]: boolean | null | undefined | string
70 | }
71 |
72 | export interface CustomCategories {
73 | [key: string]: CustomCategory
74 | }
75 |
76 | interface CustomCategory {
77 | integrations: string[]
78 | purpose: string
79 | }
80 |
81 | export interface PreferencesCategories {
82 | key: string
83 | name?: string
84 | description?: string
85 | example?: string
86 | }
87 |
88 | export interface PreferenceDialogTemplate {
89 | headings?: {
90 | allowValue?: string
91 | categoryValue?: string
92 | purposeValue?: string
93 | toolsValue?: string
94 | }
95 | checkboxes?: {
96 | noValue?: string
97 | yesValue?: string
98 | }
99 | actionButtons?: {
100 | saveValue?: string
101 | cancelValue?: string
102 | }
103 | cancelDialogButtons?: {
104 | cancelValue?: string
105 | backValue?: string
106 | }
107 | categories?: PreferencesCategories[]
108 | }
109 |
110 | export interface ConsentManagerProps {
111 | writeKey: string
112 | otherWriteKeys?: string[]
113 | shouldRequireConsent?: () => Promise | boolean
114 | implyConsentOnInteraction?: boolean
115 | cookieDomain?: string
116 | cookieName?: string
117 | cookieAttributes?: CookieAttributes
118 | cookieExpires?: number
119 | bannerContent: React.ReactNode
120 | bannerSubContent?: string
121 | bannerActionsBlock?: ((props: ActionsBlockProps) => React.ReactElement) | true
122 | bannerTextColor?: string
123 | bannerBackgroundColor?: string
124 | bannerHideCloseButton: boolean
125 | bannerAsModal?: boolean
126 | preferencesDialogTitle?: React.ReactNode
127 | preferencesDialogContent: React.ReactNode
128 | onError?: (error: Error | undefined) => void
129 | cancelDialogTitle?: React.ReactNode
130 | cancelDialogContent: React.ReactNode
131 | closeBehavior?: CloseBehavior | CloseBehaviorFunction
132 | initialPreferences?: CategoryPreferences
133 | customCategories?: CustomCategories
134 | defaultDestinationBehavior?: DefaultDestinationBehavior
135 | cdnHost?: string
136 | preferencesDialogTemplate?: PreferenceDialogTemplate
137 | }
138 |
139 | export interface ActionsBlockProps {
140 | acceptAll: () => void
141 | denyAll: () => void
142 | changePreferences: () => void
143 | }
144 |
--------------------------------------------------------------------------------
/stories/0.2-consent-manager-custom-cookie-name.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import cookies from 'js-cookie'
3 | import { Pane, Heading, Button } from 'evergreen-ui'
4 | import { ConsentManager, openConsentManager, loadPreferences, onPreferencesSaved } from '../src'
5 | import { storiesOf } from '@storybook/react'
6 | import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs'
7 | import SyntaxHighlighter from 'react-syntax-highlighter'
8 | import { Preferences } from '../src/types'
9 | import CookieView from './components/CookieView'
10 |
11 | const bannerContent = (
12 |
13 | We use cookies (and other similar technologies) to collect data to improve your experience on
14 | our site. By using our website, you’re agreeing to the collection of data as described in our{' '}
15 |
20 | Website Data Collection Policy
21 |
22 | .
23 |
24 | )
25 | const bannerSubContent = 'You can manage your preferences here!'
26 | const preferencesDialogTitle = 'Website Data Collection Preferences'
27 | const preferencesDialogContent = (
28 |
29 |
30 | Segment uses data collected by cookies and JavaScript libraries to improve your browsing
31 | experience, analyze site traffic, deliver personalized advertisements, and increase the
32 | overall performance of our site.
33 |
34 |
35 | By using our website, you’re agreeing to our{' '}
36 |
41 | Website Data Collection Policy
42 |
43 | .
44 |
45 |
46 | The table below outlines how we use this data by category. To opt out of a category of data
47 | collection, select “No” and save your preferences.
48 |
49 |
50 | )
51 | const cancelDialogTitle = 'Are you sure you want to cancel?'
52 | const cancelDialogContent = (
53 |
64 | )
65 |
66 | const ConsentManagerExample = (props: { cookieName: string }) => {
67 | const [prefs, updatePrefs] = React.useState(
68 | loadPreferences('custom-tracking-preferences')
69 | )
70 |
71 | const cleanup = onPreferencesSaved(preferences => {
72 | updatePrefs(preferences)
73 | })
74 |
75 | React.useEffect(() => {
76 | return () => {
77 | cleanup()
78 | }
79 | })
80 |
81 | return (
82 |
83 |
94 |
95 |
96 | Your website content
97 |
98 |
104 |
105 |
111 |
112 |
113 |
114 |
115 | Current Preferences
116 |
117 | {JSON.stringify(prefs, null, 2)}
118 |
119 |
120 |
121 | Change Cookie Preferences
122 |
123 | {
125 | cookies.remove('tracking-preferences')
126 | window.location.reload()
127 | }}
128 | >
129 | Clear
130 |
131 |
132 |
133 |
134 |
135 | )
136 | }
137 |
138 | storiesOf('React Component / Custom Cookie Name', module).add(`Custom Cookie Name`, () => (
139 |
140 | ))
141 |
--------------------------------------------------------------------------------
/stories/8-consent-manager-actions-block.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import cookies from 'js-cookie'
3 | import { Pane, Heading, Button } from 'evergreen-ui'
4 | import { ConsentManager, openConsentManager } from '../src'
5 | import { storiesOf } from '@storybook/react'
6 | import CookieView from './components/CookieView'
7 |
8 | const bannerContent = (
9 |
10 | We use cookies (and other similar technologies) to collect data to improve your experience on
11 | our site.
12 |
13 | By using our website, you’re agreeing to the collection of data as described in our{' '}
14 |
19 | Website Data Collection Policy
20 |
21 | .
22 |
23 | )
24 | const bannerSubContent = 'You can manage your preferences here!'
25 | const bannerActionsBlock = ({ acceptAll, denyAll }) => (
26 |
27 |
28 | Allow all
29 |
30 |
31 | Deny all
32 |
33 |
34 | )
35 | const preferencesDialogTitle = 'Website Data Collection Preferences'
36 | const preferencesDialogContent = (
37 |
38 |
39 | Segment uses data collected by cookies and JavaScript libraries to improve your browsing
40 | experience, analyze site traffic, deliver personalized advertisements, and increase the
41 | overall performance of our site.
42 |
43 |
44 | By using our website, you’re agreeing to our{' '}
45 |
50 | Website Data Collection Policy
51 |
52 | .
53 |
54 |
55 | The table below outlines how we use this data by category. To opt out of a category of data
56 | collection, select “No” and save your preferences.
57 |
58 |
59 | )
60 | const cancelDialogTitle = 'Are you sure you want to cancel?'
61 | const cancelDialogContent = (
62 |
73 | )
74 |
75 | const ConsentManagerExample = props => {
76 | return (
77 |
78 |
91 |
92 |
93 | Your website content
94 |
95 |
101 |
102 |
108 |
109 |
110 |
111 | Data Collection and Cookie Preferences
112 |
113 |
114 |
115 | to see the banner again:
116 | {
118 | cookies.remove('tracking-preferences')
119 | window.location.reload()
120 | }}
121 | >
122 | Clear tracking preferences cookie
123 |
124 |
125 |
126 |
127 |
128 |
129 | )
130 | }
131 |
132 | storiesOf('React Component / With Banner Actions Block', module)
133 | .add(`Default Banner Actions`, () => )
134 | .add(`Default Banner Actions without Close Button`, () => (
135 |
136 | ))
137 | .add(`Custom Banner Actions`, () => )
138 | .add(`Custom Banner Action without Close Button`, () => (
139 |
140 | ))
141 |
--------------------------------------------------------------------------------
/stories/0.3-consent-manager-custom-cookie-attributes.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import cookies, { CookieAttributes } from 'js-cookie'
3 | import { Pane, Heading, Button } from 'evergreen-ui'
4 | import { ConsentManager, openConsentManager, loadPreferences, onPreferencesSaved } from '../src'
5 | import { storiesOf } from '@storybook/react'
6 | import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs'
7 | import SyntaxHighlighter from 'react-syntax-highlighter'
8 | import { Preferences } from '../src/types'
9 | import CookieView from './components/CookieView'
10 |
11 | const bannerContent = (
12 |
13 | We use cookies (and other similar technologies) to collect data to improve your experience on
14 | our site. By using our website, you’re agreeing to the collection of data as described in our{' '}
15 |
20 | Website Data Collection Policy
21 |
22 | .
23 |
24 | )
25 | const bannerSubContent = 'You can manage your preferences here!'
26 | const preferencesDialogTitle = 'Website Data Collection Preferences'
27 | const preferencesDialogContent = (
28 |
29 |
30 | Segment uses data collected by cookies and JavaScript libraries to improve your browsing
31 | experience, analyze site traffic, deliver personalized advertisements, and increase the
32 | overall performance of our site.
33 |
34 |
35 | By using our website, you’re agreeing to our{' '}
36 |
41 | Website Data Collection Policy
42 |
43 | .
44 |
45 |
46 | The table below outlines how we use this data by category. To opt out of a category of data
47 | collection, select “No” and save your preferences.
48 |
49 |
50 | )
51 | const cancelDialogTitle = 'Are you sure you want to cancel?'
52 | const cancelDialogContent = (
53 |
64 | )
65 |
66 | const ConsentManagerExample = (props: { cookieAttributes: CookieAttributes }) => {
67 | const [prefs, updatePrefs] = React.useState(loadPreferences())
68 |
69 | const cleanup = onPreferencesSaved(preferences => {
70 | updatePrefs(preferences)
71 | })
72 |
73 | React.useEffect(() => {
74 | return () => {
75 | cleanup()
76 | }
77 | })
78 |
79 | return (
80 |
81 |
92 |
93 |
94 | Your website content
95 |
96 |
102 |
103 |
109 |
110 |
111 |
112 |
113 | Current Preferences
114 |
115 | {JSON.stringify(prefs, null, 2)}
116 |
117 |
118 |
119 | Change Cookie Preferences
120 |
121 | {
123 | cookies.remove('tracking-preferences')
124 | window.location.reload()
125 | }}
126 | >
127 | Clear
128 |
129 |
130 |
131 |
132 |
133 | )
134 | }
135 |
136 | storiesOf('React Component / Custom Cookie Attributes', module).add(
137 | `Custom Cookie Attributes`,
138 | () =>
139 | )
140 |
--------------------------------------------------------------------------------
/stories/9-consent-manager-as-modal.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import cookies from 'js-cookie'
3 | import { Pane, Heading, Button } from 'evergreen-ui'
4 | import { ConsentManager, openConsentManager } from '../src'
5 | import { storiesOf } from '@storybook/react'
6 | import CookieView from './components/CookieView'
7 |
8 | const bannerContent = (
9 |
10 | We use cookies (and other similar technologies) to collect data to improve your experience on
11 | our site.
12 |
13 | By using our website, you’re agreeing to the collection of data as described in our{' '}
14 |
19 | Website Data Collection Policy
20 |
21 | .
22 |
23 | )
24 | const bannerSubContent = 'You can manage your preferences here!'
25 | const bannerActionsBlock = ({ acceptAll, denyAll }) => (
26 |
27 |
28 | Allow all
29 |
30 |
31 | Deny all
32 |
33 |
34 | )
35 | const preferencesDialogTitle = 'Website Data Collection Preferences'
36 | const preferencesDialogContent = (
37 |
38 |
39 | Segment uses data collected by cookies and JavaScript libraries to improve your browsing
40 | experience, analyze site traffic, deliver personalized advertisements, and increase the
41 | overall performance of our site.
42 |
43 |
44 | By using our website, you’re agreeing to our{' '}
45 |
50 | Website Data Collection Policy
51 |
52 | .
53 |
54 |
55 | The table below outlines how we use this data by category. To opt out of a category of data
56 | collection, select “No” and save your preferences.
57 |
58 |
59 | )
60 | const cancelDialogTitle = 'Are you sure you want to cancel?'
61 | const cancelDialogContent = (
62 |
73 | )
74 |
75 | const ConsentManagerExample = props => {
76 | return (
77 |
78 |
92 |
93 |
94 | Your website content
95 |
96 |
102 |
103 |
109 |
110 |
111 |
112 | Data Collection and Cookie Preferences
113 |
114 |
115 |
116 | to see the banner again:
117 | {
119 | cookies.remove('tracking-preferences')
120 | window.location.reload()
121 | }}
122 | >
123 | Clear tracking preferences cookie
124 |
125 |
126 |
127 |
128 |
129 |
130 | )
131 | }
132 |
133 | storiesOf('React Component / Banner as Modal', module)
134 | .add(`Banner as Modal`, () => (
135 |
136 | ))
137 | .add(`Banner as Modal with close button`, () => )
138 | .add(`Banner as Modal with custom buttons`, () => (
139 |
140 | ))
141 |
142 | .add(`Banner as Modal with custom buttons and close button`, () => (
143 |
144 | ))
145 |
--------------------------------------------------------------------------------
/stories/0-consent-manager.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import cookies from 'js-cookie'
3 | import { Pane, Heading, Button } from 'evergreen-ui'
4 | import { ConsentManager, openConsentManager, loadPreferences, onPreferencesSaved } from '../src'
5 | import { storiesOf } from '@storybook/react'
6 | import { CloseBehaviorFunction } from '../src/consent-manager/container'
7 | import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs'
8 | import SyntaxHighlighter from 'react-syntax-highlighter'
9 | import { CloseBehavior, Preferences } from '../src/types'
10 | import CookieView from './components/CookieView'
11 |
12 | const bannerContent = (
13 |
14 | We use cookies (and other similar technologies) to collect data to improve your experience on
15 | our site. By using our website, you’re agreeing to the collection of data as described in our{' '}
16 |
21 | Website Data Collection Policy
22 |
23 | .
24 |
25 | )
26 | const bannerSubContent = 'You can manage your preferences here!'
27 | const preferencesDialogTitle = 'Website Data Collection Preferences'
28 | const preferencesDialogContent = (
29 |
30 |
31 | Segment uses data collected by cookies and JavaScript libraries to improve your browsing
32 | experience, analyze site traffic, deliver personalized advertisements, and increase the
33 | overall performance of our site.
34 |
35 |
36 | By using our website, you’re agreeing to our{' '}
37 |
42 | Website Data Collection Policy
43 |
44 | .
45 |
46 |
47 | The table below outlines how we use this data by category. To opt out of a category of data
48 | collection, select “No” and save your preferences.
49 |
50 |
51 | )
52 | const cancelDialogTitle = 'Are you sure you want to cancel?'
53 | const cancelDialogContent = (
54 |
65 | )
66 |
67 | const ConsentManagerExample = (props: { closeBehavior: CloseBehavior | CloseBehaviorFunction }) => {
68 | const [prefs, updatePrefs] = React.useState(loadPreferences())
69 |
70 | const cleanup = onPreferencesSaved(preferences => {
71 | updatePrefs(preferences)
72 | })
73 |
74 | React.useEffect(() => {
75 | return () => {
76 | cleanup()
77 | }
78 | })
79 |
80 | return (
81 |
82 |
93 |
94 |
95 | Your website content
96 |
97 |
103 |
104 |
110 |
111 |
112 |
113 |
114 | Current Preferences
115 |
116 | {JSON.stringify(prefs, null, 2)}
117 |
118 |
119 |
120 | Change Cookie Preferences
121 |
122 | {
124 | cookies.remove('tracking-preferences')
125 | window.location.reload()
126 | }}
127 | >
128 | Clear
129 |
130 |
131 |
132 |
133 |
134 | )
135 | }
136 |
137 | storiesOf('React Component / OnClose interactions', module)
138 | .add(`Dismiss`, () => )
139 | .add(`Accept`, () => )
140 | .add(`Deny`, () => )
141 | .add(`Custom Close Behavior`, () => (
142 | ({
144 | ...categories,
145 | advertising: false
146 | })}
147 | />
148 | ))
149 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@segment/consent-manager",
3 | "version": "5.8.1",
4 | "description": "Drop-in consent management plugin for analytics.js",
5 | "keywords": [
6 | "gdpr",
7 | "tracking",
8 | "analytics",
9 | "analytics.js"
10 | ],
11 | "repository": "segmentio/consent-manager",
12 | "license": "MIT",
13 | "main": "commonjs/index.js",
14 | "module": "esm/index.js",
15 | "types": "types/index.d.ts",
16 | "sideEffects": false,
17 | "files": [
18 | "commonjs",
19 | "esm",
20 | "standalone",
21 | "types",
22 | "src"
23 | ],
24 | "scripts": {
25 | "test": "jest src/__tests__",
26 | "prepublishOnly": "yarn run clean && yarn run build",
27 | "postpublish": "yarn build-storybook && yarn deploy-storybook -- --existing-output-dir=storybook-static",
28 | "dev": "concurrently 'yarn build-standalone --watch' 'yarn start-storybook -s ./ -p 9009'",
29 | "build-commonjs": "tsc --outDir commonjs --module CommonJS --inlineSourceMap",
30 | "build-esm": "tsc --module es2015 --outDir esm --inlineSourceMap",
31 | "build-standalone": "webpack",
32 | "build": "concurrently --names \"commonjs,esm,standalone\" \"yarn run build-commonjs\" \"yarn run build-esm\" \"yarn run build-standalone\"",
33 | "clean": "rm -rf commonjs esm standalone storybook-static types",
34 | "deploy-storybook": "storybook-to-ghpages",
35 | "standalone-hash": "shasum -b -a 256 standalone/consent-manager.js | xxd -r -p | base64",
36 | "build-storybook": "yarn build-standalone && build-storybook && cp -r ./standalone ./storybook-static/standalone",
37 | "lint": "eslint \"src/**/*.{ts,tsx}\""
38 | },
39 | "dependencies": {
40 | "@emotion/react": "^11.8.2",
41 | "@emotion/styled": "^11.8.1",
42 | "@segment/in-regions": "^1.2.0",
43 | "@segment/top-domain": "^3.0.0",
44 | "emotion": "^9.1.2",
45 | "isomorphic-fetch": "^3.0.0",
46 | "js-cookie": "^2.2.0",
47 | "lodash": "^4.17.21",
48 | "nanoid": "^1.0.2",
49 | "prop-types": "^15.6.1",
50 | "styled-components": "^5.3.5"
51 | },
52 | "devDependencies": {
53 | "@babel/core": "^7.6.2",
54 | "@segment/in-eu": "^0.3.0",
55 | "@storybook/addon-actions": "^5.2.3",
56 | "@storybook/addon-info": "^5.2.3",
57 | "@storybook/addon-links": "^5.2.3",
58 | "@storybook/addon-options": "^3.4.1",
59 | "@storybook/addon-storysource": "^5.2.3",
60 | "@storybook/addons": "^5.2.3",
61 | "@storybook/react": "^5.2.3",
62 | "@storybook/storybook-deployer": "^2.8.1",
63 | "@types/enzyme": "^3.10.3",
64 | "@types/enzyme-adapter-react-16": "^1.0.5",
65 | "@types/isomorphic-fetch": "^0.0.35",
66 | "@types/jest": "^24.0.18",
67 | "@types/js-cookie": "^2.2.2",
68 | "@types/lodash": "^4.14.186",
69 | "@types/nanoid": "^2.1.0",
70 | "@types/nock": "^11.1.0",
71 | "@types/node": "^12.7.11",
72 | "@types/react": "^16.9.5",
73 | "@types/react-dom": "^16.9.1",
74 | "@types/segment-analytics": "^0.0.32",
75 | "@types/sinon": "^7.5.0",
76 | "@types/storybook__react": "^4.0.2",
77 | "@typescript-eslint/eslint-plugin": "^2.3.3",
78 | "@typescript-eslint/parser": "^2.3.3",
79 | "babel-loader": "^8.0.6",
80 | "concurrently": "^3.5.1",
81 | "enzyme": "^3.10.0",
82 | "enzyme-adapter-react-16": "^1.1.1",
83 | "eslint": "^6.5.1",
84 | "eslint-config-prettier": "^6.4.0",
85 | "eslint-config-react": "^1.1.7",
86 | "eslint-plugin-prettier": "^3.1.1",
87 | "eslint-plugin-react": "^7.16.0",
88 | "evergreen-ui": "^3.0.0",
89 | "file-loader": "^4.2.0",
90 | "husky": "^3.0.8",
91 | "jest": "^24.9.0",
92 | "lint-staged": "^7.0.4",
93 | "nock": "^9.2.5",
94 | "preact": "^10.0.0",
95 | "prettier": "^1.19.1",
96 | "pretty-quick": "^1.11.1",
97 | "prismjs": "^1.17.1",
98 | "raw-loader": "^3.1.0",
99 | "react": "^16.10.2",
100 | "react-docgen-typescript-loader": "^3.3.0",
101 | "react-dom": "^16.10.2",
102 | "react-simple-code-editor": "^0.10.0",
103 | "react-syntax-highlighter": "^11.0.2",
104 | "sinon": "^5.0.7",
105 | "size-limit": "^0.17.0",
106 | "ts-jest": "^24.1.0",
107 | "ts-loader": "^6.2.0",
108 | "ts-node": "^8.4.1",
109 | "typescript": "^3.7.0-beta",
110 | "webpack": "^4.8.2",
111 | "webpack-cli": "^3.1.1"
112 | },
113 | "peerDependencies": {
114 | "react": "^18.1.0",
115 | "react-dom": "^18.1.0"
116 | },
117 | "jest": {
118 | "preset": "ts-jest/presets/js-with-ts",
119 | "testEnvironment": "jsdom",
120 | "setupFiles": [
121 | "./test/setupTests.ts"
122 | ],
123 | "transform": {
124 | "^.+\\.ts?$": "ts-jest"
125 | },
126 | "transformIgnorePatterns": [
127 | "/node_modules/"
128 | ],
129 | "modulePathIgnorePatterns": [
130 | "/types/",
131 | "/commonjs/",
132 | "/esm/",
133 | "/standalone/"
134 | ]
135 | },
136 | "size-limit": [
137 | {
138 | "path": "esm/index.js",
139 | "limit": "55 KB"
140 | },
141 | {
142 | "path": "standalone/consent-manager.js",
143 | "limit": "55 KB"
144 | }
145 | ],
146 | "husky": {
147 | "hooks": {
148 | "pre-commit": "pretty-quick --staged"
149 | }
150 | },
151 | "prettier": {
152 | "tabWidth": 2,
153 | "semi": false,
154 | "singleQuote": true,
155 | "printWidth": 100
156 | },
157 | "storybook-deployer": {
158 | "gitUsername": "GitHub Pages Bot",
159 | "gitEmail": "friends@segment.com",
160 | "commitMessage": "Deploy Storybook to GitHub Pages [skip ci]"
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/stories/7-default-destination-behavior.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Pane, Heading, Paragraph, Button } from 'evergreen-ui'
3 | import { ConsentManager, openConsentManager, loadPreferences, onPreferencesSaved } from '../src'
4 | import { storiesOf } from '@storybook/react'
5 | import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs'
6 | import SyntaxHighlighter from 'react-syntax-highlighter'
7 | import { Preferences, DefaultDestinationBehavior } from '../src/types'
8 | import CookieView from './components/CookieView'
9 |
10 | const bannerContent = (
11 |
12 | We use cookies (and other similar technologies) to collect data to improve your experience on
13 | our site. By using our website, you’re agreeing to the collection of data as described in our{' '}
14 |
19 | Website Data Collection Policy
20 |
21 | .
22 |
23 | )
24 | const bannerSubContent = 'You can manage your preferences here!'
25 | const preferencesDialogTitle = 'Website Data Collection Preferences'
26 | const preferencesDialogContent = (
27 |
28 |
29 | Segment uses data collected by cookies and JavaScript libraries to improve your browsing
30 | experience, analyze site traffic, deliver personalized advertisements, and increase the
31 | overall performance of our site.
32 |
33 |
34 | By using our website, you’re agreeing to our{' '}
35 |
40 | Website Data Collection Policy
41 |
42 | .
43 |
44 |
45 | The table below outlines how we use this data by category. To opt out of a category of data
46 | collection, select “No” and save your preferences.
47 |
48 |
49 | )
50 | const cancelDialogTitle = 'Are you sure you want to cancel?'
51 | const cancelDialogContent = (
52 |
63 | )
64 |
65 | const ConsentManagerExample = (props: {
66 | defaultDestinationBehavior: DefaultDestinationBehavior
67 | }) => {
68 | const [prefs, updatePrefs] = React.useState(loadPreferences())
69 |
70 | const cleanup = onPreferencesSaved(preferences => {
71 | updatePrefs(preferences)
72 | })
73 |
74 | React.useEffect(() => {
75 | return () => {
76 | cleanup()
77 | }
78 | })
79 |
80 | return (
81 |
82 |
94 |
95 |
96 | Cute Cats
97 |
98 |
104 |
105 |
111 |
112 |
113 |
114 | This example highlights default destination behavior. The cookie set is missing a
115 | destination that is enabled on the source, imitating a newly added destination. In the
116 | console, verify behavior by looking at analytics.options.
117 |
118 |
119 |
120 | Current Preferences
121 |
122 | {JSON.stringify(prefs, null, 2)}
123 |
124 |
125 |
126 | Change Cookie Preferences
127 |
128 | {
130 | window.location.reload()
131 | }}
132 | >
133 | Reset Example
134 |
135 |
136 |
137 |
138 |
139 | )
140 | }
141 |
142 | storiesOf('Default Destination Behavior', module).add(`disable`, () => (
143 |
144 | ))
145 | storiesOf('Default Destination Behavior', module).add(`enable`, () => (
146 |
147 | ))
148 | storiesOf('Default Destination Behavior', module).add(`imply`, () => (
149 |
150 | ))
151 | storiesOf('Default Destination Behavior', module).add(`ask`, () => (
152 |
153 | ))
154 |
--------------------------------------------------------------------------------
/stories/5-custom-categories.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import cookies from 'js-cookie'
3 | import { Pane, Heading, Button } from 'evergreen-ui'
4 | import { ConsentManager, openConsentManager, loadPreferences, onPreferencesSaved } from '../src'
5 | import { storiesOf } from '@storybook/react'
6 | import { CloseBehaviorFunction } from '../src/consent-manager/container'
7 | import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs'
8 | import SyntaxHighlighter from 'react-syntax-highlighter'
9 | import { CloseBehavior, Preferences } from '../src/types'
10 | import CookieView from './components/CookieView'
11 |
12 | const bannerContent = (
13 |
14 | We use cookies (and other similar technologies) to collect data to improve your experience on
15 | our site. By using our website, you’re agreeing to the collection of data as described in our{' '}
16 |
21 | Website Data Collection Policy
22 |
23 | .
24 |
25 | )
26 | const bannerSubContent = 'You can manage your preferences here!'
27 | const preferencesDialogTitle = 'Website Data Collection Preferences'
28 | const preferencesDialogContent = (
29 |
30 |
31 | Segment uses data collected by cookies and JavaScript libraries to improve your browsing
32 | experience, analyze site traffic, deliver personalized advertisements, and increase the
33 | overall performance of our site.
34 |
35 |
36 | By using our website, you’re agreeing to our{' '}
37 |
42 | Website Data Collection Policy
43 |
44 | .
45 |
46 |
47 | The table below outlines how we use this data by category. To opt out of a category of data
48 | collection, select “No” and save your preferences.
49 |
50 |
51 | )
52 | const cancelDialogTitle = 'Are you sure you want to cancel?'
53 | const cancelDialogContent = (
54 |
65 | )
66 |
67 | const initialPreferences = {
68 | Essential: 'N/A'
69 | }
70 |
71 | const ConsentManagerExample = (props: { closeBehavior: CloseBehavior | CloseBehaviorFunction }) => {
72 | const [prefs, updatePrefs] = React.useState(loadPreferences())
73 |
74 | const cleanup = onPreferencesSaved(preferences => {
75 | updatePrefs(preferences)
76 | })
77 |
78 | React.useEffect(() => {
79 | return () => {
80 | cleanup()
81 | }
82 | })
83 |
84 | return (
85 |
86 |
108 |
109 |
110 | Cute Cats
111 |
112 |
118 |
119 |
125 |
126 |
127 |
128 |
129 | Current Preferences
130 |
131 | {JSON.stringify(prefs, null, 2)}
132 |
133 |
134 |
135 | Change Cookie Preferences
136 |
137 | {
139 | cookies.remove('tracking-preferences')
140 | window.location.reload()
141 | }}
142 | >
143 | Clear
144 |
145 |
146 |
147 |
148 |
149 | )
150 | }
151 |
152 | storiesOf('Custom Categories - Do Not Sell', module)
153 | .add(`Dismiss`, () => )
154 | .add(`Accept`, () => )
155 | .add(`Deny`, () => )
156 | .add(`Custom Close Behavior`, () => (
157 | ({
159 | ...categories,
160 | 'Do Not Sell': false
161 | })}
162 | />
163 | ))
164 |
--------------------------------------------------------------------------------
/stories/6-ccpa-gdpr-example.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import cookies from 'js-cookie'
3 | import { Pane, Heading, Paragraph, Button } from 'evergreen-ui'
4 | import { ConsentManager, openConsentManager, loadPreferences, onPreferencesSaved } from '../src'
5 | import { storiesOf } from '@storybook/react'
6 | import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs'
7 | import SyntaxHighlighter from 'react-syntax-highlighter'
8 | import { Preferences } from '../src/types'
9 | import CookieView from './components/CookieView'
10 | import inRegions from '@segment/in-regions'
11 |
12 | const bannerContent = (
13 |
14 | We use cookies (and other similar technologies) to collect data to improve your experience on
15 | our site. By using our website, you’re agreeing to the collection of data as described in our{' '}
16 |
21 | Website Data Collection Policy
22 |
23 | .
24 |
25 | )
26 | const bannerSubContent = 'You can manage your preferences here!'
27 | const preferencesDialogTitle = 'Website Data Collection Preferences'
28 | const preferencesDialogContent = (
29 |
30 |
31 | Segment uses data collected by cookies and JavaScript libraries to improve your browsing
32 | experience, analyze site traffic, deliver personalized advertisements, and increase the
33 | overall performance of our site.
34 |
35 |
36 | By using our website, you’re agreeing to our{' '}
37 |
42 | Website Data Collection Policy
43 |
44 | .
45 |
46 |
47 | The table below outlines how we use this data by category. To opt out of a category of data
48 | collection, select “No” and save your preferences.
49 |
50 |
51 | )
52 | const cancelDialogTitle = 'Are you sure you want to cancel?'
53 | const cancelDialogContent = (
54 |
65 | )
66 |
67 | const ConsentManagerExample = () => {
68 | const [prefs, updatePrefs] = React.useState(loadPreferences())
69 |
70 | const cleanup = onPreferencesSaved(preferences => {
71 | updatePrefs(preferences)
72 | })
73 |
74 | React.useEffect(() => {
75 | return () => {
76 | cleanup()
77 | }
78 | })
79 |
80 | const inCA = inRegions(['CA'])
81 | const inEU = inRegions(['EU'])
82 | const shouldRequireConsent = inRegions(['CA', 'EU'])
83 | const caDefaultPreferences = {
84 | advertising: false,
85 | marketingAndAnalytics: true,
86 | functional: true
87 | }
88 | const euDefaultPreferences = {
89 | advertising: false,
90 | marketingAndAnalytics: false,
91 | functional: false
92 | }
93 |
94 | const closeBehavior = inCA() ? _categories => caDefaultPreferences : inEU() ? 'deny' : 'accept'
95 |
96 | const initialPreferences = inCA()
97 | ? caDefaultPreferences
98 | : inEU()
99 | ? euDefaultPreferences
100 | : undefined
101 |
102 | return (
103 |
104 |
116 |
117 |
118 | Cute Cats
119 |
120 |
126 |
127 |
133 |
134 | window.analytics.track('Send Track Event Clicked')}>
135 | Send Track Event
136 |
137 |
138 |
139 | This example highlights checking for EU or CA residency, then changing the closeBehavior
140 | based on membership in each.
141 |
142 |
143 |
144 | Current Preferences
145 |
146 | {JSON.stringify(prefs, null, 2)}
147 |
148 |
149 |
150 | Change Cookie Preferences
151 |
152 | {
154 | cookies.remove('tracking-preferences')
155 | window.location.reload()
156 | }}
157 | >
158 | Clear
159 |
160 |
161 |
162 |
163 |
164 | )
165 | }
166 |
167 | storiesOf('CCPA + GDPR Example', module).add(`Basic`, () => )
168 |
--------------------------------------------------------------------------------
/src/consent-manager/banner.tsx:
--------------------------------------------------------------------------------
1 | import React, { Fragment, PureComponent } from 'react'
2 | import styled from '@emotion/styled'
3 | import fontStyles from './font-styles'
4 | import { ActionsBlockProps } from '../types'
5 | import { DefaultButton, GreenButton } from './buttons'
6 |
7 | interface RootProps {
8 | readonly backgroundColor: string
9 | readonly textColor: string
10 | readonly hideCloseButton: boolean
11 | }
12 |
13 | interface ContentProps {
14 | asModal?: boolean
15 | }
16 |
17 | const Overlay = styled('div')`
18 | position: fixed;
19 | top: 0;
20 | bottom: 0;
21 | left: 0;
22 | right: 0;
23 | background: #fff;
24 | opacity: 0.8;
25 | `
26 |
27 | const Centered = styled('div')`
28 | position: fixed;
29 | top: 50%;
30 | left: 50%;
31 | transform: translate(-50%, -50%);
32 | max-width: 500px;
33 | @media (max-width: 767px) {
34 | width: 80vw;
35 | }
36 | `
37 |
38 | const RootCentered = styled('div')`
39 | ${fontStyles};
40 | position: relative;
41 | max-width: 500px;
42 | padding: 18px;
43 | padding-right: ${props => (props.hideCloseButton ? '18px' : '40px')};
44 | background: ${props => props.backgroundColor};
45 | color: ${props => props.textColor};
46 | text-align: center;
47 | font-size: 14px;
48 | line-height: 1.3;
49 | `
50 |
51 | const Root = styled('div')`
52 | ${fontStyles};
53 | position: relative;
54 | padding: 8px;
55 | padding-right: ${props => (props.hideCloseButton ? '8px' : '40px')};
56 | background: ${props => props.backgroundColor};
57 | color: ${props => props.textColor};
58 | text-align: center;
59 | font-size: 12px;
60 | line-height: 1.3;
61 | @media (min-width: 768px) {
62 | display: flex;
63 | align-items: center;
64 | }
65 | `
66 |
67 | const Content = styled('div')`
68 | margin-bottom: ${props => (props.asModal ? '20px' : '8px')};
69 | @media (min-width: 768px) {
70 | flex: auto;
71 | margin-bottom: ${props => (props.asModal ? '20px' : '0')};
72 | }
73 | a,
74 | button {
75 | display: inline;
76 | padding: 0;
77 | border: none;
78 | background: none;
79 | color: inherit;
80 | font: inherit;
81 | text-decoration: underline;
82 | cursor: pointer;
83 | }
84 | `
85 |
86 | const ActionsBlock = styled('div')`
87 | color: #000;
88 | button {
89 | margin: 4px 0;
90 | width: 100%;
91 | @media (min-width: 768px) {
92 | margin: 4px 8px;
93 | width: 200px;
94 | }
95 | }
96 | `
97 |
98 | const P = styled('p')`
99 | margin: 0;
100 | &:not(:last-child) {
101 | margin-bottom: 6px;
102 | }
103 | `
104 |
105 | interface CloseButtonProps {
106 | isTop?: boolean
107 | }
108 |
109 | const CloseButton = styled('button')`
110 | position: absolute;
111 | right: 8px;
112 | top: ${props => (props.isTop ? '20px' : '50%')};
113 | transform: translateY(-50%);
114 | padding: 8px;
115 | border: none;
116 | background: none;
117 | color: inherit;
118 | font: inherit;
119 | font-size: 14px;
120 | line-height: 1;
121 | cursor: pointer;
122 | `
123 |
124 | interface BannerProps {
125 | innerRef: (node: HTMLElement | null) => void
126 | onClose: () => void
127 | onChangePreferences: () => void
128 | content: React.ReactNode
129 | subContent: string | undefined
130 | actionsBlock?: ((props: ActionsBlockProps) => React.ReactElement) | true
131 | backgroundColor: string
132 | textColor: string
133 | onAcceptAll: () => void
134 | onDenyAll: () => void
135 | hideCloseButton: boolean
136 | asModal?: boolean
137 | }
138 |
139 | export default class Banner extends PureComponent {
140 | static displayName = 'Banner'
141 |
142 | render() {
143 | const {
144 | innerRef,
145 | onClose,
146 | onChangePreferences,
147 | content,
148 | subContent,
149 | actionsBlock,
150 | backgroundColor,
151 | textColor,
152 | onAcceptAll,
153 | onDenyAll,
154 | hideCloseButton,
155 | asModal
156 | } = this.props
157 |
158 | const RootContent = (
159 |
160 |
161 | {content}
162 |
163 |
164 | {subContent}
165 |
166 |
167 |
168 | {typeof actionsBlock === 'function' &&
169 | actionsBlock({
170 | acceptAll: onAcceptAll,
171 | denyAll: onDenyAll,
172 | changePreferences: onChangePreferences
173 | })}
174 | {actionsBlock === true && (
175 |
176 |
177 | Allow all
178 |
179 |
180 | Deny all
181 |
182 |
183 | )}
184 | {!hideCloseButton && (
185 |
193 | ✕
194 |
195 | )}
196 |
197 | )
198 |
199 | if (asModal) {
200 | return (
201 |
202 |
203 |
204 |
210 | {RootContent}
211 |
212 |
213 |
214 | )
215 | }
216 | return (
217 |
223 | {RootContent}
224 |
225 | )
226 | }
227 | }
228 |
--------------------------------------------------------------------------------
/src/consent-manager/dialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react'
2 | import ReactDOM from 'react-dom'
3 | import styled from '@emotion/styled'
4 | import { keyframes } from '@emotion/react'
5 |
6 | import nanoid from 'nanoid'
7 | import fontStyles from './font-styles'
8 |
9 | const ANIMATION_DURATION = '200ms'
10 | const ANIMATION_EASING = 'cubic-bezier(0.0, 0.0, 0.2, 1)'
11 |
12 | const Overlay = styled('div')`
13 | position: fixed;
14 | left: 0;
15 | right: 0;
16 | top: 0;
17 | bottom: 0;
18 | z-index: 1000;
19 | display: flex;
20 | align-items: center;
21 | justify-content: center;
22 | background: rgba(67, 90, 111, 0.699);
23 | `
24 |
25 | const openAnimation = keyframes`
26 | from {
27 | transform: scale(0.8);
28 | opacity: 0;
29 | }
30 | to {
31 | transform: scale(1);
32 | opacity: 1;
33 | }
34 | `
35 |
36 | interface RootProps {
37 | readonly width: number | string | undefined
38 | }
39 |
40 | const Root = styled('section')`
41 | ${fontStyles};
42 | display: flex;
43 | flex-direction: column;
44 | max-width: calc(100vw - 16px);
45 | max-height: calc(100% - 16px);
46 | width: ${props => props.width};
47 | margin: 8px;
48 | background: #fff;
49 | border-radius: 8px;
50 | animation: ${openAnimation} ${ANIMATION_DURATION} ${ANIMATION_EASING} both;
51 | `
52 |
53 | const Form = styled('form')`
54 | display: flex;
55 | flex-direction: column;
56 | min-height: 0;
57 | `
58 |
59 | const Header = styled('div')`
60 | flex: 1 0 auto;
61 | display: flex;
62 | align-items: center;
63 | justify-content: space-between;
64 | padding: 12px 16px;
65 | border-bottom: 1px solid rgba(67, 90, 111, 0.079);
66 | `
67 |
68 | const Title = styled('h2')`
69 | margin: 0;
70 | color: #1f4160;
71 | font-size: 16px;
72 | font-weight: 600;
73 | line-height: 1.3;
74 | `
75 |
76 | const HeaderCancelButton = styled('button')`
77 | padding: 8px;
78 | border: none;
79 | background: none;
80 | color: inherit;
81 | font: inherit;
82 | font-size: 14px;
83 | line-height: 1;
84 | cursor: pointer;
85 | `
86 |
87 | const Content = styled('div')`
88 | overflow-y: auto;
89 | padding: 16px;
90 | padding-bottom: 0;
91 | min-height: 0;
92 | font-size: 14px;
93 | line-height: 1.2;
94 |
95 | p {
96 | margin: 0;
97 | &:not(:last-child) {
98 | margin-bottom: 0.7em;
99 | }
100 | }
101 |
102 | a {
103 | color: #47b881;
104 | &:hover {
105 | color: #64c395;
106 | }
107 | &:active {
108 | color: #248953;
109 | }
110 | }
111 | `
112 |
113 | const Buttons = styled('div')`
114 | padding: 16px;
115 | text-align: right;
116 | `
117 |
118 | interface DialogProps {
119 | innerRef: (element: HTMLElement | null) => void
120 | onCancel?: () => void
121 | onSubmit: (e: React.FormEvent) => void
122 | title: React.ReactNode
123 | buttons: React.ReactNode
124 | width?: string
125 | }
126 |
127 | export default class Dialog extends PureComponent {
128 | static displayName = 'Dialog'
129 | private titleId: string
130 | private container: HTMLElement
131 | private root: HTMLElement
132 | private form: HTMLFormElement
133 |
134 | static defaultProps = {
135 | onCancel: undefined,
136 | width: '750px'
137 | }
138 |
139 | constructor(props: DialogProps) {
140 | super(props)
141 |
142 | this.titleId = nanoid()
143 | this.container = document.createElement('div')
144 | this.container.setAttribute('data-consent-manager-dialog', '')
145 | }
146 |
147 | render() {
148 | const { onCancel, onSubmit, title, children, buttons, width } = this.props
149 |
150 | const dialog = (
151 |
152 |
160 |
173 |
174 |
183 |
184 |
185 | )
186 |
187 | return ReactDOM.createPortal(dialog, this.container)
188 | }
189 |
190 | componentDidMount() {
191 | const { innerRef } = this.props
192 |
193 | if (this.form) {
194 | const input: HTMLInputElement | null = this.form.querySelector('input,button')
195 | if (input) {
196 | input.focus()
197 | }
198 | }
199 | document.body.appendChild(this.container)
200 | document.body.addEventListener('keydown', this.handleEsc, false)
201 | document.body.style.overflow = 'hidden'
202 | innerRef(this.container)
203 | }
204 |
205 | componentWillUnmount() {
206 | const { innerRef } = this.props
207 | document.body.removeEventListener('keydown', this.handleEsc, false)
208 | document.body.style.overflow = ''
209 | document.body.removeChild(this.container)
210 | innerRef(null)
211 | }
212 |
213 | handleRootRef = (node: HTMLElement) => {
214 | this.root = node
215 | }
216 |
217 | handleFormRef = (node: HTMLFormElement) => {
218 | this.form = node
219 | }
220 |
221 | handleOverlayClick = e => {
222 | const { onCancel } = this.props
223 | // Ignore propogated clicks from inside the dialog
224 | if (onCancel && this.root && !this.root.contains(e.target)) {
225 | onCancel()
226 | }
227 | }
228 |
229 | handleEsc = (e: KeyboardEvent) => {
230 | const { onCancel } = this.props
231 | // Esc key
232 | if (onCancel && e.keyCode === 27) {
233 | onCancel()
234 | }
235 | }
236 | }
237 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 5.8.1 (May 30, 2023)
4 |
5 | - [333](https://github.com/segmentio/consent-manager/pull/333) Allows setting the initial value of preferences to `N/A`.
6 |
7 | ## 5.8.0 (Mar 22, 2023)
8 |
9 | - [297](https://github.com/segmentio/consent-manager/pull/297) Add devmode to disable analytics.load
10 | - [301](https://github.com/segmentio/consent-manager/pull/301) Update libraries dependencies
11 | - [310](https://github.com/segmentio/consent-manager/pull/310) Fixed Fullstory destination name
12 |
13 | ## 5.7.0 (Dec 13, 2022)
14 |
15 | - [288](https://github.com/segmentio/consent-manager/pull/288) Fix reload clicking inside banner
16 | - [283](https://github.com/segmentio/consent-manager/pull/283) Add shouldReload on ConsentManagerBuilder like optional attribute
17 | - [281](https://github.com/segmentio/consent-manager/pull/281) Replace lodash-es by lodash to fix error with nextJs
18 | - [277](https://github.com/segmentio/consent-manager/pull/277) docs: fix link to nextjs example in table of contents
19 | - [274](https://github.com/segmentio/consent-manager/pull/274) Allow passing additional cookie attributes
20 | - [272](https://github.com/segmentio/consent-manager/pull/272) Add src folder to files array on package.json
21 | - [271](https://github.com/segmentio/consent-manager/pull/271) Change enum closeBehavior to type
22 | - [269](https://github.com/segmentio/consent-manager/pull/269) Add nanoid like sufix to preferenceDialogForm
23 | - [268](https://github.com/segmentio/consent-manager/pull/268) Update type for bannerSubContent and subContent
24 | - [261](https://github.com/segmentio/consent-manager/pull/261) Fix acceptAll method on container
25 | - [258](https://github.com/segmentio/consent-manager/pull/258) Add param coockeName to method on loadPreference on Preference interface
26 | - [248](https://github.com/segmentio/consent-manager/pull/248) Remove validation to show deny button on modal
27 |
28 | ## 5.6.0 (July 8, 2022)
29 |
30 | - [237](https://github.com/segmentio/consent-manager/pull/237) Change emotion/core to emotion/react, and fix issue on dialog
31 | - [234](https://github.com/segmentio/consent-manager/pull/234) Update imports libraries of lodash to lodash-es
32 | - [232](https://github.com/segmentio/consent-manager/pull/232) Update library on eventsource
33 | - [228](https://github.com/segmentio/consent-manager/pull/228) Change tracking preference to save automatically the cookies
34 |
35 | ## 5.5.0 (May 25, 2022)
36 |
37 | - [#216](https://github.com/segmentio/consent-manager/pull/216) Update emotion library
38 | - [#220](https://github.com/segmentio/consent-manager/pull/220) Update isomorphic-fetch library to fix vulnerabilities
39 | - [#222](https://github.com/segmentio/consent-manager/pull/222) Upgrade to react 18 library
40 | - [#223](https://github.com/segmentio/consent-manager/pull/223) Replace container div with Fragment
41 | - [#227](https://github.com/segmentio/consent-manager/pull/227) support FullStory (actions) as integration
42 | - [#228](https://github.com/segmentio/consent-manager/pull/228) Fix Tracking Preferences cookie is saved automatically
43 |
44 | ## 5.4.0 (Nov 30, 2021)
45 |
46 | - [#184](https://github.com/segmentio/consent-manager/pull/184) Fix the behavior of `initialPreferences`
47 | - [#192](https://github.com/segmentio/consent-manager/pull/192) Fix linting, dependency upgrade
48 | - [#188](https://github.com/segmentio/consent-manager/pull/188) Allow TS to generate code for enum
49 | - [#180](https://github.com/segmentio/consent-manager/pull/180) Add id to preference dialog form
50 | - [#179](https://github.com/segmentio/consent-manager/pull/179) Typescript improvement
51 | - [#176](https://github.com/segmentio/consent-manager/pull/176) Allow customizing the texts of the preferences dialog
52 | - [#173](https://github.com/segmentio/consent-manager/pull/173) Fix implyConsentOnInteraction behavior
53 | - [#170](https://github.com/segmentio/consent-manager/pull/170) Dependency upgrades
54 |
55 | ## 5.3.0 (Sept 13, 2021)
56 |
57 | - [#145](https://github.com/segmentio/consent-manager/pull/145) Introduce cookieName attribute to allow a custom cookie name
58 | - [#126](https://github.com/segmentio/consent-manager/pull/126) Fixing types directory
59 | - [#164](https://github.com/segmentio/consent-manager/pull/164) Fix On Standalone script closeBehavior: accept does not work
60 | - [#164](https://github.com/segmentio/consent-manager/pull/164) A possibility to add default buttons to accept/deny cookies via bannerActionsBlock
61 | - [#164](https://github.com/segmentio/consent-manager/pull/164) A possibility to add custom buttons block via bannerActionsBlock
62 | - [#164](https://github.com/segmentio/consent-manager/pull/164) Hide Clone button via bannerHideCloseButton
63 | - [#164](https://github.com/segmentio/consent-manager/pull/164) Fix the dialog on iPhone
64 | - [#164](https://github.com/segmentio/consent-manager/pull/164) Add option to show banner as modal
65 | - [#164](https://github.com/segmentio/consent-manager/pull/164) Use only loadsh-es and remove lodash package and include only used functions instead of the whole package (reduce the final bundle size)
66 | - [#164](https://github.com/segmentio/consent-manager/pull/164) Update webpack CLI
67 |
68 | ## 5.2.0(May 11, 2021)
69 |
70 | - [#152](https://github.com/segmentio/consent-manager/pull/152) Add configurable expirey date for the preferences cookie
71 | - Chore: Dependency upgrades
72 |
73 | ## 5.1.0(Nov 17, 2020)
74 |
75 | - [#123](https://github.com/segmentio/consent-manager/pull/123) Fixed an issue where the react state wasn't being updated after the user updates the preferences via the `.savePreferences` API. This change also slightly changes how the Cancel confirmation modal is displayed.
76 |
77 | ## 5.0.2(Nov 9, 2020)
78 |
79 | - [#111](https://github.com/segmentio/consent-manager/pull/111) Added missing TypeScript declarations in packaged output
80 |
81 | ### Added
82 |
83 | - [#110](https://github.com/segmentio/consent-manager/pull/110) Added `cdnHost` property to allow using non-default CDN host
84 |
85 | ## 5.0.1(July 13, 2020)
86 |
87 | ### Added
88 |
89 | - [#110](https://github.com/segmentio/consent-manager/pull/110) Added `cdnHost` property to allow using non-default CDN host
90 |
91 | ## 4.1.0(Dec 11, 2019)
92 |
93 | ### Added
94 |
95 | - [#60](https://github.com/segmentio/consent-manager/pull/60) Add new `customCategories` option
96 |
97 | ## 4.0.1(Oct 10, 2019)
98 |
99 | ### Fixed
100 |
101 | - Fix commonJS bundle
102 |
103 | ## 4.0.0(Oct 10, 2019)
104 |
105 | ### Breaking
106 |
107 | - [#51](https://github.com/segmentio/consent-manager/pull/51) Deprecate data attributes and dataset
108 |
109 | ### Added
110 |
111 | - [#48](https://github.com/segmentio/consent-manager/pull/48) Add new `closeBehavior` option
112 | - [#49](https://github.com/segmentio/consent-manager/pull/49) Initial Preferences override
113 | - [#52](https://github.com/segmentio/consent-manager/pull/52) Expose preferences manager
114 |
115 | ## 3.0.0(Oct 8, 2019)
116 |
117 | ### Breaking
118 |
119 | - [#47](https://github.com/segmentio/consent-manager/pull/47) No longer imply consent on interaction
120 |
121 | ## 2.0.0
122 |
123 | ### Added
124 |
125 | - [#46](https://github.com/segmentio/consent-manager/pull/46) ⚡️ Modernize
126 | - [#47](https://github.com/segmentio/consent-manager/pull/47) 🙅🏻♀️No longer imply consent on interaction
127 |
128 | ## 1.3.1(Sep 24, 2019)
129 |
130 | ### Fixed
131 |
132 | - [86387e6](https://github.com/segmentio/consent-manager/commit/86387e63f259fff9f34ee511b2fa6218341dfa17) Fix integrity hash
133 |
--------------------------------------------------------------------------------
/src/__tests__/consent-manager-builder/analytics.test.ts:
--------------------------------------------------------------------------------
1 | import sinon from 'sinon'
2 | import { WindowWithAJS, Destination, Middleware } from '../../types'
3 | import conditionallyLoadAnalytics from '../../consent-manager-builder/analytics'
4 |
5 | describe('analytics', () => {
6 | let wd
7 |
8 | beforeEach(() => {
9 | window = {} as WindowWithAJS
10 | wd = window
11 | wd.analytics = {
12 | /*eslint-disable */
13 | track: (_event, _properties, _optionsWithConsent, _callback) => {},
14 | addSourceMiddleware: (_middleware: Middleware) => {}
15 | /*eslint-enable */
16 | }
17 | })
18 |
19 | test('loads analytics.js with preferences', () => {
20 | const ajsLoad = sinon.spy()
21 | wd.analytics.load = ajsLoad
22 | const writeKey = '123'
23 | const destinations = [{ id: 'Amplitude' } as Destination]
24 | const destinationPreferences = {
25 | Amplitude: true
26 | }
27 |
28 | conditionallyLoadAnalytics({
29 | writeKey,
30 | destinations,
31 | destinationPreferences,
32 | isConsentRequired: true,
33 | categoryPreferences: {}
34 | })
35 |
36 | expect(ajsLoad.calledOnce).toBe(true)
37 | expect(ajsLoad.args[0][0]).toBe(writeKey)
38 | expect(ajsLoad.args[0][1]).toMatchObject({
39 | integrations: {
40 | All: false,
41 | Amplitude: true,
42 | 'Segment.io': true
43 | }
44 | })
45 | })
46 |
47 | test('doesn՚t load analytics.js when there are no preferences', () => {
48 | const ajsLoad = sinon.spy()
49 | wd.analytics.load = ajsLoad
50 | const writeKey = '123'
51 | const destinations = [{ id: 'Amplitude' } as Destination]
52 | const destinationPreferences = null
53 |
54 | conditionallyLoadAnalytics({
55 | writeKey,
56 | destinations,
57 | destinationPreferences,
58 | isConsentRequired: true,
59 | categoryPreferences: {}
60 | })
61 |
62 | expect(ajsLoad.notCalled).toBe(true)
63 | })
64 |
65 | test('doesn՚t load analytics.js when all preferences are false', () => {
66 | const ajsLoad = sinon.spy()
67 | wd.analytics.load = ajsLoad
68 | const writeKey = '123'
69 | const destinations = [{ id: 'Amplitude' } as Destination]
70 | const destinationPreferences = {
71 | Amplitude: false
72 | }
73 |
74 | conditionallyLoadAnalytics({
75 | writeKey,
76 | destinations,
77 | destinationPreferences,
78 | isConsentRequired: true,
79 | categoryPreferences: {}
80 | })
81 |
82 | expect(ajsLoad.notCalled).toBe(true)
83 | })
84 |
85 | test('reloads the page when analytics.js has already been initialised', () => {
86 | wd.analytics.load = function load() {
87 | this.initialized = true
88 | }
89 |
90 | jest.spyOn(window.location, 'reload')
91 |
92 | const writeKey = '123'
93 | const destinations = [{ id: 'Amplitude' } as Destination]
94 | const destinationPreferences = {
95 | Amplitude: true
96 | }
97 |
98 | conditionallyLoadAnalytics({
99 | writeKey,
100 | destinations,
101 | destinationPreferences,
102 | isConsentRequired: true,
103 | categoryPreferences: {}
104 | })
105 | conditionallyLoadAnalytics({
106 | writeKey,
107 | destinations,
108 | destinationPreferences,
109 | isConsentRequired: true,
110 | categoryPreferences: {}
111 | })
112 |
113 | expect(window.location.reload).toHaveBeenCalled()
114 | })
115 |
116 | test('should allow the reload behvaiour to be disabled', () => {
117 | const reload = sinon.spy()
118 | wd.analytics.load = function load() {
119 | this.initialized = true
120 | }
121 | wd.location = { reload }
122 | const writeKey = '123'
123 | const destinations = [{ id: 'Amplitude' } as Destination]
124 | const destinationPreferences = {
125 | Amplitude: true
126 | }
127 |
128 | conditionallyLoadAnalytics({
129 | writeKey,
130 | destinations,
131 | destinationPreferences,
132 | isConsentRequired: true,
133 | categoryPreferences: {}
134 | })
135 | conditionallyLoadAnalytics({
136 | writeKey,
137 | destinations,
138 | destinationPreferences,
139 | isConsentRequired: true,
140 | shouldReload: false,
141 | categoryPreferences: {}
142 | })
143 |
144 | expect(reload.calledOnce).toBe(false)
145 | })
146 |
147 | test('loads analytics.js normally when consent isn՚t required', () => {
148 | const ajsLoad = sinon.spy()
149 | wd.analytics.load = ajsLoad
150 | const writeKey = '123'
151 | const destinations = [{ id: 'Amplitude' } as Destination]
152 | const destinationPreferences = null
153 |
154 | conditionallyLoadAnalytics({
155 | writeKey,
156 | destinations,
157 | destinationPreferences,
158 | isConsentRequired: false,
159 | categoryPreferences: {}
160 | })
161 |
162 | expect(ajsLoad.calledOnce).toBe(true)
163 | expect(ajsLoad.args[0][0]).toBe(writeKey)
164 | expect(ajsLoad.args[0][1]).toBeUndefined()
165 | })
166 |
167 | test('still applies preferences when consent isn՚t required', () => {
168 | const ajsLoad = sinon.spy()
169 | wd.analytics.load = ajsLoad
170 | const writeKey = '123'
171 | const destinations = [{ id: 'Amplitude' } as Destination]
172 | const destinationPreferences = {
173 | Amplitude: true
174 | }
175 |
176 | conditionallyLoadAnalytics({
177 | writeKey,
178 | destinations,
179 | destinationPreferences,
180 | isConsentRequired: false,
181 | categoryPreferences: {}
182 | })
183 |
184 | expect(ajsLoad.calledOnce).toBe(true)
185 | expect(ajsLoad.args[0][0]).toBe(writeKey)
186 | expect(ajsLoad.args[0][1]).toMatchObject({
187 | integrations: {
188 | All: false,
189 | Amplitude: true,
190 | 'Segment.io': true
191 | }
192 | })
193 | })
194 |
195 | test('sets new destinations to false if defaultDestinationBehavior is set to "disable"', () => {
196 | const ajsLoad = sinon.spy()
197 | wd.analytics.load = ajsLoad
198 | const writeKey = '123'
199 | const destinations = [
200 | { id: 'Amplitude' } as Destination,
201 | { id: 'Google Analytics' } as Destination
202 | ]
203 | const destinationPreferences = {
204 | Amplitude: true
205 | }
206 |
207 | conditionallyLoadAnalytics({
208 | writeKey,
209 | destinations,
210 | destinationPreferences,
211 | isConsentRequired: false,
212 | shouldReload: true,
213 | defaultDestinationBehavior: 'disable',
214 | categoryPreferences: {}
215 | })
216 |
217 | expect(ajsLoad.args[0][1]).toMatchObject({
218 | integrations: {
219 | All: false,
220 | Amplitude: true,
221 | 'Google Analytics': false,
222 | 'Segment.io': true
223 | }
224 | })
225 | })
226 |
227 | test('sets new destinations to true if defaultDestinationBehavior is set to "enable"', () => {
228 | const ajsLoad = sinon.spy()
229 | wd.analytics.load = ajsLoad
230 | const writeKey = '123'
231 | const destinations = [
232 | { id: 'Amplitude' } as Destination,
233 | { id: 'Google Analytics' } as Destination
234 | ]
235 | const destinationPreferences = {
236 | Amplitude: true
237 | }
238 |
239 | conditionallyLoadAnalytics({
240 | writeKey,
241 | destinations,
242 | destinationPreferences,
243 | isConsentRequired: false,
244 | shouldReload: true,
245 | defaultDestinationBehavior: 'enable',
246 | categoryPreferences: {}
247 | })
248 |
249 | expect(ajsLoad.args[0][1]).toMatchObject({
250 | integrations: {
251 | All: false,
252 | Amplitude: true,
253 | 'Google Analytics': true,
254 | 'Segment.io': true
255 | }
256 | })
257 | })
258 |
259 | test('Set devMode on true to disabled analytics load', () => {
260 | const ajsLoad = sinon.spy()
261 | wd.analytics.load = ajsLoad
262 | const writeKey = '123'
263 | const destinations = [{ id: 'Amplitude' } as Destination]
264 | const destinationPreferences = {
265 | Amplitude: true
266 | }
267 |
268 | conditionallyLoadAnalytics({
269 | writeKey,
270 | destinations,
271 | destinationPreferences,
272 | isConsentRequired: false,
273 | categoryPreferences: {},
274 | devMode: true
275 | })
276 |
277 | expect(ajsLoad.calledOnce).toBe(false)
278 | })
279 | })
280 |
--------------------------------------------------------------------------------
/src/consent-manager/container.tsx:
--------------------------------------------------------------------------------
1 | import EventEmitter from 'events'
2 | import React from 'react'
3 | import Banner from './banner'
4 | import PreferenceDialog from './preference-dialog'
5 | import CancelDialog from './cancel-dialog'
6 | import { ADVERTISING_CATEGORIES, FUNCTIONAL_CATEGORIES } from './categories'
7 | import {
8 | Destination,
9 | CategoryPreferences,
10 | CustomCategories,
11 | DefaultDestinationBehavior,
12 | ActionsBlockProps,
13 | PreferenceDialogTemplate,
14 | CloseBehavior
15 | } from '../types'
16 |
17 | const emitter = new EventEmitter()
18 | export function openDialog() {
19 | emitter.emit('openDialog')
20 | }
21 |
22 | export interface CloseBehaviorFunction {
23 | (categories: CategoryPreferences): CategoryPreferences
24 | }
25 |
26 | interface ContainerProps {
27 | setPreferences: (prefs: CategoryPreferences) => void
28 | saveConsent: (
29 | newPreferences?: CategoryPreferences,
30 | shouldReload?: boolean,
31 | devMode?: boolean
32 | ) => void
33 | resetPreferences: () => void
34 | closeBehavior?: CloseBehavior | CloseBehaviorFunction
35 | destinations: Destination[]
36 | customCategories?: CustomCategories | undefined
37 | newDestinations: Destination[]
38 | preferences: CategoryPreferences
39 | havePreferencesChanged: boolean
40 | isConsentRequired: boolean
41 | implyConsentOnInteraction: boolean
42 | bannerContent: React.ReactNode
43 | bannerSubContent: string | undefined
44 | bannerActionsBlock?: ((props: ActionsBlockProps) => React.ReactElement) | true
45 | bannerTextColor: string
46 | bannerBackgroundColor: string
47 | bannerHideCloseButton: boolean
48 | bannerAsModal?: boolean
49 | preferencesDialogTitle: React.ReactNode
50 | preferencesDialogContent: React.ReactNode
51 | cancelDialogTitle: React.ReactNode
52 | cancelDialogContent: React.ReactNode
53 | workspaceAddedNewDestinations?: boolean
54 | defaultDestinationBehavior?: DefaultDestinationBehavior
55 | preferencesDialogTemplate?: PreferenceDialogTemplate
56 | }
57 |
58 | function normalizeDestinations(destinations: Destination[]) {
59 | const marketingDestinations: Destination[] = []
60 | const advertisingDestinations: Destination[] = []
61 | const functionalDestinations: Destination[] = []
62 |
63 | for (const destination of destinations) {
64 | if (ADVERTISING_CATEGORIES.find(c => c === destination.category)) {
65 | advertisingDestinations.push(destination)
66 | } else if (FUNCTIONAL_CATEGORIES.find(c => c === destination.category)) {
67 | functionalDestinations.push(destination)
68 | } else {
69 | // Fallback to marketing
70 | marketingDestinations.push(destination)
71 | }
72 | }
73 |
74 | return { marketingDestinations, advertisingDestinations, functionalDestinations }
75 | }
76 |
77 | const Container: React.FC = props => {
78 | const [isDialogOpen, toggleDialog] = React.useState(
79 | false || (props.workspaceAddedNewDestinations && props.defaultDestinationBehavior === 'ask')
80 | )
81 | const [showBanner, toggleBanner] = React.useState(true)
82 | const [isCancelling, toggleCancel] = React.useState(false)
83 |
84 | let banner = React.useRef(null)
85 | let preferenceDialog = React.useRef(null)
86 | let cancelDialog = React.useRef(null)
87 |
88 | const {
89 | marketingDestinations,
90 | advertisingDestinations,
91 | functionalDestinations
92 | } = normalizeDestinations(props.destinations)
93 |
94 | const onAcceptAll = () => {
95 | const truePreferences: CategoryPreferences = props.preferences
96 | for (const preferenceName of Object.keys(props.preferences)) {
97 | const value = props.preferences[preferenceName]
98 | if (typeof value === 'string') {
99 | truePreferences[preferenceName] = value
100 | } else {
101 | truePreferences[preferenceName] = true
102 | }
103 | }
104 |
105 | props.setPreferences(truePreferences)
106 | return props.saveConsent()
107 | }
108 |
109 | const onDenyAll = () => {
110 | const falsePreferences: CategoryPreferences = props.preferences
111 | for (const preferenceName of Object.keys(props.preferences)) {
112 | const value = props.preferences[preferenceName]
113 | if (typeof value === 'string') {
114 | falsePreferences[preferenceName] = value
115 | } else {
116 | falsePreferences[preferenceName] = false
117 | }
118 | }
119 |
120 | props.setPreferences(falsePreferences)
121 | return props.saveConsent()
122 | }
123 |
124 | const onClose = () => {
125 | if (props.closeBehavior === undefined || props.closeBehavior === 'dismiss') {
126 | return toggleBanner(false)
127 | }
128 |
129 | if (props.closeBehavior === 'accept') {
130 | toggleBanner(false)
131 | return onAcceptAll()
132 | }
133 |
134 | if (props.closeBehavior === 'deny') {
135 | toggleBanner(false)
136 | return onDenyAll()
137 | }
138 |
139 | // closeBehavior is a custom function
140 | const customClosePreferences = props.closeBehavior(props.preferences)
141 | props.setPreferences(customClosePreferences)
142 | props.saveConsent()
143 | return toggleBanner(false)
144 | }
145 |
146 | const showDialog = () => toggleDialog(true)
147 |
148 | const handleBodyClick = e => {
149 | // Do nothing if no new implicit consent needs to be saved
150 | if (
151 | !props.isConsentRequired ||
152 | !props.implyConsentOnInteraction ||
153 | props.newDestinations.length === 0
154 | ) {
155 | return
156 | }
157 |
158 | // Ignore propogated clicks from inside the consent manager
159 | if (
160 | (banner.current && banner.current.contains(e.target)) ||
161 | (preferenceDialog.current && preferenceDialog.current.contains(e.target)) ||
162 | (cancelDialog.current && cancelDialog.current.contains(e.target)) ||
163 | 'subContentBtn' === e.target.id
164 | ) {
165 | return
166 | }
167 |
168 | // Accept all consent on page interaction.
169 | if (!isDialogOpen && props.implyConsentOnInteraction) {
170 | onAcceptAll()
171 | }
172 | }
173 |
174 | React.useEffect(() => {
175 | emitter.on('openDialog', showDialog)
176 | if (props.isConsentRequired && props.implyConsentOnInteraction) {
177 | document.body.addEventListener('click', handleBodyClick, false)
178 | }
179 |
180 | return () => {
181 | emitter.removeListener('openDialog', showDialog)
182 | document.body.removeEventListener('click', handleBodyClick, false)
183 | }
184 | })
185 |
186 | React.useEffect(() => {
187 | if (isDialogOpen) {
188 | props.resetPreferences()
189 | }
190 | }, [isDialogOpen])
191 |
192 | const handleCategoryChange = (category: string, value: boolean) => {
193 | props.setPreferences({
194 | [category]: value
195 | })
196 | }
197 |
198 | const handleSave = () => {
199 | toggleDialog(false)
200 | props.saveConsent(undefined, false)
201 | }
202 |
203 | const handleCancel = () => {
204 | // Only show the cancel confirmation if there's unconsented destinations
205 | if (props.newDestinations.length > 0) {
206 | toggleCancel(true)
207 | } else {
208 | toggleDialog(false)
209 | props.resetPreferences()
210 | }
211 | }
212 |
213 | const handleCancelBack = () => {
214 | toggleCancel(false)
215 | }
216 |
217 | const handleCancelConfirm = () => {
218 | toggleCancel(false)
219 | toggleDialog(false)
220 | props.resetPreferences()
221 | }
222 |
223 | return (
224 | <>
225 | {showBanner && props.isConsentRequired && props.newDestinations.length > 0 && (
226 | (banner = { current })}
228 | onClose={onClose}
229 | onChangePreferences={() => toggleDialog(true)}
230 | content={props.bannerContent}
231 | subContent={props.bannerSubContent}
232 | actionsBlock={props.bannerActionsBlock}
233 | textColor={props.bannerTextColor}
234 | backgroundColor={props.bannerBackgroundColor}
235 | onAcceptAll={onAcceptAll}
236 | onDenyAll={onDenyAll}
237 | hideCloseButton={props.bannerHideCloseButton}
238 | asModal={props.bannerAsModal}
239 | />
240 | )}
241 |
242 | {isDialogOpen && (
243 | (preferenceDialog = { current })}
248 | onCancel={handleCancel}
249 | onSave={handleSave}
250 | onChange={handleCategoryChange}
251 | marketingDestinations={marketingDestinations}
252 | advertisingDestinations={advertisingDestinations}
253 | functionalDestinations={functionalDestinations}
254 | marketingAndAnalytics={props.preferences.marketingAndAnalytics}
255 | advertising={props.preferences.advertising}
256 | functional={props.preferences.functional}
257 | title={props.preferencesDialogTitle}
258 | content={props.preferencesDialogContent}
259 | preferencesDialogTemplate={props.preferencesDialogTemplate}
260 | />
261 | )}
262 |
263 | {isCancelling && (
264 | (cancelDialog = { current })}
266 | onBack={handleCancelBack}
267 | onConfirm={handleCancelConfirm}
268 | title={props.cancelDialogTitle}
269 | content={props.cancelDialogContent}
270 | preferencesDialogTemplate={props.preferencesDialogTemplate}
271 | />
272 | )}
273 | >
274 | )
275 | }
276 |
277 | export default Container
278 |
--------------------------------------------------------------------------------
/src/consent-manager/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react'
2 | import ConsentManagerBuilder from '../consent-manager-builder'
3 | import Container from './container'
4 | import { ADVERTISING_CATEGORIES, FUNCTIONAL_CATEGORIES } from './categories'
5 | import {
6 | CategoryPreferences,
7 | Destination,
8 | ConsentManagerProps,
9 | PreferenceDialogTemplate
10 | } from '../types'
11 |
12 | const zeroValuePreferences: CategoryPreferences = {
13 | marketingAndAnalytics: null,
14 | advertising: null,
15 | functional: null
16 | }
17 |
18 | const defaultPreferencesDialogTemplate: PreferenceDialogTemplate = {
19 | headings: {
20 | allowValue: 'Allow',
21 | categoryValue: 'Category',
22 | purposeValue: 'Purpose',
23 | toolsValue: 'Tools'
24 | },
25 | checkboxes: {
26 | noValue: 'No',
27 | yesValue: 'Yes'
28 | },
29 | actionButtons: {
30 | cancelValue: 'Cancel',
31 | saveValue: 'Save'
32 | },
33 | cancelDialogButtons: {
34 | cancelValue: 'Yes, Cancel',
35 | backValue: 'Go Back'
36 | },
37 | categories: [
38 | {
39 | key: 'functional',
40 | name: 'Functional',
41 | description:
42 | 'To monitor the performance of our site and to enhance your browsing experience.',
43 | example: 'For example, these tools enable you to communicate with us via live chat.'
44 | },
45 | {
46 | key: 'marketing',
47 | name: 'Marketing and Analytics',
48 | description:
49 | 'To understand user behavior in order to provide you with a more relevant browsing experience or personalize the content on our site.',
50 | example:
51 | 'For example, we collect information about which pages you visit to help us present more relevant information.'
52 | },
53 | {
54 | key: 'advertising',
55 | name: 'Advertising',
56 | description:
57 | 'To personalize and measure the effectiveness of advertising on our site and other websites.',
58 | example:
59 | 'For example, we may serve you a personalized ad based on the pages you visit on our site.'
60 | },
61 | {
62 | key: 'essential',
63 | name: 'Essential',
64 | description: 'We use browser cookies that are necessary for the site to work as intended.',
65 | example:
66 | 'For example, we store your website data collection preferences so we can honor them if you return to our site. You can disable these cookies in your browser settings but if you do the site may not work as intended.'
67 | }
68 | ]
69 | }
70 | export default class ConsentManager extends PureComponent {
71 | static displayName = 'ConsentManager'
72 |
73 | static defaultProps = {
74 | otherWriteKeys: [],
75 | shouldRequireConsent: () => true,
76 | implyConsentOnInteraction: false,
77 | onError: undefined,
78 | cookieDomain: undefined,
79 | cookieName: undefined,
80 | cookieExpires: undefined,
81 | cookieAttributes: {},
82 | customCategories: undefined,
83 | bannerActionsBlock: undefined,
84 | bannerHideCloseButton: false,
85 | bannerTextColor: '#fff',
86 | bannerSubContent: 'You can change your preferences at any time.',
87 | bannerBackgroundColor: '#1f4160',
88 | preferencesDialogTitle: 'Website Data Collection Preferences',
89 | cancelDialogTitle: 'Are you sure you want to cancel?',
90 | defaultDestinationBehavior: 'disable',
91 | preferencesDialogTemplate: defaultPreferencesDialogTemplate
92 | }
93 |
94 | render() {
95 | const {
96 | writeKey,
97 | otherWriteKeys,
98 | shouldRequireConsent,
99 | implyConsentOnInteraction,
100 | cookieDomain,
101 | cookieName,
102 | cookieExpires,
103 | cookieAttributes,
104 | bannerContent,
105 | bannerActionsBlock,
106 | bannerSubContent,
107 | bannerTextColor,
108 | bannerBackgroundColor,
109 | bannerHideCloseButton,
110 | bannerAsModal,
111 | preferencesDialogTitle,
112 | preferencesDialogContent,
113 | cancelDialogTitle,
114 | cancelDialogContent,
115 | customCategories,
116 | defaultDestinationBehavior,
117 | cdnHost,
118 | preferencesDialogTemplate,
119 | onError
120 | } = this.props
121 |
122 | return (
123 |
138 | {({
139 | destinations,
140 | customCategories,
141 | newDestinations,
142 | preferences,
143 | isConsentRequired,
144 | setPreferences,
145 | resetPreferences,
146 | saveConsent,
147 | havePreferencesChanged,
148 | workspaceAddedNewDestinations
149 | }) => {
150 | return (
151 |
186 | )
187 | }}
188 |
189 | )
190 | }
191 |
192 | mergeTemplates = (
193 | newProps: PreferenceDialogTemplate,
194 | defaultPreferencesDialogTemplate: PreferenceDialogTemplate
195 | ): PreferenceDialogTemplate => {
196 | const headingsMerge = {
197 | ...defaultPreferencesDialogTemplate.headings,
198 | ...newProps.headings
199 | }
200 | const checkboxesMerge = {
201 | ...defaultPreferencesDialogTemplate.checkboxes,
202 | ...newProps.checkboxes
203 | }
204 | const actionButtonsMerge = {
205 | ...defaultPreferencesDialogTemplate.actionButtons,
206 | ...newProps.actionButtons
207 | }
208 | const cancelDialogButtonsMerge = {
209 | ...defaultPreferencesDialogTemplate.cancelDialogButtons,
210 | ...newProps.cancelDialogButtons
211 | }
212 | const categoriesMerge = defaultPreferencesDialogTemplate?.categories!.map(category => ({
213 | ...category,
214 | ...newProps?.categories?.find(c => c.key === category.key)
215 | }))
216 | return {
217 | headings: headingsMerge,
218 | checkboxes: checkboxesMerge,
219 | actionButtons: actionButtonsMerge,
220 | cancelDialogButtons: cancelDialogButtonsMerge,
221 | categories: categoriesMerge
222 | }
223 | }
224 |
225 | getInitialPreferences = () => {
226 | const { initialPreferences, customCategories } = this.props
227 | if (initialPreferences) {
228 | return initialPreferences
229 | }
230 |
231 | if (!customCategories) {
232 | return zeroValuePreferences
233 | }
234 |
235 | const initialCustomPreferences = {}
236 | Object.keys(customCategories).forEach(category => {
237 | initialCustomPreferences[category] = null
238 | })
239 |
240 | return initialCustomPreferences
241 | }
242 |
243 | handleMapCustomPreferences = (destinations: Destination[], preferences: CategoryPreferences) => {
244 | const { customCategories } = this.props
245 | const destinationPreferences = {}
246 | const customPreferences = {}
247 |
248 | if (customCategories) {
249 | for (const preferenceName of Object.keys(customCategories)) {
250 | const value = preferences[preferenceName]
251 | if (typeof value === 'boolean' || typeof value === 'string') {
252 | customPreferences[preferenceName] = value
253 | } else {
254 | customPreferences[preferenceName] = true
255 | }
256 | }
257 |
258 | destinations.forEach(destination => {
259 | // Mark custom categories
260 | Object.entries(customCategories).forEach(([categoryName, { integrations }]) => {
261 | const consentAlreadySetToFalse = destinationPreferences[destination.id] === false
262 | const shouldSetConsent = integrations.includes(destination.id)
263 | if (shouldSetConsent && !consentAlreadySetToFalse) {
264 | destinationPreferences[destination.id] = customPreferences[categoryName]
265 | }
266 | })
267 | })
268 |
269 | return { destinationPreferences, customPreferences }
270 | }
271 |
272 | // Default unset preferences to true (for implicit consent)
273 | for (const preferenceName of Object.keys(preferences)) {
274 | const value = preferences[preferenceName]
275 | if (typeof value === 'boolean') {
276 | customPreferences[preferenceName] = value
277 | } else {
278 | customPreferences[preferenceName] = true
279 | }
280 | }
281 |
282 | const customPrefs = customPreferences as CategoryPreferences
283 |
284 | for (const destination of destinations) {
285 | // Mark advertising destinations
286 | if (
287 | ADVERTISING_CATEGORIES.find(c => c === destination.category) &&
288 | destinationPreferences[destination.id] !== false
289 | ) {
290 | destinationPreferences[destination.id] = customPrefs.advertising
291 | }
292 |
293 | // Mark function destinations
294 | if (
295 | FUNCTIONAL_CATEGORIES.find(c => c === destination.category) &&
296 | destinationPreferences[destination.id] !== false
297 | ) {
298 | destinationPreferences[destination.id] = customPrefs.functional
299 | }
300 |
301 | // Fallback to marketing
302 | if (!(destination.id in destinationPreferences)) {
303 | destinationPreferences[destination.id] = customPrefs.marketingAndAnalytics
304 | }
305 | }
306 |
307 | return { destinationPreferences, customPreferences }
308 | }
309 | }
310 |
--------------------------------------------------------------------------------
/src/__tests__/consent-manager-builder/index.todo.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { shallow } from 'enzyme'
3 | import nock from 'nock'
4 | import sinon from 'sinon'
5 | import ConsentManagerBuilder from '../../consent-manager-builder'
6 | import { ADVERTISING_CATEGORIES, FUNCTIONAL_CATEGORIES } from '../../consent-manager/categories'
7 |
8 | describe('ConsentManagerBuilder', () => {
9 | beforeEach(() => {
10 | document = {}
11 | window = {}
12 | })
13 |
14 | test.todo('doesn՚t load analytics.js when consent is required')
15 |
16 | test('provides a list of enabled destinations', done => {
17 | nock('https://cdn.segment.com')
18 | .get('/v1/projects/123/integrations')
19 | .reply(200, [
20 | {
21 | name: 'Google Analytics',
22 | creationName: 'Google Analytics'
23 | },
24 | {
25 | name: 'Amplitude',
26 | creationName: 'Amplitude'
27 | }
28 | ])
29 | .get('/v1/projects/abc/integrations')
30 | .reply(200, [
31 | {
32 | name: 'FullStory',
33 | creationName: 'FullStory'
34 | }
35 | ])
36 |
37 | shallow(
38 |
39 | {({ destinations }) => {
40 | expect(destinations).toMatchObject([
41 | {
42 | id: 'Amplitude',
43 | name: 'Amplitude'
44 | },
45 | {
46 | id: 'FullStory',
47 | name: 'FullStory'
48 | },
49 | {
50 | id: 'Google Analytics',
51 | name: 'Google Analytics'
52 | }
53 | ])
54 | done()
55 | }}
56 |
57 | )
58 | })
59 |
60 | test('provides a list of newly added destinations', done => {
61 | document.cookie =
62 | 'tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Amplitude%22:true}}'
63 | window.analytics = { load() {}, track() {}, addSourceMiddleware() {} }
64 |
65 | nock('https://cdn.segment.com')
66 | .get('/v1/projects/123/integrations')
67 | .reply(200, [
68 | {
69 | name: 'Google Analytics',
70 | creationName: 'Google Analytics'
71 | },
72 | {
73 | name: 'Amplitude',
74 | creationName: 'Amplitude'
75 | }
76 | ])
77 |
78 | shallow(
79 |
80 | {({ newDestinations }) => {
81 | expect(newDestinations).toMatchObject([
82 | {
83 | name: 'Google Analytics',
84 | id: 'Google Analytics'
85 | }
86 | ])
87 | done()
88 | }}
89 |
90 | )
91 | })
92 |
93 | test('loads analytics.js with the user՚s preferences', done => {
94 | const ajsLoad = sinon.spy()
95 | document.cookie =
96 | 'tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Amplitude%22:true}}'
97 | window.analytics = { load: ajsLoad, track() {}, addSourceMiddleware() {} }
98 | const writeKey = '123'
99 |
100 | nock('https://cdn.segment.com')
101 | .get('/v1/projects/123/integrations')
102 | .reply(200, [
103 | {
104 | name: 'Amplitude',
105 | creationName: 'Amplitude'
106 | }
107 | ])
108 |
109 | shallow(
110 |
111 | {() => {
112 | expect(ajsLoad.calledOnce).toBe(true)
113 | expect(ajsLoad.args[0][0]).toBe(writeKey)
114 | expect(ajsLoad.args[0][1]).toMatchObject({
115 | integrations: {
116 | All: false,
117 | Amplitude: true,
118 | 'Segment.io': true
119 | }
120 | })
121 | done()
122 | }}
123 |
124 | )
125 | })
126 |
127 | test('provides an object containing the WIP preferences', done => {
128 | document.cookie =
129 | 'tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Amplitude%22:true}}'
130 | window.analytics = { load() {}, track() {}, addSourceMiddleware() {}, addSourceMiddleware() {} }
131 |
132 | nock('https://cdn.segment.com')
133 | .get('/v1/projects/123/integrations')
134 | .reply(200, [
135 | {
136 | name: 'Amplitude',
137 | creationName: 'Amplitude'
138 | }
139 | ])
140 |
141 | shallow(
142 |
143 | {({ preferences }) => {
144 | expect(preferences).toMatchObject({
145 | Amplitude: true
146 | })
147 | done()
148 | }}
149 |
150 | )
151 | })
152 |
153 | test('does not imply consent on interacation', done => {
154 | nock('https://cdn.segment.com')
155 | .get('/v1/projects/123/integrations')
156 | .reply(200, [
157 | {
158 | name: 'Amplitude',
159 | creationName: 'Amplitude'
160 | }
161 | ])
162 |
163 | shallow(
164 |
165 | {({ preferences }) => {
166 | expect(preferences).toMatchObject({})
167 | done()
168 | }}
169 |
170 | )
171 | })
172 |
173 | test('if defaultDestinationBehavior is set to imply and category is set to true, loads new destination', done => {
174 | document.cookie =
175 | 'tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Amplitude%22:true}%2C%22custom%22:{%22advertising%22:false%2C%22marketingAndAnalytics%22:true%2C%22functional%22:true}}'
176 | window.analytics = { load() {}, identify() {}, track() {}, addSourceMiddleware() {} }
177 |
178 | nock('https://cdn.segment.com')
179 | .get('/v1/projects/123/integrations')
180 | .reply(200, [
181 | {
182 | name: 'Google Analytics',
183 | creationName: 'Google Analytics'
184 | },
185 | {
186 | name: 'Amplitude',
187 | creationName: 'Amplitude'
188 | }
189 | ])
190 |
191 | shallow(
192 | {
196 | const destinationPreferences = {}
197 | const customPreferences = {}
198 | // Default unset preferences to true (for implicit consent)
199 | for (const preferenceName of Object.keys(preferences)) {
200 | const value = preferences[preferenceName]
201 | if (typeof value === 'boolean') {
202 | customPreferences[preferenceName] = value
203 | } else {
204 | customPreferences[preferenceName] = true
205 | }
206 | }
207 |
208 | const customPrefs = customPreferences
209 |
210 | for (const destination of destinations) {
211 | // Mark advertising destinations
212 | if (
213 | ADVERTISING_CATEGORIES.find(c => c === destination.category) &&
214 | destinationPreferences[destination.id] !== false
215 | ) {
216 | destinationPreferences[destination.id] = customPrefs.advertising
217 | }
218 |
219 | // Mark function destinations
220 | if (
221 | FUNCTIONAL_CATEGORIES.find(c => c === destination.category) &&
222 | destinationPreferences[destination.id] !== false
223 | ) {
224 | destinationPreferences[destination.id] = customPrefs.functional
225 | }
226 |
227 | // Fallback to marketing
228 | if (!(destination.id in destinationPreferences)) {
229 | destinationPreferences[destination.id] = customPrefs.marketingAndAnalytics
230 | }
231 | }
232 |
233 | return { destinationPreferences, customPreferences }
234 | }}
235 | >
236 | {({ destinationPreferences }) => {
237 | expect(destinationPreferences).toMatchObject({
238 | Amplitude: true,
239 | 'Google Analytics': true
240 | })
241 | done()
242 | }}
243 |
244 | )
245 | })
246 |
247 | test('if defaultDestinationBehavior is set to imply and category is set to false, does not load new destination', done => {
248 | document.cookie =
249 | 'tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Amplitude%22:true}%2C%22custom%22:{%22advertising%22:false%2C%22marketingAndAnalytics%22:false%2C%22functional%22:true}}'
250 | window.analytics = {
251 | load() {},
252 | identify() {},
253 | track() {},
254 | addSourceMiddleware() {}
255 | }
256 |
257 | nock('https://cdn.segment.com')
258 | .get('/v1/projects/123/integrations')
259 | .reply(200, [
260 | {
261 | name: 'Google Analytics',
262 | creationName: 'Google Analytics'
263 | },
264 | {
265 | name: 'Amplitude',
266 | creationName: 'Amplitude'
267 | }
268 | ])
269 |
270 | shallow(
271 | {
275 | const destinationPreferences = {}
276 | const customPreferences = {}
277 |
278 | // Default unset preferences to true (for implicit consent)
279 | for (const preferenceName of Object.keys(preferences)) {
280 | const value = preferences[preferenceName]
281 | if (typeof value === 'boolean') {
282 | customPreferences[preferenceName] = value
283 | } else {
284 | customPreferences[preferenceName] = true
285 | }
286 | }
287 |
288 | const customPrefs = customPreferences
289 |
290 | for (const destination of destinations) {
291 | // Mark advertising destinations
292 | if (
293 | ADVERTISING_CATEGORIES.find(c => c === destination.category) &&
294 | destinationPreferences[destination.id] !== false
295 | ) {
296 | destinationPreferences[destination.id] = customPrefs.advertising
297 | }
298 |
299 | // Mark function destinations
300 | if (
301 | FUNCTIONAL_CATEGORIES.find(c => c === destination.category) &&
302 | destinationPreferences[destination.id] !== false
303 | ) {
304 | destinationPreferences[destination.id] = customPrefs.functional
305 | }
306 |
307 | // Fallback to marketing
308 | if (!(destination.id in destinationPreferences)) {
309 | destinationPreferences[destination.id] = customPrefs.marketingAndAnalytics
310 | }
311 | }
312 |
313 | return { destinationPreferences, customPreferences }
314 | }}
315 | >
316 | {({ destinationPreferences }) => {
317 | expect(destinationPreferences).toMatchObject({
318 | Amplitude: false,
319 | 'Google Analytics': false
320 | })
321 | done()
322 | }}
323 |
324 | )
325 | })
326 |
327 | test('a different cdn is used when cdnHost is set', done => {
328 | nock('https://foo.bar.com')
329 | .get('/v1/projects/123/integrations')
330 | .reply(200, [
331 | {
332 | name: 'Google Analytics',
333 | creationName: 'Google Analytics'
334 | },
335 | {
336 | name: 'Amplitude',
337 | creationName: 'Amplitude'
338 | }
339 | ])
340 | .get('/v1/projects/abc/integrations')
341 | .reply(200, [
342 | {
343 | name: 'FullStory',
344 | creationName: 'FullStory'
345 | }
346 | ])
347 |
348 | shallow(
349 |
350 | {({ destinations }) => {
351 | expect(destinations).toMatchObject([
352 | {
353 | id: 'Amplitude',
354 | name: 'Amplitude'
355 | },
356 | {
357 | id: 'FullStory',
358 | name: 'FullStory'
359 | },
360 | {
361 | id: 'Google Analytics',
362 | name: 'Google Analytics'
363 | }
364 | ])
365 | done()
366 | }}
367 |
368 | )
369 | })
370 | test.todo('loads analytics.js normally when consent isn՚t required')
371 | test.todo('still applies preferences when consent isn՚t required')
372 | test.todo('provides a setPreferences() function for setting the preferences')
373 | test.todo('setPreferences() function can be passed a boolean to set all preferences')
374 | test.todo('provides a resetPreferences() function for resetting the preferences')
375 | test.todo(
376 | 'provides a saveConsent() function for persisting the preferences and loading analytics.js'
377 | )
378 | test.todo('saveConsent() can be passed additional preferences to persist')
379 | test.todo('saveConsent() can be passed a boolean to set all preferences')
380 | test.todo('saveConsent() fills in missing preferences')
381 | test.todo('initialPreferences sets the initial preferences')
382 | test.todo('loads custom preferences')
383 | test.todo('saveConsent() maps custom preferences to destination preferences')
384 | test.todo('mapCustomPreferences allows customPreferences to be updated')
385 | test.todo('saveConsent() saves custom preferences')
386 | test.todo('cookieDomain sets the cookie domain')
387 | })
388 |
--------------------------------------------------------------------------------
/src/consent-manager-builder/index.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'react'
2 | import { loadPreferences, savePreferences } from './preferences'
3 | import fetchDestinations from './fetch-destinations'
4 | import conditionallyLoadAnalytics from './analytics'
5 | import {
6 | Destination,
7 | CategoryPreferences,
8 | CustomCategories,
9 | DefaultDestinationBehavior
10 | } from '../types'
11 | import { CookieAttributes } from 'js-cookie'
12 |
13 | function getNewDestinations(destinations: Destination[], preferences: CategoryPreferences) {
14 | const newDestinations: Destination[] = []
15 |
16 | // If there are no preferences then all destinations are new
17 | if (!preferences) {
18 | return destinations
19 | }
20 |
21 | for (const destination of destinations) {
22 | if (preferences[destination.id] === undefined) {
23 | newDestinations.push(destination)
24 | }
25 | }
26 |
27 | return newDestinations
28 | }
29 |
30 | interface Props {
31 | /** Your Segment Write key for your website */
32 | writeKey: string
33 |
34 | /** A list of other write keys you may want to provide */
35 | otherWriteKeys?: string[]
36 |
37 | cookieDomain?: string
38 | cookieName?: string
39 | cookieAttributes?: CookieAttributes
40 |
41 | /**
42 | * Number of days until the preferences cookie should expire
43 | */
44 | cookieExpires?: number
45 |
46 | /**
47 | * An initial selection of Preferences
48 | */
49 | initialPreferences?: CategoryPreferences
50 |
51 | /**
52 | * Provide a function to define whether or not consent should be required
53 | */
54 | shouldRequireConsent?: () => Promise | boolean
55 |
56 | /**
57 | * Render props for the Consent Manager builder
58 | */
59 | children: (props: RenderProps) => React.ReactElement
60 |
61 | /**
62 | * Allows for customizing how to show different categories of consent.
63 | */
64 | mapCustomPreferences?: (
65 | destinations: Destination[],
66 | preferences: CategoryPreferences
67 | ) => { destinationPreferences: CategoryPreferences; customPreferences: CategoryPreferences }
68 |
69 | /**
70 | * Allows for adding custom consent categories by mapping a custom category to Segment integrations
71 | */
72 | customCategories?: CustomCategories
73 |
74 | /**
75 | * Specified default behavior for when new destinations are detected on the source(s) of this consent manager.
76 | */
77 | defaultDestinationBehavior?: DefaultDestinationBehavior
78 |
79 | /**
80 | * A callback for dealing with errors in the Consent Manager
81 | */
82 | onError?: (err: Error) => void | Promise
83 |
84 | /**
85 | * CDN to fetch list of integrations from
86 | */
87 | cdnHost?: string
88 |
89 | /**
90 | * Default true
91 | * Reload the page if the trackers have already been initialized so that
92 | * the user's new preferences can take effect.
93 | */
94 | shouldReload?: boolean
95 |
96 | /**
97 | * Default false
98 | * Disable the analitics.load to make local testing.
99 | */
100 | devMode?: boolean
101 |
102 | /**
103 | * Default false
104 | * Use default categories set by Consent Manager instead of detinations
105 | */
106 | useDefaultCategories?: boolean
107 | }
108 |
109 | interface RenderProps {
110 | destinations: Destination[]
111 | newDestinations: Destination[]
112 | preferences: CategoryPreferences
113 | destinationPreferences: CategoryPreferences
114 | isConsentRequired: boolean
115 | customCategories?: CustomCategories
116 | havePreferencesChanged: boolean
117 | workspaceAddedNewDestinations: boolean
118 | setPreferences: (newPreferences: CategoryPreferences) => void
119 | resetPreferences: () => void
120 | saveConsent: (
121 | newPreferences?: CategoryPreferences | boolean,
122 | shouldReload?: boolean,
123 | devMode?: boolean
124 | ) => void
125 | }
126 |
127 | interface State {
128 | isLoading: boolean
129 | destinations: Destination[]
130 | newDestinations: Destination[]
131 | preferences?: CategoryPreferences
132 | destinationPreferences?: CategoryPreferences
133 | isConsentRequired: boolean
134 | havePreferencesChanged: boolean
135 | workspaceAddedNewDestinations: boolean
136 | }
137 |
138 | const DEFAULT_CATEGORIES = {
139 | functional: false,
140 | marketingAndAnalytics: false,
141 | advertising: false,
142 | essential: false
143 | }
144 |
145 | export default class ConsentManagerBuilder extends Component {
146 | static displayName = 'ConsentManagerBuilder'
147 |
148 | static defaultProps = {
149 | otherWriteKeys: [],
150 | onError: undefined,
151 | shouldRequireConsent: () => true,
152 | initialPreferences: {},
153 | cdnHost: 'cdn.segment.com',
154 | shouldReload: true,
155 | devMode: false,
156 | useDefaultCategories: false
157 | }
158 |
159 | state = {
160 | isLoading: true,
161 | destinations: [],
162 | newDestinations: [],
163 | preferences: {},
164 | destinationPreferences: {},
165 | isConsentRequired: true,
166 | havePreferencesChanged: false,
167 | workspaceAddedNewDestinations: false,
168 | useDefaultCategories: false
169 | }
170 |
171 | render() {
172 | const { children, customCategories } = this.props
173 | const {
174 | isLoading,
175 | destinations,
176 | preferences,
177 | newDestinations,
178 | isConsentRequired,
179 | havePreferencesChanged,
180 | workspaceAddedNewDestinations,
181 | destinationPreferences
182 | } = this.state
183 | if (isLoading) {
184 | return null
185 | }
186 |
187 | return children({
188 | destinations,
189 | customCategories,
190 | newDestinations,
191 | preferences,
192 | isConsentRequired,
193 | havePreferencesChanged,
194 | workspaceAddedNewDestinations,
195 | destinationPreferences,
196 | setPreferences: this.handleSetPreferences,
197 | resetPreferences: this.handleResetPreferences,
198 | saveConsent: this.handleSaveConsent
199 | })
200 | }
201 |
202 | async componentDidMount() {
203 | const { onError } = this.props
204 | if (onError && typeof onError === 'function') {
205 | try {
206 | await this.initialise()
207 | } catch (e) {
208 | await onError(e)
209 | }
210 | } else {
211 | await this.initialise()
212 | }
213 | }
214 |
215 | initialise = async () => {
216 | const {
217 | writeKey,
218 | otherWriteKeys = ConsentManagerBuilder.defaultProps.otherWriteKeys,
219 | shouldRequireConsent = ConsentManagerBuilder.defaultProps.shouldRequireConsent,
220 | initialPreferences,
221 | mapCustomPreferences,
222 | defaultDestinationBehavior,
223 | cookieName,
224 | cdnHost = ConsentManagerBuilder.defaultProps.cdnHost,
225 | shouldReload = ConsentManagerBuilder.defaultProps.shouldReload,
226 | devMode = ConsentManagerBuilder.defaultProps.devMode,
227 | useDefaultCategories = ConsentManagerBuilder.defaultProps.useDefaultCategories
228 | } = this.props
229 |
230 | // TODO: add option to run mapCustomPreferences on load so that the destination preferences automatically get updated
231 | let { destinationPreferences, customPreferences } = loadPreferences(cookieName)
232 | const [isConsentRequired, destinations] = await Promise.all([
233 | shouldRequireConsent(),
234 | fetchDestinations(cdnHost, [writeKey, ...otherWriteKeys])
235 | ])
236 | const newDestinations = getNewDestinations(destinations, destinationPreferences || {})
237 | const workspaceAddedNewDestinations =
238 | destinationPreferences &&
239 | Object.keys(destinationPreferences).length > 0 &&
240 | newDestinations.length > 0
241 |
242 | let preferences: CategoryPreferences | undefined
243 | const initialPrefencesHaveValue = Object.values(initialPreferences || {}).some(
244 | v => v === true || v === false || v === 'N/A'
245 | )
246 | const emptyCustomPreferecences = Object.values(customPreferences || {}).every(
247 | v => v === null || v === undefined || v === 'N/A'
248 | )
249 |
250 | if (mapCustomPreferences) {
251 | preferences = useDefaultCategories
252 | ? DEFAULT_CATEGORIES
253 | : customPreferences || initialPreferences || {}
254 | if (
255 | (initialPrefencesHaveValue && emptyCustomPreferecences) ||
256 | (defaultDestinationBehavior === 'imply' && workspaceAddedNewDestinations)
257 | ) {
258 | const mapped = mapCustomPreferences(destinations, preferences)
259 | destinationPreferences = mapped.destinationPreferences
260 | customPreferences = mapped.customPreferences
261 | preferences = customPreferences
262 | }
263 | } else {
264 | preferences = useDefaultCategories
265 | ? DEFAULT_CATEGORIES
266 | : destinationPreferences || initialPreferences
267 | }
268 |
269 | conditionallyLoadAnalytics({
270 | writeKey,
271 | destinations,
272 | destinationPreferences,
273 | isConsentRequired,
274 | shouldReload,
275 | devMode,
276 | defaultDestinationBehavior,
277 | categoryPreferences: preferences
278 | })
279 |
280 | this.setState({
281 | isLoading: false,
282 | destinations,
283 | newDestinations,
284 | preferences,
285 | isConsentRequired,
286 | destinationPreferences,
287 | workspaceAddedNewDestinations: Boolean(workspaceAddedNewDestinations)
288 | })
289 | }
290 |
291 | handleSetPreferences = (newPreferences: CategoryPreferences) => {
292 | this.setState(prevState => {
293 | const { destinations, preferences: existingPreferences } = prevState
294 | const preferences = this.mergePreferences({
295 | destinations,
296 | newPreferences,
297 | existingPreferences
298 | })
299 | return { ...prevState, preferences, havePreferencesChanged: true }
300 | })
301 | }
302 |
303 | handleResetPreferences = () => {
304 | const { initialPreferences, mapCustomPreferences, cookieName } = this.props
305 | const { destinationPreferences, customPreferences } = loadPreferences(cookieName)
306 |
307 | let preferences: CategoryPreferences | undefined
308 | if (mapCustomPreferences) {
309 | preferences = customPreferences || initialPreferences
310 | } else {
311 | preferences = destinationPreferences || initialPreferences
312 | }
313 |
314 | this.setState({ preferences })
315 | }
316 |
317 | handleSaveConsent = (
318 | newPreferences: CategoryPreferences | undefined,
319 | shouldReload: boolean,
320 | devMode?: boolean
321 | ) => {
322 | const {
323 | writeKey,
324 | cookieDomain,
325 | cookieName,
326 | cookieExpires,
327 | cookieAttributes,
328 | mapCustomPreferences,
329 | defaultDestinationBehavior
330 | } = this.props
331 |
332 | this.setState(prevState => {
333 | const { destinations, preferences: existingPreferences, isConsentRequired } = prevState
334 |
335 | let preferences = this.mergePreferences({
336 | destinations,
337 | newPreferences,
338 | existingPreferences
339 | })
340 |
341 | let destinationPreferences: CategoryPreferences
342 | let customPreferences: CategoryPreferences | undefined
343 |
344 | if (mapCustomPreferences) {
345 | const custom = mapCustomPreferences(destinations, preferences)
346 | destinationPreferences = custom.destinationPreferences
347 | customPreferences = custom.customPreferences
348 |
349 | if (customPreferences) {
350 | // Allow the customPreferences to be updated from mapCustomPreferences
351 | preferences = customPreferences
352 | } else {
353 | // Make returning the customPreferences from mapCustomPreferences optional
354 | customPreferences = preferences
355 | }
356 | } else {
357 | destinationPreferences = preferences
358 | }
359 |
360 | const newDestinations = getNewDestinations(destinations, destinationPreferences)
361 |
362 | if (
363 | prevState.havePreferencesChanged ||
364 | newDestinations.length > 0 ||
365 | typeof newPreferences === 'boolean'
366 | ) {
367 | shouldReload = true
368 | }
369 | savePreferences({
370 | destinationPreferences,
371 | customPreferences,
372 | cookieDomain,
373 | cookieName,
374 | cookieExpires,
375 | cookieAttributes
376 | })
377 | conditionallyLoadAnalytics({
378 | writeKey,
379 | destinations,
380 | destinationPreferences,
381 | isConsentRequired,
382 | shouldReload,
383 | devMode,
384 | defaultDestinationBehavior,
385 | categoryPreferences: customPreferences
386 | })
387 |
388 | return {
389 | ...prevState,
390 | destinationPreferences,
391 | preferences,
392 | newDestinations
393 | }
394 | })
395 | }
396 |
397 | mergePreferences = (args: {
398 | destinations: Destination[]
399 | existingPreferences?: CategoryPreferences
400 | newPreferences?: CategoryPreferences
401 | }) => {
402 | const { destinations, existingPreferences, newPreferences } = args
403 |
404 | let preferences: CategoryPreferences
405 |
406 | if (typeof newPreferences === 'boolean') {
407 | const destinationPreferences = {}
408 | for (const destination of destinations) {
409 | destinationPreferences[destination.id] = newPreferences
410 | }
411 | preferences = destinationPreferences
412 | } else if (newPreferences) {
413 | preferences = {
414 | ...existingPreferences,
415 | ...newPreferences
416 | }
417 | } else {
418 | preferences = existingPreferences!
419 | }
420 |
421 | return preferences
422 | }
423 | }
424 |
--------------------------------------------------------------------------------
/src/consent-manager/preference-dialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react'
2 | import styled from '@emotion/styled'
3 | import { css } from '@emotion/react'
4 | import Dialog from './dialog'
5 | import { DefaultButton, GreenButton } from './buttons'
6 | import {
7 | Destination,
8 | CustomCategories,
9 | CategoryPreferences,
10 | PreferenceDialogTemplate
11 | } from '../types'
12 |
13 | const hideOnMobile = css`
14 | @media (max-width: 600px) {
15 | display: none;
16 | }
17 | `
18 |
19 | const TableScroll = styled('div')`
20 | overflow-x: auto;
21 | margin-top: 16px;
22 | `
23 |
24 | const Table = styled('table')`
25 | border-collapse: collapse;
26 | font-size: 12px;
27 | `
28 |
29 | const ColumnHeading = styled('th')`
30 | background: #f7f8fa;
31 | color: #1f4160;
32 | font-weight: 600;
33 | text-align: left;
34 | border-width: 2px;
35 | `
36 |
37 | const RowHeading = styled('th')`
38 | font-weight: normal;
39 | text-align: left;
40 | `
41 |
42 | const Row = styled('tr')`
43 | th,
44 | td {
45 | vertical-align: top;
46 | padding: 8px 12px;
47 | border: 1px solid rgba(67, 90, 111, 0.114);
48 | }
49 | td {
50 | border-top: none;
51 | }
52 | `
53 |
54 | const InputCell = styled('td')`
55 | input {
56 | vertical-align: middle;
57 | }
58 | label {
59 | display: block;
60 | margin-bottom: 4px;
61 | white-space: nowrap;
62 | }
63 | td {
64 | border: none;
65 | vertical-align: middle;
66 | }
67 | `
68 |
69 | interface PreferenceDialogProps {
70 | innerRef: (element: HTMLElement | null) => void
71 | onCancel: () => void
72 | onSave: () => void
73 | onChange: (name: string, value: boolean) => void
74 | marketingDestinations: Destination[]
75 | advertisingDestinations: Destination[]
76 | functionalDestinations: Destination[]
77 | marketingAndAnalytics?: boolean | null
78 | advertising?: boolean | null
79 | functional?: boolean | null
80 | customCategories?: CustomCategories
81 | destinations: Destination[]
82 | preferences: CategoryPreferences
83 | title: React.ReactNode
84 | content: React.ReactNode
85 | preferencesDialogTemplate?: PreferenceDialogTemplate
86 | }
87 |
88 | export default class PreferenceDialog extends PureComponent {
89 | static displayName = 'PreferenceDialog'
90 |
91 | static defaultProps = {
92 | marketingAndAnalytics: null,
93 | advertising: null,
94 | functional: null
95 | }
96 |
97 | render() {
98 | const {
99 | innerRef,
100 | onCancel,
101 | marketingDestinations,
102 | advertisingDestinations,
103 | functionalDestinations,
104 | marketingAndAnalytics,
105 | advertising,
106 | functional,
107 | customCategories,
108 | destinations,
109 | title,
110 | content,
111 | preferences,
112 | preferencesDialogTemplate
113 | } = this.props
114 |
115 | const { headings, checkboxes, actionButtons } = preferencesDialogTemplate!
116 |
117 | const functionalInfo = preferencesDialogTemplate?.categories!.find(c => c.key === 'functional')
118 | const marketingInfo = preferencesDialogTemplate?.categories!.find(c => c.key === 'marketing')
119 | const advertisingInfo = preferencesDialogTemplate?.categories!.find(
120 | c => c.key === 'advertising'
121 | )
122 | const essentialInfo = preferencesDialogTemplate?.categories!.find(c => c.key === 'essential')
123 |
124 | const buttons = (
125 |
126 |
127 | {actionButtons!.cancelValue}
128 |
129 |
130 | {actionButtons!.saveValue}
131 |
132 |
133 | )
134 |
135 | return (
136 |
143 | {content}
144 |
145 |
146 |
331 |
332 |
333 | )
334 | }
335 |
336 | handleChange = e => {
337 | const { onChange } = this.props
338 | onChange(e.target.name, e.target.value === 'true')
339 | }
340 |
341 | handleSubmit = (e: React.FormEvent) => {
342 | const {
343 | onSave,
344 | preferences,
345 | marketingAndAnalytics,
346 | advertising,
347 | functional,
348 | customCategories
349 | } = this.props
350 | e.preventDefault()
351 | // Safe guard against browsers that don't prevent the
352 | // submission of invalid forms (Safari < 10.1)
353 | if (
354 | !customCategories &&
355 | (marketingAndAnalytics === null || advertising === null || functional === null)
356 | ) {
357 | return
358 | }
359 |
360 | // Safe guard against custom categories being null
361 | if (
362 | customCategories &&
363 | Object.keys(customCategories).some(category => preferences[category] === null)
364 | ) {
365 | return
366 | }
367 | onSave()
368 | }
369 | }
370 |
--------------------------------------------------------------------------------