Merge pull request #480 from m-tartari/feat/GA4

✨ feat(analytics): add GA4
This commit is contained in:
2025-05-28 14:57:44 +02:00
committed by GitHub
39 changed files with 1116 additions and 95 deletions
+2
View File
@@ -74,6 +74,8 @@
"diffEditor.ignoreTrimWhitespace": false,
"diffEditor.wordWrap": "off",
// Javascript
"javascript.preferences.importModuleSpecifierEnding": "js",
"typescript.preferences.importModuleSpecifierEnding": "js",
"javascript.updateImportsOnFileMove.enabled": "always",
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.updateImportsOnFileMove.enabled": "always",
+1
View File
@@ -0,0 +1 @@
VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX
+6
View File
@@ -45,3 +45,9 @@ updates:
patterns:
- 'lodash*'
- '@types/lodash*'
analytics:
patterns:
- 'analytics'
- '@analytics/*'
- 'use-analytics'
- '@types/use-analytics'
+3
View File
@@ -37,6 +37,9 @@ jobs:
node-version: 'lts/*'
cache: 'npm'
- name: Set VITE_GA_MEASUREMENT_ID
run: sed -ri "s/VITE_GA_MEASUREMENT_ID/${{ secrets.VITE_GA_MEASUREMENT_ID }}/" .env
- name: Install dependencies
run: npm ci --omit=dev
+7
View File
@@ -12,6 +12,13 @@ dist
dist-ssr
*.local
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Editor directories and files
# .vscode/*
!.vscode/extensions.json
+6
View File
@@ -69,4 +69,10 @@
"**/node_modules"
],
"vitest.enable": true,
"javascript.preferences.importModuleSpecifierEnding": "js",
"typescript.preferences.importModuleSpecifierEnding": "js",
"cSpell.words": [
"SRDF",
"URDF"
],
}
+175
View File
@@ -8,16 +8,19 @@
"name": "m-tartari.github.io",
"version": "1.0.0-alpha.0",
"dependencies": {
"@analytics/google-analytics": "^1.1.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.2.5",
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.0.0",
"@vitejs/plugin-react-swc": "^3.10.0",
"analytics": "^0.8.16",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.1",
"react-scroll-parallax": "^3.4.5",
"use-analytics": "^1.1.0",
"vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4"
},
@@ -26,8 +29,10 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.15.23",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@types/use-analytics": "^0.0.3",
"@typescript-eslint/eslint-plugin": "^8.33.0",
"@typescript-eslint/parser": "^8.32.1",
"@vitest/coverage-istanbul": "^3.1.4",
@@ -70,6 +75,84 @@
"node": ">=6.0.0"
}
},
"node_modules/@analytics/cookie-utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@analytics/cookie-utils/-/cookie-utils-0.2.12.tgz",
"integrity": "sha512-2h/yuIu3kmu+ZJlKmlT6GoRvUEY2k1BbQBezEv5kGhnn9KpmzPz715Y3GmM2i+m7Y0QmBdVUoA260dQZkofs2A==",
"license": "MIT",
"dependencies": {
"@analytics/global-storage-utils": "^0.1.7"
}
},
"node_modules/@analytics/core": {
"version": "0.12.17",
"resolved": "https://registry.npmjs.org/@analytics/core/-/core-0.12.17.tgz",
"integrity": "sha512-GMxRm5Dp3Wam/w5NNvqNKMO6zWecozbVv21Kn4WhftCx6OjJI7zMlVtiLpjGjxa0RRZfVG80YhupF0Qh9XL2gw==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/davidwells"
}
],
"license": "MIT",
"dependencies": {
"@analytics/global-storage-utils": "^0.1.7",
"@analytics/type-utils": "^0.6.2",
"analytics-utils": "^1.0.14"
}
},
"node_modules/@analytics/global-storage-utils": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/@analytics/global-storage-utils/-/global-storage-utils-0.1.7.tgz",
"integrity": "sha512-V+spzGLZYm4biZT4uefaylm80SrLXf8WOTv9hCgA46cLcyxx3LD4GCpssp1lj+RcWLl/uXJQBRO4Mnn/o1x6Gw==",
"license": "MIT",
"dependencies": {
"@analytics/type-utils": "^0.6.2"
}
},
"node_modules/@analytics/google-analytics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@analytics/google-analytics/-/google-analytics-1.1.0.tgz",
"integrity": "sha512-i8uGyELMtwEUAf3GNWNLNBzhRvReDn1RUxvMdMhjUA7+GNGxPOM4kkzFfv3giQXKNxTEjfsh75kqNcscbJsuaA==",
"license": "MIT"
},
"node_modules/@analytics/localstorage-utils": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/@analytics/localstorage-utils/-/localstorage-utils-0.1.10.tgz",
"integrity": "sha512-uJS+Jp1yLG5VFCgA5T82ZODYBS0xuDQx0NtAZrgbqt9j51BX3TcgmOez5LVkrUNu/lpbxjCLq35I4TKj78VmOQ==",
"license": "MIT",
"dependencies": {
"@analytics/global-storage-utils": "^0.1.7"
}
},
"node_modules/@analytics/session-storage-utils": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@analytics/session-storage-utils/-/session-storage-utils-0.0.7.tgz",
"integrity": "sha512-PSv40UxG96HVcjY15e3zOqU2n8IqXnH8XvTkg1X43uXNTKVSebiI2kUjA3Q7ESFbw5DPwcLbJhV7GforpuBLDw==",
"license": "MIT",
"dependencies": {
"@analytics/global-storage-utils": "^0.1.7"
}
},
"node_modules/@analytics/storage-utils": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@analytics/storage-utils/-/storage-utils-0.4.2.tgz",
"integrity": "sha512-AXObwyVQw9h2uJh1t2hUgabtVxzYpW+7uKVbdHQK80vr3Td5rrmCxrCxarh7HUuAgSDZ0bZWqmYxVgmwKceaLg==",
"license": "MIT",
"dependencies": {
"@analytics/cookie-utils": "^0.2.12",
"@analytics/global-storage-utils": "^0.1.7",
"@analytics/localstorage-utils": "^0.1.10",
"@analytics/session-storage-utils": "^0.0.7",
"@analytics/type-utils": "^0.6.2"
}
},
"node_modules/@analytics/type-utils": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@analytics/type-utils/-/type-utils-0.6.2.tgz",
"integrity": "sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg==",
"license": "MIT"
},
"node_modules/@asamuzakjp/css-color": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-2.8.2.tgz",
@@ -2421,6 +2504,13 @@
"dev": true,
"peer": true
},
"node_modules/@types/dlv": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/dlv/-/dlv-1.1.5.tgz",
"integrity": "sha512-JHOWNfiWepAhfwlSw17kiWrWrk6od2dEQgHltJw9AS0JPFoLZJBge5+Dnil2NfdjAvJ/+vGSX60/BRW20PpUXw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@@ -2434,6 +2524,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.15.23",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.23.tgz",
"integrity": "sha512-7Ec1zaFPF4RJ0eXu1YT/xgiebqwqoJz8rYPDi/O2BcZ++Wpt0Kq9cl0eg6NN6bYbPnR67ZLo7St5Q3UK0SnARw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -2472,6 +2572,17 @@
"@types/react": "*"
}
},
"node_modules/@types/use-analytics": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@types/use-analytics/-/use-analytics-0.0.3.tgz",
"integrity": "sha512-s/b/R8q/pKj1INYyRWw46aI4rZFr6p0yW80EdD47FBytTGXPNJFOaPlvw2Atsrf+k6S4idl1nN5kXiLBl1MrJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*",
"analytics": "^0.8.1"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.33.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.0.tgz",
@@ -2913,6 +3024,35 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/analytics": {
"version": "0.8.16",
"resolved": "https://registry.npmjs.org/analytics/-/analytics-0.8.16.tgz",
"integrity": "sha512-LEFQ47G9V1zVp9WIh2xhnbmSFEJq+WEzSv6voJ5uba88lefiIIYeG2nq87gFu83ocz1qtb9u7XgeaKKVBbbgWA==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/davidwells"
}
],
"license": "MIT",
"dependencies": {
"@analytics/core": "^0.12.17",
"@analytics/storage-utils": "^0.4.2"
}
},
"node_modules/analytics-utils": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/analytics-utils/-/analytics-utils-1.0.14.tgz",
"integrity": "sha512-9v0kPd8v0GuBvfQcg5BO48AElaEAr9IXMAfJWXYMAhrD3QprgozEIUgMp/de0vS136PUOBB+10XQH9eBgBmfMw==",
"license": "MIT",
"dependencies": {
"@analytics/type-utils": "^0.6.2",
"dlv": "^1.1.3"
},
"peerDependencies": {
"@types/dlv": "^1.0.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -3292,6 +3432,12 @@
"node": ">=6"
}
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"license": "MIT"
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@@ -5373,6 +5519,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -5579,6 +5731,13 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
@@ -5619,6 +5778,22 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-analytics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/use-analytics/-/use-analytics-1.1.0.tgz",
"integrity": "sha512-1PMT5doiCoTm6ng9PscCHuFgl8vYNSFgGizXuFlmEBqnaRJ+KnygKnETUbBJ1W7MJPWQeHwA5Mys9t7b4P8ijw==",
"license": "MIT",
"dependencies": {
"hoist-non-react-statics": "^3.3.2",
"tiny-invariant": "^1.1.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
+5
View File
@@ -19,16 +19,19 @@
"test:ci-workflow": "npm ci --omit=dev && npm run build:prod && npm ci && npm run build && npm run lint && npm run test:coverage"
},
"dependencies": {
"@analytics/google-analytics": "^1.1.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.2.5",
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.0.0",
"@vitejs/plugin-react-swc": "^3.10.0",
"analytics": "^0.8.16",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.1",
"react-scroll-parallax": "^3.4.5",
"use-analytics": "^1.1.0",
"vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4"
},
@@ -37,8 +40,10 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.15.23",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@types/use-analytics": "^0.0.3",
"@typescript-eslint/eslint-plugin": "^8.33.0",
"@typescript-eslint/parser": "^8.32.1",
"@vitest/coverage-istanbul": "^3.1.4",
+7
View File
@@ -1,7 +1,14 @@
import { render, screen } from '@testing-library/react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import App from './App'
// Mock the analytics module
vi.mock('use-analytics', () => ({
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() }, page: vi.fn() }),
}))
describe('App component', () => {
test('renders MainPage without crashing', async () => {
render(<App />)
+26 -16
View File
@@ -1,32 +1,32 @@
import React from 'react'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { Backdrop, CircularProgress } from '@mui/material'
import { ErrorPage } from 'components/pages'
import ErrorPage from 'components/pages/ErrorPage/index.js'
import RootLayout from 'components/templates/RouteTracker/index.js'
import Loading from 'components/molecules/LoadingScreen/index.js'
const MainPage = React.lazy(() => import('components/pages/MainPage'))
const ProjectsPage = React.lazy(() => import('components/pages/ProjectsPage'))
const CurriculumPage = React.lazy(() => import('components/pages/CurriculumPage'))
const ContactPage = React.lazy(() => import('components/pages/ContactPage'))
const Loading = () => (
<Backdrop open={true} aria-label="loading-screen">
<CircularProgress />
</Backdrop>
)
const MainPage = React.lazy(() => import('components/pages/MainPage/index.js'))
const ProjectsPage = React.lazy(() => import('components/pages/ProjectsPage/index.js'))
const CurriculumPage = React.lazy(() => import('components/pages/CurriculumPage/index.js'))
const ContactPage = React.lazy(() => import('components/pages/ContactPage/index.js'))
const PrivacyPage = React.lazy(() => import('components/pages/PrivacyPage/index.js'))
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
errorElement: <ErrorPage />,
children: [
{
index: true,
element: (
<React.Suspense fallback={<Loading />}>
<MainPage />
</React.Suspense>
),
errorElement: <ErrorPage />,
},
{
path: '/projects',
path: 'projects',
element: (
<React.Suspense fallback={<Loading />}>
<ProjectsPage />
@@ -34,7 +34,7 @@ const router = createBrowserRouter([
),
},
{
path: '/curriculum',
path: 'curriculum',
element: (
<React.Suspense fallback={<Loading />}>
<CurriculumPage />
@@ -42,13 +42,23 @@ const router = createBrowserRouter([
),
},
{
path: '/contacts',
path: 'contacts',
element: (
<React.Suspense fallback={<Loading />}>
<ContactPage />
</React.Suspense>
),
},
{
path: 'privacy',
element: (
<React.Suspense fallback={<Loading />}>
<PrivacyPage />
</React.Suspense>
),
},
],
},
])
const App = () => <RouterProvider router={router} />
@@ -0,0 +1,18 @@
import React from 'react'
import { ListItem, Typography, ListItemProps } from '@mui/material'
interface ListItemTextProps extends ListItemProps {
title?: string
}
const ListItemText: React.FC<ListItemTextProps> = ({ title, children, ...props }) => (
<ListItem component={Typography} color="text.secondary" {...props}>
{title && (
<Typography component="b" fontWeight={700} color="text.primary">
{title}{' '}
</Typography>
)}
{children}
</ListItem>
)
export default ListItemText
@@ -0,0 +1,64 @@
import { render, screen, fireEvent } from '@testing-library/react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import CookieConsent from './index.js'
const analytics = { plugins: { enable: vi.fn(), disable: vi.fn() } }
// Mock the analytics module
vi.mock('use-analytics', () => ({
useAnalytics: () => analytics,
}))
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
clear: vi.fn(() => {
store = {}
}),
}
})()
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
describe('CookieConsent', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorageMock.clear()
})
it('renders the cookie consent dialog when no consent is stored', () => {
render(<CookieConsent />)
expect(screen.getByText('Cookie Preferences')).toBeInTheDocument()
expect(screen.getByText(/We use cookies to improve your browsing experience/)).toBeInTheDocument()
expect(screen.getByText('Accept All')).toBeInTheDocument()
expect(screen.getByText('Reject All')).toBeInTheDocument()
})
it('accepts all cookies when clicking Accept All', () => {
render(<CookieConsent />)
fireEvent.click(screen.getByText('Accept All'))
expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ analytics: true, necessary: true }))
expect(analytics.plugins.disable).not.toHaveBeenCalled()
expect(analytics.plugins.enable).toHaveBeenCalled()
})
it('rejects analytics cookies when clicking Reject All', () => {
render(<CookieConsent />)
fireEvent.click(screen.getByText('Reject All'))
expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ analytics: false, necessary: true }))
expect(analytics.plugins.enable).not.toHaveBeenCalled()
expect(analytics.plugins.disable).toHaveBeenCalled()
})
})
@@ -0,0 +1,100 @@
import { useState, useEffect, useCallback } from 'react'
import { Box, Button, Link, Stack, Typography, Unstable_TrapFocus as TrapFocus, Paper, Fade } from '@mui/material'
import { useAnalytics } from 'use-analytics'
import { ANALYTICS_PLUGINS, COOKIE_CONSENT_KEY } from 'components/utils/analytics/index.js'
interface Consent {
necessary: boolean // Always true, but included for completeness
analytics: boolean
}
const CookieConsent = () => {
const [open, setOpen] = useState<boolean>(false)
const analytics = useAnalytics()
useEffect(() => {
// Check if user has already given consent
const savedConsent = localStorage.getItem(COOKIE_CONSENT_KEY)
if (!savedConsent) {
setOpen(true)
} else {
const parsedConsent = JSON.parse(savedConsent) as Consent
if (parsedConsent.analytics) {
analytics.plugins.enable(ANALYTICS_PLUGINS)
}
}
}, [analytics])
const handleAcceptAll = useCallback(() => {
localStorage.setItem(COOKIE_CONSENT_KEY, JSON.stringify({ analytics: true, necessary: true }))
analytics.plugins.enable(ANALYTICS_PLUGINS)
setOpen(false)
}, [analytics.plugins])
const handleRejectAll = useCallback(() => {
localStorage.setItem(COOKIE_CONSENT_KEY, JSON.stringify({ analytics: false, necessary: true }))
analytics.plugins.disable(ANALYTICS_PLUGINS)
setOpen(false)
}, [analytics.plugins])
return (
<>
<TrapFocus open disableAutoFocus disableEnforceFocus>
<Fade appear={false} in={open}>
<Paper
role="dialog"
aria-modal="false"
// aria-label="Cookie banner"
aria-labelledby="cookie-consent-dialog-title"
aria-describedby="cookie-consent-dialog-description"
variant="outlined"
tabIndex={-1}
sx={theme => ({
position: 'fixed',
bottom: 0,
left: '50%',
transform: 'translate(-50%, 0)',
width: theme.breakpoints.values.sm,
maxWidth: `calc(100% - ${theme.spacing(2)})`,
mb: theme.spacing(1),
p: theme.spacing(3),
zIndex: 9999,
})}>
<Box>
<Typography id="cookie-consent-dialog-title" variant="h6" component="h2" fontWeight="bold" gutterBottom>
Cookie Preferences
</Typography>
<Typography id="cookie-consent-dialog-description" variant="body2" color="textSecondary">
<span style={{ display: 'inline-block' }}>We use cookies to improve your browsing experience and analyze our traffic.</span>
<span style={{ display: 'inline-block' }}>By clicking "Accept All", you consent to our use of cookies.</span>
<span style={{ display: 'inline-block' }}>
For more details, please see our{' '}
<Link href="/privacy" color="textPrimary">
Privacy Policy
</Link>
.
</span>
</Typography>
</Box>
<Stack direction="row" sx={{ mt: 2, flexGrow: 0, flexShrink: 0, alignItems: 'center', justifyContent: 'flex-end', gap: 1 }}>
<Link href="/privacy#cookie-preferences" color="textPrimary">
Customize
</Link>
<Button onClick={handleRejectAll} size="small" color="inherit" variant="outlined">
Reject All
</Button>
<Button onClick={handleAcceptAll} size="small" color="inherit" variant="outlined">
Accept All
</Button>
</Stack>
</Paper>
</Fade>
</TrapFocus>
</>
)
}
export default CookieConsent
@@ -0,0 +1,89 @@
import { screen, fireEvent } from '@testing-library/react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import { renderWithRouter as render } from 'components/utils/tests/index.js'
import CookiePreferences from './index.js'
// Mock the analytics module
const analytics = { plugins: { enable: vi.fn(), disable: vi.fn() } }
vi.mock('use-analytics', () => ({
useAnalytics: () => analytics,
}))
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
clear: vi.fn(() => {
store = {}
}),
}
})()
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
describe('CookiePreferences', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorageMock.clear()
})
it('accepts all cookies when clicking Accept All', () => {
render(<CookiePreferences />)
expect(screen.getByText('Cookie Preferences')).toBeInTheDocument()
fireEvent.click(screen.getByText('Accept All'))
fireEvent.click(screen.getByText('Save Preferences'))
expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ necessary: true, analytics: true }))
expect(analytics.plugins.disable).not.toHaveBeenCalled()
expect(analytics.plugins.enable).toHaveBeenCalled()
})
it('rejects analytics cookies when clicking Reject All', () => {
render(<CookiePreferences />)
expect(screen.getByText('Cookie Preferences')).toBeInTheDocument()
fireEvent.click(screen.getByText('Reject All'))
fireEvent.click(screen.getByText('Save Preferences'))
expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ necessary: true, analytics: false }))
expect(analytics.plugins.enable).not.toHaveBeenCalled()
expect(analytics.plugins.disable).toHaveBeenCalled()
})
it('toggles analytics cookies when clicking the Analytics button', () => {
render(<CookiePreferences />)
expect(screen.getByText('Cookie Preferences')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('analytics-cookies-toggle'))
fireEvent.click(screen.getByText('Save Preferences'))
expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ necessary: true, analytics: true }))
expect(analytics.plugins.disable).not.toHaveBeenCalled()
expect(analytics.plugins.enable).toHaveBeenCalled()
})
it('renders the cookie consent in the right state when consent in local storage', () => {
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify({ necessary: true, analytics: true }))
render(<CookiePreferences />)
expect(screen.getByText('Cookie Preferences')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('analytics-cookies-toggle'))
fireEvent.click(screen.getByText('Save Preferences'))
expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ necessary: true, analytics: false }))
expect(analytics.plugins.enable).not.toHaveBeenCalled()
expect(analytics.plugins.disable).toHaveBeenCalled()
})
})
@@ -0,0 +1,141 @@
import React from 'react'
import { useState, useEffect, useCallback } from 'react'
import { Box, Button, Link, Stack, Switch, Typography, SnackbarCloseReason, Snackbar, Alert } from '@mui/material'
import { useAnalytics } from 'use-analytics'
import { ANALYTICS_PLUGINS, COOKIE_CONSENT_KEY } from 'components/utils/analytics/index.js'
interface CookieConsentState {
necessary: boolean // Always true, but included for completeness
analytics: boolean
}
export type CookieConsentProps = {
consentDisplayDefaultOpen?: boolean
}
const CookieConsent: React.FC<CookieConsentProps> = ({ consentDisplayDefaultOpen = true }) => {
const analytics = useAnalytics()
const [consent, setConsent] = useState<CookieConsentState>({
necessary: true,
analytics: false,
})
const [open, setOpen] = React.useState(false)
const handleClose = (_event: React.SyntheticEvent | Event, reason?: SnackbarCloseReason) => {
if (reason === 'clickaway') {
return
}
setOpen(false)
}
useEffect(() => {
// Check if user has already given consent
const savedConsent = localStorage.getItem(COOKIE_CONSENT_KEY)
if (savedConsent) {
const parsedConsent = JSON.parse(savedConsent) as CookieConsentState
setConsent(parsedConsent)
}
}, [analytics.plugins, consentDisplayDefaultOpen])
const handleSave = useCallback(() => {
localStorage.setItem(COOKIE_CONSENT_KEY, JSON.stringify(consent))
// Initialize GA if analytics consent was given
if (consent.analytics) {
analytics.plugins.enable(ANALYTICS_PLUGINS)
} else {
analytics.plugins.disable(ANALYTICS_PLUGINS)
}
setOpen(true)
}, [consent, analytics.plugins])
const handleChange = useCallback((type: keyof CookieConsentState) => {
if (type === 'necessary') {
return
}
setConsent(prev => ({
...prev,
[type]: !prev[type],
}))
}, [])
return (
<Box id="cookie-preferences" aria-labelledby="cookie-preferences-title" aria-describedby="cookie-preferences-description">
<Typography id="cookie-preferences-title" aria-label="cookie-preferences" variant="h3" component="h2" gutterBottom>
Cookie Preferences
</Typography>
<Typography id="cookie-preferences-description" color="textSecondary" flexGrow={1}>
Please set your preferences below. You can change these settings at any time in the future.
<br />
For more details, please see our{' '}
<Link href="/privacy" color="textPrimary">
Privacy Policy
</Link>
.
</Typography>
<Box
sx={theme => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
my: theme.spacing(2),
p: theme.spacing(4),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
})}>
<Stack direction="row" alignSelf="flex-end" mb={-2}>
<Button onClick={() => setConsent({ necessary: true, analytics: false })} color="inherit" variant="text" sx={{ textTransform: 'none' }}>
Reject All
</Button>
<Button onClick={() => setConsent({ necessary: true, analytics: true })} color="inherit" variant="text" sx={{ textTransform: 'none' }}>
Accept All
</Button>
</Stack>
<Box>
<Stack direction="row" alignItems="center" justifyContent="space-between" alignContent="center">
<Typography variant="body1" fontWeight="bold">
Necessary Cookies
</Typography>
<Switch slotProps={{ input: { 'aria-label': 'necessary-cookies-toggle' } }} color="default" disabled checked />
</Stack>
<Typography variant="body2" color="textSecondary">
These cookies are essential for the website to function properly and cannot be disabled.
</Typography>
</Box>
<Box>
<Stack direction="row" alignItems="center" justifyContent="space-between" alignContent="center">
<Typography variant="body1" fontWeight="bold">
Analytics Cookies
</Typography>
<Switch
checked={consent.analytics}
slotProps={{ input: { 'aria-label': 'analytics-cookies-toggle' } }}
onChange={() => handleChange('analytics')}
color="default"
/>
</Stack>
<Typography variant="body2" color="textSecondary">
These cookies help us understand how visitors interact with our website, helping us improve our services.
</Typography>
</Box>
<Button onClick={handleSave} variant="outlined" sx={{ alignSelf: 'flex-end' }}>
Save Preferences
</Button>
</Box>
<Snackbar open={open} autoHideDuration={3000} onClose={handleClose}>
<Alert onClose={handleClose} sx={{ backgroundColor: 'background.paper', width: '100%' }}>
Preferences saved!
</Alert>
</Snackbar>
</Box>
)
}
export default CookieConsent
@@ -0,0 +1,30 @@
import React from 'react'
import { screen } from '@testing-library/react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import { renderWithRouter as render } from 'components/utils/tests/index.js'
import Footer from './index.js'
// Mock the analytics module
const analytics = { plugins: { enable: vi.fn(), disable: vi.fn() } }
vi.mock('use-analytics', () => ({
useAnalytics: () => analytics,
}))
describe('Footer', () => {
it('renders the Footer', async () => {
render(<Footer />)
expect(screen.getAllByText('Privacy Policy')).toHaveLength(2) // link and dialog
expect(screen.getAllByText('Cookie Preferences')).toHaveLength(2) // link and dialog
})
it('does not show the dialog if the `showCookieConsent` prop is false', () => {
render(<Footer showCookieConsent={false} />)
expect(screen.getAllByText('Privacy Policy')).toHaveLength(1) // link only
expect(screen.getAllByText('Cookie Preferences')).toHaveLength(1) // link only
})
})
@@ -1,21 +0,0 @@
import { Box, Stack, Typography } from '@mui/material'
import { EmailIconLink, GitHubIconLink, LinkedInIconLink } from 'components/atoms'
const Footer = () => {
const year = new Date().getFullYear()
return (
<Box sx={{ bgcolor: 'background.paper', p: 6, display: 'flex', alignItems: 'center', flexDirection: 'column' }} component="footer">
<Stack direction="row" gap={2}>
<LinkedInIconLink fontSize="large" href="https://www.linkedin.com/in/m-tartari/" />
<GitHubIconLink fontSize="large" href="https://www.github.com/m-tartari/" />
<EmailIconLink fontSize="large" href="mailto:info@m-tartari.eu" />
</Stack>
<Typography variant="subtitle2" align="center" color="text.secondary" component="em" mt={3}>
&copy; {year} Michele Tartari. All rights reserved.
</Typography>
</Box>
)
}
export default Footer
-1
View File
@@ -1 +0,0 @@
export { default } from './Footer'
+52
View File
@@ -0,0 +1,52 @@
import React from 'react'
import { Link as RouterLink } from 'react-router-dom'
import { Box, Link, Stack, Typography } from '@mui/material'
import PolicyIcon from '@mui/icons-material/Policy'
import CookieIcon from '@mui/icons-material/Cookie'
import { EmailIconLink, GitHubIconLink, LinkedInIconLink } from 'components/atoms'
import CookieConsent from 'components/molecules/CookieConsent/index.js'
type FooterProps = {
showCookieConsent?: boolean
}
const Footer: React.FC<FooterProps> = ({ showCookieConsent = true }) => {
const year = new Date().getFullYear()
return (
<Box sx={{ bgcolor: 'background.paper', p: 6, display: 'flex', alignItems: 'center', flexDirection: 'column' }} component="footer">
<Stack direction="row" gap={2}>
<LinkedInIconLink fontSize="large" href="https://www.linkedin.com/in/m-tartari/" />
<GitHubIconLink fontSize="large" href="https://www.github.com/m-tartari/" />
<EmailIconLink fontSize="large" href="mailto:info@m-tartari.eu" />
</Stack>
<Typography variant="subtitle2" align="center" color="text.secondary" component="em" mt={3}>
© {year} Michele Tartari. All rights reserved.
</Typography>
<Stack mt={1}>
<Link component={RouterLink} to="/privacy#cookie-preferences" color="textSecondary">
<Stack direction="row" alignItems="center" justifyContent="center" gap={1}>
<CookieIcon fontSize="inherit" />
<Typography variant="subtitle2" component="span">
Cookie Preferences
</Typography>
</Stack>
</Link>
<Link component={RouterLink} to="/privacy" color="textSecondary">
<Stack direction="row" alignItems="center" justifyContent="center" gap={1}>
<PolicyIcon fontSize="inherit" />
<Typography variant="subtitle2" component="span">
Privacy Policy
</Typography>
</Stack>
</Link>
</Stack>
{showCookieConsent && <CookieConsent />}
</Box>
)
}
export default Footer
@@ -0,0 +1,9 @@
import { Backdrop, CircularProgress } from '@mui/material'
const Loading = () => (
<Backdrop open={true} aria-label="loading-screen">
<CircularProgress />
</Backdrop>
)
export default Loading
@@ -0,0 +1,19 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import PrivacyPolicy from './index.js'
describe('Privacy Policy', () => {
it('renders the Privacy Policy page', async () => {
render(
<BrowserRouter>
<PrivacyPolicy />
</BrowserRouter>
)
expect(screen.getByText('Privacy Policy')).toBeInTheDocument()
expect(screen.getByText('Google Analytics')).toBeInTheDocument()
expect(screen.getByText('Your Rights')).toBeInTheDocument()
expect(screen.getByText('Contact Us')).toBeInTheDocument()
})
})
@@ -0,0 +1,125 @@
import React from 'react'
import { Typography, Link, List as MuiList, styled, Box } from '@mui/material'
import ListItemText from 'components/atoms/ListItemText/index.js'
const List = styled(MuiList)(({ theme }) => ({
listStyleType: 'disc',
paddingLeft: theme.spacing(4),
'& .MuiListItem-root': {
display: 'list-item',
padding: 0,
},
}))
const PrivacyPolicy: React.FC = () => {
return (
<Box aria-labelledby="privacy-policy">
<Typography id="privacy-policy" aria-label="privacy-policy" variant="h3" component="h2" gutterBottom>
Privacy Policy
</Typography>
<Typography variant="subtitle1" color="textSecondary" component="p" pb={4}>
Last updated: May 27, 2025
</Typography>
<Typography variant="h5" component="h2" gutterBottom>
Introduction
</Typography>
<Typography component="p" color="textSecondary">
m-tartari ("we", "our", or "us") respects your privacy and is committed to protecting your personal data. This privacy policy will inform you
about how we look after your personal data when you visit our website and tell you about your privacy rights and how the law protects you.
</Typography>
<Typography variant="h5" component="h2" gutterBottom sx={{ mt: 4 }}>
The Data We Collect About You
</Typography>
<Typography component="p" color="textSecondary">
We collect and process the following information when you visit our website:
</Typography>
<List dense>
<ListItemText title="Technical Data">
includes internet protocol (IP) address, browser type and version, time zone setting and location, browser plug-in types and versions,
operating system and platform, and other technology on the devices you use to access this website.
</ListItemText>
<ListItemText title="Usage Data"> includes information about how you use our website, products, and services.</ListItemText>
</List>
<Typography variant="h5" component="h2" gutterBottom sx={{ mt: 4 }}>
Cookies
</Typography>
<Typography component="p" color="textSecondary">
We use cookies to distinguish you from other users of our website. This helps us to provide you with a good experience when you browse our
website and also allows us to improve our site.
</Typography>
<Typography component="p" color="textSecondary">
We use the following types of cookies:
</Typography>
<List dense>
<ListItemText title="Necessary Cookies:">
These are essential to enable you to use the website and its features. Without these cookies, services you have asked for cannot be
provided.
</ListItemText>
<ListItemText title="Analytics Cookies:">
We use Google Analytics to help us understand how visitors interact with our website. These cookies collect information about your visit to
our website, the content you viewed, the links you followed, and information about your browser, device, and IP address. We do this to
improve how our website works.
</ListItemText>
</List>
<Typography variant="h5" component="h2" gutterBottom sx={{ mt: 4 }}>
Google Analytics
</Typography>
<Typography component="p" color="textSecondary">
This website uses Google Analytics 4, a web analytics service provided by Google, Inc. ("Google"). Google Analytics uses cookies to help the
website analyze how users use the site. The information generated by the cookie about your use of the website will be transmitted to and
stored by Google on servers in the United States.
</Typography>
<Typography component="p" color="textSecondary">
We have implemented the following measures to be GDPR compliant:
</Typography>
<List dense>
<ListItemText>We have obtained your consent before processing your data through cookies</ListItemText>
<ListItemText>We have implemented IP address anonymization</ListItemText>
<ListItemText>We have disabled data sharing with Google</ListItemText>
<ListItemText>We have set the data retention period to the minimum required for our analysis</ListItemText>
</List>
<Typography component="p" color="textSecondary">
You can prevent the collection of your data by Google Analytics by declining analytics cookies in our cookie consent banner or by using the
Google Analytics Opt-out Browser Add-on available at{' '}
<Link color="textPrimary" href="https://tools.google.com/dlpage/gaoptout" target="_blank" rel="noopener noreferrer">
https://tools.google.com/dlpage/gaoptout
</Link>
.
</Typography>
<Typography variant="h5" component="h2" gutterBottom sx={{ mt: 4 }}>
Your Rights
</Typography>
<Typography component="p" color="textSecondary">
Under the General Data Protection Regulation (GDPR), you have various rights in relation to your personal data, including:
</Typography>
<List dense>
<ListItemText>The right to access your personal data</ListItemText>
<ListItemText>The right to rectification of your personal data</ListItemText>
<ListItemText>The right to erasure of your personal data</ListItemText>
<ListItemText>The right to restrict processing of your personal data</ListItemText>
<ListItemText>The right to data portability</ListItemText>
<ListItemText>The right to object to processing of your personal data</ListItemText>
</List>
<Typography variant="h5" component="h2" gutterBottom sx={{ mt: 4 }}>
Contact Us
</Typography>
<Typography component="p" color="textSecondary">
If you have any questions about this privacy policy or our privacy practices, please contact us at{' '}
<Link color="textPrimary" href="mailto:privacy@m-tartari.eu" target="_blank" rel="noopener noreferrer">
privacy@m-tartari.eu
</Link>
.
</Typography>
</Box>
)
}
export default PrivacyPolicy
+23 -10
View File
@@ -17,6 +17,7 @@ import {
Typography,
styled,
useScrollTrigger,
useTheme,
} from '@mui/material'
import MenuIcon from '@mui/icons-material/Menu'
@@ -43,6 +44,7 @@ interface CustomToolbarProps extends AppBarProps {
}
const CustomToolbar: React.FC<CustomToolbarProps> = ({ drawerWidth = 240, window, ...props }) => {
const [mobileOpen, setMobileOpen] = React.useState(false)
const theme = useTheme()
const trigger = useScrollTrigger({
disableHysteresis: true,
@@ -66,11 +68,6 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({ drawerWidth = 240, window
<ListItemText primary="Home" />
</DrawerButton>
</ListItem>
{/* <ListItem disablePadding>
<DrawerButton component={RouterLink} to="/">
<Typography variant="h6">Home</Typography>
</DrawerButton>
</ListItem> */}
<ListItem key="Projects" disablePadding>
<DrawerButton component={RouterLink} to="/projects">
<ListItemText primary="Projects" />
@@ -96,10 +93,15 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({ drawerWidth = 240, window
elevation={0}
{...props}
sx={{
backgroundColor: trigger ? theme => (theme.palette.mode === 'dark' ? 'background.paper' : undefined) : 'transparent',
backgroundColor: trigger ? (theme.palette.mode === 'dark' ? 'background.paper' : undefined) : 'transparent',
...props.sx,
}}>
<Toolbar sx={{ display: 'flex' }}>
<Toolbar
sx={{
display: 'flex',
// maxWidth: '100%',
// [theme.breakpoints.up('md')]: { width: theme => theme.breakpoints.values.lg, mx: 'auto' },
}}>
<IconButton
color="primary"
aria-label="open drawer"
@@ -108,9 +110,20 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({ drawerWidth = 240, window
sx={{ mr: 2, display: { md: 'none' }, position: 'absolute', backgroundColor: 'primary' }}>
<MenuIcon />
</IconButton>
<Typography variant="h6" component="div" sx={{ flexGrow: 1, textAlign: { xs: 'center', md: 'left' } }}>
Michele Tartari
</Typography>
<AppBarButton
component={RouterLink}
to="/"
disableRipple
sx={theme => ({
[theme.breakpoints.down('md')]: {
mx: 'auto',
},
[theme.breakpoints.up('md')]: {
mr: 'auto',
},
})}>
<Typography variant="h6"> Michele Tartari</Typography>
</AppBarButton>
<Stack component="div" direction="row" spacing={2} sx={{ display: { xs: 'none', md: 'flex' } }}>
<AppBarButton component={RouterLink} to="/">
<Typography variant="h6">Home</Typography>
@@ -1,8 +1,15 @@
import { screen } from '@testing-library/react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import { renderWithRouter as render } from 'components/utils/tests/index.js'
import ContactPage from './index.js'
// Mock the analytics module
vi.mock('use-analytics', () => ({
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
}))
vi.stubGlobal('open', vi.fn())
describe('ContactPage', () => {
test('renders without crashing and displays the correct contact methods', () => {
@@ -1,11 +1,16 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import { renderWithRouter as render } from 'components/utils/tests'
import CurriculumPage from '.'
import CurriculumPage from './index.js'
vi.stubGlobal('open', vi.fn())
vi.mock('use-analytics', () => ({
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
}))
describe('Curriculum component', () => {
const open = vi.spyOn(window, 'open')
@@ -1,7 +1,14 @@
import { screen, cleanup, render } from '@testing-library/react'
import { RouterProvider, createMemoryRouter } from 'react-router-dom'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import ErrorPage from '.'
import ErrorPage from './index.js'
// Mock the analytics module
vi.mock('use-analytics', () => ({
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
}))
// Tests
describe('Renders ErrorPage correctly', async () => {
+5 -3
View File
@@ -1,10 +1,12 @@
import { isRouteErrorResponse, useRouteError } from 'react-router-dom'
import { Box, Typography } from '@mui/material'
import { Typography } from '@mui/material'
import { Page } from 'components/templates/index.js'
const ErrorPage: React.FC = () => {
const error: unknown = useRouteError()
return (
<Box display="flex" flexDirection="column" justifyContent="center" alignItems="center" minHeight="100vh">
<Page sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', minHeight: '90vh' }}>
<Typography variant="h1" color="primary.main">
Oops!
</Typography>
@@ -14,7 +16,7 @@ const ErrorPage: React.FC = () => {
{error.status} {error.data}
</Typography>
)}
</Box>
</Page>
)
}
@@ -1,9 +1,15 @@
import { screen } from '@testing-library/react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import { renderWithRouter as render } from 'components/utils/tests'
import { renderWithRouter as render } from 'components/utils/tests/index.js'
import MainPage from '.'
// Mock the analytics module
vi.mock('use-analytics', () => ({
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
}))
// Tests
describe('Main page', () => {
it('Should render the page correctly on desktop', async () => {
@@ -0,0 +1,24 @@
import React from 'react'
import { screen } from '@testing-library/react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import { renderWithRouter as render } from 'components/utils/tests/index.js'
import PrivacyPage from './index.js'
// Mock the analytics module
vi.mock('use-analytics', () => ({
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
}))
describe('PrivacyPage', () => {
it('renders the Privacy Policy page', () => {
render(<PrivacyPage />)
expect(screen.getAllByText('Privacy Policy')).toHaveLength(3) // title, body (cookie preferences section) and footer
expect(screen.getByText('Google Analytics')).toBeInTheDocument()
expect(screen.getByText('Your Rights')).toBeInTheDocument()
expect(screen.getByText('Contact Us')).toBeInTheDocument()
expect(screen.getAllByText('Cookie Preferences')).toHaveLength(2) // body and footer
})
})
@@ -0,0 +1,18 @@
import React from 'react'
import Page from 'components/templates/Page/index.js'
import CookiePreferences from 'components/molecules/CookiePreferences/index.js'
import PrivacyPolicy from 'components/molecules/PrivacyPolicy/index.js'
import { Divider } from '@mui/material'
const PrivacyPage: React.FC = () => {
return (
<Page title="Privacy" showCookieConsent={false}>
<PrivacyPolicy />
<Divider sx={{ my: 4 }} />
<CookiePreferences />
</Page>
)
}
export default PrivacyPage
@@ -1,8 +1,15 @@
import { screen } from '@testing-library/react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import { renderWithRouter as render } from 'components/utils/tests/index.js'
import ProjectsPage from '.'
// Mock the analytics module
vi.mock('use-analytics', () => ({
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
}))
vi.stubGlobal('open', vi.fn())
describe('ProjectsPage component', () => {
test('renders without crashing and displays the correct project titles', () => {
+8 -2
View File
@@ -1,9 +1,15 @@
import { screen } from '@testing-library/react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import { renderWithRouter as render } from 'components/utils/tests'
import { renderWithRouter as render } from 'components/utils/tests/index.js'
import Page from '.'
// Mock the analytics module
vi.mock('use-analytics', () => ({
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
}))
describe('Page component', () => {
const defaultProps = {
title: 'Test Page',
+7 -3
View File
@@ -3,11 +3,15 @@ import { Box, BoxProps, Container, Divider, Typography } from '@mui/material'
import { Footer, Toolbar } from 'components/molecules'
interface PageProps extends BoxProps {
showCookieConsent?: boolean
}
/**
* React function component that implements the default page template.
* @see BoxProps from '@mui/material' for props
*/
const Page: React.FC<BoxProps> = ({ title, children, ...props }) => {
const Page: React.FC<PageProps> = ({ title, children, showCookieConsent, ...props }) => {
React.useEffect(() => {
if (title) {
document.title = `Michele Tartari - ${title}`
@@ -52,12 +56,12 @@ const Page: React.FC<BoxProps> = ({ title, children, ...props }) => {
minHeight: '80vh',
mx: 'auto',
maxWidth: { xs: '100%', md: 'calc(100% - 4em)' },
width: '70em',
width: theme => theme.breakpoints.values.lg,
...props.sx,
}}>
{children}
</Container>
<Footer />
<Footer showCookieConsent={showCookieConsent} />
</Box>
)
}
@@ -0,0 +1,19 @@
import { useEffect } from 'react'
import { useAnalytics } from 'use-analytics'
import { Outlet, useLocation } from 'react-router-dom'
/**
* Route tracking component that wraps all routes and includes analytics page tracking
*/
const RouteTracker = () => {
const location = useLocation()
const analytics = useAnalytics()
useEffect(() => {
analytics.page()
}, [analytics, location])
return <Outlet />
}
export default RouteTracker
+2 -1
View File
@@ -1 +1,2 @@
export { default as Page } from './Page'
export { default as Page } from './Page/index.js'
export { default as RouteTracker } from './RouteTracker/index.js'
+44
View File
@@ -0,0 +1,44 @@
import Analytics from 'analytics'
import googleAnalytics from '@analytics/google-analytics'
// Get measurement ID from environment variables
const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID || ''
export const COOKIE_CONSENT_KEY = 'm-tartari-cookie-consent'
export const ANALYTICS_PLUGINS = ['google-analytics']
// Log warning if measurement ID is not set
if (!GA_MEASUREMENT_ID) {
console.warn('Google Analytics Measurement ID is not set. Analytics will not track events.')
}
const analyticsInstance = Analytics({
app: 'm-tartari-portfolio',
plugins: [
googleAnalytics({
measurementIds: [GA_MEASUREMENT_ID],
// Start disabled for GDPR compliance
enabled: false,
}),
],
})
// Initialize analytics
analyticsInstance.ready(() => {
if (process.env.NODE_ENV !== 'test') console.info('Analytics system initialized')
})
// Track page views
export const trackPageView = (path: string) => {
analyticsInstance.page({
path,
title: document.title,
})
}
// Track custom events
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const trackEvent = (event: string, payload: Record<string, any> = {}) => {
analyticsInstance.track(event, payload)
}
export default analyticsInstance
+7 -4
View File
@@ -1,19 +1,22 @@
/* istanbul ignore file */
import React from 'react'
import ReactDOM from 'react-dom/client'
import { ThemeProvider } from '@mui/material/styles'
import { CssBaseline } from '@mui/material'
import { AnalyticsProvider } from 'use-analytics'
import { ThemeProvider, CssBaseline } from '@mui/material'
import App from './App.tsx'
import theme from 'style/theme.ts'
import analytics from 'components/utils/analytics/index.js'
import theme from 'style/theme.js'
import App from './App.js'
import './index.css'
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
<React.StrictMode>
<AnalyticsProvider instance={analytics}>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</AnalyticsProvider>
</React.StrictMode>
)
+8
View File
@@ -1 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_GA_MEASUREMENT_ID: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+1 -1
View File
@@ -14,7 +14,7 @@
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",