✨(frontend) add stat from Crisp
We want to track document views with user authentication status using Crisp analytics.
This commit is contained in:
@@ -11,6 +11,7 @@ and this project adheres to
|
|||||||
- ✨(frontend) integrate configurable Waffle #1795
|
- ✨(frontend) integrate configurable Waffle #1795
|
||||||
- ✨ Import of documents #1609
|
- ✨ Import of documents #1609
|
||||||
- 🚨(CI) gives warning if theme not updated #1811
|
- 🚨(CI) gives warning if theme not updated #1811
|
||||||
|
- ✨(frontend) Add stat from Crisp #1824
|
||||||
- ✨(auth) add silent login #1690
|
- ✨(auth) add silent login #1690
|
||||||
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
|
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
useSynchronizedLanguage,
|
useSynchronizedLanguage,
|
||||||
} from '@/features/language';
|
} from '@/features/language';
|
||||||
import { useAnalytics } from '@/libs';
|
import { useAnalytics } from '@/libs';
|
||||||
import { CrispProvider, PostHogAnalytic } from '@/services';
|
import { CrispAnalytic, PostHogAnalytic } from '@/services';
|
||||||
import { useSentryStore } from '@/stores/useSentryStore';
|
import { useSentryStore } from '@/stores/useSentryStore';
|
||||||
|
|
||||||
import { useConfig } from './api/useConfig';
|
import { useConfig } from './api/useConfig';
|
||||||
@@ -73,6 +73,14 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
|||||||
new PostHogAnalytic(conf.POSTHOG_KEY);
|
new PostHogAnalytic(conf.POSTHOG_KEY);
|
||||||
}, [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) {
|
if (!conf) {
|
||||||
return (
|
return (
|
||||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||||
@@ -91,11 +99,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
|||||||
{conf?.FRONTEND_JS_URL && (
|
{conf?.FRONTEND_JS_URL && (
|
||||||
<Script src={conf?.FRONTEND_JS_URL} strategy="afterInteractive" />
|
<Script src={conf?.FRONTEND_JS_URL} strategy="afterInteractive" />
|
||||||
)}
|
)}
|
||||||
<AnalyticsProvider>
|
<AnalyticsProvider>{children}</AnalyticsProvider>
|
||||||
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
|
|
||||||
{children}
|
|
||||||
</CrispProvider>
|
|
||||||
</AnalyticsProvider>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Box, Loading } from '@/components';
|
import { Box, Loading } from '@/components';
|
||||||
import { DocHeader } from '@/docs/doc-header/';
|
import { DocHeader } from '@/docs/doc-header/';
|
||||||
import {
|
import {
|
||||||
Doc,
|
Doc,
|
||||||
|
LinkReach,
|
||||||
|
getDocLinkReach,
|
||||||
useIsCollaborativeEditable,
|
useIsCollaborativeEditable,
|
||||||
useProviderStore,
|
useProviderStore,
|
||||||
} from '@/docs/doc-management';
|
} from '@/docs/doc-management';
|
||||||
import { TableContent } from '@/docs/doc-table-content/';
|
import { TableContent } from '@/docs/doc-table-content/';
|
||||||
|
import { useAuth } from '@/features/auth/';
|
||||||
import { useSkeletonStore } from '@/features/skeletons';
|
import { useSkeletonStore } from '@/features/skeletons';
|
||||||
|
import { useAnalytics } from '@/libs';
|
||||||
import { useResponsiveStore } from '@/stores';
|
import { useResponsiveStore } from '@/stores';
|
||||||
|
|
||||||
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
|
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
|
||||||
@@ -83,6 +87,10 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
|||||||
!doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc;
|
!doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc;
|
||||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||||
const isProviderReady = isReady && provider;
|
const isProviderReady = isReady && provider;
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
const [hasTracked, setHasTracked] = useState(false);
|
||||||
|
const { authenticated } = useAuth();
|
||||||
|
const isPublicDoc = getDocLinkReach(doc) === LinkReach.PUBLIC;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isProviderReady) {
|
if (isProviderReady) {
|
||||||
@@ -90,6 +98,30 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
|||||||
}
|
}
|
||||||
}, [isProviderReady, setIsSkeletonVisible]);
|
}, [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) {
|
if (!isProviderReady || provider?.configuration.name !== doc.id) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,16 @@ type AnalyticEventUser = {
|
|||||||
id: string;
|
id: string;
|
||||||
email: 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 {
|
export abstract class AbstractAnalytic {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Crisp } from 'crisp-sdk-web';
|
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 { createGlobalStyle } from 'styled-components';
|
||||||
|
|
||||||
import { User } from '@/features/auth';
|
import { User } from '@/features/auth';
|
||||||
|
import { AbstractAnalytic, AnalyticEvent } from '@/libs';
|
||||||
|
|
||||||
const CrispStyle = createGlobalStyle`
|
const CrispStyle = createGlobalStyle`
|
||||||
#crisp-chatbox a{
|
#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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user