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.
+
+ 
+ 
+ 
+
+
- [`@scaleway/countries`](./packages/countries/README.md): ISO 3166/3166-2 coutries JSON database.

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: