Merge pull request #480 from m-tartari/feat/GA4
✨ feat(analytics): add GA4
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||
@@ -45,3 +45,9 @@ updates:
|
||||
patterns:
|
||||
- 'lodash*'
|
||||
- '@types/lodash*'
|
||||
analytics:
|
||||
patterns:
|
||||
- 'analytics'
|
||||
- '@analytics/*'
|
||||
- 'use-analytics'
|
||||
- '@types/use-analytics'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+6
@@ -69,4 +69,10 @@
|
||||
"**/node_modules"
|
||||
],
|
||||
"vitest.enable": true,
|
||||
"javascript.preferences.importModuleSpecifierEnding": "js",
|
||||
"typescript.preferences.importModuleSpecifierEnding": "js",
|
||||
"cSpell.words": [
|
||||
"SRDF",
|
||||
"URDF"
|
||||
],
|
||||
}
|
||||
|
||||
Generated
+175
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { useAnalytics } from 'use-analytics'
|
||||
|
||||
import App from './App'
|
||||
|
||||
// Mock the analytics module
|
||||
vi.mock('use-analytics', () => ({
|
||||
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() }, page: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('App component', () => {
|
||||
test('renders MainPage without crashing', async () => {
|
||||
render(<App />)
|
||||
|
||||
+51
-41
@@ -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 = () => (
|
||||
<Backdrop open={true} aria-label="loading-screen">
|
||||
<CircularProgress />
|
||||
</Backdrop>
|
||||
)
|
||||
const MainPage = React.lazy(() => import('components/pages/MainPage/index.js'))
|
||||
const ProjectsPage = React.lazy(() => import('components/pages/ProjectsPage/index.js'))
|
||||
const CurriculumPage = React.lazy(() => import('components/pages/CurriculumPage/index.js'))
|
||||
const ContactPage = React.lazy(() => import('components/pages/ContactPage/index.js'))
|
||||
const PrivacyPage = React.lazy(() => import('components/pages/PrivacyPage/index.js'))
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<React.Suspense fallback={<Loading />}>
|
||||
<MainPage />
|
||||
</React.Suspense>
|
||||
),
|
||||
element: <RootLayout />,
|
||||
errorElement: <ErrorPage />,
|
||||
},
|
||||
{
|
||||
path: '/projects',
|
||||
element: (
|
||||
<React.Suspense fallback={<Loading />}>
|
||||
<ProjectsPage />
|
||||
</React.Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/curriculum',
|
||||
element: (
|
||||
<React.Suspense fallback={<Loading />}>
|
||||
<CurriculumPage />
|
||||
</React.Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/contacts',
|
||||
element: (
|
||||
<React.Suspense fallback={<Loading />}>
|
||||
<ContactPage />
|
||||
</React.Suspense>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: (
|
||||
<React.Suspense fallback={<Loading />}>
|
||||
<MainPage />
|
||||
</React.Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'projects',
|
||||
element: (
|
||||
<React.Suspense fallback={<Loading />}>
|
||||
<ProjectsPage />
|
||||
</React.Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'curriculum',
|
||||
element: (
|
||||
<React.Suspense fallback={<Loading />}>
|
||||
<CurriculumPage />
|
||||
</React.Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'contacts',
|
||||
element: (
|
||||
<React.Suspense fallback={<Loading />}>
|
||||
<ContactPage />
|
||||
</React.Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'privacy',
|
||||
element: (
|
||||
<React.Suspense fallback={<Loading />}>
|
||||
<PrivacyPage />
|
||||
</React.Suspense>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import { ListItem, Typography, ListItemProps } from '@mui/material'
|
||||
|
||||
interface ListItemTextProps extends ListItemProps {
|
||||
title?: string
|
||||
}
|
||||
const ListItemText: React.FC<ListItemTextProps> = ({ title, children, ...props }) => (
|
||||
<ListItem component={Typography} color="text.secondary" {...props}>
|
||||
{title && (
|
||||
<Typography component="b" fontWeight={700} color="text.primary">
|
||||
{title}{' '}
|
||||
</Typography>
|
||||
)}
|
||||
{children}
|
||||
</ListItem>
|
||||
)
|
||||
|
||||
export default ListItemText
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { useAnalytics } from 'use-analytics'
|
||||
|
||||
import CookieConsent from './index.js'
|
||||
|
||||
const analytics = { plugins: { enable: vi.fn(), disable: vi.fn() } }
|
||||
|
||||
// Mock the analytics module
|
||||
vi.mock('use-analytics', () => ({
|
||||
useAnalytics: () => analytics,
|
||||
}))
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {}
|
||||
}),
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
|
||||
describe('CookieConsent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorageMock.clear()
|
||||
})
|
||||
|
||||
it('renders the cookie consent dialog when no consent is stored', () => {
|
||||
render(<CookieConsent />)
|
||||
|
||||
expect(screen.getByText('Cookie Preferences')).toBeInTheDocument()
|
||||
expect(screen.getByText(/We use cookies to improve your browsing experience/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Accept All')).toBeInTheDocument()
|
||||
expect(screen.getByText('Reject All')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('accepts all cookies when clicking Accept All', () => {
|
||||
render(<CookieConsent />)
|
||||
|
||||
fireEvent.click(screen.getByText('Accept All'))
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ analytics: true, necessary: true }))
|
||||
expect(analytics.plugins.disable).not.toHaveBeenCalled()
|
||||
expect(analytics.plugins.enable).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects analytics cookies when clicking Reject All', () => {
|
||||
render(<CookieConsent />)
|
||||
|
||||
fireEvent.click(screen.getByText('Reject All'))
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ analytics: false, necessary: true }))
|
||||
expect(analytics.plugins.enable).not.toHaveBeenCalled()
|
||||
expect(analytics.plugins.disable).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Box, Button, Link, Stack, Typography, Unstable_TrapFocus as TrapFocus, Paper, Fade } from '@mui/material'
|
||||
import { useAnalytics } from 'use-analytics'
|
||||
|
||||
import { ANALYTICS_PLUGINS, COOKIE_CONSENT_KEY } from 'components/utils/analytics/index.js'
|
||||
|
||||
interface Consent {
|
||||
necessary: boolean // Always true, but included for completeness
|
||||
analytics: boolean
|
||||
}
|
||||
|
||||
const CookieConsent = () => {
|
||||
const [open, setOpen] = useState<boolean>(false)
|
||||
const analytics = useAnalytics()
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already given consent
|
||||
const savedConsent = localStorage.getItem(COOKIE_CONSENT_KEY)
|
||||
|
||||
if (!savedConsent) {
|
||||
setOpen(true)
|
||||
} else {
|
||||
const parsedConsent = JSON.parse(savedConsent) as Consent
|
||||
if (parsedConsent.analytics) {
|
||||
analytics.plugins.enable(ANALYTICS_PLUGINS)
|
||||
}
|
||||
}
|
||||
}, [analytics])
|
||||
|
||||
const handleAcceptAll = useCallback(() => {
|
||||
localStorage.setItem(COOKIE_CONSENT_KEY, JSON.stringify({ analytics: true, necessary: true }))
|
||||
analytics.plugins.enable(ANALYTICS_PLUGINS)
|
||||
setOpen(false)
|
||||
}, [analytics.plugins])
|
||||
|
||||
const handleRejectAll = useCallback(() => {
|
||||
localStorage.setItem(COOKIE_CONSENT_KEY, JSON.stringify({ analytics: false, necessary: true }))
|
||||
analytics.plugins.disable(ANALYTICS_PLUGINS)
|
||||
setOpen(false)
|
||||
}, [analytics.plugins])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TrapFocus open disableAutoFocus disableEnforceFocus>
|
||||
<Fade appear={false} in={open}>
|
||||
<Paper
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
// aria-label="Cookie banner"
|
||||
aria-labelledby="cookie-consent-dialog-title"
|
||||
aria-describedby="cookie-consent-dialog-description"
|
||||
variant="outlined"
|
||||
tabIndex={-1}
|
||||
sx={theme => ({
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, 0)',
|
||||
width: theme.breakpoints.values.sm,
|
||||
maxWidth: `calc(100% - ${theme.spacing(2)})`,
|
||||
mb: theme.spacing(1),
|
||||
p: theme.spacing(3),
|
||||
zIndex: 9999,
|
||||
})}>
|
||||
<Box>
|
||||
<Typography id="cookie-consent-dialog-title" variant="h6" component="h2" fontWeight="bold" gutterBottom>
|
||||
Cookie Preferences
|
||||
</Typography>
|
||||
<Typography id="cookie-consent-dialog-description" variant="body2" color="textSecondary">
|
||||
<span style={{ display: 'inline-block' }}>We use cookies to improve your browsing experience and analyze our traffic.</span>
|
||||
<span style={{ display: 'inline-block' }}>By clicking "Accept All", you consent to our use of cookies.</span>
|
||||
<span style={{ display: 'inline-block' }}>
|
||||
For more details, please see our{' '}
|
||||
<Link href="/privacy" color="textPrimary">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" sx={{ mt: 2, flexGrow: 0, flexShrink: 0, alignItems: 'center', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Link href="/privacy#cookie-preferences" color="textPrimary">
|
||||
Customize
|
||||
</Link>
|
||||
<Button onClick={handleRejectAll} size="small" color="inherit" variant="outlined">
|
||||
Reject All
|
||||
</Button>
|
||||
<Button onClick={handleAcceptAll} size="small" color="inherit" variant="outlined">
|
||||
Accept All
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Fade>
|
||||
</TrapFocus>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CookieConsent
|
||||
@@ -0,0 +1,89 @@
|
||||
import { screen, fireEvent } from '@testing-library/react'
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { useAnalytics } from 'use-analytics'
|
||||
|
||||
import { renderWithRouter as render } from 'components/utils/tests/index.js'
|
||||
|
||||
import CookiePreferences from './index.js'
|
||||
|
||||
// Mock the analytics module
|
||||
const analytics = { plugins: { enable: vi.fn(), disable: vi.fn() } }
|
||||
vi.mock('use-analytics', () => ({
|
||||
useAnalytics: () => analytics,
|
||||
}))
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {}
|
||||
}),
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
|
||||
describe('CookiePreferences', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorageMock.clear()
|
||||
})
|
||||
|
||||
it('accepts all cookies when clicking Accept All', () => {
|
||||
render(<CookiePreferences />)
|
||||
|
||||
expect(screen.getByText('Cookie Preferences')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Accept All'))
|
||||
fireEvent.click(screen.getByText('Save Preferences'))
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ necessary: true, analytics: true }))
|
||||
expect(analytics.plugins.disable).not.toHaveBeenCalled()
|
||||
expect(analytics.plugins.enable).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects analytics cookies when clicking Reject All', () => {
|
||||
render(<CookiePreferences />)
|
||||
|
||||
expect(screen.getByText('Cookie Preferences')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Reject All'))
|
||||
fireEvent.click(screen.getByText('Save Preferences'))
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ necessary: true, analytics: false }))
|
||||
expect(analytics.plugins.enable).not.toHaveBeenCalled()
|
||||
expect(analytics.plugins.disable).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles analytics cookies when clicking the Analytics button', () => {
|
||||
render(<CookiePreferences />)
|
||||
|
||||
expect(screen.getByText('Cookie Preferences')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('analytics-cookies-toggle'))
|
||||
fireEvent.click(screen.getByText('Save Preferences'))
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ necessary: true, analytics: true }))
|
||||
expect(analytics.plugins.disable).not.toHaveBeenCalled()
|
||||
expect(analytics.plugins.enable).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the cookie consent in the right state when consent in local storage', () => {
|
||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify({ necessary: true, analytics: true }))
|
||||
render(<CookiePreferences />)
|
||||
|
||||
expect(screen.getByText('Cookie Preferences')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('analytics-cookies-toggle'))
|
||||
fireEvent.click(screen.getByText('Save Preferences'))
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ necessary: true, analytics: false }))
|
||||
expect(analytics.plugins.enable).not.toHaveBeenCalled()
|
||||
expect(analytics.plugins.disable).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import React from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Box, Button, Link, Stack, Switch, Typography, SnackbarCloseReason, Snackbar, Alert } from '@mui/material'
|
||||
import { useAnalytics } from 'use-analytics'
|
||||
|
||||
import { ANALYTICS_PLUGINS, COOKIE_CONSENT_KEY } from 'components/utils/analytics/index.js'
|
||||
|
||||
interface CookieConsentState {
|
||||
necessary: boolean // Always true, but included for completeness
|
||||
analytics: boolean
|
||||
}
|
||||
|
||||
export type CookieConsentProps = {
|
||||
consentDisplayDefaultOpen?: boolean
|
||||
}
|
||||
|
||||
const CookieConsent: React.FC<CookieConsentProps> = ({ consentDisplayDefaultOpen = true }) => {
|
||||
const analytics = useAnalytics()
|
||||
const [consent, setConsent] = useState<CookieConsentState>({
|
||||
necessary: true,
|
||||
analytics: false,
|
||||
})
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const handleClose = (_event: React.SyntheticEvent | Event, reason?: SnackbarCloseReason) => {
|
||||
if (reason === 'clickaway') {
|
||||
return
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already given consent
|
||||
const savedConsent = localStorage.getItem(COOKIE_CONSENT_KEY)
|
||||
|
||||
if (savedConsent) {
|
||||
const parsedConsent = JSON.parse(savedConsent) as CookieConsentState
|
||||
setConsent(parsedConsent)
|
||||
}
|
||||
}, [analytics.plugins, consentDisplayDefaultOpen])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
localStorage.setItem(COOKIE_CONSENT_KEY, JSON.stringify(consent))
|
||||
|
||||
// Initialize GA if analytics consent was given
|
||||
if (consent.analytics) {
|
||||
analytics.plugins.enable(ANALYTICS_PLUGINS)
|
||||
} else {
|
||||
analytics.plugins.disable(ANALYTICS_PLUGINS)
|
||||
}
|
||||
setOpen(true)
|
||||
}, [consent, analytics.plugins])
|
||||
|
||||
const handleChange = useCallback((type: keyof CookieConsentState) => {
|
||||
if (type === 'necessary') {
|
||||
return
|
||||
}
|
||||
|
||||
setConsent(prev => ({
|
||||
...prev,
|
||||
[type]: !prev[type],
|
||||
}))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box id="cookie-preferences" aria-labelledby="cookie-preferences-title" aria-describedby="cookie-preferences-description">
|
||||
<Typography id="cookie-preferences-title" aria-label="cookie-preferences" variant="h3" component="h2" gutterBottom>
|
||||
Cookie Preferences
|
||||
</Typography>
|
||||
<Typography id="cookie-preferences-description" color="textSecondary" flexGrow={1}>
|
||||
Please set your preferences below. You can change these settings at any time in the future.
|
||||
<br />
|
||||
For more details, please see our{' '}
|
||||
<Link href="/privacy" color="textPrimary">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={theme => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
my: theme.spacing(2),
|
||||
p: theme.spacing(4),
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
})}>
|
||||
<Stack direction="row" alignSelf="flex-end" mb={-2}>
|
||||
<Button onClick={() => setConsent({ necessary: true, analytics: false })} color="inherit" variant="text" sx={{ textTransform: 'none' }}>
|
||||
Reject All
|
||||
</Button>
|
||||
<Button onClick={() => setConsent({ necessary: true, analytics: true })} color="inherit" variant="text" sx={{ textTransform: 'none' }}>
|
||||
Accept All
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" alignContent="center">
|
||||
<Typography variant="body1" fontWeight="bold">
|
||||
Necessary Cookies
|
||||
</Typography>
|
||||
<Switch slotProps={{ input: { 'aria-label': 'necessary-cookies-toggle' } }} color="default" disabled checked />
|
||||
</Stack>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
These cookies are essential for the website to function properly and cannot be disabled.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" alignContent="center">
|
||||
<Typography variant="body1" fontWeight="bold">
|
||||
Analytics Cookies
|
||||
</Typography>
|
||||
<Switch
|
||||
checked={consent.analytics}
|
||||
slotProps={{ input: { 'aria-label': 'analytics-cookies-toggle' } }}
|
||||
onChange={() => handleChange('analytics')}
|
||||
color="default"
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
These cookies help us understand how visitors interact with our website, helping us improve our services.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Button onClick={handleSave} variant="outlined" sx={{ alignSelf: 'flex-end' }}>
|
||||
Save Preferences
|
||||
</Button>
|
||||
</Box>
|
||||
<Snackbar open={open} autoHideDuration={3000} onClose={handleClose}>
|
||||
<Alert onClose={handleClose} sx={{ backgroundColor: 'background.paper', width: '100%' }}>
|
||||
Preferences saved!
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default CookieConsent
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { screen } from '@testing-library/react'
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { useAnalytics } from 'use-analytics'
|
||||
|
||||
import { renderWithRouter as render } from 'components/utils/tests/index.js'
|
||||
|
||||
import Footer from './index.js'
|
||||
|
||||
// Mock the analytics module
|
||||
const analytics = { plugins: { enable: vi.fn(), disable: vi.fn() } }
|
||||
vi.mock('use-analytics', () => ({
|
||||
useAnalytics: () => analytics,
|
||||
}))
|
||||
|
||||
describe('Footer', () => {
|
||||
it('renders the Footer', async () => {
|
||||
render(<Footer />)
|
||||
|
||||
expect(screen.getAllByText('Privacy Policy')).toHaveLength(2) // link and dialog
|
||||
expect(screen.getAllByText('Cookie Preferences')).toHaveLength(2) // link and dialog
|
||||
})
|
||||
|
||||
it('does not show the dialog if the `showCookieConsent` prop is false', () => {
|
||||
render(<Footer showCookieConsent={false} />)
|
||||
|
||||
expect(screen.getAllByText('Privacy Policy')).toHaveLength(1) // link only
|
||||
expect(screen.getAllByText('Cookie Preferences')).toHaveLength(1) // link only
|
||||
})
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Box, Stack, Typography } from '@mui/material'
|
||||
|
||||
import { EmailIconLink, GitHubIconLink, LinkedInIconLink } from 'components/atoms'
|
||||
|
||||
const Footer = () => {
|
||||
const year = new Date().getFullYear()
|
||||
return (
|
||||
<Box sx={{ bgcolor: 'background.paper', p: 6, display: 'flex', alignItems: 'center', flexDirection: 'column' }} component="footer">
|
||||
<Stack direction="row" gap={2}>
|
||||
<LinkedInIconLink fontSize="large" href="https://www.linkedin.com/in/m-tartari/" />
|
||||
<GitHubIconLink fontSize="large" href="https://www.github.com/m-tartari/" />
|
||||
<EmailIconLink fontSize="large" href="mailto:info@m-tartari.eu" />
|
||||
</Stack>
|
||||
<Typography variant="subtitle2" align="center" color="text.secondary" component="em" mt={3}>
|
||||
© {year} Michele Tartari. All rights reserved.
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './Footer'
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { Box, Link, Stack, Typography } from '@mui/material'
|
||||
import PolicyIcon from '@mui/icons-material/Policy'
|
||||
import CookieIcon from '@mui/icons-material/Cookie'
|
||||
|
||||
import { EmailIconLink, GitHubIconLink, LinkedInIconLink } from 'components/atoms'
|
||||
import CookieConsent from 'components/molecules/CookieConsent/index.js'
|
||||
|
||||
type FooterProps = {
|
||||
showCookieConsent?: boolean
|
||||
}
|
||||
|
||||
const Footer: React.FC<FooterProps> = ({ showCookieConsent = true }) => {
|
||||
const year = new Date().getFullYear()
|
||||
return (
|
||||
<Box sx={{ bgcolor: 'background.paper', p: 6, display: 'flex', alignItems: 'center', flexDirection: 'column' }} component="footer">
|
||||
<Stack direction="row" gap={2}>
|
||||
<LinkedInIconLink fontSize="large" href="https://www.linkedin.com/in/m-tartari/" />
|
||||
<GitHubIconLink fontSize="large" href="https://www.github.com/m-tartari/" />
|
||||
<EmailIconLink fontSize="large" href="mailto:info@m-tartari.eu" />
|
||||
</Stack>
|
||||
|
||||
<Typography variant="subtitle2" align="center" color="text.secondary" component="em" mt={3}>
|
||||
© {year} Michele Tartari. All rights reserved.
|
||||
</Typography>
|
||||
|
||||
<Stack mt={1}>
|
||||
<Link component={RouterLink} to="/privacy#cookie-preferences" color="textSecondary">
|
||||
<Stack direction="row" alignItems="center" justifyContent="center" gap={1}>
|
||||
<CookieIcon fontSize="inherit" />
|
||||
<Typography variant="subtitle2" component="span">
|
||||
Cookie Preferences
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/privacy" color="textSecondary">
|
||||
<Stack direction="row" alignItems="center" justifyContent="center" gap={1}>
|
||||
<PolicyIcon fontSize="inherit" />
|
||||
<Typography variant="subtitle2" component="span">
|
||||
Privacy Policy
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Link>
|
||||
</Stack>
|
||||
|
||||
{showCookieConsent && <CookieConsent />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Backdrop, CircularProgress } from '@mui/material'
|
||||
|
||||
const Loading = () => (
|
||||
<Backdrop open={true} aria-label="loading-screen">
|
||||
<CircularProgress />
|
||||
</Backdrop>
|
||||
)
|
||||
|
||||
export default Loading
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
import PrivacyPolicy from './index.js'
|
||||
|
||||
describe('Privacy Policy', () => {
|
||||
it('renders the Privacy Policy page', async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PrivacyPolicy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
expect(screen.getByText('Privacy Policy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Google Analytics')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your Rights')).toBeInTheDocument()
|
||||
expect(screen.getByText('Contact Us')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react'
|
||||
import { Typography, Link, List as MuiList, styled, Box } from '@mui/material'
|
||||
|
||||
import ListItemText from 'components/atoms/ListItemText/index.js'
|
||||
|
||||
const List = styled(MuiList)(({ theme }) => ({
|
||||
listStyleType: 'disc',
|
||||
paddingLeft: theme.spacing(4),
|
||||
'& .MuiListItem-root': {
|
||||
display: 'list-item',
|
||||
padding: 0,
|
||||
},
|
||||
}))
|
||||
|
||||
const PrivacyPolicy: React.FC = () => {
|
||||
return (
|
||||
<Box aria-labelledby="privacy-policy">
|
||||
<Typography id="privacy-policy" aria-label="privacy-policy" variant="h3" component="h2" gutterBottom>
|
||||
Privacy Policy
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="textSecondary" component="p" pb={4}>
|
||||
Last updated: May 27, 2025
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Introduction
|
||||
</Typography>
|
||||
<Typography component="p" color="textSecondary">
|
||||
m-tartari ("we", "our", or "us") respects your privacy and is committed to protecting your personal data. This privacy policy will inform you
|
||||
about how we look after your personal data when you visit our website and tell you about your privacy rights and how the law protects you.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h5" component="h2" gutterBottom sx={{ mt: 4 }}>
|
||||
The Data We Collect About You
|
||||
</Typography>
|
||||
<Typography component="p" color="textSecondary">
|
||||
We collect and process the following information when you visit our website:
|
||||
</Typography>
|
||||
<List dense>
|
||||
<ListItemText title="Technical Data">
|
||||
includes internet protocol (IP) address, browser type and version, time zone setting and location, browser plug-in types and versions,
|
||||
operating system and platform, and other technology on the devices you use to access this website.
|
||||
</ListItemText>
|
||||
<ListItemText title="Usage Data"> includes information about how you use our website, products, and services.</ListItemText>
|
||||
</List>
|
||||
|
||||
<Typography variant="h5" component="h2" gutterBottom sx={{ mt: 4 }}>
|
||||
Cookies
|
||||
</Typography>
|
||||
<Typography component="p" color="textSecondary">
|
||||
We use cookies to distinguish you from other users of our website. This helps us to provide you with a good experience when you browse our
|
||||
website and also allows us to improve our site.
|
||||
</Typography>
|
||||
<Typography component="p" color="textSecondary">
|
||||
We use the following types of cookies:
|
||||
</Typography>
|
||||
<List dense>
|
||||
<ListItemText title="Necessary Cookies:">
|
||||
These are essential to enable you to use the website and its features. Without these cookies, services you have asked for cannot be
|
||||
provided.
|
||||
</ListItemText>
|
||||
<ListItemText title="Analytics Cookies:">
|
||||
We use Google Analytics to help us understand how visitors interact with our website. These cookies collect information about your visit to
|
||||
our website, the content you viewed, the links you followed, and information about your browser, device, and IP address. We do this to
|
||||
improve how our website works.
|
||||
</ListItemText>
|
||||
</List>
|
||||
|
||||
<Typography variant="h5" component="h2" gutterBottom sx={{ mt: 4 }}>
|
||||
Google Analytics
|
||||
</Typography>
|
||||
<Typography component="p" color="textSecondary">
|
||||
This website uses Google Analytics 4, a web analytics service provided by Google, Inc. ("Google"). Google Analytics uses cookies to help the
|
||||
website analyze how users use the site. The information generated by the cookie about your use of the website will be transmitted to and
|
||||
stored by Google on servers in the United States.
|
||||
</Typography>
|
||||
<Typography component="p" color="textSecondary">
|
||||
We have implemented the following measures to be GDPR compliant:
|
||||
</Typography>
|
||||
<List dense>
|
||||
<ListItemText>We have obtained your consent before processing your data through cookies</ListItemText>
|
||||
<ListItemText>We have implemented IP address anonymization</ListItemText>
|
||||
<ListItemText>We have disabled data sharing with Google</ListItemText>
|
||||
<ListItemText>We have set the data retention period to the minimum required for our analysis</ListItemText>
|
||||
</List>
|
||||
<Typography component="p" color="textSecondary">
|
||||
You can prevent the collection of your data by Google Analytics by declining analytics cookies in our cookie consent banner or by using the
|
||||
Google Analytics Opt-out Browser Add-on available at{' '}
|
||||
<Link color="textPrimary" href="https://tools.google.com/dlpage/gaoptout" target="_blank" rel="noopener noreferrer">
|
||||
https://tools.google.com/dlpage/gaoptout
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h5" component="h2" gutterBottom sx={{ mt: 4 }}>
|
||||
Your Rights
|
||||
</Typography>
|
||||
<Typography component="p" color="textSecondary">
|
||||
Under the General Data Protection Regulation (GDPR), you have various rights in relation to your personal data, including:
|
||||
</Typography>
|
||||
|
||||
<List dense>
|
||||
<ListItemText>The right to access your personal data</ListItemText>
|
||||
<ListItemText>The right to rectification of your personal data</ListItemText>
|
||||
<ListItemText>The right to erasure of your personal data</ListItemText>
|
||||
<ListItemText>The right to restrict processing of your personal data</ListItemText>
|
||||
<ListItemText>The right to data portability</ListItemText>
|
||||
<ListItemText>The right to object to processing of your personal data</ListItemText>
|
||||
</List>
|
||||
|
||||
<Typography variant="h5" component="h2" gutterBottom sx={{ mt: 4 }}>
|
||||
Contact Us
|
||||
</Typography>
|
||||
<Typography component="p" color="textSecondary">
|
||||
If you have any questions about this privacy policy or our privacy practices, please contact us at{' '}
|
||||
<Link color="textPrimary" href="mailto:privacy@m-tartari.eu" target="_blank" rel="noopener noreferrer">
|
||||
privacy@m-tartari.eu
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default PrivacyPolicy
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Typography,
|
||||
styled,
|
||||
useScrollTrigger,
|
||||
useTheme,
|
||||
} from '@mui/material'
|
||||
import MenuIcon from '@mui/icons-material/Menu'
|
||||
|
||||
@@ -43,6 +44,7 @@ interface CustomToolbarProps extends AppBarProps {
|
||||
}
|
||||
const CustomToolbar: React.FC<CustomToolbarProps> = ({ drawerWidth = 240, window, ...props }) => {
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false)
|
||||
const theme = useTheme()
|
||||
|
||||
const trigger = useScrollTrigger({
|
||||
disableHysteresis: true,
|
||||
@@ -66,11 +68,6 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({ drawerWidth = 240, window
|
||||
<ListItemText primary="Home" />
|
||||
</DrawerButton>
|
||||
</ListItem>
|
||||
{/* <ListItem disablePadding>
|
||||
<DrawerButton component={RouterLink} to="/">
|
||||
<Typography variant="h6">Home</Typography>
|
||||
</DrawerButton>
|
||||
</ListItem> */}
|
||||
<ListItem key="Projects" disablePadding>
|
||||
<DrawerButton component={RouterLink} to="/projects">
|
||||
<ListItemText primary="Projects" />
|
||||
@@ -96,10 +93,15 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({ drawerWidth = 240, window
|
||||
elevation={0}
|
||||
{...props}
|
||||
sx={{
|
||||
backgroundColor: trigger ? theme => (theme.palette.mode === 'dark' ? 'background.paper' : undefined) : 'transparent',
|
||||
backgroundColor: trigger ? (theme.palette.mode === 'dark' ? 'background.paper' : undefined) : 'transparent',
|
||||
...props.sx,
|
||||
}}>
|
||||
<Toolbar sx={{ display: 'flex' }}>
|
||||
<Toolbar
|
||||
sx={{
|
||||
display: 'flex',
|
||||
// maxWidth: '100%',
|
||||
// [theme.breakpoints.up('md')]: { width: theme => theme.breakpoints.values.lg, mx: 'auto' },
|
||||
}}>
|
||||
<IconButton
|
||||
color="primary"
|
||||
aria-label="open drawer"
|
||||
@@ -108,9 +110,20 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({ drawerWidth = 240, window
|
||||
sx={{ mr: 2, display: { md: 'none' }, position: 'absolute', backgroundColor: 'primary' }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1, textAlign: { xs: 'center', md: 'left' } }}>
|
||||
Michele Tartari
|
||||
</Typography>
|
||||
<AppBarButton
|
||||
component={RouterLink}
|
||||
to="/"
|
||||
disableRipple
|
||||
sx={theme => ({
|
||||
[theme.breakpoints.down('md')]: {
|
||||
mx: 'auto',
|
||||
},
|
||||
[theme.breakpoints.up('md')]: {
|
||||
mr: 'auto',
|
||||
},
|
||||
})}>
|
||||
<Typography variant="h6"> Michele Tartari</Typography>
|
||||
</AppBarButton>
|
||||
<Stack component="div" direction="row" spacing={2} sx={{ display: { xs: 'none', md: 'flex' } }}>
|
||||
<AppBarButton component={RouterLink} to="/">
|
||||
<Typography variant="h6">Home</Typography>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { useAnalytics } from 'use-analytics'
|
||||
|
||||
import { renderWithRouter as render } from 'components/utils/tests/index.js'
|
||||
import ContactPage from './index.js'
|
||||
|
||||
// Mock the analytics module
|
||||
vi.mock('use-analytics', () => ({
|
||||
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
|
||||
}))
|
||||
|
||||
vi.stubGlobal('open', vi.fn())
|
||||
describe('ContactPage', () => {
|
||||
test('renders without crashing and displays the correct contact methods', () => {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { useAnalytics } from 'use-analytics'
|
||||
|
||||
import { renderWithRouter as render } from 'components/utils/tests'
|
||||
|
||||
import CurriculumPage from '.'
|
||||
import CurriculumPage from './index.js'
|
||||
|
||||
vi.stubGlobal('open', vi.fn())
|
||||
vi.mock('use-analytics', () => ({
|
||||
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
|
||||
}))
|
||||
|
||||
describe('Curriculum component', () => {
|
||||
const open = vi.spyOn(window, 'open')
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { screen, cleanup, render } from '@testing-library/react'
|
||||
import { RouterProvider, createMemoryRouter } from 'react-router-dom'
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { useAnalytics } from 'use-analytics'
|
||||
|
||||
import ErrorPage from '.'
|
||||
import ErrorPage from './index.js'
|
||||
|
||||
// Mock the analytics module
|
||||
vi.mock('use-analytics', () => ({
|
||||
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
|
||||
}))
|
||||
|
||||
// Tests
|
||||
describe('Renders ErrorPage correctly', async () => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { isRouteErrorResponse, useRouteError } from 'react-router-dom'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { Typography } from '@mui/material'
|
||||
|
||||
import { Page } from 'components/templates/index.js'
|
||||
|
||||
const ErrorPage: React.FC = () => {
|
||||
const error: unknown = useRouteError()
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" justifyContent="center" alignItems="center" minHeight="100vh">
|
||||
<Page sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', minHeight: '90vh' }}>
|
||||
<Typography variant="h1" color="primary.main">
|
||||
Oops!
|
||||
</Typography>
|
||||
@@ -14,7 +16,7 @@ const ErrorPage: React.FC = () => {
|
||||
{error.status} {error.data}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { useAnalytics } from 'use-analytics'
|
||||
|
||||
import { renderWithRouter as render } from 'components/utils/tests'
|
||||
|
||||
import { renderWithRouter as render } from 'components/utils/tests/index.js'
|
||||
import MainPage from '.'
|
||||
|
||||
// Mock the analytics module
|
||||
vi.mock('use-analytics', () => ({
|
||||
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
|
||||
}))
|
||||
|
||||
// Tests
|
||||
describe('Main page', () => {
|
||||
it('Should render the page correctly on desktop', async () => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import { screen } from '@testing-library/react'
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { useAnalytics } from 'use-analytics'
|
||||
|
||||
import { renderWithRouter as render } from 'components/utils/tests/index.js'
|
||||
|
||||
import PrivacyPage from './index.js'
|
||||
|
||||
// Mock the analytics module
|
||||
vi.mock('use-analytics', () => ({
|
||||
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
|
||||
}))
|
||||
|
||||
describe('PrivacyPage', () => {
|
||||
it('renders the Privacy Policy page', () => {
|
||||
render(<PrivacyPage />)
|
||||
expect(screen.getAllByText('Privacy Policy')).toHaveLength(3) // title, body (cookie preferences section) and footer
|
||||
expect(screen.getByText('Google Analytics')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your Rights')).toBeInTheDocument()
|
||||
expect(screen.getByText('Contact Us')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Cookie Preferences')).toHaveLength(2) // body and footer
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
import Page from 'components/templates/Page/index.js'
|
||||
import CookiePreferences from 'components/molecules/CookiePreferences/index.js'
|
||||
import PrivacyPolicy from 'components/molecules/PrivacyPolicy/index.js'
|
||||
import { Divider } from '@mui/material'
|
||||
|
||||
const PrivacyPage: React.FC = () => {
|
||||
return (
|
||||
<Page title="Privacy" showCookieConsent={false}>
|
||||
<PrivacyPolicy />
|
||||
<Divider sx={{ my: 4 }} />
|
||||
<CookiePreferences />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export default PrivacyPage
|
||||
@@ -1,8 +1,15 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { useAnalytics } from 'use-analytics'
|
||||
|
||||
import { renderWithRouter as render } from 'components/utils/tests/index.js'
|
||||
import ProjectsPage from '.'
|
||||
|
||||
// Mock the analytics module
|
||||
vi.mock('use-analytics', () => ({
|
||||
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
|
||||
}))
|
||||
|
||||
vi.stubGlobal('open', vi.fn())
|
||||
describe('ProjectsPage component', () => {
|
||||
test('renders without crashing and displays the correct project titles', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3,11 +3,15 @@ import { Box, BoxProps, Container, Divider, Typography } from '@mui/material'
|
||||
|
||||
import { Footer, Toolbar } from 'components/molecules'
|
||||
|
||||
interface PageProps extends BoxProps {
|
||||
showCookieConsent?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* React function component that implements the default page template.
|
||||
* @see BoxProps from '@mui/material' for props
|
||||
*/
|
||||
const Page: React.FC<BoxProps> = ({ title, children, ...props }) => {
|
||||
const Page: React.FC<PageProps> = ({ title, children, showCookieConsent, ...props }) => {
|
||||
React.useEffect(() => {
|
||||
if (title) {
|
||||
document.title = `Michele Tartari - ${title}`
|
||||
@@ -52,12 +56,12 @@ const Page: React.FC<BoxProps> = ({ title, children, ...props }) => {
|
||||
minHeight: '80vh',
|
||||
mx: 'auto',
|
||||
maxWidth: { xs: '100%', md: 'calc(100% - 4em)' },
|
||||
width: '70em',
|
||||
width: theme => theme.breakpoints.values.lg,
|
||||
...props.sx,
|
||||
}}>
|
||||
{children}
|
||||
</Container>
|
||||
<Footer />
|
||||
<Footer showCookieConsent={showCookieConsent} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useAnalytics } from 'use-analytics'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
|
||||
/**
|
||||
* Route tracking component that wraps all routes and includes analytics page tracking
|
||||
*/
|
||||
const RouteTracker = () => {
|
||||
const location = useLocation()
|
||||
const analytics = useAnalytics()
|
||||
|
||||
useEffect(() => {
|
||||
analytics.page()
|
||||
}, [analytics, location])
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
export default RouteTracker
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import Analytics from 'analytics'
|
||||
import googleAnalytics from '@analytics/google-analytics'
|
||||
|
||||
// Get measurement ID from environment variables
|
||||
const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID || ''
|
||||
export const COOKIE_CONSENT_KEY = 'm-tartari-cookie-consent'
|
||||
export const ANALYTICS_PLUGINS = ['google-analytics']
|
||||
|
||||
// Log warning if measurement ID is not set
|
||||
if (!GA_MEASUREMENT_ID) {
|
||||
console.warn('Google Analytics Measurement ID is not set. Analytics will not track events.')
|
||||
}
|
||||
|
||||
const analyticsInstance = Analytics({
|
||||
app: 'm-tartari-portfolio',
|
||||
plugins: [
|
||||
googleAnalytics({
|
||||
measurementIds: [GA_MEASUREMENT_ID],
|
||||
// Start disabled for GDPR compliance
|
||||
enabled: false,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
// Initialize analytics
|
||||
analyticsInstance.ready(() => {
|
||||
if (process.env.NODE_ENV !== 'test') console.info('Analytics system initialized')
|
||||
})
|
||||
|
||||
// Track page views
|
||||
export const trackPageView = (path: string) => {
|
||||
analyticsInstance.page({
|
||||
path,
|
||||
title: document.title,
|
||||
})
|
||||
}
|
||||
|
||||
// Track custom events
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const trackEvent = (event: string, payload: Record<string, any> = {}) => {
|
||||
analyticsInstance.track(event, payload)
|
||||
}
|
||||
|
||||
export default analyticsInstance
|
||||
+11
-8
@@ -1,19 +1,22 @@
|
||||
/* istanbul ignore file */
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { ThemeProvider } from '@mui/material/styles'
|
||||
import { CssBaseline } from '@mui/material'
|
||||
import { AnalyticsProvider } from 'use-analytics'
|
||||
import { ThemeProvider, CssBaseline } from '@mui/material'
|
||||
|
||||
import App from './App.tsx'
|
||||
import theme from 'style/theme.ts'
|
||||
import analytics from 'components/utils/analytics/index.js'
|
||||
import theme from 'style/theme.js'
|
||||
import App from './App.js'
|
||||
import './index.css'
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
<AnalyticsProvider instance={analytics}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</AnalyticsProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
Vendored
+8
@@ -1 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_GA_MEASUREMENT_ID: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
|
||||
Reference in New Issue
Block a user