diff --git a/src/app/[spaceId]/[[...pathname]]/page.tsx b/src/app/[spaceId]/[[...pathname]]/page.tsx
index 0b4534825..9a81b32b6 100644
--- a/src/app/[spaceId]/[[...pathname]]/page.tsx
+++ b/src/app/[spaceId]/[[...pathname]]/page.tsx
@@ -1,7 +1,9 @@
import { CustomizationThemeMode } from '@gitbook/api';
import { Metadata, Viewport } from 'next';
import { notFound, redirect } from 'next/navigation';
+import React from 'react';
+import { CookiesToast } from '@/components/Cookies';
import { SpaceContent } from '@/components/SpaceContent';
import { getSpaceLanguage } from '@/intl/server';
import { PageHrefContext, absoluteHref, baseUrl, pageHref } from '@/lib/links';
@@ -29,6 +31,7 @@ export default async function Page(props: { params: PagePathParams }) {
collectionSpaces,
ancestors,
document,
+ scripts,
} = await fetchPageData(params);
const linksContext: PageHrefContext = {};
@@ -53,6 +56,11 @@ export default async function Page(props: { params: PagePathParams }) {
collection={collection}
collectionSpaces={collectionSpaces}
/>
+ {scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
+
+
+
+ ) : null}
);
}
diff --git a/src/components/Cookies/CookiesToast.tsx b/src/components/Cookies/CookiesToast.tsx
new file mode 100644
index 000000000..7e844d6e5
--- /dev/null
+++ b/src/components/Cookies/CookiesToast.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import * as React from 'react';
+
+import { useLanguage } from '@/intl/client';
+import { t } from '@/intl/translate';
+import { isCookiesTrackingDisabled, setCookiesTracking } from '@/lib/analytics';
+import { tcls } from '@/lib/tailwind';
+
+/**
+ * Toast to accept or reject the use of cookies.
+ */
+export function CookiesToast(props: { privacyPolicy?: string }) {
+ const { privacyPolicy = 'https://policies.gitbook.com/privacy/cookies' } = props;
+ const [show, setShow] = React.useState(false);
+ const language = useLanguage();
+
+ React.useEffect(() => {
+ setShow(isCookiesTrackingDisabled() === undefined);
+ }, []);
+
+ if (!show) {
+ return null;
+ }
+
+ const onUpdateState = (enabled: boolean) => {
+ setCookiesTracking(enabled);
+
+ // Reload the page to take the change in consideration
+ window.location.reload();
+ };
+
+ return (
+
+
+ {t(
+ language,
+ 'cookies_prompt',
+
+ {t(language, 'cookies_prompt_privacy')}
+ ,
+ )}
+
+
+ {
+ onUpdateState(true);
+ }}
+ >
+ {t(language, 'cookies_accept')}
+
+ {
+ onUpdateState(false);
+ }}
+ >
+ {t(language, 'cookies_reject')}
+
+
+
+ );
+}
+
+function ToastButton(props: { onClick: () => void; children: React.ReactNode }) {
+ const { onClick, children } = props;
+
+ return (
+
+ );
+}
diff --git a/src/components/Cookies/index.ts b/src/components/Cookies/index.ts
new file mode 100644
index 000000000..49c11d15f
--- /dev/null
+++ b/src/components/Cookies/index.ts
@@ -0,0 +1 @@
+export * from './CookiesToast';
diff --git a/src/intl/translations/en.json b/src/intl/translations/en.json
index 72f58ec3b..4dcc0ec34 100644
--- a/src/intl/translations/en.json
+++ b/src/intl/translations/en.json
@@ -18,5 +18,9 @@
"annotation_button_label": "Open annotation",
"code_copied": "Copied!",
"code_copy": "Copy",
- "table_of_contents_button_label": "Open table of contents"
+ "table_of_contents_button_label": "Open table of contents",
+ "cookies_prompt": "This site uses cookies to deliver its service and to analyse traffic. By browsing this site, you accept the ${1}.",
+ "cookies_prompt_privacy": "privacy policy",
+ "cookies_accept": "Accept",
+ "cookies_reject": "Reject"
}
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
index ee5c0e362..22ee78ed9 100644
--- a/src/lib/analytics.ts
+++ b/src/lib/analytics.ts
@@ -3,6 +3,7 @@
import cookies from 'js-cookie';
const VISITORID_COOKIE = '__session';
+const GRANTED_COOKIE = '__gitbook_cookie_granted';
let visitorId: string | null = null;
let pendingVisitorId: Promise | null = null;
@@ -28,6 +29,12 @@ export async function getVisitorId(): Promise {
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
*/
async function fetchVisitorID(): Promise {
+ const withoutCookies = isCookiesTrackingDisabled();
+
+ if (withoutCookies) {
+ return getNewVisitorId();
+ }
+
const existingTrackingCookie = cookies.get(VISITORID_COOKIE);
if (existingTrackingCookie) {
@@ -35,7 +42,7 @@ async function fetchVisitorID(): Promise {
return existingTrackingCookie;
} else {
// No tracking deviceId set, we'll need to consolidate with the server.
- const proposed = `${crypto.randomUUID()}R`;
+ const proposed = getNewVisitorId();
const url = new URL(process.env.NEXT_PUBLIC_GITBOOK_APP_URL ?? `https://app.gitbook.com`);
url.pathname = '/__session';
@@ -56,3 +63,33 @@ async function fetchVisitorID(): Promise {
}
}
}
+
+/**
+ * Accept or reject cookies.
+ */
+export function setCookiesTracking(enabled: boolean) {
+ cookies.set(GRANTED_COOKIE, enabled ? 'yes' : 'no');
+}
+
+/**
+ * Return true if cookies are accepted or not.
+ * Return `undefined` if state is not known.
+ */
+export function isCookiesTrackingDisabled() {
+ const state = cookies.get(GRANTED_COOKIE);
+
+ if (state === 'yes') {
+ return false;
+ } else if (state === 'no') {
+ return true;
+ }
+
+ return undefined;
+}
+
+/**
+ * Get a proposed visitor ID.
+ */
+function getNewVisitorId(): string {
+ return `${crypto.randomUUID()}R`;
+}