(frontend) add stat from Crisp

We want to track document views with user
authentication status using Crisp analytics.
This commit is contained in:
Anthony LC
2026-01-23 14:52:51 +01:00
parent 235c1828e6
commit adb216fbdf
6 changed files with 205 additions and 9 deletions

View File

@@ -11,6 +11,7 @@ and this project adheres to
- ✨(frontend) integrate configurable Waffle #1795
- ✨ Import of documents #1609
- 🚨(CI) gives warning if theme not updated #1811
- ✨(frontend) Add stat from Crisp #1824
- ✨(auth) add silent login #1690
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825

View File

@@ -12,7 +12,7 @@ import {
useSynchronizedLanguage,
} from '@/features/language';
import { useAnalytics } from '@/libs';
import { CrispProvider, PostHogAnalytic } from '@/services';
import { CrispAnalytic, PostHogAnalytic } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
import { useConfig } from './api/useConfig';
@@ -73,6 +73,14 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
new PostHogAnalytic(conf.POSTHOG_KEY);
}, [conf?.POSTHOG_KEY]);
useEffect(() => {
if (!conf?.CRISP_WEBSITE_ID) {
return;
}
new CrispAnalytic({ websiteId: conf.CRISP_WEBSITE_ID });
}, [conf?.CRISP_WEBSITE_ID]);
if (!conf) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
@@ -91,11 +99,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
{conf?.FRONTEND_JS_URL && (
<Script src={conf?.FRONTEND_JS_URL} strategy="afterInteractive" />
)}
<AnalyticsProvider>
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
{children}
</CrispProvider>
</AnalyticsProvider>
<AnalyticsProvider>{children}</AnalyticsProvider>
</>
);
};

View File

@@ -0,0 +1,119 @@
import { render } from '@testing-library/react';
import React from 'react';
import { describe, expect, test, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
import { LinkReach } from '../../doc-management';
import { DocEditor } from '../components/DocEditor';
vi.mock('@/stores', () => ({
useResponsiveStore: () => ({ isDesktop: false }),
}));
vi.mock('@/features/skeletons', () => ({
useSkeletonStore: () => ({
setIsSkeletonVisible: vi.fn(),
}),
}));
vi.mock('../../doc-management', async () => {
const actual = await vi.importActual<any>('../../doc-management');
return {
...actual,
useIsCollaborativeEditable: () => ({ isEditable: true, isLoading: false }),
useProviderStore: () => ({
provider: {
configuration: { name: 'test-doc-id' },
document: {
getXmlFragment: () => null,
},
},
isReady: true,
}),
getDocLinkReach: (doc: any) => doc.computed_link_reach,
};
});
vi.mock('../../doc-table-content', () => ({
TableContent: () => null,
}));
vi.mock('../../doc-header', () => ({
DocHeader: () => null,
}));
vi.mock('../components/BlockNoteEditor', () => ({
BlockNoteEditor: () => null,
BlockNoteReader: () => null,
}));
vi.mock('../../../auth', async () => {
const actual = await vi.importActual<any>('../../../auth');
return {
...actual,
useAuth: () => ({ authenticated: true }),
};
});
const TrackEventMock = vi.fn();
vi.mock('../../../../libs', async () => {
const actual = await vi.importActual<any>('../../../../libs');
return {
...actual,
useAnalytics: () => ({
trackEvent: TrackEventMock,
}),
};
});
describe('DocEditor', () => {
test('it checks that trackevent is called with correct parameters', () => {
const doc = {
id: 'test-doc-id-1',
computed_link_reach: LinkReach.PUBLIC,
deleted_at: null,
abilities: {
partial_update: true,
},
} as any;
const { rerender } = render(<DocEditor doc={doc} />, {
wrapper: AppWrapper,
});
expect(TrackEventMock).toHaveBeenCalledWith({
eventName: 'doc',
isPublic: true,
authenticated: true,
});
// Rerender with same doc to check that event is not tracked again
rerender(
<DocEditor doc={{ ...doc, computed_link_reach: LinkReach.RESTRICTED }} />,
);
expect(TrackEventMock).toHaveBeenNthCalledWith(1, {
eventName: 'doc',
isPublic: true,
authenticated: true,
});
// Rerender with different doc to check that event is tracked again
rerender(
<DocEditor
doc={{
...doc,
id: 'test-doc-id-2',
computed_link_reach: LinkReach.RESTRICTED,
}}
/>,
);
expect(TrackEventMock).toHaveBeenNthCalledWith(2, {
eventName: 'doc',
isPublic: false,
authenticated: true,
});
});
});

View File

@@ -1,15 +1,19 @@
import clsx from 'clsx';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Box, Loading } from '@/components';
import { DocHeader } from '@/docs/doc-header/';
import {
Doc,
LinkReach,
getDocLinkReach,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { TableContent } from '@/docs/doc-table-content/';
import { useAuth } from '@/features/auth/';
import { useSkeletonStore } from '@/features/skeletons';
import { useAnalytics } from '@/libs';
import { useResponsiveStore } from '@/stores';
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
@@ -83,6 +87,10 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
!doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc;
const { setIsSkeletonVisible } = useSkeletonStore();
const isProviderReady = isReady && provider;
const { trackEvent } = useAnalytics();
const [hasTracked, setHasTracked] = useState(false);
const { authenticated } = useAuth();
const isPublicDoc = getDocLinkReach(doc) === LinkReach.PUBLIC;
useEffect(() => {
if (isProviderReady) {
@@ -90,6 +98,30 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
}
}, [isProviderReady, setIsSkeletonVisible]);
/**
* Track doc view event only once per doc change
*/
useEffect(() => {
setHasTracked(false);
}, [doc.id]);
/**
* Track doc view event
*/
useEffect(() => {
if (hasTracked) {
return;
}
setHasTracked(true);
trackEvent({
eventName: 'doc',
isPublic: isPublicDoc,
authenticated,
});
}, [authenticated, hasTracked, isPublicDoc, trackEvent]);
if (!isProviderReady || provider?.configuration.name !== doc.id) {
return <Loading />;
}

View File

@@ -8,8 +8,16 @@ type AnalyticEventUser = {
id: string;
email: string;
};
type AnalyticEventDoc = {
eventName: 'doc';
isPublic: boolean;
authenticated: boolean;
};
export type AnalyticEvent = AnalyticEventClick | AnalyticEventUser;
export type AnalyticEvent =
| AnalyticEventClick
| AnalyticEventUser
| AnalyticEventDoc;
export abstract class AbstractAnalytic {
public constructor() {

View File

@@ -3,10 +3,11 @@
*/
import { Crisp } from 'crisp-sdk-web';
import { PropsWithChildren, useEffect, useState } from 'react';
import { JSX, PropsWithChildren, ReactNode, useEffect, useState } from 'react';
import { createGlobalStyle } from 'styled-components';
import { User } from '@/features/auth';
import { AbstractAnalytic, AnalyticEvent } from '@/libs';
const CrispStyle = createGlobalStyle`
#crisp-chatbox a{
@@ -70,3 +71,34 @@ export const CrispProvider = ({
</>
);
};
export class CrispAnalytic extends AbstractAnalytic {
private conf?: CrispProviderProps = undefined;
private EVENT = {
PUBLIC_DOC_NOT_CONNECTED: 'public-doc-not-connected',
};
public constructor(conf?: CrispProviderProps) {
super();
this.conf = conf;
}
public Provider(children?: ReactNode): JSX.Element {
return (
<CrispProvider websiteId={this.conf?.websiteId}>{children}</CrispProvider>
);
}
public trackEvent(evt: AnalyticEvent): void {
if (evt.eventName === 'doc') {
if (evt.isPublic && !evt.authenticated) {
Crisp.trigger.run(this.EVENT.PUBLIC_DOC_NOT_CONNECTED);
}
}
}
public isFeatureFlagActivated(): boolean {
return true;
}
}