From e1efa62fe09fccec6b102fb2de3357a1a6ff8554 Mon Sep 17 00:00:00 2001 From: Axel KIRK Date: Fri, 1 Sep 2023 15:10:52 +0200 Subject: [PATCH 01/14] feat(cookie): init cookie consent package --- .changeset/breezy-donuts-scream.md | 5 + packages/cookie-consent/.eslintrc.cjs | 10 + packages/cookie-consent/.npmignore | 5 + packages/cookie-consent/README.md | 18 ++ packages/cookie-consent/package.json | 18 ++ .../CookieConsentProvider.tsx | 227 ++++++++++++++++ .../CookieConsentProvider/__tests__/index.tsx | 244 ++++++++++++++++++ .../useSegmentIntegrations/emptyConfig.tsx | 15 ++ .../useSegmentIntegrations/fetchError.tsx | 26 ++ .../useSegmentIntegrations/networkError.tsx | 26 ++ .../useSegmentIntegrations/working.tsx | 82 ++++++ .../src/CookieConsentProvider/index.tsx | 6 + .../src/CookieConsentProvider/types.ts | 20 ++ .../useSegmentIntegrations.ts | 86 ++++++ packages/cookie-consent/src/helpers/array.ts | 2 + packages/cookie-consent/src/helpers/misc.ts | 3 + packages/cookie-consent/src/index.ts | 5 + pnpm-lock.yaml | 33 +++ 18 files changed, 831 insertions(+) create mode 100644 .changeset/breezy-donuts-scream.md create mode 100644 packages/cookie-consent/.eslintrc.cjs create mode 100644 packages/cookie-consent/.npmignore create mode 100644 packages/cookie-consent/README.md create mode 100644 packages/cookie-consent/package.json create mode 100644 packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx create mode 100644 packages/cookie-consent/src/CookieConsentProvider/__tests__/index.tsx create mode 100644 packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/emptyConfig.tsx create mode 100644 packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/fetchError.tsx create mode 100644 packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/networkError.tsx create mode 100644 packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/working.tsx create mode 100644 packages/cookie-consent/src/CookieConsentProvider/index.tsx create mode 100644 packages/cookie-consent/src/CookieConsentProvider/types.ts create mode 100644 packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts create mode 100644 packages/cookie-consent/src/helpers/array.ts create mode 100644 packages/cookie-consent/src/helpers/misc.ts create mode 100644 packages/cookie-consent/src/index.ts diff --git a/.changeset/breezy-donuts-scream.md b/.changeset/breezy-donuts-scream.md new file mode 100644 index 000000000..68a6a453a --- /dev/null +++ b/.changeset/breezy-donuts-scream.md @@ -0,0 +1,5 @@ +--- +'@scaleway/cookie-consent': major +--- + +Extract internal cookie consent provider strategy to shared package diff --git a/packages/cookie-consent/.eslintrc.cjs b/packages/cookie-consent/.eslintrc.cjs new file mode 100644 index 000000000..a2dbbe3d7 --- /dev/null +++ b/packages/cookie-consent/.eslintrc.cjs @@ -0,0 +1,10 @@ +const { join } = require('path') + +module.exports = { + rules: { + 'import/no-extraneous-dependencies': [ + 'error', + { packageDir: [__dirname, join(__dirname, '../../')] }, + ], + }, +} diff --git a/packages/cookie-consent/.npmignore b/packages/cookie-consent/.npmignore new file mode 100644 index 000000000..5600eef5f --- /dev/null +++ b/packages/cookie-consent/.npmignore @@ -0,0 +1,5 @@ +**/__tests__/** +examples/ +src +.eslintrc.cjs +!.npmignore diff --git a/packages/cookie-consent/README.md b/packages/cookie-consent/README.md new file mode 100644 index 000000000..0e0103510 --- /dev/null +++ b/packages/cookie-consent/README.md @@ -0,0 +1,18 @@ +# Shire - Cookie Consent + +This package contains the Cookie Consent modals and providers logic. + +## QuickStart + +### Prerequisites + +``` +$ cd packages/cookie-consent +``` + +### Start + +```bash +$ pnpm build # Build the package +$ pnpm watch # Build the package and watch for changes +``` diff --git a/packages/cookie-consent/package.json b/packages/cookie-consent/package.json new file mode 100644 index 000000000..38db49e66 --- /dev/null +++ b/packages/cookie-consent/package.json @@ -0,0 +1,18 @@ +{ + "name": "@scaleway/cookie-consent", + "version": "0.2.14", + "description": "Cookie consentment banner", + "type": "module", + "sideEffects": false, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "cookie": "0.5.0", + "react": "18.2.0" + }, + "devDependencies": { + "@types/cookie": "0.5.1", + "@types/react": "18.2.21" + } +} diff --git a/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx b/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx new file mode 100644 index 000000000..679f2786e --- /dev/null +++ b/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx @@ -0,0 +1,227 @@ +import cookie from 'cookie' +import type { ReactNode } from 'react' +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import { uniq } from '../helpers/array' +import { stringToHash } from '../helpers/misc' +import type { CategoryKind, Config, Consent, Integrations } from './types' +import { useSegmentIntegrations } from './useSegmentIntegrations' + +const COOKIE_PREFIX = '_scw_rgpd' +const HASH_COOKIE = `${COOKIE_PREFIX}_hash` + +// Appx 13 Months +const CONSENT_MAX_AGE = 13 * 30 * 24 * 60 * 60 +// Appx 6 Months +const CONSENT_ADVERTISING_MAX_AGE = 6 * 30 * 24 * 60 * 60 + +const COOKIES_OPTIONS = { + sameSite: 'strict', + secure: true, + path: '/', +} as const + +type Context = { + integrations: Integrations + needConsent: boolean + isSegmentAllowed: boolean + segmentIntegrations: { All: boolean } & Record + categoriesConsent: Partial + saveConsent: (categoriesConsent: Partial) => void +} + +const CookieConsentContext = createContext(undefined) + +export const useCookieConsent = (): Context => { + const context = useContext(CookieConsentContext) + if (context === undefined) { + throw new Error( + 'useCookieConsent must be used within a CookieConsentProvider', + ) + } + + return context +} + +export const CookieConsentProvider = ({ + children, + isConsentRequired, + essentialIntegrations, + config, +}: { + children: ReactNode + isConsentRequired: boolean + essentialIntegrations: string[] + config: Config +}) => { + const [needConsent, setNeedsConsent] = useState(false) + + const [cookies, setCookies] = useState>() + const segmentIntegrations = useSegmentIntegrations(config) + + useEffect(() => { + setCookies(cookie.parse(document.cookie)) + }, [needConsent]) + + const integrations: Integrations = useMemo( + () => + uniq([ + ...(segmentIntegrations ?? []), + ...(essentialIntegrations.map(integration => ({ + name: integration, + category: 'essential', + })) as Integrations), + ]), + [segmentIntegrations, essentialIntegrations], + ) + + // We compute a hash with all the integrations that are enabled + // This hash will be used to know if we need to ask for consent + // when a new integration is added + const integrationsHash = useMemo( + () => + stringToHash( + uniq([ + ...(segmentIntegrations ?? []).map(({ name }) => name), + ...essentialIntegrations, + ]) + .sort() + .join(), + ), + [segmentIntegrations, essentialIntegrations], + ) + + useEffect(() => { + // We set needConsent at false until we have an answer from segment + // This is to avoid showing setting needConsent to true only to be set + // to false after receiving segment answer and flciker the UI + setNeedsConsent( + isConsentRequired && + cookies?.[HASH_COOKIE] !== integrationsHash.toString() && + segmentIntegrations !== undefined, + ) + }, [isConsentRequired, cookies, integrationsHash, segmentIntegrations]) + + // We store unique categories names in an array + const categories = useMemo( + () => + uniq([ + ...(segmentIntegrations ?? []).map(({ category }) => category), + ]).sort(), + [segmentIntegrations], + ) + + // From the unique categories names we can now build our consent object + // and check if there is already a consent in a cookie + // Default consent if none is found is false + const cookieConsent = useMemo( + () => + categories.reduce>( + (acc, category) => ({ + ...acc, + [category]: isConsentRequired + ? cookies?.[`${COOKIE_PREFIX}_${category}`] === 'true' + : true, + }), + {}, + ), + [isConsentRequired, categories, cookies], + ) + + const saveConsent = useCallback( + (categoriesConsent: Partial) => { + for (const [consentName, consentValue] of Object.entries( + categoriesConsent, + ) as [CategoryKind, boolean][]) { + const cookieName = `${COOKIE_PREFIX}_${consentName}` + + if (!consentValue) { + // If consent is set to false we have to delete the cookie + document.cookie = cookie.serialize(cookieName, '', { + expires: new Date(0), + }) + } else { + document.cookie = cookie.serialize( + `${COOKIE_PREFIX}_${consentName}`, + consentValue.toString(), + { + ...COOKIES_OPTIONS, + maxAge: + consentName === 'advertising' + ? CONSENT_ADVERTISING_MAX_AGE + : CONSENT_MAX_AGE, + }, + ) + } + } + // We set the hash cookie to the current consented integrations + document.cookie = cookie.serialize( + HASH_COOKIE, + integrationsHash.toString(), + { + ...COOKIES_OPTIONS, + // Here we use the shortest max age to force to ask again for expired consent + maxAge: CONSENT_ADVERTISING_MAX_AGE, + }, + ) + setNeedsConsent(false) + }, + [integrationsHash], + ) + + const isSegmentAllowed = useMemo( + () => + isConsentRequired + ? !needConsent && + !!segmentIntegrations?.some( + integration => cookieConsent[integration.category], + ) + : true, + [isConsentRequired, segmentIntegrations, cookieConsent, needConsent], + ) + + const segmentEnabledIntegrations = useMemo( + () => ({ + All: false, + ...segmentIntegrations?.reduce( + (acc, integration) => ({ + ...acc, + [integration.name]: cookieConsent[integration.category], + }), + {}, + ), + }), + [cookieConsent, segmentIntegrations], + ) + + const value = useMemo( + () => ({ + integrations, + needConsent, + isSegmentAllowed, + segmentIntegrations: segmentEnabledIntegrations, + categoriesConsent: cookieConsent, + saveConsent, + }), + [ + integrations, + cookieConsent, + saveConsent, + needConsent, + isSegmentAllowed, + segmentEnabledIntegrations, + ], + ) + + return ( + + {children} + + ) +} diff --git a/packages/cookie-consent/src/CookieConsentProvider/__tests__/index.tsx b/packages/cookie-consent/src/CookieConsentProvider/__tests__/index.tsx new file mode 100644 index 000000000..8560a3d64 --- /dev/null +++ b/packages/cookie-consent/src/CookieConsentProvider/__tests__/index.tsx @@ -0,0 +1,244 @@ +// useSegmentIntegrations tests have been splitted in multiple files because of https://github.com/facebook/jest/issues/8987 +import { afterEach, describe, expect, it, jest } from '@jest/globals' +import { act, renderHook } from '@testing-library/react' +import cookie from 'cookie' +import type { ComponentProps, ReactNode } from 'react' +import { CookieConsentProvider, useCookieConsent } from '..' + +const wrapper = + ({ + isConsentRequired, + }: Omit, 'children'>) => + ({ children }: { children: ReactNode }) => ( + + {children} + + ) + +jest.mock('../useSegmentIntegrations', () => ({ + __esModule: true, + useSegmentIntegrations: () => [ + { + category: 'analytics', + name: 'Google Universal Analytics', + }, + { + category: 'marketing', + name: 'Salesforce custom destination (Scaleway)', + }, + { + category: 'marketing', + name: 'Salesforce', + }, + ], +})) + +describe('CookieConsent - CookieConsentProvider', () => { + afterEach(() => { + document.cookie = '' + }) + + it('useCookieConsent should throw without provider', () => { + const spy = jest.spyOn(console, 'error') + spy.mockImplementation(() => {}) + + expect(() => renderHook(() => useCookieConsent())).toThrow( + Error('useCookieConsent must be used within a CookieConsentProvider'), + ) + + spy.mockRestore() + }) + + it('should enable everything when isConsentRequired = false', () => { + const { result } = renderHook(() => useCookieConsent(), { + wrapper: wrapper({ + isConsentRequired: false, + essentialIntegrations: ['Deskpro', 'Stripe', 'Sentry'], + config: { + segment: { + cdnURL: 'url', + writeKey: 'key', + }, + }, + }), + }) + + expect(result.current.needConsent).toBe(false) + expect(result.current.isSegmentAllowed).toBe(true) + expect(result.current.categoriesConsent).toStrictEqual({ + analytics: true, + marketing: true, + }) + expect(result.current.segmentIntegrations).toStrictEqual({ + All: false, + 'Google Universal Analytics': true, + Salesforce: true, + 'Salesforce custom destination (Scaleway)': true, + }) + }) + + it('should know to ask for content when no cookie is set and consent is required', () => { + const { result } = renderHook(() => useCookieConsent(), { + wrapper: wrapper({ + isConsentRequired: true, + essentialIntegrations: ['Deskpro', 'Stripe', 'Sentry'], + config: { + segment: { + cdnURL: 'url', + writeKey: 'key', + }, + }, + }), + }) + + expect(result.current.needConsent).toBe(true) + expect(result.current.isSegmentAllowed).toBe(false) + expect(result.current.categoriesConsent).toStrictEqual({ + marketing: false, + analytics: false, + }) + expect(result.current.segmentIntegrations).toStrictEqual({ + All: false, + 'Google Universal Analytics': false, + Salesforce: false, + 'Salesforce custom destination (Scaleway)': false, + }) + }) + + it('should save consent correctly', () => { + const spy = jest.spyOn(cookie, 'serialize') + const { result } = renderHook(() => useCookieConsent(), { + wrapper: wrapper({ + isConsentRequired: true, + essentialIntegrations: ['Deskpro', 'Stripe', 'Sentry'], + config: { + segment: { + cdnURL: 'url', + writeKey: 'key', + }, + }, + }), + }) + + expect(result.current.needConsent).toBe(true) + expect(result.current.isSegmentAllowed).toBe(false) + expect(result.current.categoriesConsent).toStrictEqual({ + analytics: false, + marketing: false, + }) + expect(result.current.segmentIntegrations).toStrictEqual({ + All: false, + 'Google Universal Analytics': false, + Salesforce: false, + 'Salesforce custom destination (Scaleway)': false, + }) + + act(() => { + result.current.saveConsent({ + advertising: true, + marketing: true, + }) + }) + + const cookieOptions = { sameSite: 'strict', secure: true } + + expect(spy).toHaveBeenCalledTimes(3) + expect(spy).toHaveBeenNthCalledWith(2, '_scw_rgpd_marketing', 'true', { + ...cookieOptions, + maxAge: 33696000, + path: '/', + }) + expect(spy).toHaveBeenNthCalledWith(3, '_scw_rgpd_hash', '913003917', { + ...cookieOptions, + maxAge: 15552000, + path: '/', + }) + + act(() => { + result.current.saveConsent({ + advertising: false, + marketing: false, + }) + }) + + expect(spy).toHaveBeenCalledTimes(6) + expect(spy).toHaveBeenNthCalledWith(5, '_scw_rgpd_marketing', '', { + expires: new Date(0), + }) + expect(spy).toHaveBeenNthCalledWith(6, '_scw_rgpd_hash', '913003917', { + ...cookieOptions, + maxAge: 15552000, + path: '/', + }) + }) + + it('should not need consent if hash cookie is set', () => { + jest.spyOn(cookie, 'parse').mockReturnValue({ _scw_rgpd_hash: '913003917' }) + const { result } = renderHook(() => useCookieConsent(), { + wrapper: wrapper({ + isConsentRequired: true, + essentialIntegrations: ['Deskpro', 'Stripe', 'Sentry'], + config: { + segment: { + cdnURL: 'url', + writeKey: 'key', + }, + }, + }), + }) + + expect(result.current.needConsent).toBe(false) + expect(result.current.isSegmentAllowed).toBe(false) + expect(result.current.categoriesConsent).toStrictEqual({ + analytics: false, + marketing: false, + }) + expect(result.current.segmentIntegrations).toStrictEqual({ + All: false, + 'Google Universal Analytics': false, + Salesforce: false, + 'Salesforce custom destination (Scaleway)': false, + }) + }) + + it('should not need consent if hash cookie is set and some categories already approved', () => { + jest.spyOn(cookie, 'parse').mockReturnValue({ + _scw_rgpd_hash: '913003917', + _scw_rgpd_marketing: 'true', + }) + const { result } = renderHook(() => useCookieConsent(), { + wrapper: wrapper({ + isConsentRequired: true, + essentialIntegrations: ['Deskpro', 'Stripe', 'Sentry'], + config: { + segment: { + cdnURL: 'url', + writeKey: 'key', + }, + }, + }), + }) + + expect(result.current.needConsent).toBe(false) + expect(result.current.isSegmentAllowed).toBe(true) + expect(result.current.categoriesConsent).toStrictEqual({ + analytics: false, + marketing: true, + }) + expect(result.current.segmentIntegrations).toStrictEqual({ + All: false, + 'Google Universal Analytics': false, + Salesforce: true, + 'Salesforce custom destination (Scaleway)': true, + }) + }) +}) diff --git a/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/emptyConfig.tsx b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/emptyConfig.tsx new file mode 100644 index 000000000..af0764733 --- /dev/null +++ b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/emptyConfig.tsx @@ -0,0 +1,15 @@ +import { describe, expect, it } from '@jest/globals' +import { renderHook, waitFor } from '@testing-library/react' +import { useSegmentIntegrations } from '../..' + +describe('CookieConsent - useSegmentIntegrations', () => { + it('should not call segment if config is empty and return empty array', async () => { + const { result } = renderHook(() => + useSegmentIntegrations({ segment: null }), + ) + + await waitFor(() => { + expect(result.current).toStrictEqual([]) + }) + }) +}) diff --git a/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/fetchError.tsx b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/fetchError.tsx new file mode 100644 index 000000000..73dc01a14 --- /dev/null +++ b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/fetchError.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it, jest } from '@jest/globals' +import { renderHook, waitFor } from '@testing-library/react' +import { useSegmentIntegrations } from '../..' + +globalThis.fetch = jest.fn(() => Promise.resolve({ ok: false })) + +describe('CookieConsent - useSegmentIntegrations', () => { + it('should call segment and return empty array if any error occurs in the response', async () => { + const { result } = renderHook(() => + useSegmentIntegrations({ + segment: { + cdnURL: 'https://segment.test', + writeKey: 'sampleWriteKey', + }, + }), + ) + + await waitFor(() => { + expect(result.current).toStrictEqual([]) + }) + + await waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/networkError.tsx b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/networkError.tsx new file mode 100644 index 000000000..8d70c78da --- /dev/null +++ b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/networkError.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it, jest } from '@jest/globals' +import { renderHook, waitFor } from '@testing-library/react' +import { useSegmentIntegrations } from '../..' + +globalThis.fetch = jest.fn(() => Promise.reject(new Error('randomError'))) + +describe('CookieConsent - useSegmentIntegrations', () => { + it('should call segment and return empty array if any error occurs in the fetch', async () => { + const { result } = renderHook(() => + useSegmentIntegrations({ + segment: { + cdnURL: 'https://segment.test', + writeKey: 'sampleWriteKey', + }, + }), + ) + + await waitFor(() => { + expect(result.current).toStrictEqual([]) + }) + + await waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/working.tsx b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/working.tsx new file mode 100644 index 000000000..e07a5a541 --- /dev/null +++ b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/working.tsx @@ -0,0 +1,82 @@ +import { describe, expect, it, jest } from '@jest/globals' +import { renderHook, waitFor } from '@testing-library/react' +import { useSegmentIntegrations } from '../..' + +globalThis.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + name: 'Google Universal Analytics', + creationName: 'Google Analytics', + description: + 'Google Universal Analytics is the most popular analytics tool for the web. It’s free and provides a wide range of features. It’s especially good at measuring traffic sources and ad campaigns.', + website: 'http://google.com/analytics', + category: 'Analytics', + }, + { + name: 'Salesforce custom destination (Scaleway)', + creationName: 'Salesforce custom destination (Scaleway)', + description: + 'Custom destination to transform from Group call into Track call in order to use custom actions', + website: 'https://www.segment.com', + category: 'Other', + }, + { + name: 'Salesforce', + creationName: 'Salesforce', + description: + 'Salesforce is the most popular CRM on the market. It lets you store your new leads, and manage them throughout your sales pipeline as they turn into paying accounts.', + website: 'http://salesforce.com', + category: 'CRM', + }, + { + name: 'Scaleway Custom', + creationName: 'bonjour', + description: 'hello', + website: 'http://google-ta.com', + category: 'Unknown Category', + }, + ]), + }), +) + +describe('CookieConsent - useSegmentIntegrations', () => { + it('should call segment and processed results when everything is alright', async () => { + const { result } = renderHook(() => + useSegmentIntegrations({ + segment: { cdnURL: 'https://segment.test', writeKey: 'sampleWriteKey' }, + }), + ) + + await waitFor(() => { + expect(result.current).toStrictEqual([ + { + category: 'functional', + name: 'Segment.io', + }, + { + category: 'analytics', + name: 'Google Universal Analytics', + }, + { + category: 'marketing', + name: 'Salesforce custom destination (Scaleway)', + }, + { + category: 'marketing', + name: 'Salesforce', + }, + { + category: 'marketing', + name: 'Scaleway Custom', + }, + ]) + }) + + await waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/cookie-consent/src/CookieConsentProvider/index.tsx b/packages/cookie-consent/src/CookieConsentProvider/index.tsx new file mode 100644 index 000000000..9e2c05d19 --- /dev/null +++ b/packages/cookie-consent/src/CookieConsentProvider/index.tsx @@ -0,0 +1,6 @@ +export { + CookieConsentProvider, + useCookieConsent, +} from './CookieConsentProvider' +export type { CategoryKind, Consent } from './types' +export { useSegmentIntegrations } from './useSegmentIntegrations' diff --git a/packages/cookie-consent/src/CookieConsentProvider/types.ts b/packages/cookie-consent/src/CookieConsentProvider/types.ts new file mode 100644 index 000000000..21ffe8595 --- /dev/null +++ b/packages/cookie-consent/src/CookieConsentProvider/types.ts @@ -0,0 +1,20 @@ +export type CategoryKind = + | 'essential' + | 'functional' + | 'marketing' + | 'analytics' + | 'advertising' + +export type Consent = { [K in CategoryKind]: boolean } + +type Integration = { category: CategoryKind; name: string } + +export type Integrations = Integration[] + +// TODO: avoid duplicating this type in @shire/console +export type Config = { + segment?: { + writeKey: string + cdnURL: string + } | null +} diff --git a/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts b/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts new file mode 100644 index 000000000..80134d4dc --- /dev/null +++ b/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react' +import type { CategoryKind, Config, Integrations } from './types' + +type SegmentIntegration = { + category: string + creationName: string + description: string + name: string + website: string +} + +const defaultSegmentIoIntegration: SegmentIntegration = { + category: 'Functional', + creationName: 'Segment.io', + description: '', + name: 'Segment.io', + website: 'https://segment.io', +} + +const timeout = (time: number) => { + const controller = new AbortController() + setTimeout(() => controller.abort(), time * 1000) + + return controller +} + +type SegmentIntegrations = SegmentIntegration[] + +const CATEGORY_MATCH: Record = { + Analytics: 'analytics', + CRM: 'marketing', + Other: 'marketing', + Functional: 'functional', +} + +const transformSegmentIntegrationsToIntegrations = ( + segmentIntegrations: SegmentIntegrations, +): Integrations => + [defaultSegmentIoIntegration, ...segmentIntegrations].map( + ({ name, category, creationName }) => ({ + // Segment requires the `creationName` for this destination. + // This condition is a test (as of 2023-02-28) + // and should either be improved or deleted. + name: name === 'Google Ads (Gtag)' ? creationName : name, + category: CATEGORY_MATCH[category] ?? 'marketing', + }), + ) + +// Will return undefined if loading, empty array if no response or error, response else +export const useSegmentIntegrations = (config: Config) => { + const [integrations, setIntegrations] = useState( + undefined, + ) + + useEffect(() => { + const fetchIntegrations = async () => { + if (config.segment?.cdnURL && config.segment.writeKey) { + const response = await fetch( + `${config.segment.cdnURL}/v1/projects/${config.segment.writeKey}/integrations`, + { + // We'd rather have an half consent than no consent at all + signal: timeout(10).signal, + }, + ) + if (!response.ok) { + throw new Error('Failed to fetch segment integrations') + } + const json = (await response.json()) as SegmentIntegrations + + return transformSegmentIntegrationsToIntegrations(json) + } + + return [] + } + + fetchIntegrations() + .then(response => { + setIntegrations(response) + }) + .catch(() => { + setIntegrations([]) + }) + }, [setIntegrations, config.segment]) + + return integrations +} diff --git a/packages/cookie-consent/src/helpers/array.ts b/packages/cookie-consent/src/helpers/array.ts new file mode 100644 index 000000000..3215f8496 --- /dev/null +++ b/packages/cookie-consent/src/helpers/array.ts @@ -0,0 +1,2 @@ +// TODO: avoid duplicating this function in @shire/console +export const uniq = (array: T[]): T[] => [...new Set(array)] diff --git a/packages/cookie-consent/src/helpers/misc.ts b/packages/cookie-consent/src/helpers/misc.ts new file mode 100644 index 000000000..2375cd44b --- /dev/null +++ b/packages/cookie-consent/src/helpers/misc.ts @@ -0,0 +1,3 @@ +// TODO: avoid duplicating this function in @shire/console +export const stringToHash = (str = ''): number => + Array.from(str).reduce((s, c) => Math.imul(31, s) + c.charCodeAt(0) || 0, 0) diff --git a/packages/cookie-consent/src/index.ts b/packages/cookie-consent/src/index.ts new file mode 100644 index 000000000..9dda253b2 --- /dev/null +++ b/packages/cookie-consent/src/index.ts @@ -0,0 +1,5 @@ +export { + CookieConsentProvider, + useCookieConsent, +} from './CookieConsentProvider' +export type { CategoryKind, Consent } from './CookieConsentProvider/types' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ed597be1..b28bcc58c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,22 @@ importers: specifier: 3.19.1 version: 3.19.1 + packages/cookie-consent: + dependencies: + cookie: + specifier: 0.5.0 + version: 0.5.0 + react: + specifier: 18.2.0 + version: 18.2.0 + devDependencies: + '@types/cookie': + specifier: 0.5.1 + version: 0.5.1 + '@types/react': + specifier: 18.2.21 + version: 18.2.21 + packages/eslint-config-react: dependencies: '@emotion/eslint-plugin': @@ -4032,6 +4048,10 @@ packages: '@babel/types': 7.22.19 dev: true + /@types/cookie@0.5.1: + resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==} + dev: true + /@types/estree@1.0.0: resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==} dev: true @@ -4120,6 +4140,14 @@ packages: '@types/react': 18.2.22 dev: true + /@types/react@18.2.21: + resolution: {integrity: sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==} + dependencies: + '@types/prop-types': 15.7.6 + '@types/scheduler': 0.16.3 + csstype: 3.1.2 + dev: true + /@types/react@18.2.22: resolution: {integrity: sha512-60fLTOLqzarLED2O3UQImc/lsNRgG0jE/a1mPW9KjMemY0LMITWEsbS4VvZ4p6rorEHd5YKxxmMKSDK505GHpA==} dependencies: @@ -4966,6 +4994,11 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true + /cookie@0.5.0: + resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} + engines: {node: '>= 0.6'} + dev: false + /core-js-compat@3.31.0: resolution: {integrity: sha512-hM7YCu1cU6Opx7MXNu0NuumM0ezNeAeRKadixyiQELWY3vT3De9S4J5ZBMraWV2vZnrE1Cirl0GtFtDtMUXzPw==} dependencies: From 971fb631cdc3789b2a493f88a95d7e3ad715f62e Mon Sep 17 00:00:00 2001 From: Axel KIRK Date: Wed, 20 Sep 2023 11:23:35 +0200 Subject: [PATCH 02/14] chore(clean): remove comments --- packages/cookie-consent/src/helpers/array.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cookie-consent/src/helpers/array.ts b/packages/cookie-consent/src/helpers/array.ts index 3215f8496..7aedb4a98 100644 --- a/packages/cookie-consent/src/helpers/array.ts +++ b/packages/cookie-consent/src/helpers/array.ts @@ -1,2 +1 @@ -// TODO: avoid duplicating this function in @shire/console export const uniq = (array: T[]): T[] => [...new Set(array)] From 8da7023915b481c09d16234fc916e891f9854886 Mon Sep 17 00:00:00 2001 From: KIRK Axel <38718957+Axel-KIRK@users.noreply.github.com> Date: Mon, 4 Sep 2023 15:55:46 +0200 Subject: [PATCH 03/14] Update README.md --- packages/cookie-consent/README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/cookie-consent/README.md b/packages/cookie-consent/README.md index 0e0103510..d7c2868eb 100644 --- a/packages/cookie-consent/README.md +++ b/packages/cookie-consent/README.md @@ -16,3 +16,31 @@ $ cd packages/cookie-consent $ pnpm build # Build the package $ pnpm watch # Build the package and watch for changes ``` + +### How it works + +flowchart TD + Z[Application boot] -..-> A + subgraph "Cookie Consent booting" + A[First user navigation in app] --> B{isConsentRequired} + B --> |false| C[do nothing with cookies] + B --> |true| D[Fetch segment integrations configuration] + D --> E[Generate hash of integration and define required consent categories depending on integration] + E -..->F[Set needConsent to true] + end + subgraph "Consent storage" + F -..-> | | G[/User saveConsent with categories/] + G --> H[Hash of integration is stored in _scw_rgpd_hash cookie with storage of 6 months] + G --> I[_scw_rgpd_$category$ cookie is stored for each accepted cookie consent category, 6 months for ad consent, 13 month for others] + H & I --> J[needConsent is set to false] + end + subgraph "User come back on website in futur (within cookie duration)" + J -..-> K[Application boot] + K -..-> L[Check value fo cookies _scw_rgpd_hash and _scw_rgpd_$categorie$] + L --> M[Load in context accepted categories] + end + subgraph "User come back after 6 months" + J -...-> N[Application boot] + N -..-> O[Check value fo cookies _scw_rgpd_hash and _scw_rgpd_$categorie$] + O --> B + end From 522a404966a849f2a1ea28ce62d607bbea47e2bc Mon Sep 17 00:00:00 2001 From: KIRK Axel <38718957+Axel-KIRK@users.noreply.github.com> Date: Mon, 4 Sep 2023 15:57:57 +0200 Subject: [PATCH 04/14] Update README.md --- packages/cookie-consent/README.md | 40 +++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/cookie-consent/README.md b/packages/cookie-consent/README.md index d7c2868eb..3448d8dbc 100644 --- a/packages/cookie-consent/README.md +++ b/packages/cookie-consent/README.md @@ -19,26 +19,26 @@ $ pnpm watch # Build the package and watch for changes ### How it works -flowchart TD - Z[Application boot] -..-> A - subgraph "Cookie Consent booting" - A[First user navigation in app] --> B{isConsentRequired} - B --> |false| C[do nothing with cookies] - B --> |true| D[Fetch segment integrations configuration] - D --> E[Generate hash of integration and define required consent categories depending on integration] - E -..->F[Set needConsent to true] - end - subgraph "Consent storage" - F -..-> | | G[/User saveConsent with categories/] - G --> H[Hash of integration is stored in _scw_rgpd_hash cookie with storage of 6 months] - G --> I[_scw_rgpd_$category$ cookie is stored for each accepted cookie consent category, 6 months for ad consent, 13 month for others] - H & I --> J[needConsent is set to false] - end - subgraph "User come back on website in futur (within cookie duration)" - J -..-> K[Application boot] - K -..-> L[Check value fo cookies _scw_rgpd_hash and _scw_rgpd_$categorie$] - L --> M[Load in context accepted categories] - end + flowchart TD + Z[Application boot] -..-> A + subgraph "Cookie Consent booting" + A[First user navigation in app] --> B{isConsentRequired} + B --> |false| C[do nothing with cookies] + B --> |true| D[Fetch segment integrations configuration] + D --> E[Generate hash of integration and define required consent categories depending on integration] + E -..->F[Set needConsent to true] + end + subgraph "Consent storage" + F -..-> | | G[/User saveConsent with categories/] + G --> H[Hash of integration is stored in _scw_rgpd_hash cookie with storage of 6 months] + G --> I[_scw_rgpd_$category$ cookie is stored for each accepted cookie consent category, 6 months for ad consent, 13 month for others] + H & I --> J[needConsent is set to false] + end + subgraph "User come back on website in futur (within cookie duration)" + J -..-> K[Application boot] + K -..-> L[Check value fo cookies _scw_rgpd_hash and _scw_rgpd_$categorie$] + L --> M[Load in context accepted categories] + end subgraph "User come back after 6 months" J -...-> N[Application boot] N -..-> O[Check value fo cookies _scw_rgpd_hash and _scw_rgpd_$categorie$] From 23a60ed23d8fc97721c178aefbccd6884368136e Mon Sep 17 00:00:00 2001 From: KIRK Axel <38718957+Axel-KIRK@users.noreply.github.com> Date: Mon, 4 Sep 2023 16:02:27 +0200 Subject: [PATCH 05/14] Update README.md --- packages/cookie-consent/README.md | 43 +++++++++++++++++-------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/cookie-consent/README.md b/packages/cookie-consent/README.md index 3448d8dbc..871b8aa14 100644 --- a/packages/cookie-consent/README.md +++ b/packages/cookie-consent/README.md @@ -19,28 +19,31 @@ $ pnpm watch # Build the package and watch for changes ### How it works - flowchart TD - Z[Application boot] -..-> A - subgraph "Cookie Consent booting" - A[First user navigation in app] --> B{isConsentRequired} - B --> |false| C[do nothing with cookies] - B --> |true| D[Fetch segment integrations configuration] - D --> E[Generate hash of integration and define required consent categories depending on integration] - E -..->F[Set needConsent to true] - end - subgraph "Consent storage" - F -..-> | | G[/User saveConsent with categories/] - G --> H[Hash of integration is stored in _scw_rgpd_hash cookie with storage of 6 months] - G --> I[_scw_rgpd_$category$ cookie is stored for each accepted cookie consent category, 6 months for ad consent, 13 month for others] - H & I --> J[needConsent is set to false] - end - subgraph "User come back on website in futur (within cookie duration)" - J -..-> K[Application boot] - K -..-> L[Check value fo cookies _scw_rgpd_hash and _scw_rgpd_$categorie$] - L --> M[Load in context accepted categories] - end + +```mermaid +flowchart TD + Z[Application boot] -..-> A + subgraph "Cookie Consent booting" + A[First user navigation in app] --> B{isConsentRequired} + B --> |false| C[do nothing with cookies] + B --> |true| D[Fetch segment integrations configuration] + D --> E[Generate hash of integration and define required consent categories depending on integration] + E -..->F[Set needConsent to true] + end + subgraph "Consent storage" + F -..-> | | G[/User saveConsent with categories/] + G --> H[Hash of integration is stored in _scw_rgpd_hash cookie with storage of 6 months] + G --> I[_scw_rgpd_$category$ cookie is stored for each accepted cookie consent category, 6 months for ad consent, 13 month for others] + H & I --> J[needConsent is set to false] + end + subgraph "User come back on website in futur (within cookie duration)" + J -..-> K[Application boot] + K -..-> L[Check value fo cookies _scw_rgpd_hash and _scw_rgpd_$categorie$] + L --> M[Load in context accepted categories] + end subgraph "User come back after 6 months" J -...-> N[Application boot] N -..-> O[Check value fo cookies _scw_rgpd_hash and _scw_rgpd_$categorie$] O --> B end +``` From 3a31eec56772e4422dbc8fe91065e72103165758 Mon Sep 17 00:00:00 2001 From: Antoine Caron Date: Wed, 20 Sep 2023 11:42:34 +0200 Subject: [PATCH 06/14] docs: add usage documentation --- packages/cookie-consent/README.md | 87 +++++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 10 deletions(-) diff --git a/packages/cookie-consent/README.md b/packages/cookie-consent/README.md index 871b8aa14..d708b4c7c 100644 --- a/packages/cookie-consent/README.md +++ b/packages/cookie-consent/README.md @@ -1,24 +1,76 @@ # Shire - Cookie Consent -This package contains the Cookie Consent modals and providers logic. +This package is an helper to handle cookie consents with Segment integrations. +It will handle the cookie consent for each categories. + +This package does not contain design element to display a cookie consent modal, +it only handle the storage and the init of cookie consent in a React provider. ## QuickStart -### Prerequisites +In order to use it, first you need to provide a context at the top level of your application -``` -$ cd packages/cookie-consent +```tsx +import { PropsWithChildren } from 'react' + +const MyApp = ({ children }: PropsWithChildren) => { + return ( + + {children} + + ) +} ``` -### Start +Then in your cookie modal component you could simply use exposed hook to get and modify consents -```bash -$ pnpm build # Build the package -$ pnpm watch # Build the package and watch for changes -``` +```tsx +export function PanelConsent() { + const { saveConsent, categoriesConsent } = useCookieConsent() + + const setAllConsents = ({ + categoriesConsent, + value, + }: { + categoriesConsent: Partial + value: boolean + }) => + Object.keys(categoriesConsent).reduce( + (acc, category) => ({ ...acc, [category]: value }), + {}, + ) + + const handleClick = (consentForAll: boolean) => () => { + saveConsent(setAllConsents({ categoriesConsent, value: consentForAll })) + } -### How it works + const onAgreeAll = handleClick(true) + + const onReject = handleClick(false) + + return ( +
+
Do you accept consents ?
+
+ + +
+
+ ) +} +``` +### User flow ```mermaid flowchart TD @@ -47,3 +99,18 @@ flowchart TD O --> B end ``` + +## How to contribute ? + +### Prerequisites + +``` +$ cd packages/cookie-consent +``` + +### Start + +```bash +$ pnpm build # Build the package +$ pnpm watch # Build the package and watch for changes +``` From 903645b03b17eeeec5775d5b84160976c88e68aa Mon Sep 17 00:00:00 2001 From: Axel KIRK Date: Wed, 20 Sep 2023 11:55:06 +0200 Subject: [PATCH 07/14] refactor(options): add props cookie config --- packages/cookie-consent/README.md | 13 +++-- .../CookieConsentProvider.tsx | 51 ++++++++++++++----- .../CookieConsentProvider/__tests__/index.tsx | 2 +- .../src/CookieConsentProvider/types.ts | 3 +- packages/cookie-consent/src/helpers/misc.ts | 1 - 5 files changed, 47 insertions(+), 23 deletions(-) diff --git a/packages/cookie-consent/README.md b/packages/cookie-consent/README.md index d708b4c7c..bf61308bf 100644 --- a/packages/cookie-consent/README.md +++ b/packages/cookie-consent/README.md @@ -3,7 +3,7 @@ This package is an helper to handle cookie consents with Segment integrations. It will handle the cookie consent for each categories. -This package does not contain design element to display a cookie consent modal, +This package does not contain design element to display a cookie consent modal, it only handle the storage and the init of cookie consent in a React provider. ## QuickStart @@ -18,9 +18,14 @@ const MyApp = ({ children }: PropsWithChildren) => { {children} @@ -61,9 +66,7 @@ export function PanelConsent() { - + ) diff --git a/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx b/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx index 679f2786e..bb33160c1 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx +++ b/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx @@ -10,7 +10,12 @@ import { } from 'react' import { uniq } from '../helpers/array' import { stringToHash } from '../helpers/misc' -import type { CategoryKind, Config, Consent, Integrations } from './types' +import type { + CategoryKind, + Consent, + Integrations, + SegmentConfig, +} from './types' import { useSegmentIntegrations } from './useSegmentIntegrations' const COOKIE_PREFIX = '_scw_rgpd' @@ -53,17 +58,29 @@ export const CookieConsentProvider = ({ children, isConsentRequired, essentialIntegrations, - config, + segmentConfig, + cookiePrefix = COOKIE_PREFIX, + consentMaxAge = CONSENT_MAX_AGE, + consentAdvertisingMaxAge = CONSENT_ADVERTISING_MAX_AGE, + cookiesOptions = COOKIES_OPTIONS, }: { children: ReactNode isConsentRequired: boolean essentialIntegrations: string[] - config: Config + segmentConfig: SegmentConfig + cookiePrefix: string + consentMaxAge: number + consentAdvertisingMaxAge: number + cookiesOptions: { + sameSite: boolean | 'strict' | 'lax' | 'none' | undefined + secure: boolean + path: string + } }) => { const [needConsent, setNeedsConsent] = useState(false) const [cookies, setCookies] = useState>() - const segmentIntegrations = useSegmentIntegrations(config) + const segmentIntegrations = useSegmentIntegrations(segmentConfig) useEffect(() => { setCookies(cookie.parse(document.cookie)) @@ -126,12 +143,12 @@ export const CookieConsentProvider = ({ (acc, category) => ({ ...acc, [category]: isConsentRequired - ? cookies?.[`${COOKIE_PREFIX}_${category}`] === 'true' + ? cookies?.[`${cookiePrefix}_${category}`] === 'true' : true, }), {}, ), - [isConsentRequired, categories, cookies], + [isConsentRequired, categories, cookies, cookiePrefix], ) const saveConsent = useCallback( @@ -139,7 +156,7 @@ export const CookieConsentProvider = ({ for (const [consentName, consentValue] of Object.entries( categoriesConsent, ) as [CategoryKind, boolean][]) { - const cookieName = `${COOKIE_PREFIX}_${consentName}` + const cookieName = `${cookiePrefix}_${consentName}` if (!consentValue) { // If consent is set to false we have to delete the cookie @@ -148,14 +165,14 @@ export const CookieConsentProvider = ({ }) } else { document.cookie = cookie.serialize( - `${COOKIE_PREFIX}_${consentName}`, + `${cookiePrefix}_${consentName}`, consentValue.toString(), { - ...COOKIES_OPTIONS, + ...cookiesOptions, maxAge: consentName === 'advertising' - ? CONSENT_ADVERTISING_MAX_AGE - : CONSENT_MAX_AGE, + ? consentAdvertisingMaxAge + : consentMaxAge, }, ) } @@ -165,14 +182,20 @@ export const CookieConsentProvider = ({ HASH_COOKIE, integrationsHash.toString(), { - ...COOKIES_OPTIONS, + ...cookiesOptions, // Here we use the shortest max age to force to ask again for expired consent - maxAge: CONSENT_ADVERTISING_MAX_AGE, + maxAge: consentAdvertisingMaxAge, }, ) setNeedsConsent(false) }, - [integrationsHash], + [ + integrationsHash, + consentAdvertisingMaxAge, + consentMaxAge, + cookiePrefix, + cookiesOptions, + ], ) const isSegmentAllowed = useMemo( diff --git a/packages/cookie-consent/src/CookieConsentProvider/__tests__/index.tsx b/packages/cookie-consent/src/CookieConsentProvider/__tests__/index.tsx index 8560a3d64..c690888f5 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/__tests__/index.tsx +++ b/packages/cookie-consent/src/CookieConsentProvider/__tests__/index.tsx @@ -13,7 +13,7 @@ const wrapper = Array.from(str).reduce((s, c) => Math.imul(31, s) + c.charCodeAt(0) || 0, 0) From 1a6f6471f2b477c231972f8d493ba0f8d339a0f0 Mon Sep 17 00:00:00 2001 From: Axel KIRK Date: Wed, 20 Sep 2023 11:55:06 +0200 Subject: [PATCH 08/14] refactor(options): add props cookie config --- .../src/CookieConsentProvider/useSegmentIntegrations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts b/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts index 80134d4dc..a30358e81 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts +++ b/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import type { CategoryKind, Config, Integrations } from './types' +import type { CategoryKind, Integrations, SegmentConfig } from './types' type SegmentIntegration = { category: string @@ -47,7 +47,7 @@ const transformSegmentIntegrationsToIntegrations = ( ) // Will return undefined if loading, empty array if no response or error, response else -export const useSegmentIntegrations = (config: Config) => { +export const useSegmentIntegrations = (config: SegmentConfig) => { const [integrations, setIntegrations] = useState( undefined, ) From 8eb4f238480ca4912649ba92cad0ed9dfb0d4685 Mon Sep 17 00:00:00 2001 From: Axel KIRK Date: Wed, 20 Sep 2023 11:55:06 +0200 Subject: [PATCH 09/14] refactor(options): add props cookie config --- packages/cookie-consent/README.md | 4 ++-- .../CookieConsentProvider/CookieConsentProvider.tsx | 13 ++++--------- .../src/CookieConsentProvider/__tests__/index.tsx | 2 +- .../src/CookieConsentProvider/types.ts | 2 +- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/cookie-consent/README.md b/packages/cookie-consent/README.md index bf61308bf..1324a118b 100644 --- a/packages/cookie-consent/README.md +++ b/packages/cookie-consent/README.md @@ -18,10 +18,10 @@ const MyApp = ({ children }: PropsWithChildren) => { >() - const segmentIntegrations = useSegmentIntegrations(segmentConfig) + const segmentIntegrations = useSegmentIntegrations(config) useEffect(() => { setCookies(cookie.parse(document.cookie)) diff --git a/packages/cookie-consent/src/CookieConsentProvider/__tests__/index.tsx b/packages/cookie-consent/src/CookieConsentProvider/__tests__/index.tsx index c690888f5..8560a3d64 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/__tests__/index.tsx +++ b/packages/cookie-consent/src/CookieConsentProvider/__tests__/index.tsx @@ -13,7 +13,7 @@ const wrapper = Date: Wed, 20 Sep 2023 11:55:06 +0200 Subject: [PATCH 10/14] refactor(options): add props cookie config --- .../src/CookieConsentProvider/useSegmentIntegrations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts b/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts index a30358e81..80134d4dc 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts +++ b/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import type { CategoryKind, Integrations, SegmentConfig } from './types' +import type { CategoryKind, Config, Integrations } from './types' type SegmentIntegration = { category: string @@ -47,7 +47,7 @@ const transformSegmentIntegrationsToIntegrations = ( ) // Will return undefined if loading, empty array if no response or error, response else -export const useSegmentIntegrations = (config: SegmentConfig) => { +export const useSegmentIntegrations = (config: Config) => { const [integrations, setIntegrations] = useState( undefined, ) From def9b0ce7eaff8d2c7e690885fd82c1a8d001ba7 Mon Sep 17 00:00:00 2001 From: Antoine Caron Date: Thu, 21 Sep 2023 14:28:54 +0200 Subject: [PATCH 11/14] fix: review suggestions --- packages/cookie-consent/package.json | 36 ++++++++++++++----- .../CookieConsentProvider.tsx | 7 ++-- .../useSegmentIntegrations.ts | 7 ++-- packages/cookie-consent/src/helpers/misc.ts | 2 +- pnpm-lock.yaml | 6 ++-- 5 files changed, 36 insertions(+), 22 deletions(-) diff --git a/packages/cookie-consent/package.json b/packages/cookie-consent/package.json index 38db49e66..9164044cc 100644 --- a/packages/cookie-consent/package.json +++ b/packages/cookie-consent/package.json @@ -1,18 +1,38 @@ { "name": "@scaleway/cookie-consent", - "version": "0.2.14", - "description": "Cookie consentment banner", + "version": "0.1.0", + "description": "React provider to handle website end user consent cookie storage based on segment integrations", "type": "module", "sideEffects": false, - "main": "dist/index.js", - "module": "dist/index.js", - "types": "dist/index.d.ts", + "exports": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "react", + "reactjs", + "hooks", + "segment", + "cookies", + "gdpr" + ], + "repository": { + "type": "git", + "url": "https://github.com/scaleway/scaleway-lib", + "directory": "packages/cookie-consent" + }, "dependencies": { - "cookie": "0.5.0", - "react": "18.2.0" + "cookie": "0.5.0" }, "devDependencies": { "@types/cookie": "0.5.1", - "@types/react": "18.2.21" + "@types/react": "18.2.21", + "react": "18.2.0" + }, + "peerDependencies": { + "react": "18.x || 18" } } diff --git a/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx b/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx index 06f812dbe..6aac94c75 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx +++ b/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx @@ -1,3 +1,4 @@ +import type { CookieSerializeOptions } from 'cookie'; import cookie from 'cookie' import type { ReactNode } from 'react' import { @@ -66,11 +67,7 @@ export const CookieConsentProvider = ({ cookiePrefix: string consentMaxAge: number consentAdvertisingMaxAge: number - cookiesOptions: { - sameSite: boolean | 'strict' | 'lax' | 'none' | undefined - secure: boolean - path: string - } + cookiesOptions: CookieSerializeOptions }) => { const [needConsent, setNeedsConsent] = useState(false) diff --git a/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts b/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts index 80134d4dc..bc848aac1 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts +++ b/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts @@ -37,11 +37,8 @@ const transformSegmentIntegrationsToIntegrations = ( segmentIntegrations: SegmentIntegrations, ): Integrations => [defaultSegmentIoIntegration, ...segmentIntegrations].map( - ({ name, category, creationName }) => ({ - // Segment requires the `creationName` for this destination. - // This condition is a test (as of 2023-02-28) - // and should either be improved or deleted. - name: name === 'Google Ads (Gtag)' ? creationName : name, + ({ category, creationName }) => ({ + name: creationName, category: CATEGORY_MATCH[category] ?? 'marketing', }), ) diff --git a/packages/cookie-consent/src/helpers/misc.ts b/packages/cookie-consent/src/helpers/misc.ts index dac65228c..3029d3034 100644 --- a/packages/cookie-consent/src/helpers/misc.ts +++ b/packages/cookie-consent/src/helpers/misc.ts @@ -1,2 +1,2 @@ -export const stringToHash = (str = ''): number => +export const stringToHash = (str: string): number => Array.from(str).reduce((s, c) => Math.imul(31, s) + c.charCodeAt(0) || 0, 0) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b28bcc58c..e25a653b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,9 +152,6 @@ importers: cookie: specifier: 0.5.0 version: 0.5.0 - react: - specifier: 18.2.0 - version: 18.2.0 devDependencies: '@types/cookie': specifier: 0.5.1 @@ -162,6 +159,9 @@ importers: '@types/react': specifier: 18.2.21 version: 18.2.21 + react: + specifier: 18.2.0 + version: 18.2.0 packages/eslint-config-react: dependencies: From 25a584d0f8a0a1908ee41ae4efe75b558ffcf929 Mon Sep 17 00:00:00 2001 From: Antoine Caron Date: Thu, 21 Sep 2023 14:55:43 +0200 Subject: [PATCH 12/14] fix: fix typing and test --- .../CookieConsentProvider.tsx | 17 ++++++++--------- .../useSegmentIntegrations/working.tsx | 4 ++-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx b/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx index 6aac94c75..dd38305ba 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx +++ b/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx @@ -1,6 +1,6 @@ -import type { CookieSerializeOptions } from 'cookie'; +import type { CookieSerializeOptions } from 'cookie' import cookie from 'cookie' -import type { ReactNode } from 'react' +import type { PropsWithChildren } from 'react' import { createContext, useCallback, @@ -59,16 +59,15 @@ export const CookieConsentProvider = ({ consentMaxAge = CONSENT_MAX_AGE, consentAdvertisingMaxAge = CONSENT_ADVERTISING_MAX_AGE, cookiesOptions = COOKIES_OPTIONS, -}: { - children: ReactNode +}: PropsWithChildren<{ isConsentRequired: boolean essentialIntegrations: string[] config: Config - cookiePrefix: string - consentMaxAge: number - consentAdvertisingMaxAge: number - cookiesOptions: CookieSerializeOptions -}) => { + cookiePrefix?: string + consentMaxAge?: number + consentAdvertisingMaxAge?: number + cookiesOptions?: CookieSerializeOptions +}>) => { const [needConsent, setNeedsConsent] = useState(false) const [cookies, setCookies] = useState>() diff --git a/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/working.tsx b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/working.tsx index e07a5a541..6fe91e61d 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/working.tsx +++ b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/working.tsx @@ -58,7 +58,7 @@ describe('CookieConsent - useSegmentIntegrations', () => { }, { category: 'analytics', - name: 'Google Universal Analytics', + name: 'Google Analytics', }, { category: 'marketing', @@ -70,7 +70,7 @@ describe('CookieConsent - useSegmentIntegrations', () => { }, { category: 'marketing', - name: 'Scaleway Custom', + name: 'bonjour', }, ]) }) From 0ee93c0151836c7adf19c691e04fe9cee7adf47c Mon Sep 17 00:00:00 2001 From: Antoine Caron Date: Thu, 21 Sep 2023 15:01:39 +0200 Subject: [PATCH 13/14] fix: remove useless react typings inside package --- packages/cookie-consent/package.json | 1 - pnpm-lock.yaml | 11 ----------- 2 files changed, 12 deletions(-) diff --git a/packages/cookie-consent/package.json b/packages/cookie-consent/package.json index 9164044cc..ede8bc255 100644 --- a/packages/cookie-consent/package.json +++ b/packages/cookie-consent/package.json @@ -29,7 +29,6 @@ }, "devDependencies": { "@types/cookie": "0.5.1", - "@types/react": "18.2.21", "react": "18.2.0" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e25a653b8..3731ecd39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,9 +156,6 @@ importers: '@types/cookie': specifier: 0.5.1 version: 0.5.1 - '@types/react': - specifier: 18.2.21 - version: 18.2.21 react: specifier: 18.2.0 version: 18.2.0 @@ -4140,14 +4137,6 @@ packages: '@types/react': 18.2.22 dev: true - /@types/react@18.2.21: - resolution: {integrity: sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==} - dependencies: - '@types/prop-types': 15.7.6 - '@types/scheduler': 0.16.3 - csstype: 3.1.2 - dev: true - /@types/react@18.2.22: resolution: {integrity: sha512-60fLTOLqzarLED2O3UQImc/lsNRgG0jE/a1mPW9KjMemY0LMITWEsbS4VvZ4p6rorEHd5YKxxmMKSDK505GHpA==} dependencies: From beda6602a96ce498188064aeb2a6cd870525e2a1 Mon Sep 17 00:00:00 2001 From: Antoine Caron Date: Thu, 21 Sep 2023 15:09:16 +0200 Subject: [PATCH 14/14] docs: reference new package in global README --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index c48ca1012..f89375bd0 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,13 @@ scaleway-lib is a set of NPM packages used at Scaleway. ## Available packages +- [`@scaleway/cookie-consent`](./packages/countries/README.md): React provider to handle website end user consent cookie storage based on segment integrations. + + ![npm](https://img.shields.io/npm/dm/@scaleway/cookie-consent) + ![npm bundle size](https://img.shields.io/bundlephobia/min/@scaleway/cookie-consent) + ![npm](https://img.shields.io/npm/v/@scaleway/cookie-consent) + + - [`@scaleway/countries`](./packages/countries/README.md): ISO 3166/3166-2 coutries JSON database. ![npm](https://img.shields.io/npm/dm/@scaleway/countries)