From f2043e406353e3ccb29c39768067e32c95ae8006 Mon Sep 17 00:00:00 2001
From: m-tartari <37861893+m-tartari@users.noreply.github.com>
Date: Fri, 23 May 2025 14:41:38 +0000
Subject: [PATCH 01/14] =?UTF-8?q?=E2=9E=95=20chore(deps):=20analytics=20an?=
=?UTF-8?q?d=20@analytics/google-analytics=20dependencies?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
package-lock.json | 122 ++++++++++++++++++++++++++++++++++++++++++++++
package.json | 2 +
2 files changed, 124 insertions(+)
diff --git a/package-lock.json b/package-lock.json
index 3ee2dee..4ef05c3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,12 +8,14 @@
"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",
@@ -70,6 +72,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 +2501,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",
@@ -2913,6 +3000,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 +3408,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",
diff --git a/package.json b/package.json
index efe6cab..241e2f3 100644
--- a/package.json
+++ b/package.json
@@ -19,12 +19,14 @@
"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",
From 8ed278df190570aec2d2256d9bf02d59cbfcb697 Mon Sep 17 00:00:00 2001
From: m-tartari <37861893+m-tartari@users.noreply.github.com>
Date: Fri, 23 May 2025 14:43:47 +0000
Subject: [PATCH 02/14] =?UTF-8?q?=F0=9F=94=A7=20chore(env):=20add=20`VITE?=
=?UTF-8?q?=5FGA=5FMEASUREMENT=5FID`=20to=20env=20vars?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 1 +
.gitignore | 7 +++++++
src/vite-env.d.ts | 8 ++++++++
3 files changed, 16 insertions(+)
create mode 100644 .env.example
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/.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/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
+}
From 820d43ec3d8e44ddbc3caecc2794b85e9e4b8659 Mon Sep 17 00:00:00 2001
From: m-tartari <37861893+m-tartari@users.noreply.github.com>
Date: Tue, 27 May 2025 09:57:08 +0000
Subject: [PATCH 03/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(Toolbar):?=
=?UTF-8?q?=20convert=20title=20to=20home=20page=20link?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/molecules/Toolbar/index.tsx | 33 +++++++++++++++-------
src/components/templates/Page/Page.tsx | 2 +-
2 files changed, 24 insertions(+), 11 deletions(-)
diff --git a/src/components/molecules/Toolbar/index.tsx b/src/components/molecules/Toolbar/index.tsx
index d7954b1..13be7a6 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,
}}>
-
+
= ({ 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/templates/Page/Page.tsx b/src/components/templates/Page/Page.tsx
index 1793612..c2538ae 100644
--- a/src/components/templates/Page/Page.tsx
+++ b/src/components/templates/Page/Page.tsx
@@ -52,7 +52,7 @@ const Page: React.FC = ({ title, children, ...props }) => {
minHeight: '80vh',
mx: 'auto',
maxWidth: { xs: '100%', md: 'calc(100% - 4em)' },
- width: '70em',
+ width: '80em',
...props.sx,
}}>
{children}
From 18c81f951c7a7b4b49c554c84385cb32e2e06aba Mon Sep 17 00:00:00 2001
From: m-tartari <37861893+m-tartari@users.noreply.github.com>
Date: Wed, 28 May 2025 10:11:20 +0000
Subject: [PATCH 04/14] =?UTF-8?q?=E2=9E=95=20chore(deps):=20add=20use-anal?=
=?UTF-8?q?ytics=20and=20@types/use-analytics=20dependencies?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
package-lock.json | 35 +++++++++++++++++++++++++++++++++++
package.json | 2 ++
2 files changed, 37 insertions(+)
diff --git a/package-lock.json b/package-lock.json
index 4ef05c3..2522cee 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"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"
},
@@ -30,6 +31,7 @@
"@testing-library/user-event": "^14.6.1",
"@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",
@@ -2559,6 +2561,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",
@@ -5495,6 +5508,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",
@@ -5741,6 +5760,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 241e2f3..d7af18b 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"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"
},
@@ -41,6 +42,7 @@
"@testing-library/user-event": "^14.6.1",
"@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",
From 5d1e609b67365e96332a04ec3068698d930ae729 Mon Sep 17 00:00:00 2001
From: m-tartari <37861893+m-tartari@users.noreply.github.com>
Date: Wed, 28 May 2025 10:14:22 +0000
Subject: [PATCH 05/14] =?UTF-8?q?=F0=9F=91=B7=20chore(ci):=20add=20analyti?=
=?UTF-8?q?cs=20patterns=20to=20dependabot=20configs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/dependabot.yml | 6 ++++++
1 file changed, 6 insertions(+)
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'
From 642dff328bb9b727f1d4a5dc4bf882008f7621ae Mon Sep 17 00:00:00 2001
From: m-tartari <37861893+m-tartari@users.noreply.github.com>
Date: Wed, 28 May 2025 10:15:21 +0000
Subject: [PATCH 06/14] =?UTF-8?q?=F0=9F=94=A7=20chore(devcontainer):=20fav?=
=?UTF-8?q?our=20ESM=20import=20notation?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.devcontainer/devcontainer.json | 2 ++
.vscode/settings.json | 6 ++++++
tsconfig.base.json | 2 +-
3 files changed, 9 insertions(+), 1 deletion(-)
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/.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/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",
From fc67e227724aa48fd2c454823d68e70d6597510b Mon Sep 17 00:00:00 2001
From: m-tartari <37861893+m-tartari@users.noreply.github.com>
Date: Wed, 28 May 2025 10:24:53 +0000
Subject: [PATCH 07/14] =?UTF-8?q?=E2=9E=95=20chore(deps-dev):=20add=20@typ?=
=?UTF-8?q?es/node=20dependency?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
package-lock.json | 18 ++++++++++++++++++
package.json | 1 +
2 files changed, 19 insertions(+)
diff --git a/package-lock.json b/package-lock.json
index 2522cee..e426a63 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"@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",
@@ -2523,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",
@@ -5720,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",
diff --git a/package.json b/package.json
index d7af18b..224f072 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"@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",
From bf6d945c61e4cf4e6f365cb36a0a5db8fbd21258 Mon Sep 17 00:00:00 2001
From: m-tartari <37861893+m-tartari@users.noreply.github.com>
Date: Wed, 28 May 2025 10:26:52 +0000
Subject: [PATCH 08/14] =?UTF-8?q?=E2=9C=A8=20feat(utils):=20add=20analytic?=
=?UTF-8?q?s=20util?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/utils/analytics/index.ts | 44 +++++++++++++++++++++++++
1 file changed, 44 insertions(+)
create mode 100644 src/components/utils/analytics/index.ts
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
From e0f90d5d8767d0f9f3019d1e2363f1bfc8ca1354 Mon Sep 17 00:00:00 2001
From: m-tartari <37861893+m-tartari@users.noreply.github.com>
Date: Wed, 28 May 2025 10:29:48 +0000
Subject: [PATCH 09/14] =?UTF-8?q?=E2=9C=A8=20feat(App):=20add=20page=20tra?=
=?UTF-8?q?cking?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/App.test.tsx | 7 ++
src/App.tsx | 68 ++++++++++---------
src/components/molecules/Toolbar/index.tsx | 2 +-
.../templates/RouteTracker/index.tsx | 19 ++++++
src/components/templates/index.ts | 3 +-
src/main.tsx | 19 +++---
6 files changed, 77 insertions(+), 41 deletions(-)
create mode 100644 src/components/templates/RouteTracker/index.tsx
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..047dccd 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,8 +1,8 @@
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'
const MainPage = React.lazy(() => import('components/pages/MainPage'))
const ProjectsPage = React.lazy(() => import('components/pages/ProjectsPage'))
@@ -18,36 +18,42 @@ const Loading = () => (
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: (
+ }>
+
+
+ ),
+ },
+ ],
},
])
diff --git a/src/components/molecules/Toolbar/index.tsx b/src/components/molecules/Toolbar/index.tsx
index 13be7a6..261ec60 100644
--- a/src/components/molecules/Toolbar/index.tsx
+++ b/src/components/molecules/Toolbar/index.tsx
@@ -100,7 +100,7 @@ const CustomToolbar: React.FC = ({ drawerWidth = 240, window
sx={{
display: 'flex',
// maxWidth: '100%',
- // [theme.breakpoints.up('md')]: { width: '80em', mx: 'auto' },
+ // [theme.breakpoints.up('md')]: { width: theme => theme.breakpoints.values.lg, mx: 'auto' },
}}>
{
+ 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/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(
-
-
-
-
+
+
+
+
+
+
)
From 2547572e1cb164f8aac3941cec8e44c3736f0548 Mon Sep 17 00:00:00 2001
From: m-tartari <37861893+m-tartari@users.noreply.github.com>
Date: Wed, 28 May 2025 10:32:29 +0000
Subject: [PATCH 10/14] =?UTF-8?q?=E2=9C=A8=20feat(analytics):=20add=20cook?=
=?UTF-8?q?ie=20consent=20dialog?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../CookieConsent/CookieConsent.test.tsx | 64 +++++++++++
.../molecules/CookieConsent/index.tsx | 100 ++++++++++++++++++
.../molecules/Footer/Footer.test.tsx | 30 ++++++
src/components/molecules/Footer/Footer.tsx | 21 ----
src/components/molecules/Footer/index.ts | 1 -
src/components/molecules/Footer/index.tsx | 52 +++++++++
.../pages/ContactPage/ContactPage.test.tsx | 7 ++
.../CurriculumPage/CurriculumPage.test.tsx | 7 +-
.../pages/ErrorPage/ErrorPage.test.tsx | 9 +-
src/components/pages/ErrorPage/ErrorPage.tsx | 8 +-
.../pages/MainPage/MainPage.test.tsx | 10 +-
.../pages/ProjectsPage/ProjectsPage.test.tsx | 7 ++
src/components/templates/Page/Page.test.tsx | 10 +-
src/components/templates/Page/Page.tsx | 10 +-
14 files changed, 302 insertions(+), 34 deletions(-)
create mode 100644 src/components/molecules/CookieConsent/CookieConsent.test.tsx
create mode 100644 src/components/molecules/CookieConsent/index.tsx
create mode 100644 src/components/molecules/Footer/Footer.test.tsx
delete mode 100644 src/components/molecules/Footer/Footer.tsx
delete mode 100644 src/components/molecules/Footer/index.ts
create mode 100644 src/components/molecules/Footer/index.tsx
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/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..e5ef00f
--- /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 (
+
+
+
+
+
+
+
+
+ & copy; {year} Michele Tartari.All rights reserved.
+
+
+
+
+
+
+
+ Cookie Preferences
+
+
+
+
+
+
+
+ Privacy Policy
+
+
+
+
+
+ {showCookieConsent && }
+
+ )
+}
+
+export default Footer
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/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 c2538ae..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: '80em',
+ width: theme => theme.breakpoints.values.lg,
...props.sx,
}}>
{children}
-
+
)
}
From 585aed9233a33b434611f19ed022078b9cd9efce Mon Sep 17 00:00:00 2001
From: m-tartari <37861893+m-tartari@users.noreply.github.com>
Date: Wed, 28 May 2025 10:35:44 +0000
Subject: [PATCH 11/14] =?UTF-8?q?=E2=9C=A8=20feat(analytics):=20add=20`Pri?=
=?UTF-8?q?vacyPolicy`=20page?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/App.tsx | 17 ++-
src/components/atoms/ListItemText/index.tsx | 18 +++
.../CookiePreferences.test.tsx | 89 +++++++++++
.../molecules/CookiePreferences/index.tsx | 141 ++++++++++++++++++
.../PrivacyPolicy/PrivacyPolicy.test.tsx | 19 +++
.../molecules/PrivacyPolicy/index.tsx | 125 ++++++++++++++++
.../pages/PrivacyPage/PrivacyPage.test.tsx | 24 +++
src/components/pages/PrivacyPage/index.tsx | 18 +++
8 files changed, 447 insertions(+), 4 deletions(-)
create mode 100644 src/components/atoms/ListItemText/index.tsx
create mode 100644 src/components/molecules/CookiePreferences/CookiePreferences.test.tsx
create mode 100644 src/components/molecules/CookiePreferences/index.tsx
create mode 100644 src/components/molecules/PrivacyPolicy/PrivacyPolicy.test.tsx
create mode 100644 src/components/molecules/PrivacyPolicy/index.tsx
create mode 100644 src/components/pages/PrivacyPage/PrivacyPage.test.tsx
create mode 100644 src/components/pages/PrivacyPage/index.tsx
diff --git a/src/App.tsx b/src/App.tsx
index 047dccd..d2f4f42 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,10 +4,11 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import ErrorPage from 'components/pages/ErrorPage/index.js'
import RootLayout from 'components/templates/RouteTracker/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 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 Loading = () => (
@@ -53,6 +54,14 @@ const router = createBrowserRouter([
),
},
+ {
+ 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/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/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/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
From ece4c49ab9821a55c2825f3374f0a22400baaa45 Mon Sep 17 00:00:00 2001
From: m-tartari <37861893+m-tartari@users.noreply.github.com>
Date: Wed, 28 May 2025 10:36:44 +0000
Subject: [PATCH 12/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(App):=20mov?=
=?UTF-8?q?e=20loading=20screen=20to=20standalone=20component?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/App.tsx | 7 +------
src/components/molecules/LoadingScreen/index.tsx | 9 +++++++++
2 files changed, 10 insertions(+), 6 deletions(-)
create mode 100644 src/components/molecules/LoadingScreen/index.tsx
diff --git a/src/App.tsx b/src/App.tsx
index d2f4f42..a429bed 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,6 +3,7 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom'
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/index.js'))
const ProjectsPage = React.lazy(() => import('components/pages/ProjectsPage/index.js'))
@@ -10,12 +11,6 @@ const CurriculumPage = React.lazy(() => import('components/pages/CurriculumPage/
const ContactPage = React.lazy(() => import('components/pages/ContactPage/index.js'))
const PrivacyPage = React.lazy(() => import('components/pages/PrivacyPage/index.js'))
-const Loading = () => (
-
-
-
-)
-
const router = createBrowserRouter([
{
path: '/',
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
From 0b0b875b305d192e83439124529bc523ef9f8870 Mon Sep 17 00:00:00 2001
From: m-tartari <37861893+m-tartari@users.noreply.github.com>
Date: Wed, 28 May 2025 12:40:06 +0000
Subject: [PATCH 13/14] =?UTF-8?q?=F0=9F=91=B7=20chore(ci):=20set=20VITE=5F?=
=?UTF-8?q?GA=5FMEASUREMENT=5FID=20from=20secrets=20on=20deploy?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/deploy.yml | 3 +++
1 file changed, 3 insertions(+)
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
From db1dcb6fc554752b4c00fbb50361bad1e4a944d3 Mon Sep 17 00:00:00 2001
From: m-tartari <37861893+m-tartari@users.noreply.github.com>
Date: Wed, 28 May 2025 12:50:08 +0000
Subject: [PATCH 14/14] =?UTF-8?q?=F0=9F=90=9B=20fix(footer):=20fix=20copyr?=
=?UTF-8?q?ight=20symbol=20and=20formatting?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/molecules/Footer/index.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/molecules/Footer/index.tsx b/src/components/molecules/Footer/index.tsx
index e5ef00f..4c3c915 100644
--- a/src/components/molecules/Footer/index.tsx
+++ b/src/components/molecules/Footer/index.tsx
@@ -22,7 +22,7 @@ const Footer: React.FC = ({ showCookieConsent = true }) => {
- & copy; {year} Michele Tartari.All rights reserved.
+ © {year} Michele Tartari. All rights reserved.