diff --git a/README.md b/README.md
index 7deac1610..2a65cf51b 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,13 @@ scaleway-lib is a set of NPM packages used at Scaleway.


+- [`@scaleway/use-gtm`](./packages/use-gtm/README.md):
+ A tiny hook to handle gtm.
+
+ 
+ 
+ 
+
- [`@scaleway/use-i18n`](./packages/use-i18n/README.md):
A tiny hook to handle i18n.
diff --git a/packages/use-gtm/.eslintrc.cjs b/packages/use-gtm/.eslintrc.cjs
new file mode 100644
index 000000000..a2dbbe3d7
--- /dev/null
+++ b/packages/use-gtm/.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/use-gtm/.npmignore b/packages/use-gtm/.npmignore
new file mode 100644
index 000000000..5600eef5f
--- /dev/null
+++ b/packages/use-gtm/.npmignore
@@ -0,0 +1,5 @@
+**/__tests__/**
+examples/
+src
+.eslintrc.cjs
+!.npmignore
diff --git a/packages/use-gtm/README.md b/packages/use-gtm/README.md
new file mode 100644
index 000000000..c76db3eb8
--- /dev/null
+++ b/packages/use-gtm/README.md
@@ -0,0 +1,82 @@
+# `@scaleway/use-gtm`
+
+## A tiny provider to handle Google Tag Manager in React
+
+## Install
+
+```bash
+$ pnpm add @scaleway/use-gtm
+```
+
+## Usage
+
+### Basic
+
+```tsx
+import GTMProvider, { useGTM } from '@scaleway/use-gtm'
+
+const Page = () => {
+ const { sendGTM } = useGTM()
+
+ sendGTM?.({
+ hello: 'world
+ })
+
+ return
Hello World
+}
+
+const App = () => (
+
+
+
+)
+```
+
+### With injected events
+
+```tsx
+import GTMProvider, { useGTM } from '@scaleway/use-gtm'
+
+const events = {
+ sampleEvent: (sendGTM?: SendGTM) => (message: string) => {
+ sendGTM?.({
+ event: 'sampleEvent',
+ hello: message,
+ })
+ }
+}
+
+const Page = () => {
+ const { events } = useGTM()
+
+ events.sampleEvent?.('world')
+
+ return Hello World
+}
+
+const App = () => (
+
+
+
+)
+```
+
+### With global setter
+
+```tsx
+import GTMProvider, { sendGTM } from '@scaleway/use-gtm'
+
+const Page = () => {
+ sendGTM?.({
+ hello: 'world
+ })
+
+ return Hello World
+}
+
+const App = () => (
+
+
+
+)
+```
diff --git a/packages/use-gtm/package.json b/packages/use-gtm/package.json
new file mode 100644
index 000000000..4cb951339
--- /dev/null
+++ b/packages/use-gtm/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@scaleway/use-gtm",
+ "version": "1.0.0",
+ "description": "A small hook to handle gtm in a react app",
+ "keywords": [
+ "react",
+ "reactjs",
+ "hooks",
+ "google",
+ "google tag manager",
+ "gtm"
+ ],
+ "type": "module",
+ "main": "dist/index.js",
+ "module": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "browser": {
+ "dist/index.js": "./dist/index.browser.js"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/scaleway/scaleway-lib",
+ "directory": "packages/use-gtm"
+ },
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "17.x"
+ }
+}
diff --git a/packages/use-gtm/src/__tests__/__snapshots__/index.tsx.snap b/packages/use-gtm/src/__tests__/__snapshots__/index.tsx.snap
new file mode 100644
index 000000000..358a025c0
--- /dev/null
+++ b/packages/use-gtm/src/__tests__/__snapshots__/index.tsx.snap
@@ -0,0 +1,59 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`GTM hook Provider should call onLoadError if script fail to load 1`] = `
+""
+`;
+
+exports[`GTM hook Provider should load when id and environment is provided 1`] = `
+""
+`;
+
+exports[`GTM hook Provider should load when id is provided 1`] = `
+""
+`;
+
+exports[`GTM hook Provider should load with events when provided 1`] = `
+Array [
+ Object {
+ "event": "gtm.js",
+ "gtm.start": 1618272000000,
+ },
+ Object {
+ "event": "sampleEvent",
+ "extra": "test",
+ },
+]
+`;
diff --git a/packages/use-gtm/src/__tests__/index.tsx b/packages/use-gtm/src/__tests__/index.tsx
new file mode 100644
index 000000000..6ac73a19f
--- /dev/null
+++ b/packages/use-gtm/src/__tests__/index.tsx
@@ -0,0 +1,121 @@
+import { fireEvent } from '@testing-library/react'
+import { renderHook } from '@testing-library/react-hooks'
+import mockdate from 'mockdate'
+import { ReactNode } from 'react'
+import GTMProvider, { SendGTM, useGTM } from '..'
+import { GTMProviderProps } from '../useGTM'
+
+const defaultEvents = {
+ sampleEvent: (sendGTM?: SendGTM) => (extraValue: string) => {
+ sendGTM?.({
+ event: 'sampleEvent',
+ extra: extraValue,
+ })
+ },
+}
+
+type DefaultEvents = typeof defaultEvents
+
+const wrapper =
+ ({
+ id,
+ events,
+ environment,
+ onLoadError,
+ }: Omit, 'children'>) =>
+ ({ children }: { children: ReactNode }) =>
+ (
+
+ {children}
+
+ )
+
+describe('GTM hook', () => {
+ beforeEach(() => {
+ mockdate.set('4/13/2021')
+ })
+
+ afterEach(() => {
+ mockdate.reset()
+ document.head.innerHTML = ''
+ window.dataLayer = undefined
+ jest.restoreAllMocks()
+ })
+
+ it('useGTM should not be defined without GTMProvider', () => {
+ const { result } = renderHook(() => useGTM())
+ expect(() => {
+ expect(result.current).toBe(undefined)
+ }).toThrow(Error('useGTM must be used within a GTMProvider'))
+ })
+
+ it('Provider should call onLoadError if script fail to load', () => {
+ renderHook(() => useGTM(), {
+ wrapper: wrapper({
+ id: 'testId',
+ }),
+ })
+
+ expect(document.head.innerHTML).toMatchSnapshot()
+ })
+
+ it('Provider should load when id is provided', () => {
+ renderHook(() => useGTM(), {
+ wrapper: wrapper({
+ id: 'testId',
+ }),
+ })
+
+ expect(document.head.innerHTML).toMatchSnapshot()
+ })
+
+ it('Provider should load when id and environment is provided', () => {
+ renderHook(() => useGTM(), {
+ wrapper: wrapper({
+ environment: {
+ auth: 'gtm',
+ preview: 'world',
+ },
+ id: 'testId',
+ }),
+ })
+
+ expect(document.head.innerHTML).toMatchSnapshot()
+ })
+
+ it('Provider should load with events when provided', () => {
+ const { result } = renderHook(() => useGTM(), {
+ wrapper: wrapper({
+ events: defaultEvents,
+ id: 'testId',
+ }),
+ })
+
+ expect(result.current.events.sampleEvent('test')).toBe(undefined)
+ expect(window.dataLayer).toMatchSnapshot()
+ // @ts-expect-error if type infering works this should be an error
+ expect(result.current.events.sampleEvent()).toBe(undefined)
+ })
+
+ it('Provider should load onLoadError when script fail to load', () => {
+ const onLoadError = jest.fn()
+
+ renderHook(() => useGTM(), {
+ wrapper: wrapper({
+ id: 'testId',
+ onLoadError,
+ }),
+ })
+
+ const script = document.querySelector(
+ `script[src="https://www.googletagmanager.com/gtm.js?id=testId"]`,
+ ) as Element
+ fireEvent.error(script)
+ expect(onLoadError).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/packages/use-gtm/src/index.ts b/packages/use-gtm/src/index.ts
new file mode 100644
index 000000000..0d40b4680
--- /dev/null
+++ b/packages/use-gtm/src/index.ts
@@ -0,0 +1,6 @@
+import GTMProvider from './useGTM'
+
+export { useGTM, sendGTM } from './useGTM'
+export type { SendGTM } from './types'
+
+export default GTMProvider
diff --git a/packages/use-gtm/src/scripts.ts b/packages/use-gtm/src/scripts.ts
new file mode 100644
index 000000000..9124a9a5f
--- /dev/null
+++ b/packages/use-gtm/src/scripts.ts
@@ -0,0 +1,56 @@
+import { GTMEnvironment } from './types'
+
+export const DATALAYER_NAME = 'dataLayer'
+export const LOAD_ERROR_EVENT = 'gtm_loading_error'
+
+const flattenEnvironment = (environment?: GTMEnvironment) =>
+ environment
+ ? `&${Object.entries(environment)
+ .filter(([, value]) => !!value)
+ .map(([key, value]) => `gtm_${key}=${value}`, '')
+ .join('&')}>m_cookies_win=x`
+ : ''
+
+const generateSnippets = (id: string, environment?: GTMEnvironment) => {
+ const env = flattenEnvironment(environment)
+
+ return {
+ dataLayerInit: `window.${DATALAYER_NAME} = window.${DATALAYER_NAME} || [];`,
+ noScript: ``,
+ script: `(function(w,d,s,l,i){w[l]=w[l]||[];
+ w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});
+ var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';
+ j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl+'${env}';
+
+ j.addEventListener('error', function() {
+ var _ge = new CustomEvent('${LOAD_ERROR_EVENT}', { bubbles: true });
+ d.dispatchEvent(_ge);
+ });
+
+ f.parentNode.insertBefore(j,f);
+ })(window,document,'script','${DATALAYER_NAME}','${id}');`,
+ }
+}
+
+const generateScripts = (id: string, environment?: GTMEnvironment) => {
+ const {
+ dataLayerInit: dataLayerInitSnippet,
+ noScript: noScriptSnippet,
+ script: scriptSnippet,
+ } = generateSnippets(id, environment)
+
+ const dataLayerInit = document.createElement('script')
+ dataLayerInit.innerHTML = dataLayerInitSnippet
+ const noScript = document.createElement('noscript')
+ noScript.innerHTML = noScriptSnippet
+ const script = document.createElement('script')
+ script.innerHTML = scriptSnippet
+
+ return {
+ dataLayerInit,
+ noScript,
+ script,
+ }
+}
+
+export default generateScripts
diff --git a/packages/use-gtm/src/types.ts b/packages/use-gtm/src/types.ts
new file mode 100644
index 000000000..b76df7c9a
--- /dev/null
+++ b/packages/use-gtm/src/types.ts
@@ -0,0 +1,10 @@
+type PrimitiveType = string | number | boolean | null | undefined | Date
+export type DataLayerEvent = Record
+export type SendGTM = (event: DataLayerEvent) => void
+export type GTMEnvironment = {
+ auth: string
+ preview?: string
+}
+
+export type EventFunction = (...args: never[]) => void
+export type Events = Record EventFunction>
diff --git a/packages/use-gtm/src/useGTM.tsx b/packages/use-gtm/src/useGTM.tsx
new file mode 100644
index 000000000..ef8720978
--- /dev/null
+++ b/packages/use-gtm/src/useGTM.tsx
@@ -0,0 +1,81 @@
+import { ReactNode, createContext, useContext, useEffect, useMemo } from 'react'
+import generateScripts, { DATALAYER_NAME, LOAD_ERROR_EVENT } from './scripts'
+import { DataLayerEvent, Events, GTMEnvironment, SendGTM } from './types'
+
+interface GTMContextInterface {
+ sendGTM: SendGTM | undefined
+ events: { [K in keyof T]: ReturnType }
+}
+
+declare global {
+ interface Window {
+ dataLayer: DataLayerEvent[] | undefined
+ }
+}
+
+export const sendGTM = (data: DataLayerEvent) => {
+ window?.[DATALAYER_NAME]?.push(data)
+}
+
+const GTMContext = createContext(undefined)
+
+export function useGTM(): GTMContextInterface {
+ // @ts-expect-error Here we force cast the generic onto the useContext because the context is a
+ // global variable and cannot be generic
+ const context = useContext | undefined>(GTMContext)
+ if (context === undefined) {
+ throw new Error('useGTM must be used within a GTMProvider')
+ }
+
+ return context
+}
+
+export type GTMProviderProps = {
+ id: string
+ environment?: GTMEnvironment
+ children: ReactNode
+ onLoadError?: () => void
+ events?: T
+}
+
+function GTMProvider({
+ children,
+ id,
+ environment,
+ onLoadError,
+ events,
+}: GTMProviderProps) {
+ useEffect(() => {
+ const { noScript, script, dataLayerInit } = generateScripts(id, environment)
+
+ document.head.insertBefore(dataLayerInit, document.head.childNodes[0])
+ document.head.insertBefore(script, document.head.childNodes[1])
+ document.body.insertBefore(noScript, document.body.childNodes[0])
+
+ if (onLoadError) document.addEventListener(LOAD_ERROR_EVENT, onLoadError)
+
+ return () => {
+ if (onLoadError)
+ document.removeEventListener(LOAD_ERROR_EVENT, onLoadError)
+ }
+ }, [environment, id, onLoadError])
+
+ const value = useMemo>(() => {
+ const curiedEvents = Object.entries(events || {}).reduce(
+ (acc, [eventName, eventFn]) => ({
+ ...acc,
+ [eventName]: eventFn(sendGTM),
+ }),
+ {},
+ ) as { [K in keyof T]: ReturnType }
+
+ return {
+ events: curiedEvents,
+ sendGTM,
+ }
+ }, [events])
+
+ return {children}
+}
+
+export default GTMProvider