diff --git a/.changeset/tiny-rockets-obey.md b/.changeset/tiny-rockets-obey.md new file mode 100644 index 000000000..084559e47 --- /dev/null +++ b/.changeset/tiny-rockets-obey.md @@ -0,0 +1,6 @@ +--- +"@gitbook/browser-types": patch +"gitbook": patch +--- + +Add support for custom cookie banner registration diff --git a/packages/browser-types/src/index.ts b/packages/browser-types/src/index.ts index cb625250e..53aa0b006 100644 --- a/packages/browser-types/src/index.ts +++ b/packages/browser-types/src/index.ts @@ -46,6 +46,11 @@ export type GitBookAssistant = { ui?: boolean; }; +export type GitBookCookieBannerHandler = (options: { + onApprove: () => void; + onReject: () => void; +}) => void; + export type GitBookGlobal = { /** * Register an event listener. @@ -72,6 +77,18 @@ export type GitBookGlobal = { * Register a custom assistant to be available on the site. */ registerAssistant: (assistant: GitBookAssistant) => () => void; + + /** + * Register a custom cookie banner handler. + * When registered, the built-in cookie banner will not be displayed. + */ + registerCookieBanner: (handler: GitBookCookieBannerHandler) => void; + + /** + * Whether the user has granted cookie consent. + * Returns true if cookies are granted, false if rejected or unknown. + */ + hasApprovedCookies: () => boolean; }; declare global { diff --git a/packages/gitbook/e2e/cookie-banner.spec.ts b/packages/gitbook/e2e/cookie-banner.spec.ts new file mode 100644 index 000000000..32cf33b88 --- /dev/null +++ b/packages/gitbook/e2e/cookie-banner.spec.ts @@ -0,0 +1,52 @@ +import { expect } from '@playwright/test'; + +import { type TestsCase, getCustomizationURL, runTestCases } from './util'; + +const testCases: TestsCase[] = [ + { + name: 'Cookie Banner', + contentBaseURL: 'https://gitbook.com/docs/', + tests: [ + { + name: 'should show built-in banner when no custom banner is registered', + url: getCustomizationURL({ + privacyPolicy: { + url: 'https://policies.gitbook.com/privacy/cookies', + }, + }), + screenshot: false, + run: async (page) => { + // Check that built-in banner is visible + const dialog = page.getByTestId('cookies-dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + }, + }, + { + name: 'should not show built-in banner when custom banner is registered', + url: getCustomizationURL({ + privacyPolicy: { + url: 'https://policies.gitbook.com/privacy/cookies', + }, + }), + screenshot: false, + run: async (page) => { + // Register a custom cookie banner handler + await page.waitForFunction(() => { + return typeof window !== 'undefined' && window.GitBook !== undefined; + }); + await page.evaluate(() => { + window.GitBook?.registerCookieBanner(() => { + // Custom cookie banner handler - no-op for testing to avoid reload + }); + }); + + // Check that built-in banner is not visible + const dialog = page.getByTestId('cookies-dialog'); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }, + }, + ], + }, +]; + +runTestCases(testCases); diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 5610f5c64..27a531193 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -120,7 +120,7 @@ "dev:cloudflare": "wrangler dev --port 8771 --env preview", "dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc", "dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc", - "e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium", + "e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts --project=chromium", "e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium", "unit": "bun test {src,packages} --preload ./tests/preload-bun.ts", "e2e-browserless": "bun test ./tests/", diff --git a/packages/gitbook/src/components/Cookies/CookiesToast.tsx b/packages/gitbook/src/components/Cookies/CookiesToast.tsx index d16adb871..a19920b44 100644 --- a/packages/gitbook/src/components/Cookies/CookiesToast.tsx +++ b/packages/gitbook/src/components/Cookies/CookiesToast.tsx @@ -6,6 +6,7 @@ import { useLanguage } from '@/intl/client'; import { t, tString } from '@/intl/translate'; import { tcls } from '@/lib/tailwind'; +import { useCustomCookieBanner, useIntegrationsLoaded } from '@/components/Integrations'; import { isCookiesTrackingDisabled, setCookiesTracking } from '../Insights'; /** @@ -15,10 +16,18 @@ export function CookiesToast(props: { privacyPolicy?: string }) { const { privacyPolicy = 'https://policies.gitbook.com/privacy/cookies' } = props; const [show, setShow] = React.useState(false); const language = useLanguage(); + const integrationsLoaded = useIntegrationsLoaded(); + const { hasCustomCookieBanner } = useCustomCookieBanner(); React.useEffect(() => { + // Always wait for integrations to load, and if a custom banner is registered, hide the built-in banner + if (!integrationsLoaded || hasCustomCookieBanner) { + setShow(false); + return; + } + setShow(isCookiesTrackingDisabled() === undefined); - }, []); + }, [hasCustomCookieBanner, integrationsLoaded]); if (!show) { return null; diff --git a/packages/gitbook/src/components/Insights/cookies.ts b/packages/gitbook/src/components/Insights/cookies.ts index 658ed4f83..98f5806d3 100644 --- a/packages/gitbook/src/components/Insights/cookies.ts +++ b/packages/gitbook/src/components/Insights/cookies.ts @@ -31,3 +31,12 @@ export function isCookiesTrackingDisabled() { return undefined; } + +/** + * Return true if cookies are accepted. + * Return false if cookies are rejected or unknown. + */ +export function hasApprovedCookies() { + const state = getBrowserCookie(GRANTED_COOKIE); + return state === 'yes'; +} diff --git a/packages/gitbook/src/components/Integrations/LoadIntegrations.tsx b/packages/gitbook/src/components/Integrations/LoadIntegrations.tsx index d1bbceed2..090ed89bf 100644 --- a/packages/gitbook/src/components/Integrations/LoadIntegrations.tsx +++ b/packages/gitbook/src/components/Integrations/LoadIntegrations.tsx @@ -1,14 +1,14 @@ 'use client'; -import * as React from 'react'; -import * as zustand from 'zustand'; - +import { hasApprovedCookies, setCookiesTracking } from '@/components/Insights'; import type { GitBookGlobal, GitBookIntegrationEvent, GitBookIntegrationEventCallback, GitBookIntegrationTool, } from '@gitbook/browser-types'; +import * as React from 'react'; +import * as zustand from 'zustand'; import type { Assistant } from '../AI'; const events = new Map(); @@ -26,6 +26,26 @@ export const integrationsAssistantTools = zustand.createStore<{ export const integrationAssistants = zustand.createStore>(() => []); +// Store to track when integrations have been loaded +export const integrationsStore = zustand.createStore<{ + loaded: boolean; +}>(() => { + return { + loaded: false, + }; +}); + +type CustomCookieBannerStore = { + hasCustomCookieBanner: boolean; +}; + +// Store for custom cookie banner registration +export const customCookieBannerStore = zustand.createStore(() => { + return { + hasCustomCookieBanner: false, + }; +}); + if (typeof window !== 'undefined') { const gitbookGlobal: GitBookGlobal = { addEventListener: (event, callback) => { @@ -65,6 +85,24 @@ if (typeof window !== 'undefined') { integrationAssistants.setState((state) => state.filter((a) => a.id !== id), true); }; }, + registerCookieBanner: (handler) => { + customCookieBannerStore.setState((state) => ({ + ...state, + hasCustomCookieBanner: true, + })); + + handler({ + onApprove: () => { + setCookiesTracking(true); + window.location.reload(); + }, + onReject: () => { + setCookiesTracking(false); + window.location.reload(); + }, + }); + }, + hasApprovedCookies: hasApprovedCookies, }; window.GitBook = gitbookGlobal; } @@ -76,12 +114,30 @@ export function useIntegrationAssistants(): Array { return zustand.useStore(integrationAssistants); } +/** + * Hook to check if integrations have been loaded. + */ +export function useIntegrationsLoaded(): boolean { + return zustand.useStore(integrationsStore, (state) => state.loaded); +} + +/** + * Hook to check if a custom cookie banner is registered. + */ +export function useCustomCookieBanner(): CustomCookieBannerStore { + return zustand.useStore(customCookieBannerStore); +} + /** * Dispatch the `load` event to all integrations. */ export function LoadIntegrations() { React.useEffect(() => { + // Only dispatch 'load' event when there are scripts to load + dispatchGitBookIntegrationEvent('load'); + + integrationsStore.setState({ loaded: true }); }, []); return null; } @@ -89,6 +145,6 @@ export function LoadIntegrations() { /** * Client function to dispatch a GitBook event. */ -function dispatchGitBookIntegrationEvent(type: GitBookIntegrationEvent, ...args: any[]) { +function dispatchGitBookIntegrationEvent(type: GitBookIntegrationEvent, ...args: unknown[]) { events.get(type)?.forEach((handler) => handler(...args)); } diff --git a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx index e203751f6..43c039714 100644 --- a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx +++ b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx @@ -68,14 +68,10 @@ export async function SiteLayout(props: { - {scripts.length > 0 ? ( - <> - - {scripts.map(({ script }) => ( -