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/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) 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..1324a118b --- /dev/null +++ b/packages/cookie-consent/README.md @@ -0,0 +1,119 @@ +# Shire - Cookie Consent + +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 + +In order to use it, first you need to provide a context at the top level of your application + +```tsx +import { PropsWithChildren } from 'react' + +const MyApp = ({ children }: PropsWithChildren) => { + return ( + + {children} + + ) +} +``` + +Then in your cookie modal component you could simply use exposed hook to get and modify consents + +```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 })) + } + + const onAgreeAll = handleClick(true) + + const onReject = handleClick(false) + + return ( +
+
Do you accept consents ?
+
+ + +
+
+ ) +} +``` + +### User flow + +```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 +``` + +## How to contribute ? + +### 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..ede8bc255 --- /dev/null +++ b/packages/cookie-consent/package.json @@ -0,0 +1,37 @@ +{ + "name": "@scaleway/cookie-consent", + "version": "0.1.0", + "description": "React provider to handle website end user consent cookie storage based on segment integrations", + "type": "module", + "sideEffects": false, + "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" + }, + "devDependencies": { + "@types/cookie": "0.5.1", + "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 new file mode 100644 index 000000000..dd38305ba --- /dev/null +++ b/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx @@ -0,0 +1,241 @@ +import type { CookieSerializeOptions } from 'cookie' +import cookie from 'cookie' +import type { PropsWithChildren } 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, + cookiePrefix = COOKIE_PREFIX, + consentMaxAge = CONSENT_MAX_AGE, + consentAdvertisingMaxAge = CONSENT_ADVERTISING_MAX_AGE, + cookiesOptions = COOKIES_OPTIONS, +}: PropsWithChildren<{ + isConsentRequired: boolean + essentialIntegrations: string[] + config: Config + cookiePrefix?: string + consentMaxAge?: number + consentAdvertisingMaxAge?: number + cookiesOptions?: CookieSerializeOptions +}>) => { + 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?.[`${cookiePrefix}_${category}`] === 'true' + : true, + }), + {}, + ), + [isConsentRequired, categories, cookies, cookiePrefix], + ) + + const saveConsent = useCallback( + (categoriesConsent: Partial) => { + for (const [consentName, consentValue] of Object.entries( + categoriesConsent, + ) as [CategoryKind, boolean][]) { + const cookieName = `${cookiePrefix}_${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( + `${cookiePrefix}_${consentName}`, + consentValue.toString(), + { + ...cookiesOptions, + maxAge: + consentName === 'advertising' + ? consentAdvertisingMaxAge + : consentMaxAge, + }, + ) + } + } + // We set the hash cookie to the current consented integrations + document.cookie = cookie.serialize( + HASH_COOKIE, + integrationsHash.toString(), + { + ...cookiesOptions, + // Here we use the shortest max age to force to ask again for expired consent + maxAge: consentAdvertisingMaxAge, + }, + ) + setNeedsConsent(false) + }, + [ + integrationsHash, + consentAdvertisingMaxAge, + consentMaxAge, + cookiePrefix, + cookiesOptions, + ], + ) + + 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..6fe91e61d --- /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 Analytics', + }, + { + category: 'marketing', + name: 'Salesforce custom destination (Scaleway)', + }, + { + category: 'marketing', + name: 'Salesforce', + }, + { + category: 'marketing', + name: 'bonjour', + }, + ]) + }) + + 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..27ec44cc0 --- /dev/null +++ b/packages/cookie-consent/src/CookieConsentProvider/types.ts @@ -0,0 +1,19 @@ +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[] + +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..bc848aac1 --- /dev/null +++ b/packages/cookie-consent/src/CookieConsentProvider/useSegmentIntegrations.ts @@ -0,0 +1,83 @@ +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( + ({ category, creationName }) => ({ + name: creationName, + 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..7aedb4a98 --- /dev/null +++ b/packages/cookie-consent/src/helpers/array.ts @@ -0,0 +1 @@ +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..3029d3034 --- /dev/null +++ b/packages/cookie-consent/src/helpers/misc.ts @@ -0,0 +1,2 @@ +export const stringToHash = (str: string): 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 b6d5b4578..ae49ed501 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,19 @@ importers: specifier: 3.19.1 version: 3.19.1 + packages/cookie-consent: + dependencies: + cookie: + specifier: 0.5.0 + version: 0.5.0 + devDependencies: + '@types/cookie': + specifier: 0.5.1 + version: 0.5.1 + react: + specifier: 18.2.0 + version: 18.2.0 + packages/eslint-config-react: dependencies: '@emotion/eslint-plugin': @@ -4025,6 +4038,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 @@ -4953,6 +4970,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: