diff --git a/webgui-new/package-lock.json b/webgui-new/package-lock.json index be8a42318..8c62e0837 100644 --- a/webgui-new/package-lock.json +++ b/webgui-new/package-lock.json @@ -24,6 +24,7 @@ "react": "^18.2.0", "react-diff-viewer-continued": "^3.2.6", "react-dom": "^18.2.0", + "react-ga4": "^2.1.0", "react-i18next": "^13.0.1", "react-icons": "^4.8.0", "react-toastify": "^9.1.2", @@ -5984,6 +5985,11 @@ "react": "^18.2.0" } }, + "node_modules/react-ga4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-ga4/-/react-ga4-2.1.0.tgz", + "integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==" + }, "node_modules/react-i18next": { "version": "13.2.2", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-13.2.2.tgz", diff --git a/webgui-new/package.json b/webgui-new/package.json index f693dd304..8e9743510 100644 --- a/webgui-new/package.json +++ b/webgui-new/package.json @@ -45,6 +45,7 @@ "react": "^18.2.0", "react-diff-viewer-continued": "^3.2.6", "react-dom": "^18.2.0", + "react-ga4": "^2.1.0", "react-i18next": "^13.0.1", "react-icons": "^4.8.0", "react-toastify": "^9.1.2", diff --git a/webgui-new/src/components/codebites/codebites-node.tsx b/webgui-new/src/components/codebites/codebites-node.tsx index 57acebc68..8fe03c8f7 100644 --- a/webgui-new/src/components/codebites/codebites-node.tsx +++ b/webgui-new/src/components/codebites/codebites-node.tsx @@ -18,6 +18,7 @@ import { IconButton, Tooltip } from '@mui/material'; import { Close } from '@mui/icons-material'; import { AppContext } from 'global-context/app-context'; import dagre from 'dagre'; +import { sendGAEvent } from 'utils/analytics'; type CodeBitesElement = { astNodeInfo: AstNodeInfo; @@ -94,6 +95,7 @@ class CustomOffsetGutterMarker extends GutterMarker { export const CodeBitesNode = ({ data }: NodeProps): JSX.Element => { const { diagramGenId: initialNodeId } = useContext(AppContext); + const appCtx = useContext(AppContext); const { theme } = useContext(ThemeContext); const [fileInfo, setFileInfo] = useState(undefined); @@ -108,11 +110,16 @@ export const CodeBitesNode = ({ data }: NodeProps): JSX.Element => { const init = async () => { const initFileInfo = await getFileInfo(data.astNodeInfo.range?.file as string); const initText = await getCppSourceText(data.astNodeInfo.id as string); + sendGAEvent({ + event_action: 'code_bites', + event_category: appCtx.workspaceId, + event_label: `${initFileInfo?.name}: ${initText}`, + }); setFileInfo(initFileInfo); setText(initText); }; init(); - }, [data.astNodeInfo]); + }, [appCtx.workspaceId, data.astNodeInfo]); const handleClick = async () => { if (!editorRef.current) return; diff --git a/webgui-new/src/components/codemirror-editor/codemirror-editor.tsx b/webgui-new/src/components/codemirror-editor/codemirror-editor.tsx index db086ae45..cb66a9c4b 100644 --- a/webgui-new/src/components/codemirror-editor/codemirror-editor.tsx +++ b/webgui-new/src/components/codemirror-editor/codemirror-editor.tsx @@ -18,6 +18,7 @@ import { RouterQueryType } from 'utils/types'; import { Tooltip, alpha } from '@mui/material'; import * as SC from './styled-components'; import { useTranslation } from 'react-i18next'; +import { sendGAEvent } from 'utils/analytics'; export const CodeMirrorEditor = (): JSX.Element => { const { t } = useTranslation(); @@ -111,6 +112,11 @@ export const CodeMirrorEditor = (): JSX.Element => { ? null : await getCppAstNodeInfoByPosition(fileInfo?.id as string, line.number, column); if (astNodeInfo) { + sendGAEvent({ + event_action: 'click_on_word', + event_category: appCtx.workspaceId, + event_label: `${fileInfo?.name}: ${astNodeInfo.astNodeValue}`, + }); dispatchSelection(astNodeInfo?.range?.range as Range); router.push({ pathname: '/project', @@ -132,6 +138,11 @@ export const CodeMirrorEditor = (): JSX.Element => { column: line.length + 1, }), }); + sendGAEvent({ + event_action: 'click_on_word', + event_category: appCtx.workspaceId, + event_label: `${fileInfo?.name}: ${convertSelectionRangeToString(range)}`, + }); dispatchSelection(range); router.push({ pathname: '/project', diff --git a/webgui-new/src/components/cookie-notice/cookie-notice.tsx b/webgui-new/src/components/cookie-notice/cookie-notice.tsx new file mode 100644 index 000000000..bf97c65f4 --- /dev/null +++ b/webgui-new/src/components/cookie-notice/cookie-notice.tsx @@ -0,0 +1,186 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getStore, setStore } from 'utils/store'; +import ReactGA from 'react-ga4'; +import { + Paper, + Typography, + Button, + IconButton, + Snackbar, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TableContainer, + TableRow, + TableCell, + Table, + TableHead, + TableBody, + Link, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import { useRouter } from 'next/router'; + +export const CookieNotice = (): JSX.Element => { + const { t } = useTranslation(); + const router = useRouter(); + const [isCookieConsent, setIsCookieConsent] = useState(undefined); + const [gaTrackingCode, setGaTrackingCode] = useState(undefined); + const [openPolicyModal, setOpenPolicyModal] = useState(false); + const [showNoticeSnackbar, setShowNoticeSnackbar] = useState(true); + + useEffect(() => { + const fetchGaTrackingCode = async () => { + try { + const res = await fetch(`/ga.txt`); + if (res.status !== 200) { + setGaTrackingCode(undefined); + return; + } + const gaCode = await res.text(); + setGaTrackingCode(gaCode); + } catch (e) { + // network-related error + setGaTrackingCode(undefined); + } + const store = getStore(); + setIsCookieConsent(store.storedCookieConsent); + }; + fetchGaTrackingCode(); + }, []); + + useEffect(() => { + if (!isCookieConsent || !gaTrackingCode) { + ReactGA.reset(); + return; + } + if (!ReactGA.isInitialized) { + ReactGA.initialize(gaTrackingCode); + console.log(`Google Analytics initialized - ${gaTrackingCode}`); + } + + const handleRouteChange = (url: string) => { + ReactGA.send({ hitType: 'pageview', page: url, title: window.document.title }); + }; + router.events.on('routeChangeComplete', handleRouteChange); + + return () => { + router.events.off('routeChangeComplete', handleRouteChange); + }; + }, [isCookieConsent, gaTrackingCode, router.events]); + + const handleCookieAccept = () => { + setIsCookieConsent(true); + setShowNoticeSnackbar(false); + setStore({ storedCookieConsent: true }); + }; + + if (!isCookieConsent && gaTrackingCode) + return ( + <> + + + + {t('cookie.INTRO_TEXT')} + + + + setShowNoticeSnackbar(false)} + > + + + + + setOpenPolicyModal(false)}> + {t('cookiePolicy.TITLE')} + + <> + + {t('cookiePolicy.sections.cookies.WHAT_COOKIES')} + + + {t('cookiePolicy.sections.cookies.WHAT_COOKIES_DESCRIPTION')} + + + {t('cookiePolicy.sections.cookies.HOW_USE_TITLE')} + + + + + + {t('cookiePolicy.sections.cookies.SERVICE')} + {t('cookiePolicy.sections.cookies.COOKIE_NAMES')} + {t('cookiePolicy.sections.cookies.DURATION')} + {t('cookiePolicy.sections.cookies.PURPOSE')} + {t('cookiePolicy.sections.cookies.MORE_INFORMATION')} + + + + + + {t('cookiePolicy.sections.cookies.googleAnalytics.SERVICE')} + + + {t('cookiePolicy.sections.cookies.googleAnalytics.COOKIE_NAMES')} + + + {t('cookiePolicy.sections.cookies.googleAnalytics.DURATION')} + + + {t('cookiePolicy.sections.cookies.googleAnalytics.PURPOSE')} + + + + {t('cookiePolicy.sections.cookies.MORE_INFORMATION')} + + + + +
+
+ + {t('cookiePolicy.sections.cookies.CONCLUSION')} + + +
+ + + +
+ + ); + + return <>; +}; diff --git a/webgui-new/src/components/diagrams/diagrams.tsx b/webgui-new/src/components/diagrams/diagrams.tsx index 019a6a9e7..beb54c45a 100644 --- a/webgui-new/src/components/diagrams/diagrams.tsx +++ b/webgui-new/src/components/diagrams/diagrams.tsx @@ -21,6 +21,7 @@ import { convertSelectionRangeToString } from 'utils/utils'; import { useRouter } from 'next/router'; import { RouterQueryType } from 'utils/types'; import { useTranslation } from 'react-i18next'; +import { sendGAEvent } from 'utils/analytics'; export const Diagrams = (): JSX.Element => { const { t } = useTranslation(); @@ -67,7 +68,18 @@ export const Diagrams = (): JSX.Element => { : initDiagramInfo instanceof AstNodeInfo ? await getCppDiagram(appCtx.diagramGenId, parseInt(appCtx.diagramTypeId)) : ''; - + + sendGAEvent({ + event_action: `load_diagram: ${appCtx.diagramTypeId}`, + event_category: appCtx.workspaceId, + event_label: + initDiagramInfo instanceof FileInfo + ? initDiagramInfo.name + : initDiagramInfo instanceof AstNodeInfo + ? initDiagramInfo.astNodeValue + : '', + }); + const parser = new DOMParser(); const parsedDiagram = parser.parseFromString(diagram, 'text/xml'); const diagramSvg = parsedDiagram.getElementsByTagName('svg')[0]; @@ -81,7 +93,7 @@ export const Diagrams = (): JSX.Element => { setDiagramInfo(initDiagramInfo); }; init(); - }, [appCtx.diagramGenId, appCtx.diagramTypeId, appCtx.diagramType]); + }, [appCtx.diagramGenId, appCtx.diagramTypeId, appCtx.diagramType, appCtx.workspaceId]); const generateDiagram = async (e: MouseEvent) => { const parentNode = (e.target as HTMLElement)?.parentElement; diff --git a/webgui-new/src/components/editor-context-menu/editor-context-menu.tsx b/webgui-new/src/components/editor-context-menu/editor-context-menu.tsx index b77a49667..91ff29c6e 100644 --- a/webgui-new/src/components/editor-context-menu/editor-context-menu.tsx +++ b/webgui-new/src/components/editor-context-menu/editor-context-menu.tsx @@ -21,6 +21,7 @@ import { useRouter } from 'next/router'; import { RouterQueryType } from 'utils/types'; import { useTranslation } from 'react-i18next'; import { diagramTypeArray } from 'enums/entity-types'; +import { sendGAEvent } from 'utils/analytics'; export const EditorContextMenu = ({ contextMenu, @@ -69,8 +70,14 @@ export const EditorContextMenu = ({ const getDocs = async () => { const initDocs = await getCppDocumentation(astNodeInfo?.id as string); + const fileInfo = await getFileInfo(appCtx.projectFileId as string); const parser = new DOMParser(); const parsedHTML = parser.parseFromString(initDocs, 'text/html'); + sendGAEvent({ + event_action: 'documentation', + event_category: appCtx.workspaceId, + event_label: `${fileInfo?.name}: ${astNodeInfo?.astNodeValue}`, + }); setModalOpen(true); setContextMenu(null); if (!docsContainerRef.current) return; @@ -87,6 +94,12 @@ export const EditorContextMenu = ({ const initAstHTML = await getAsHTMLForNode(astNodeInfo?.id as string); const parser = new DOMParser(); const parsedHTML = parser.parseFromString(initAstHTML, 'text/html'); + const fileInfo = await getFileInfo(appCtx.projectFileId as string); + sendGAEvent({ + event_action: 'cpp_reparse_node', + event_category: appCtx.workspaceId, + event_label: `${fileInfo?.name}: ${astNodeInfo?.astNodeValue}`, + }); setModalOpen(true); setContextMenu(null); if (!astHTMLContainerRef.current) return; @@ -115,6 +128,12 @@ export const EditorContextMenu = ({ } const fileId = def.range?.file as string; + const fileInfo = await getFileInfo(appCtx.projectFileId as string); + sendGAEvent({ + event_action: 'jump_to_def', + event_category: appCtx.workspaceId, + event_label: `${fileInfo?.name}: ${astNodeInfo.astNodeValue}`, + }); router.push({ pathname: '/project', @@ -145,6 +164,11 @@ export const EditorContextMenu = ({ currentRepo?.repoPath as string, fileInfo?.id as string ); + sendGAEvent({ + event_action: 'git_blame', + event_category: appCtx.workspaceId, + event_label: fileInfo?.name, + }); return blameInfo; }; diff --git a/webgui-new/src/components/file-context-menu/file-context-menu.tsx b/webgui-new/src/components/file-context-menu/file-context-menu.tsx index b3511a3d0..e3e58db40 100644 --- a/webgui-new/src/components/file-context-menu/file-context-menu.tsx +++ b/webgui-new/src/components/file-context-menu/file-context-menu.tsx @@ -12,6 +12,7 @@ import { useRouter } from 'next/router'; import { RouterQueryType } from 'utils/types'; import { useTranslation } from 'react-i18next'; import { diagramTypeArray } from 'enums/entity-types'; +import { sendGAEvent } from 'utils/analytics'; export const FileContextMenu = ({ contextMenu, @@ -67,6 +68,11 @@ export const FileContextMenu = ({ {fileInfo && fileInfo.isDirectory && ( { + sendGAEvent({ + event_action: 'metrics', + event_category: appCtx.workspaceId, + event_label: fileInfo.name, + }); setContextMenu(null); router.push({ pathname: '/project', diff --git a/webgui-new/src/components/header/header.tsx b/webgui-new/src/components/header/header.tsx index f64fa41a5..a76985445 100644 --- a/webgui-new/src/components/header/header.tsx +++ b/webgui-new/src/components/header/header.tsx @@ -15,6 +15,7 @@ import { AppContext } from 'global-context/app-context'; import * as SC from './styled-components'; import { useRouter } from 'next/router'; import { RouterQueryType } from 'utils/types'; +import { sendGAEvent } from 'utils/analytics'; export const Header = (): JSX.Element => { const router = useRouter(); @@ -85,6 +86,12 @@ export const Header = (): JSX.Element => { storedSearchProps: initSearchProps, }); + sendGAEvent({ + event_action: `search: ${searchType ? searchType.name : 'undefined'}`, + event_category: appCtx.workspaceId, + event_label: query, + }); + router.push({ pathname: '/project', query: { diff --git a/webgui-new/src/i18n/locales/en.json b/webgui-new/src/i18n/locales/en.json index 76080d76e..c6f671177 100644 --- a/webgui-new/src/i18n/locales/en.json +++ b/webgui-new/src/i18n/locales/en.json @@ -355,5 +355,33 @@ "INCLUDE_DEPENDENCY": "Include Dependency", "INTERFACE": "Interface Diagram", "SUBSYSTEM_DEPENDENCY": "Internal architecture of this module" + }, + "cookie": { + "ACCEPT": "Accept", + "INTRO_TEXT": "Our website uses cookies to improve your experience.", + "LEARN_MORE": "Learn more" + }, + "cookiePolicy": { + "TITLE": "Cookie policy", + "sections": { + "cookies": { + "SERVICE": "Service", + "PURPOSE": "Purpose", + "DURATION": "Duration", + "MORE_INFORMATION": "More Information", + "COOKIE_NAMES": "Cookie names", + "WHAT_COOKIES": "What are cookies?", + "WHAT_COOKIES_DESCRIPTION": "Cookies are small text files that websites store on a user's device, primarily for analytics purposes. They enable websites to gather valuable data about user interactions, helping businesses understand user behavior, preferences, and engagement. By analyzing this data, organizations can make informed decisions to improve their websites, content, and marketing strategies. Cookies play a crucial role in optimizing the online experience and tailoring content to better serve users' needs while respecting their privacy through proper consent mechanisms.", + "HOW_USE_TITLE": "How we use cookies?", + "CONCLUSION": "By clicking \"Accept\", you agree to the use of cookies on this website, enhancing your browsing experience and enabling us to gather valuable analytics data for continual improvement. You can always withdraw your consent later on by simply removing the corresponding cookies from your device.", + "googleAnalytics": { + "SERVICE": "Google Analytics", + "COOKIE_NAMES": "_ga, _ga_", + "DURATION": "2 years", + "PURPOSE": "Tracking of page views, code editor operations, diagram generations, metrics collection, and version-control actions.", + "MORE_INFORMATION": "https://policies.google.com/technologies/cookies" + } + } + } } } diff --git a/webgui-new/src/pages/_app.tsx b/webgui-new/src/pages/_app.tsx index 88daddacb..38317f59a 100644 --- a/webgui-new/src/pages/_app.tsx +++ b/webgui-new/src/pages/_app.tsx @@ -6,6 +6,7 @@ import { ThemeContextController } from 'global-context/theme-context'; import { I18nextProvider } from 'react-i18next'; import i18n from 'i18n/i18n'; import React from 'react'; +import { CookieNotice } from 'components/cookie-notice/cookie-notice'; const App = ({ Component, pageProps }: AppProps): JSX.Element => { return ( @@ -14,6 +15,7 @@ const App = ({ Component, pageProps }: AppProps): JSX.Element => { + diff --git a/webgui-new/src/server.ts b/webgui-new/src/server.ts index ac904cdbe..aa4b5cc5e 100644 --- a/webgui-new/src/server.ts +++ b/webgui-new/src/server.ts @@ -16,9 +16,10 @@ const main = async () => { const proxyHandler = createProxyMiddleware({ target: process.env.BACKEND_URL, changeOrigin: true, - pathFilter: '**/*Service', + pathFilter: ['**/*Service', '**/ga.txt'], pathRewrite: { '^/[^/]+/(.+Service)$': '/$1', + '^/ga.txt' : '/ga.txt' }, }); diff --git a/webgui-new/src/utils/analytics.ts b/webgui-new/src/utils/analytics.ts new file mode 100644 index 000000000..11a9d8f88 --- /dev/null +++ b/webgui-new/src/utils/analytics.ts @@ -0,0 +1,19 @@ +import ReactGA from 'react-ga4'; + +type EventType = { + event_action: string; + event_category: string; + event_label?: string; +}; + +export const sendGAEvent = ({ + event_action, + event_category, + event_label = 'undefined', +}: EventType) => { + if (!ReactGA.isInitialized) { + return; + } + // We have to use this signature to prevent ReactGA from making the starting letters automatically uppercase. + ReactGA.event(event_action, { event_category, event_label }); +}; diff --git a/webgui-new/src/utils/store.ts b/webgui-new/src/utils/store.ts index 005e41a70..0e4693fbc 100644 --- a/webgui-new/src/utils/store.ts +++ b/webgui-new/src/utils/store.ts @@ -3,6 +3,7 @@ import { FileNode, SearchProps, TreeNode } from './types'; type StoreOptions = { storedTheme?: 'light' | 'dark'; + storedCookieConsent?: boolean; storedSearchProps?: SearchProps; storedSearchType?: SearchType; storedSearchLanguage?: string; @@ -16,6 +17,7 @@ type StoreOptions = { type StoreOptionKey = | 'storedTheme' + | 'storedCookieConsent' | 'storedSearchProps' | 'storedSearchType' | 'storedSearchLanguage'