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`; +}