diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 53b5602..47405d7 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -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",
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..c0f1740
--- /dev/null
+++ b/.env.example
@@ -0,0 +1 @@
+VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 3b10c79..e14bf47 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -45,3 +45,9 @@ updates:
patterns:
- 'lodash*'
- '@types/lodash*'
+ analytics:
+ patterns:
+ - 'analytics'
+ - '@analytics/*'
+ - 'use-analytics'
+ - '@types/use-analytics'
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index bbac26b..1d145a2 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index 6d036fa..f3de697 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/.vscode/settings.json b/.vscode/settings.json
index d4500b7..15c6958 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -69,4 +69,10 @@
"**/node_modules"
],
"vitest.enable": true,
+ "javascript.preferences.importModuleSpecifierEnding": "js",
+ "typescript.preferences.importModuleSpecifierEnding": "js",
+ "cSpell.words": [
+ "SRDF",
+ "URDF"
+ ],
}
diff --git a/package-lock.json b/package-lock.json
index 3ee2dee..e426a63 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index efe6cab..224f072 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/App.test.tsx b/src/App.test.tsx
index a64c386..2710b73 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -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()
diff --git a/src/App.tsx b/src/App.tsx
index c1aba46..a429bed 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,53 +1,63 @@
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 = () => (
-
-
-
-)
+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: (
- }>
-
-
- ),
+ element: ,
errorElement: ,
- },
- {
- path: '/projects',
- element: (
- }>
-
-
- ),
- },
- {
- path: '/curriculum',
- element: (
- }>
-
-
- ),
- },
- {
- path: '/contacts',
- element: (
- }>
-
-
- ),
+ children: [
+ {
+ index: true,
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'projects',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'curriculum',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'contacts',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'privacy',
+ element: (
+ }>
+
+
+ ),
+ },
+ ],
},
])
diff --git a/src/components/atoms/ListItemText/index.tsx b/src/components/atoms/ListItemText/index.tsx
new file mode 100644
index 0000000..5f94dc2
--- /dev/null
+++ b/src/components/atoms/ListItemText/index.tsx
@@ -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 = ({ title, children, ...props }) => (
+
+ {title && (
+
+ {title}{' '}
+
+ )}
+ {children}
+
+)
+
+export default ListItemText
diff --git a/src/components/molecules/CookieConsent/CookieConsent.test.tsx b/src/components/molecules/CookieConsent/CookieConsent.test.tsx
new file mode 100644
index 0000000..42e707c
--- /dev/null
+++ b/src/components/molecules/CookieConsent/CookieConsent.test.tsx
@@ -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 = {}
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+ })
+})
diff --git a/src/components/molecules/CookieConsent/index.tsx b/src/components/molecules/CookieConsent/index.tsx
new file mode 100644
index 0000000..1b598ab
--- /dev/null
+++ b/src/components/molecules/CookieConsent/index.tsx
@@ -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(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 (
+ <>
+
+
+ ({
+ 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,
+ })}>
+
+
+ Cookie Preferences
+
+
+ We use cookies to improve your browsing experience and analyze our traffic.
+ By clicking "Accept All", you consent to our use of cookies.
+
+ For more details, please see our{' '}
+
+ Privacy Policy
+
+ .
+
+
+
+
+
+
+ Customize
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export default CookieConsent
diff --git a/src/components/molecules/CookiePreferences/CookiePreferences.test.tsx b/src/components/molecules/CookiePreferences/CookiePreferences.test.tsx
new file mode 100644
index 0000000..ef3115b
--- /dev/null
+++ b/src/components/molecules/CookiePreferences/CookiePreferences.test.tsx
@@ -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 = {}
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+ })
+})
diff --git a/src/components/molecules/CookiePreferences/index.tsx b/src/components/molecules/CookiePreferences/index.tsx
new file mode 100644
index 0000000..0fe4bec
--- /dev/null
+++ b/src/components/molecules/CookiePreferences/index.tsx
@@ -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 = ({ consentDisplayDefaultOpen = true }) => {
+ const analytics = useAnalytics()
+ const [consent, setConsent] = useState({
+ 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 (
+
+
+ Cookie Preferences
+
+
+ Please set your preferences below. You can change these settings at any time in the future.
+
+ For more details, please see our{' '}
+
+ Privacy Policy
+
+ .
+
+
+ ({
+ 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,
+ })}>
+
+
+
+
+
+
+
+
+ Necessary Cookies
+
+
+
+
+ These cookies are essential for the website to function properly and cannot be disabled.
+
+
+
+
+
+
+ Analytics Cookies
+
+ handleChange('analytics')}
+ color="default"
+ />
+
+
+ These cookies help us understand how visitors interact with our website, helping us improve our services.
+
+
+
+
+
+
+
+ Preferences saved!
+
+
+
+ )
+}
+
+export default CookieConsent
diff --git a/src/components/molecules/Footer/Footer.test.tsx b/src/components/molecules/Footer/Footer.test.tsx
new file mode 100644
index 0000000..ac1656a
--- /dev/null
+++ b/src/components/molecules/Footer/Footer.test.tsx
@@ -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()
+
+ 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()
+
+ expect(screen.getAllByText('Privacy Policy')).toHaveLength(1) // link only
+ expect(screen.getAllByText('Cookie Preferences')).toHaveLength(1) // link only
+ })
+})
diff --git a/src/components/molecules/Footer/Footer.tsx b/src/components/molecules/Footer/Footer.tsx
deleted file mode 100644
index cbbf388..0000000
--- a/src/components/molecules/Footer/Footer.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
- © {year} Michele Tartari. All rights reserved.
-
-
- )
-}
-
-export default Footer
diff --git a/src/components/molecules/Footer/index.ts b/src/components/molecules/Footer/index.ts
deleted file mode 100644
index 5d06e9b..0000000
--- a/src/components/molecules/Footer/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './Footer'
diff --git a/src/components/molecules/Footer/index.tsx b/src/components/molecules/Footer/index.tsx
new file mode 100644
index 0000000..4c3c915
--- /dev/null
+++ b/src/components/molecules/Footer/index.tsx
@@ -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 = ({ showCookieConsent = true }) => {
+ const year = new Date().getFullYear()
+ return (
+
+
+
+
+
+
+
+
+ © {year} Michele Tartari. All rights reserved.
+
+
+
+
+
+
+
+ Cookie Preferences
+
+
+
+
+
+
+
+ Privacy Policy
+
+
+
+
+
+ {showCookieConsent && }
+
+ )
+}
+
+export default Footer
diff --git a/src/components/molecules/LoadingScreen/index.tsx b/src/components/molecules/LoadingScreen/index.tsx
new file mode 100644
index 0000000..c5e8e92
--- /dev/null
+++ b/src/components/molecules/LoadingScreen/index.tsx
@@ -0,0 +1,9 @@
+import { Backdrop, CircularProgress } from '@mui/material'
+
+const Loading = () => (
+
+
+
+)
+
+export default Loading
diff --git a/src/components/molecules/PrivacyPolicy/PrivacyPolicy.test.tsx b/src/components/molecules/PrivacyPolicy/PrivacyPolicy.test.tsx
new file mode 100644
index 0000000..0f434c9
--- /dev/null
+++ b/src/components/molecules/PrivacyPolicy/PrivacyPolicy.test.tsx
@@ -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(
+
+
+
+ )
+ expect(screen.getByText('Privacy Policy')).toBeInTheDocument()
+ expect(screen.getByText('Google Analytics')).toBeInTheDocument()
+ expect(screen.getByText('Your Rights')).toBeInTheDocument()
+ expect(screen.getByText('Contact Us')).toBeInTheDocument()
+ })
+})
diff --git a/src/components/molecules/PrivacyPolicy/index.tsx b/src/components/molecules/PrivacyPolicy/index.tsx
new file mode 100644
index 0000000..3405e50
--- /dev/null
+++ b/src/components/molecules/PrivacyPolicy/index.tsx
@@ -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 (
+
+
+ Privacy Policy
+
+
+ Last updated: May 27, 2025
+
+
+
+ Introduction
+
+
+ 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.
+
+
+
+ The Data We Collect About You
+
+
+ We collect and process the following information when you visit our website:
+
+
+
+ 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.
+
+ includes information about how you use our website, products, and services.
+
+
+
+ Cookies
+
+
+ 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.
+
+
+ We use the following types of 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.
+
+
+ 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.
+
+
+
+
+ Google Analytics
+
+
+ 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.
+
+
+ We have implemented the following measures to be GDPR compliant:
+
+
+ We have obtained your consent before processing your data through cookies
+ We have implemented IP address anonymization
+ We have disabled data sharing with Google
+ We have set the data retention period to the minimum required for our analysis
+
+
+ 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{' '}
+
+ https://tools.google.com/dlpage/gaoptout
+
+ .
+
+
+
+ Your Rights
+
+
+ Under the General Data Protection Regulation (GDPR), you have various rights in relation to your personal data, including:
+
+
+
+ The right to access your personal data
+ The right to rectification of your personal data
+ The right to erasure of your personal data
+ The right to restrict processing of your personal data
+ The right to data portability
+ The right to object to processing of your personal data
+
+
+
+ Contact Us
+
+
+ If you have any questions about this privacy policy or our privacy practices, please contact us at{' '}
+
+ privacy@m-tartari.eu
+
+ .
+
+
+ )
+}
+
+export default PrivacyPolicy
diff --git a/src/components/molecules/Toolbar/index.tsx b/src/components/molecules/Toolbar/index.tsx
index d7954b1..261ec60 100644
--- a/src/components/molecules/Toolbar/index.tsx
+++ b/src/components/molecules/Toolbar/index.tsx
@@ -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 = ({ 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 = ({ drawerWidth = 240, window
- {/*
-
- Home
-
- */}
@@ -96,10 +93,15 @@ const CustomToolbar: React.FC = ({ 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,
}}>
-
+ theme.breakpoints.values.lg, mx: 'auto' },
+ }}>
= ({ drawerWidth = 240, window
sx={{ mr: 2, display: { md: 'none' }, position: 'absolute', backgroundColor: 'primary' }}>
-
- Michele Tartari
-
+ ({
+ [theme.breakpoints.down('md')]: {
+ mx: 'auto',
+ },
+ [theme.breakpoints.up('md')]: {
+ mr: 'auto',
+ },
+ })}>
+ Michele Tartari
+
Home
diff --git a/src/components/pages/ContactPage/ContactPage.test.tsx b/src/components/pages/ContactPage/ContactPage.test.tsx
index e1f381d..c95579a 100644
--- a/src/components/pages/ContactPage/ContactPage.test.tsx
+++ b/src/components/pages/ContactPage/ContactPage.test.tsx
@@ -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', () => {
diff --git a/src/components/pages/CurriculumPage/CurriculumPage.test.tsx b/src/components/pages/CurriculumPage/CurriculumPage.test.tsx
index 874b190..c386ff4 100644
--- a/src/components/pages/CurriculumPage/CurriculumPage.test.tsx
+++ b/src/components/pages/CurriculumPage/CurriculumPage.test.tsx
@@ -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')
diff --git a/src/components/pages/ErrorPage/ErrorPage.test.tsx b/src/components/pages/ErrorPage/ErrorPage.test.tsx
index 809c5b7..ff6dac9 100644
--- a/src/components/pages/ErrorPage/ErrorPage.test.tsx
+++ b/src/components/pages/ErrorPage/ErrorPage.test.tsx
@@ -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 () => {
diff --git a/src/components/pages/ErrorPage/ErrorPage.tsx b/src/components/pages/ErrorPage/ErrorPage.tsx
index 0c70e08..6228cb0 100644
--- a/src/components/pages/ErrorPage/ErrorPage.tsx
+++ b/src/components/pages/ErrorPage/ErrorPage.tsx
@@ -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 (
-
+
Oops!
@@ -14,7 +16,7 @@ const ErrorPage: React.FC = () => {
{error.status} {error.data}
)}
-
+
)
}
diff --git a/src/components/pages/MainPage/MainPage.test.tsx b/src/components/pages/MainPage/MainPage.test.tsx
index ef676f6..ac87bdb 100644
--- a/src/components/pages/MainPage/MainPage.test.tsx
+++ b/src/components/pages/MainPage/MainPage.test.tsx
@@ -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 () => {
diff --git a/src/components/pages/PrivacyPage/PrivacyPage.test.tsx b/src/components/pages/PrivacyPage/PrivacyPage.test.tsx
new file mode 100644
index 0000000..13e46d9
--- /dev/null
+++ b/src/components/pages/PrivacyPage/PrivacyPage.test.tsx
@@ -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()
+ 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
+ })
+})
diff --git a/src/components/pages/PrivacyPage/index.tsx b/src/components/pages/PrivacyPage/index.tsx
new file mode 100644
index 0000000..c8111fa
--- /dev/null
+++ b/src/components/pages/PrivacyPage/index.tsx
@@ -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 (
+
+
+
+
+
+ )
+}
+
+export default PrivacyPage
diff --git a/src/components/pages/ProjectsPage/ProjectsPage.test.tsx b/src/components/pages/ProjectsPage/ProjectsPage.test.tsx
index 8fed1d8..8ea6315 100644
--- a/src/components/pages/ProjectsPage/ProjectsPage.test.tsx
+++ b/src/components/pages/ProjectsPage/ProjectsPage.test.tsx
@@ -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', () => {
diff --git a/src/components/templates/Page/Page.test.tsx b/src/components/templates/Page/Page.test.tsx
index 904ecf7..c0d143c 100644
--- a/src/components/templates/Page/Page.test.tsx
+++ b/src/components/templates/Page/Page.test.tsx
@@ -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',
diff --git a/src/components/templates/Page/Page.tsx b/src/components/templates/Page/Page.tsx
index 1793612..875af51 100644
--- a/src/components/templates/Page/Page.tsx
+++ b/src/components/templates/Page/Page.tsx
@@ -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 = ({ title, children, ...props }) => {
+const Page: React.FC = ({ title, children, showCookieConsent, ...props }) => {
React.useEffect(() => {
if (title) {
document.title = `Michele Tartari - ${title}`
@@ -52,12 +56,12 @@ const Page: React.FC = ({ 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}
-
+
)
}
diff --git a/src/components/templates/RouteTracker/index.tsx b/src/components/templates/RouteTracker/index.tsx
new file mode 100644
index 0000000..0d8cfe9
--- /dev/null
+++ b/src/components/templates/RouteTracker/index.tsx
@@ -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
+}
+
+export default RouteTracker
diff --git a/src/components/templates/index.ts b/src/components/templates/index.ts
index c6f64a4..eba39ba 100644
--- a/src/components/templates/index.ts
+++ b/src/components/templates/index.ts
@@ -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'
diff --git a/src/components/utils/analytics/index.ts b/src/components/utils/analytics/index.ts
new file mode 100644
index 0000000..1dee12c
--- /dev/null
+++ b/src/components/utils/analytics/index.ts
@@ -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 = {}) => {
+ analyticsInstance.track(event, payload)
+}
+
+export default analyticsInstance
diff --git a/src/main.tsx b/src/main.tsx
index e40bf2d..fcf4f83 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -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(
-
-
-
-
+
+
+
+
+
+
)
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 11f02fe..c8142b1 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -1 +1,9 @@
///
+
+interface ImportMetaEnv {
+ readonly VITE_GA_MEASUREMENT_ID: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 8e7753a..c0e40be 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -14,7 +14,7 @@
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
+ "allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",