From 3222c3712c9d9dc1ffeda7f598fde9cd7ef5f8e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Sat, 21 Dec 2024 16:10:11 +0100 Subject: [PATCH 01/11] Flush events on unload --- .../components/Insights/InsightsProvider.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/gitbook/src/components/Insights/InsightsProvider.tsx b/packages/gitbook/src/components/Insights/InsightsProvider.tsx index 6231934d30..122a353637 100644 --- a/packages/gitbook/src/components/Insights/InsightsProvider.tsx +++ b/packages/gitbook/src/components/Insights/InsightsProvider.tsx @@ -85,7 +85,7 @@ export function InsightsProvider(props: InsightsProviderProps) { | undefined; }>({}); - const flushEventsSync = (pathname: string) => { + const flushEventsSync = useEventCallback((pathname: string) => { const visitorId = visitorIdRef.current; if (!visitorId) { throw new Error('Visitor ID not set'); @@ -124,7 +124,7 @@ export function InsightsProvider(props: InsightsProviderProps) { } else { console.log('Skipping sending events', events); } - }; + }); const flushBatchedEvents = useDebounceCallback(async (pathname: string) => { const visitorId = visitorIdRef.current ?? (await getVisitorId()); @@ -162,6 +162,20 @@ export function InsightsProvider(props: InsightsProviderProps) { }, ); + const flushAllEvents = useEventCallback(() => { + for (const pathname in eventsRef.current) { + flushEventsSync(pathname); + } + }); + + // When the page is unloaded, flush all events + React.useEffect(() => { + window.addEventListener('beforeunload', flushAllEvents); + return () => { + window.removeEventListener('beforeunload', flushAllEvents); + }; + }, [flushAllEvents]); + return ( Date: Sat, 21 Dec 2024 16:10:53 +0100 Subject: [PATCH 02/11] Set the timestamp date when trackEvent is called --- .../gitbook/src/components/Insights/InsightsProvider.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/gitbook/src/components/Insights/InsightsProvider.tsx b/packages/gitbook/src/components/Insights/InsightsProvider.tsx index 122a353637..af94c798b0 100644 --- a/packages/gitbook/src/components/Insights/InsightsProvider.tsx +++ b/packages/gitbook/src/components/Insights/InsightsProvider.tsx @@ -146,7 +146,10 @@ export function InsightsProvider(props: InsightsProviderProps) { eventsRef.current[pathname] = { pageContext: previous?.pageContext ?? ctx, url: previous?.url ?? window.location.href, - events: [...(previous?.events ?? []), event], + events: [...(previous?.events ?? []), { + ...event, + timestamp: new Date().toISOString(), + }], context, }; From b51340f0d295cafb809e9ed08eb0752724568e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Sat, 21 Dec 2024 17:33:33 +0100 Subject: [PATCH 03/11] Track events for header/footer links --- .../components/DocumentView/InlineLink.tsx | 6 ++- .../components/Footer/FooterLinksGroup.tsx | 4 ++ .../src/components/Header/Dropdown.tsx | 7 +-- .../src/components/Header/HeaderLink.tsx | 43 ++++++++++++++----- .../src/components/primitives/Button.tsx | 7 +-- .../src/components/primitives/Link.tsx | 28 +++++++++--- 6 files changed, 72 insertions(+), 23 deletions(-) diff --git a/packages/gitbook/src/components/DocumentView/InlineLink.tsx b/packages/gitbook/src/components/DocumentView/InlineLink.tsx index 801f459519..a799b43343 100644 --- a/packages/gitbook/src/components/DocumentView/InlineLink.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineLink.tsx @@ -25,7 +25,11 @@ export async function InlineLink(props: InlineProps) { return ( {link.title} diff --git a/packages/gitbook/src/components/Header/Dropdown.tsx b/packages/gitbook/src/components/Header/Dropdown.tsx index cd7c216aec..47543eb513 100644 --- a/packages/gitbook/src/components/Header/Dropdown.tsx +++ b/packages/gitbook/src/components/Header/Dropdown.tsx @@ -3,7 +3,7 @@ import { DetailedHTMLProps, HTMLAttributes, useId } from 'react'; import { ClassValue, tcls } from '@/lib/tailwind'; -import { Link } from '../primitives'; +import { Link, LinkInsightsProps } from '../primitives'; export type DropdownButtonProps = Omit< Partial, E>>, @@ -115,14 +115,15 @@ export function DropdownMenuItem(props: { active?: boolean; className?: ClassValue; children: React.ReactNode; -}) { - const { children, active = false, href, className } = props; +} & LinkInsightsProps) { + const { children, active = false, href, className, insights } = props; if (href) { return ( { - if (!target) { + if (!target || !link.to) { return ( ; headerPreset: CustomizationHeaderPreset; title: string; @@ -89,14 +93,15 @@ export type HeaderLinkNavItemProps = { } & DropdownButtonProps; function HeaderLinkNavItem(props: HeaderLinkNavItemProps) { - switch (props.linkStyle) { + const { linkStyle, ...rest } = props; + switch (linkStyle) { case 'button-secondary': case 'button-primary': - return ; + return ; case 'link': - return ; + return ; default: - assertNever(props.linkStyle); + assertNever(linkStyle); } } @@ -105,7 +110,7 @@ function HeaderItemButton( linkStyle: 'button-secondary' | 'button-primary'; }, ) { - const { linkStyle, headerPreset, title, href, isDropdown, ...rest } = props; + const { linkTarget, linkStyle, headerPreset, title, href, isDropdown, ...rest } = props; const variant = (() => { switch (linkStyle) { case 'button-secondary': @@ -139,6 +144,10 @@ function HeaderItemButton( ), }[linkStyle], )} + insights={{ + target: linkTarget, + position: 'header', + }} {...rest} > {title} @@ -158,10 +167,18 @@ function getHeaderLinkClassName(props: { headerPreset: CustomizationHeaderPreset ); } -function HeaderItemLink(props: HeaderLinkNavItemProps) { - const { headerPreset, title, isDropdown, href, ...rest } = props; +function HeaderItemLink(props: Omit) { + const { linkTarget, headerPreset, title, isDropdown, href, ...rest } = props; return ( - + {title} {isDropdown ? : null} @@ -198,5 +215,9 @@ async function SubHeaderLink(props: { return null; } - return {link.title}; + return ( + + {link.title} + + ); } diff --git a/packages/gitbook/src/components/primitives/Button.tsx b/packages/gitbook/src/components/primitives/Button.tsx index 756ada81e5..641ae8895d 100644 --- a/packages/gitbook/src/components/primitives/Button.tsx +++ b/packages/gitbook/src/components/primitives/Button.tsx @@ -4,14 +4,14 @@ import type { ComponentPropsWithoutRef, HTMLAttributes } from 'react'; import { tcls, ClassValue } from '@/lib/tailwind'; -import { Link } from './Link'; +import { Link, LinkInsightsProps } from './Link'; type ButtonProps = { href?: string; variant?: 'primary' | 'secondary'; size?: 'default' | 'medium' | 'small'; className?: ClassValue; -} & HTMLAttributes; +} & LinkInsightsProps & HTMLAttributes; export function Button({ href, @@ -19,6 +19,7 @@ export function Button({ variant = 'primary', size = 'default', className, + insights, ...rest }: ButtonProps) { const variantClasses = @@ -69,7 +70,7 @@ export function Button({ if (href) { return ( - + {children} ); diff --git a/packages/gitbook/src/components/primitives/Link.tsx b/packages/gitbook/src/components/primitives/Link.tsx index e413766a18..e88355101c 100644 --- a/packages/gitbook/src/components/primitives/Link.tsx +++ b/packages/gitbook/src/components/primitives/Link.tsx @@ -1,7 +1,9 @@ 'use client'; +import * as api from '@gitbook/api'; import NextLink, { LinkProps as NextLinkProps } from 'next/link'; import React from 'react'; +import { useTrackEvent } from '../Insights'; // Props from Next, which includes NextLinkProps and all the things anchor elements support. type BaseLinkProps = Omit, keyof NextLinkProps> & @@ -9,8 +11,15 @@ type BaseLinkProps = Omit, keyof N children?: React.ReactNode; } & React.RefAttributes; -// Enforce href is passed as a string (not a URL). -export type LinkProps = Omit & { href: string }; +export type LinkInsightsProps = { + /** Target of the link, for insights. */ + insights?: api.SiteInsightsEventLinkClick['link']; +} + +export type LinkProps = Omit & LinkInsightsProps & { + /** Enforce href is passed as a string (not a URL). */ + href: string; +}; /** * Low-level Link component that handles navigation to external urls. @@ -20,21 +29,30 @@ export const Link = React.forwardRef(function Link( props: LinkProps, ref: React.Ref, ) { - const { href, prefetch, children, ...domProps } = props; + const { href, prefetch, children, insights, ...domProps } = props; + const trackEvent = useTrackEvent(); + + const onClick = (event: React.MouseEvent) => { + if (insights) { + trackEvent({ type: 'link_click', link: insights }); + } + + domProps.onClick?.(event); + }; // Use a real anchor tag for external links,s and a Next.js Link for internal links. // If we use a NextLink for external links, Nextjs won't rerender the top-level layouts. const isExternal = URL.canParse ? URL.canParse(props.href) : props.href.startsWith('http'); if (isExternal) { return ( - + {children} ); } return ( - + {children} ); From 9de7d8aa44985c828f10099d9ea19c6b387036be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Sat, 21 Dec 2024 17:34:00 +0100 Subject: [PATCH 04/11] Track event for sidebar --- .../gitbook/src/components/TableOfContents/PageLinkItem.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/gitbook/src/components/TableOfContents/PageLinkItem.tsx b/packages/gitbook/src/components/TableOfContents/PageLinkItem.tsx index 5701b4edf3..f2e0039b2a 100644 --- a/packages/gitbook/src/components/TableOfContents/PageLinkItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/PageLinkItem.tsx @@ -35,6 +35,10 @@ export async function PageLinkItem(props: { page: RevisionPageLink; context: Con 'hover:bg-dark/1', 'dark:hover:bg-light/2', )} + insights={{ + target: page.target, + position: 'sidebar', + }} > {page.title} From 818260cab6826e0eb8c1bb8c932ed147b728cf44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Sat, 21 Dec 2024 17:44:47 +0100 Subject: [PATCH 05/11] Track all relevant links --- .../DocumentView/BlockContentRef.tsx | 4 ++ .../src/components/DocumentView/File.tsx | 9 +++- .../src/components/DocumentView/Mention.tsx | 12 ++++- .../DocumentView/Table/RecordCard.tsx | 11 ++-- .../DocumentView/Table/RecordColumnValue.tsx | 51 +++++++++++++++---- .../src/components/Header/HeaderLinkMore.tsx | 10 +++- .../PageBody/PageBodyBlankslate.tsx | 4 ++ .../src/components/primitives/Card.tsx | 7 +-- 8 files changed, 87 insertions(+), 21 deletions(-) diff --git a/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx b/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx index 6611cfabca..408da28258 100644 --- a/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx +++ b/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx @@ -34,6 +34,10 @@ export async function BlockContentRef(props: BlockProps href={resolved.href} title={resolved.text} style={style} + insights={{ + target: block.data.ref, + position: 'content', + }} /> ); } diff --git a/packages/gitbook/src/components/DocumentView/File.tsx b/packages/gitbook/src/components/DocumentView/File.tsx index 7ae0b2e101..492f77e394 100644 --- a/packages/gitbook/src/components/DocumentView/File.tsx +++ b/packages/gitbook/src/components/DocumentView/File.tsx @@ -6,6 +6,7 @@ import { tcls } from '@/lib/tailwind'; import { BlockProps } from './Block'; import { Caption } from './Caption'; import { FileIcon } from './FileIcon'; +import { Link } from '../primitives'; export async function File(props: BlockProps) { const { block, context } = props; @@ -21,9 +22,13 @@ export async function File(props: BlockProps) { return ( - ) { {contentType} - + ); } diff --git a/packages/gitbook/src/components/DocumentView/Mention.tsx b/packages/gitbook/src/components/DocumentView/Mention.tsx index 3cc0f8fccf..6e9290827e 100644 --- a/packages/gitbook/src/components/DocumentView/Mention.tsx +++ b/packages/gitbook/src/components/DocumentView/Mention.tsx @@ -15,5 +15,15 @@ export async function Mention(props: InlineProps) { return null; } - return {resolved.text}; + return ( + + {resolved.text} + + ); } diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx index 87b989616a..2d5af6aa72 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx @@ -7,6 +7,7 @@ import { ClassValue, tcls } from '@/lib/tailwind'; import { RecordColumnValue } from './RecordColumnValue'; import { TableRecordKV, TableViewProps } from './Table'; import { getRecordValue } from './utils'; +import { Link } from '@/components/primitives'; export async function RecordCard( props: TableViewProps & { @@ -153,17 +154,21 @@ export async function RecordCard( 'before:dark:ring-light/2', ] as ClassValue; - if (target) { + if (target && targetRef) { return ( - {body} - + ); } diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx index af5a00a485..fce3a31e36 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx @@ -1,4 +1,4 @@ -import { ContentRef, DocumentBlockTable } from '@gitbook/api'; +import { ContentRef, ContentRefUser, DocumentBlockTable } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; import assertNever from 'assert-never'; @@ -148,6 +148,14 @@ export async function RecordColumnValue( href={ref.href} target="_blank" style={['flex', 'flex-row', 'items-center', 'gap-2']} + insights={ref.file ?{ + target: { + kind: 'file', + file: ref.file.id, + }, + position: 'content', + } + : undefined} > {contentType === 'image' ? ( ( ); case 'content-ref': { - const resolved = value - ? await context.resolveContentRef(value as ContentRef, { + const contentRef = value ? (value as ContentRef) : null; + const resolved = contentRef + ? await context.resolveContentRef(contentRef, { resolveAnchorText: true, iconStyle: ['mr-2', 'text-dark/6', 'dark:text-light/6'], }) @@ -191,26 +200,46 @@ export async function RecordColumnValue( > {resolved?.icon ?? null} {resolved ? ( - {resolved.text} + + {resolved.text} + ) : null} ); } case 'users': { const resolved = await Promise.all( - (value as string[]).map((userId) => - context.resolveContentRef({ + (value as string[]).map(async (userId) => { + const contentRef: ContentRefUser = { kind: 'user', user: userId, - }), - ), + } + const resolved = await context.resolveContentRef(contentRef); + if (!resolved) { + return null; + } + + return [ + contentRef, + resolved, + ] as const; + }), ); return ( - {resolved.filter(filterOutNullable).map((file, index) => ( - - {file.text} + {resolved.filter(filterOutNullable).map(([contentRef, resolved], index) => ( + + {resolved.text} ))} diff --git a/packages/gitbook/src/components/Header/HeaderLinkMore.tsx b/packages/gitbook/src/components/Header/HeaderLinkMore.tsx index 991aa0ea0e..f17b7d4fcc 100644 --- a/packages/gitbook/src/components/Header/HeaderLinkMore.tsx +++ b/packages/gitbook/src/components/Header/HeaderLinkMore.tsx @@ -76,7 +76,15 @@ async function MoreMenuLink(props: { {'links' in link && link.links.length > 0 && (
)} - {link.title} + + {link.title} + {'links' in link ? link.links.map((subLink, index) => ( diff --git a/packages/gitbook/src/components/PageBody/PageBodyBlankslate.tsx b/packages/gitbook/src/components/PageBody/PageBodyBlankslate.tsx index 9b9e928187..5a730322e5 100644 --- a/packages/gitbook/src/components/PageBody/PageBodyBlankslate.tsx +++ b/packages/gitbook/src/components/PageBody/PageBodyBlankslate.tsx @@ -61,6 +61,10 @@ export async function PageBodyBlankslate(props: { leadingIcon={icon} title={child.title} href={resolved.href} + insights={{ + target: child.target, + position: 'content', + }} /> ); } else { diff --git a/packages/gitbook/src/components/primitives/Card.tsx b/packages/gitbook/src/components/primitives/Card.tsx index d326469989..8a580a8793 100644 --- a/packages/gitbook/src/components/primitives/Card.tsx +++ b/packages/gitbook/src/components/primitives/Card.tsx @@ -2,7 +2,7 @@ import { Icon } from '@gitbook/icons'; import { ClassValue, tcls } from '@/lib/tailwind'; -import { Link } from './Link'; +import { Link, LinkInsightsProps } from './Link'; export async function Card(props: { href: string; @@ -11,8 +11,8 @@ export async function Card(props: { title: string; postTitle?: string; style?: ClassValue; -}) { - const { title, leadingIcon, href, preTitle, postTitle, style } = props; +} & LinkInsightsProps) { + const { title, leadingIcon, href, preTitle, postTitle, style, insights } = props; return ( {leadingIcon} From 837cabd2981959ea8257389e690c86214cce6805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Sat, 21 Dec 2024 17:45:20 +0100 Subject: [PATCH 06/11] Format --- .../components/DocumentView/InlineLink.tsx | 2 +- .../DocumentView/Table/RecordCard.tsx | 2 +- .../DocumentView/Table/RecordColumnValue.tsx | 50 +++++++++++-------- .../src/components/Header/Dropdown.tsx | 14 +++--- .../src/components/Header/HeaderLinkMore.tsx | 12 +++-- .../components/Insights/InsightsProvider.tsx | 11 ++-- .../src/components/primitives/Button.tsx | 3 +- .../src/components/primitives/Card.tsx | 18 ++++--- .../src/components/primitives/Link.tsx | 12 +++-- 9 files changed, 73 insertions(+), 51 deletions(-) diff --git a/packages/gitbook/src/components/DocumentView/InlineLink.tsx b/packages/gitbook/src/components/DocumentView/InlineLink.tsx index a799b43343..dfc3eaf354 100644 --- a/packages/gitbook/src/components/DocumentView/InlineLink.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineLink.tsx @@ -28,7 +28,7 @@ export async function InlineLink(props: InlineProps) { className="underline underline-offset-2 text-primary hover:text-primary-700 transition-colors" insights={{ target: inline.data.ref, - position: 'content' + position: 'content', }} > & { diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx index fce3a31e36..9e5e16f667 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx @@ -148,14 +148,17 @@ export async function RecordColumnValue( href={ref.href} target="_blank" style={['flex', 'flex-row', 'items-center', 'gap-2']} - insights={ref.file ?{ - target: { - kind: 'file', - file: ref.file.id, - }, - position: 'content', - } - : undefined} + insights={ + ref.file + ? { + target: { + kind: 'file', + file: ref.file.id, + }, + position: 'content', + } + : undefined + } > {contentType === 'image' ? ( ( {resolved ? ( {resolved.text} @@ -219,26 +226,27 @@ export async function RecordColumnValue( const contentRef: ContentRefUser = { kind: 'user', user: userId, - } + }; const resolved = await context.resolveContentRef(contentRef); if (!resolved) { return null; } - return [ - contentRef, - resolved, - ] as const; + return [contentRef, resolved] as const; }), ); return ( {resolved.filter(filterOutNullable).map(([contentRef, resolved], index) => ( - + {resolved.text} ))} diff --git a/packages/gitbook/src/components/Header/Dropdown.tsx b/packages/gitbook/src/components/Header/Dropdown.tsx index 47543eb513..2ce03cc725 100644 --- a/packages/gitbook/src/components/Header/Dropdown.tsx +++ b/packages/gitbook/src/components/Header/Dropdown.tsx @@ -110,12 +110,14 @@ export function DropdownMenu(props: { children: React.ReactNode }) { /** * Menu item in a dropdown. */ -export function DropdownMenuItem(props: { - href: string | null; - active?: boolean; - className?: ClassValue; - children: React.ReactNode; -} & LinkInsightsProps) { +export function DropdownMenuItem( + props: { + href: string | null; + active?: boolean; + className?: ClassValue; + children: React.ReactNode; + } & LinkInsightsProps, +) { const { children, active = false, href, className, insights } = props; if (href) { diff --git a/packages/gitbook/src/components/Header/HeaderLinkMore.tsx b/packages/gitbook/src/components/Header/HeaderLinkMore.tsx index f17b7d4fcc..11d43b4682 100644 --- a/packages/gitbook/src/components/Header/HeaderLinkMore.tsx +++ b/packages/gitbook/src/components/Header/HeaderLinkMore.tsx @@ -78,10 +78,14 @@ async function MoreMenuLink(props: { )} {link.title} diff --git a/packages/gitbook/src/components/Insights/InsightsProvider.tsx b/packages/gitbook/src/components/Insights/InsightsProvider.tsx index af94c798b0..5aeae6d090 100644 --- a/packages/gitbook/src/components/Insights/InsightsProvider.tsx +++ b/packages/gitbook/src/components/Insights/InsightsProvider.tsx @@ -146,10 +146,13 @@ export function InsightsProvider(props: InsightsProviderProps) { eventsRef.current[pathname] = { pageContext: previous?.pageContext ?? ctx, url: previous?.url ?? window.location.href, - events: [...(previous?.events ?? []), { - ...event, - timestamp: new Date().toISOString(), - }], + events: [ + ...(previous?.events ?? []), + { + ...event, + timestamp: new Date().toISOString(), + }, + ], context, }; diff --git a/packages/gitbook/src/components/primitives/Button.tsx b/packages/gitbook/src/components/primitives/Button.tsx index 641ae8895d..d8e8a9b2ef 100644 --- a/packages/gitbook/src/components/primitives/Button.tsx +++ b/packages/gitbook/src/components/primitives/Button.tsx @@ -11,7 +11,8 @@ type ButtonProps = { variant?: 'primary' | 'secondary'; size?: 'default' | 'medium' | 'small'; className?: ClassValue; -} & LinkInsightsProps & HTMLAttributes; +} & LinkInsightsProps & + HTMLAttributes; export function Button({ href, diff --git a/packages/gitbook/src/components/primitives/Card.tsx b/packages/gitbook/src/components/primitives/Card.tsx index 8a580a8793..72ac03ff89 100644 --- a/packages/gitbook/src/components/primitives/Card.tsx +++ b/packages/gitbook/src/components/primitives/Card.tsx @@ -4,14 +4,16 @@ import { ClassValue, tcls } from '@/lib/tailwind'; import { Link, LinkInsightsProps } from './Link'; -export async function Card(props: { - href: string; - leadingIcon?: React.ReactNode; - preTitle?: string; - title: string; - postTitle?: string; - style?: ClassValue; -} & LinkInsightsProps) { +export async function Card( + props: { + href: string; + leadingIcon?: React.ReactNode; + preTitle?: string; + title: string; + postTitle?: string; + style?: ClassValue; + } & LinkInsightsProps, +) { const { title, leadingIcon, href, preTitle, postTitle, style, insights } = props; return ( diff --git a/packages/gitbook/src/components/primitives/Link.tsx b/packages/gitbook/src/components/primitives/Link.tsx index e88355101c..aae869f30b 100644 --- a/packages/gitbook/src/components/primitives/Link.tsx +++ b/packages/gitbook/src/components/primitives/Link.tsx @@ -3,6 +3,7 @@ import * as api from '@gitbook/api'; import NextLink, { LinkProps as NextLinkProps } from 'next/link'; import React from 'react'; + import { useTrackEvent } from '../Insights'; // Props from Next, which includes NextLinkProps and all the things anchor elements support. @@ -14,13 +15,14 @@ type BaseLinkProps = Omit, keyof N export type LinkInsightsProps = { /** Target of the link, for insights. */ insights?: api.SiteInsightsEventLinkClick['link']; -} - -export type LinkProps = Omit & LinkInsightsProps & { - /** Enforce href is passed as a string (not a URL). */ - href: string; }; +export type LinkProps = Omit & + LinkInsightsProps & { + /** Enforce href is passed as a string (not a URL). */ + href: string; + }; + /** * Low-level Link component that handles navigation to external urls. * It does not contain any styling. From 7e1fc12e1f27aba0f2f42f13e2cfb843184db64a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Sat, 21 Dec 2024 17:46:35 +0100 Subject: [PATCH 07/11] Changeset --- .changeset/nine-gorillas-turn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/nine-gorillas-turn.md diff --git a/.changeset/nine-gorillas-turn.md b/.changeset/nine-gorillas-turn.md new file mode 100644 index 0000000000..9db3dbb7a3 --- /dev/null +++ b/.changeset/nine-gorillas-turn.md @@ -0,0 +1,5 @@ +--- +'gitbook': minor +--- + +Track clicks on links (header, footer, content) for site insights. From e7dd83862edf2237523594400cc6f66e27c038ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Sat, 21 Dec 2024 17:53:22 +0100 Subject: [PATCH 08/11] Track events for normal page links --- .../PageBody/PageFooterNavigation.tsx | 21 ++++++++++++++++--- .../TableOfContents/PageDocumentItem.tsx | 7 +++++++ .../TableOfContents/ToggleableLinkItem.tsx | 7 ++++--- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/gitbook/src/components/PageBody/PageFooterNavigation.tsx b/packages/gitbook/src/components/PageBody/PageFooterNavigation.tsx index 7d7b33a841..a849abe2d3 100644 --- a/packages/gitbook/src/components/PageBody/PageFooterNavigation.tsx +++ b/packages/gitbook/src/components/PageBody/PageFooterNavigation.tsx @@ -13,7 +13,7 @@ import { pageHref } from '@/lib/links'; import { resolvePrevNextPages } from '@/lib/pages'; import { tcls } from '@/lib/tailwind'; -import { Link } from '../primitives'; +import { Link, LinkInsightsProps } from '../primitives'; /** * Show cards to go to previous/next pages at the bottom. @@ -47,6 +47,13 @@ export function PageFooterNavigation(props: { label={t(language, 'previous_page')} title={previous.title} href={pageHref(pages, previous)} + insights={{ + target: { + kind: 'page', + page: previous.id, + }, + position: 'content', + }} reversed /> ) : null} @@ -56,6 +63,13 @@ export function PageFooterNavigation(props: { label={t(language, 'next_page')} title={next.title} href={pageHref(pages, next)} + insights={{ + target: { + kind: 'page', + page: next.id, + }, + position: 'content', + }} /> ) : null} @@ -68,12 +82,13 @@ function NavigationCard(props: { title: string; href: string; reversed?: boolean; -}) { - const { icon, label, title, href, reversed } = props; +} & LinkInsightsProps) { + const { icon, label, title, href, reversed, insights } = props; return ( Date: Sat, 21 Dec 2024 18:03:34 +0100 Subject: [PATCH 09/11] Improve flush of events --- .../components/Insights/InsightsProvider.tsx | 85 +++++++++++-------- .../src/components/primitives/Link.tsx | 12 ++- 2 files changed, 57 insertions(+), 40 deletions(-) diff --git a/packages/gitbook/src/components/Insights/InsightsProvider.tsx b/packages/gitbook/src/components/Insights/InsightsProvider.tsx index 5aeae6d090..7fd064ed81 100644 --- a/packages/gitbook/src/components/Insights/InsightsProvider.tsx +++ b/packages/gitbook/src/components/Insights/InsightsProvider.tsx @@ -85,53 +85,72 @@ export function InsightsProvider(props: InsightsProviderProps) { | undefined; }>({}); - const flushEventsSync = useEventCallback((pathname: string) => { + /** + * Get the visitor ID and store it in a ref. + */ + React.useEffect(() => { + getVisitorId().then((visitorId) => { + visitorIdRef.current = visitorId; + }); + }, []); + + /** + * Synchronously flush all the pending events. + */ + const flushEventsSync = useEventCallback(() => { + const session = getSession(); const visitorId = visitorIdRef.current; if (!visitorId) { - throw new Error('Visitor ID not set'); + throw new Error('Visitor ID should be set before flushing events'); } - const session = getSession(); - const eventsForPathname = eventsRef.current[pathname]; - if (!eventsForPathname || !eventsForPathname.pageContext) { - console.warn('No events to flush', eventsForPathname); - return; - } + const allEvents: api.SiteInsightsEvent[] = []; - const events = transformEvents({ - url: eventsForPathname.url, - events: eventsForPathname.events, - context, - pageContext: eventsForPathname.pageContext, - visitorId, - sessionId: session.id, - }); + for (const pathname in eventsRef.current) { + const eventsForPathname = eventsRef.current[pathname]; + if (!eventsForPathname ||!eventsForPathname.events.length) { + continue; + } + if (!eventsForPathname.pageContext) { + console.warn('No page context for flushing events of', pathname,eventsForPathname); + continue; + } - // Reset the events for the next flush - eventsRef.current[pathname] = { - ...eventsForPathname, - events: [], - }; + allEvents.push(...transformEvents({ + url: eventsForPathname.url, + events: eventsForPathname.events, + context, + pageContext: eventsForPathname.pageContext, + visitorId, + sessionId: session.id, + })); + + // Reset the events for the next flush + eventsRef.current[pathname] = { + ...eventsForPathname, + events: [], + }; + } if (enabled) { - console.log('Sending events', events); + console.log('Sending events', allEvents); sendEvents({ apiHost, organizationId: context.organizationId, siteId: context.siteId, - events, + events: allEvents, }); } else { - console.log('Skipping sending events', events); + console.log('Skipping sending events', allEvents); } }); - const flushBatchedEvents = useDebounceCallback(async (pathname: string) => { + const flushBatchedEvents = useDebounceCallback(async () => { const visitorId = visitorIdRef.current ?? (await getVisitorId()); visitorIdRef.current = visitorId; - flushEventsSync(pathname); - }, 500); + flushEventsSync(); + }, 1500); const trackEvent: TrackEventCallback = useEventCallback( ( @@ -168,19 +187,13 @@ export function InsightsProvider(props: InsightsProviderProps) { }, ); - const flushAllEvents = useEventCallback(() => { - for (const pathname in eventsRef.current) { - flushEventsSync(pathname); - } - }); - // When the page is unloaded, flush all events React.useEffect(() => { - window.addEventListener('beforeunload', flushAllEvents); + window.addEventListener('beforeunload', flushEventsSync); return () => { - window.removeEventListener('beforeunload', flushAllEvents); + window.removeEventListener('beforeunload', flushEventsSync); }; - }, [flushAllEvents]); + }, [flushEventsSync]); return ( diff --git a/packages/gitbook/src/components/primitives/Link.tsx b/packages/gitbook/src/components/primitives/Link.tsx index aae869f30b..7fbef6a0cc 100644 --- a/packages/gitbook/src/components/primitives/Link.tsx +++ b/packages/gitbook/src/components/primitives/Link.tsx @@ -34,17 +34,21 @@ export const Link = React.forwardRef(function Link( const { href, prefetch, children, insights, ...domProps } = props; const trackEvent = useTrackEvent(); + + // Use a real anchor tag for external links,s and a Next.js Link for internal links. + // If we use a NextLink for external links, Nextjs won't rerender the top-level layouts. + const isExternal = URL.canParse ? URL.canParse(props.href) : props.href.startsWith('http'); + const onClick = (event: React.MouseEvent) => { if (insights) { - trackEvent({ type: 'link_click', link: insights }); + trackEvent({ type: 'link_click', link: insights }, undefined, { + immediate: isExternal, + }); } domProps.onClick?.(event); }; - // Use a real anchor tag for external links,s and a Next.js Link for internal links. - // If we use a NextLink for external links, Nextjs won't rerender the top-level layouts. - const isExternal = URL.canParse ? URL.canParse(props.href) : props.href.startsWith('http'); if (isExternal) { return ( From 017821d82d4755277b078cc12ccfaff619cf16b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Sat, 21 Dec 2024 18:07:34 +0100 Subject: [PATCH 10/11] Cancel flush when syncing --- .../components/Insights/InsightsProvider.tsx | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/packages/gitbook/src/components/Insights/InsightsProvider.tsx b/packages/gitbook/src/components/Insights/InsightsProvider.tsx index 7fd064ed81..f26b88b5c8 100644 --- a/packages/gitbook/src/components/Insights/InsightsProvider.tsx +++ b/packages/gitbook/src/components/Insights/InsightsProvider.tsx @@ -108,22 +108,24 @@ export function InsightsProvider(props: InsightsProviderProps) { for (const pathname in eventsRef.current) { const eventsForPathname = eventsRef.current[pathname]; - if (!eventsForPathname ||!eventsForPathname.events.length) { + if (!eventsForPathname || !eventsForPathname.events.length) { continue; } if (!eventsForPathname.pageContext) { - console.warn('No page context for flushing events of', pathname,eventsForPathname); + console.warn('No page context for flushing events of', pathname, eventsForPathname); continue; } - allEvents.push(...transformEvents({ - url: eventsForPathname.url, - events: eventsForPathname.events, - context, - pageContext: eventsForPathname.pageContext, - visitorId, - sessionId: session.id, - })); + allEvents.push( + ...transformEvents({ + url: eventsForPathname.url, + events: eventsForPathname.events, + context, + pageContext: eventsForPathname.pageContext, + visitorId, + sessionId: session.id, + }), + ); // Reset the events for the next flush eventsRef.current[pathname] = { @@ -132,16 +134,18 @@ export function InsightsProvider(props: InsightsProviderProps) { }; } - if (enabled) { - console.log('Sending events', allEvents); - sendEvents({ - apiHost, - organizationId: context.organizationId, - siteId: context.siteId, - events: allEvents, - }); - } else { - console.log('Skipping sending events', allEvents); + if (allEvents.length > 0) { + if (enabled) { + console.log('Sending events', allEvents); + sendEvents({ + apiHost, + organizationId: context.organizationId, + siteId: context.siteId, + events: allEvents, + }); + } else { + console.log('Skipping sending events', allEvents); + } } }); @@ -179,9 +183,10 @@ export function InsightsProvider(props: InsightsProviderProps) { // If the pageId is set, we know that the page_view event has been tracked // and we can flush the events if (options?.immediate && visitorIdRef.current) { - flushEventsSync(pathname); + flushBatchedEvents.cancel(); + flushEventsSync(); } else { - flushBatchedEvents(pathname); + flushBatchedEvents(); } } }, From 0735401fd850d3cbcc430b4cb64bb3d31738fc13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Sat, 21 Dec 2024 18:08:01 +0100 Subject: [PATCH 11/11] Format --- .../components/PageBody/PageFooterNavigation.tsx | 16 +++++++++------- .../TableOfContents/ToggleableLinkItem.tsx | 14 ++++++++------ .../gitbook/src/components/primitives/Link.tsx | 1 - 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/gitbook/src/components/PageBody/PageFooterNavigation.tsx b/packages/gitbook/src/components/PageBody/PageFooterNavigation.tsx index a849abe2d3..48ee49c158 100644 --- a/packages/gitbook/src/components/PageBody/PageFooterNavigation.tsx +++ b/packages/gitbook/src/components/PageBody/PageFooterNavigation.tsx @@ -76,13 +76,15 @@ export function PageFooterNavigation(props: { ); } -function NavigationCard(props: { - icon: IconName; - label: React.ReactNode; - title: string; - href: string; - reversed?: boolean; -} & LinkInsightsProps) { +function NavigationCard( + props: { + icon: IconName; + label: React.ReactNode; + title: string; + href: string; + reversed?: boolean; + } & LinkInsightsProps, +) { const { icon, label, title, href, reversed, insights } = props; return ( diff --git a/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx b/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx index 8750693b33..3c12e37ed3 100644 --- a/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx @@ -30,12 +30,14 @@ const staggerMenuItems = stagger(0.02, { ease: (p) => Math.pow(p, 2) }); /** * Client component for a page document to toggle its children and be marked as active. */ -export function ToggleableLinkItem(props: { - href: string; - pathname: string; - children: React.ReactNode; - descendants: React.ReactNode; -} & LinkInsightsProps) { +export function ToggleableLinkItem( + props: { + href: string; + pathname: string; + children: React.ReactNode; + descendants: React.ReactNode; + } & LinkInsightsProps, +) { const { href, children, descendants, pathname, insights } = props; const rawActiveSegment = useSelectedLayoutSegment() ?? ''; diff --git a/packages/gitbook/src/components/primitives/Link.tsx b/packages/gitbook/src/components/primitives/Link.tsx index 7fbef6a0cc..cdb3467cb3 100644 --- a/packages/gitbook/src/components/primitives/Link.tsx +++ b/packages/gitbook/src/components/primitives/Link.tsx @@ -34,7 +34,6 @@ export const Link = React.forwardRef(function Link( const { href, prefetch, children, insights, ...domProps } = props; const trackEvent = useTrackEvent(); - // Use a real anchor tag for external links,s and a Next.js Link for internal links. // If we use a NextLink for external links, Nextjs won't rerender the top-level layouts. const isExternal = URL.canParse ? URL.canParse(props.href) : props.href.startsWith('http');