diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 53b5602..47405d7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -74,6 +74,8 @@ "diffEditor.ignoreTrimWhitespace": false, "diffEditor.wordWrap": "off", // Javascript + "javascript.preferences.importModuleSpecifierEnding": "js", + "typescript.preferences.importModuleSpecifierEnding": "js", "javascript.updateImportsOnFileMove.enabled": "always", "typescript.preferences.importModuleSpecifier": "non-relative", "typescript.updateImportsOnFileMove.enabled": "always", diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c0f1740 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3b10c79..e14bf47 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -45,3 +45,9 @@ updates: patterns: - 'lodash*' - '@types/lodash*' + analytics: + patterns: + - 'analytics' + - '@analytics/*' + - 'use-analytics' + - '@types/use-analytics' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bbac26b..1d145a2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,6 +37,9 @@ jobs: node-version: 'lts/*' cache: 'npm' + - name: Set VITE_GA_MEASUREMENT_ID + run: sed -ri "s/VITE_GA_MEASUREMENT_ID/${{ secrets.VITE_GA_MEASUREMENT_ID }}/" .env + - name: Install dependencies run: npm ci --omit=dev diff --git a/.gitignore b/.gitignore index 6d036fa..f3de697 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,13 @@ dist dist-ssr *.local +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + # Editor directories and files # .vscode/* !.vscode/extensions.json diff --git a/.vscode/settings.json b/.vscode/settings.json index d4500b7..15c6958 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -69,4 +69,10 @@ "**/node_modules" ], "vitest.enable": true, + "javascript.preferences.importModuleSpecifierEnding": "js", + "typescript.preferences.importModuleSpecifierEnding": "js", + "cSpell.words": [ + "SRDF", + "URDF" + ], } diff --git a/package-lock.json b/package-lock.json index 3ee2dee..e426a63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,16 +8,19 @@ "name": "m-tartari.github.io", "version": "1.0.0-alpha.0", "dependencies": { + "@analytics/google-analytics": "^1.1.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@fontsource/roboto": "^5.2.5", "@mui/icons-material": "^7.1.0", "@mui/material": "^7.0.0", "@vitejs/plugin-react-swc": "^3.10.0", + "analytics": "^0.8.16", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.1", "react-scroll-parallax": "^3.4.5", + "use-analytics": "^1.1.0", "vite": "^6.3.5", "vite-tsconfig-paths": "^5.1.4" }, @@ -26,8 +29,10 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/node": "^22.15.23", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", + "@types/use-analytics": "^0.0.3", "@typescript-eslint/eslint-plugin": "^8.33.0", "@typescript-eslint/parser": "^8.32.1", "@vitest/coverage-istanbul": "^3.1.4", @@ -70,6 +75,84 @@ "node": ">=6.0.0" } }, + "node_modules/@analytics/cookie-utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@analytics/cookie-utils/-/cookie-utils-0.2.12.tgz", + "integrity": "sha512-2h/yuIu3kmu+ZJlKmlT6GoRvUEY2k1BbQBezEv5kGhnn9KpmzPz715Y3GmM2i+m7Y0QmBdVUoA260dQZkofs2A==", + "license": "MIT", + "dependencies": { + "@analytics/global-storage-utils": "^0.1.7" + } + }, + "node_modules/@analytics/core": { + "version": "0.12.17", + "resolved": "https://registry.npmjs.org/@analytics/core/-/core-0.12.17.tgz", + "integrity": "sha512-GMxRm5Dp3Wam/w5NNvqNKMO6zWecozbVv21Kn4WhftCx6OjJI7zMlVtiLpjGjxa0RRZfVG80YhupF0Qh9XL2gw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/davidwells" + } + ], + "license": "MIT", + "dependencies": { + "@analytics/global-storage-utils": "^0.1.7", + "@analytics/type-utils": "^0.6.2", + "analytics-utils": "^1.0.14" + } + }, + "node_modules/@analytics/global-storage-utils": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@analytics/global-storage-utils/-/global-storage-utils-0.1.7.tgz", + "integrity": "sha512-V+spzGLZYm4biZT4uefaylm80SrLXf8WOTv9hCgA46cLcyxx3LD4GCpssp1lj+RcWLl/uXJQBRO4Mnn/o1x6Gw==", + "license": "MIT", + "dependencies": { + "@analytics/type-utils": "^0.6.2" + } + }, + "node_modules/@analytics/google-analytics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@analytics/google-analytics/-/google-analytics-1.1.0.tgz", + "integrity": "sha512-i8uGyELMtwEUAf3GNWNLNBzhRvReDn1RUxvMdMhjUA7+GNGxPOM4kkzFfv3giQXKNxTEjfsh75kqNcscbJsuaA==", + "license": "MIT" + }, + "node_modules/@analytics/localstorage-utils": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@analytics/localstorage-utils/-/localstorage-utils-0.1.10.tgz", + "integrity": "sha512-uJS+Jp1yLG5VFCgA5T82ZODYBS0xuDQx0NtAZrgbqt9j51BX3TcgmOez5LVkrUNu/lpbxjCLq35I4TKj78VmOQ==", + "license": "MIT", + "dependencies": { + "@analytics/global-storage-utils": "^0.1.7" + } + }, + "node_modules/@analytics/session-storage-utils": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@analytics/session-storage-utils/-/session-storage-utils-0.0.7.tgz", + "integrity": "sha512-PSv40UxG96HVcjY15e3zOqU2n8IqXnH8XvTkg1X43uXNTKVSebiI2kUjA3Q7ESFbw5DPwcLbJhV7GforpuBLDw==", + "license": "MIT", + "dependencies": { + "@analytics/global-storage-utils": "^0.1.7" + } + }, + "node_modules/@analytics/storage-utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@analytics/storage-utils/-/storage-utils-0.4.2.tgz", + "integrity": "sha512-AXObwyVQw9h2uJh1t2hUgabtVxzYpW+7uKVbdHQK80vr3Td5rrmCxrCxarh7HUuAgSDZ0bZWqmYxVgmwKceaLg==", + "license": "MIT", + "dependencies": { + "@analytics/cookie-utils": "^0.2.12", + "@analytics/global-storage-utils": "^0.1.7", + "@analytics/localstorage-utils": "^0.1.10", + "@analytics/session-storage-utils": "^0.0.7", + "@analytics/type-utils": "^0.6.2" + } + }, + "node_modules/@analytics/type-utils": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@analytics/type-utils/-/type-utils-0.6.2.tgz", + "integrity": "sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg==", + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-2.8.2.tgz", @@ -2421,6 +2504,13 @@ "dev": true, "peer": true }, + "node_modules/@types/dlv": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/dlv/-/dlv-1.1.5.tgz", + "integrity": "sha512-JHOWNfiWepAhfwlSw17kiWrWrk6od2dEQgHltJw9AS0JPFoLZJBge5+Dnil2NfdjAvJ/+vGSX60/BRW20PpUXw==", + "license": "MIT", + "peer": true + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -2434,6 +2524,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.15.23", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.23.tgz", + "integrity": "sha512-7Ec1zaFPF4RJ0eXu1YT/xgiebqwqoJz8rYPDi/O2BcZ++Wpt0Kq9cl0eg6NN6bYbPnR67ZLo7St5Q3UK0SnARw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -2472,6 +2572,17 @@ "@types/react": "*" } }, + "node_modules/@types/use-analytics": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-analytics/-/use-analytics-0.0.3.tgz", + "integrity": "sha512-s/b/R8q/pKj1INYyRWw46aI4rZFr6p0yW80EdD47FBytTGXPNJFOaPlvw2Atsrf+k6S4idl1nN5kXiLBl1MrJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "analytics": "^0.8.1" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.33.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.0.tgz", @@ -2913,6 +3024,35 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/analytics": { + "version": "0.8.16", + "resolved": "https://registry.npmjs.org/analytics/-/analytics-0.8.16.tgz", + "integrity": "sha512-LEFQ47G9V1zVp9WIh2xhnbmSFEJq+WEzSv6voJ5uba88lefiIIYeG2nq87gFu83ocz1qtb9u7XgeaKKVBbbgWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/davidwells" + } + ], + "license": "MIT", + "dependencies": { + "@analytics/core": "^0.12.17", + "@analytics/storage-utils": "^0.4.2" + } + }, + "node_modules/analytics-utils": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/analytics-utils/-/analytics-utils-1.0.14.tgz", + "integrity": "sha512-9v0kPd8v0GuBvfQcg5BO48AElaEAr9IXMAfJWXYMAhrD3QprgozEIUgMp/de0vS136PUOBB+10XQH9eBgBmfMw==", + "license": "MIT", + "dependencies": { + "@analytics/type-utils": "^0.6.2", + "dlv": "^1.1.3" + }, + "peerDependencies": { + "@types/dlv": "^1.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3292,6 +3432,12 @@ "node": ">=6" } }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -5373,6 +5519,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5579,6 +5731,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", @@ -5619,6 +5778,22 @@ "punycode": "^2.1.0" } }, + "node_modules/use-analytics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/use-analytics/-/use-analytics-1.1.0.tgz", + "integrity": "sha512-1PMT5doiCoTm6ng9PscCHuFgl8vYNSFgGizXuFlmEBqnaRJ+KnygKnETUbBJ1W7MJPWQeHwA5Mys9t7b4P8ijw==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.2", + "tiny-invariant": "^1.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", diff --git a/package.json b/package.json index efe6cab..224f072 100644 --- a/package.json +++ b/package.json @@ -19,16 +19,19 @@ "test:ci-workflow": "npm ci --omit=dev && npm run build:prod && npm ci && npm run build && npm run lint && npm run test:coverage" }, "dependencies": { + "@analytics/google-analytics": "^1.1.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@fontsource/roboto": "^5.2.5", "@mui/icons-material": "^7.1.0", "@mui/material": "^7.0.0", "@vitejs/plugin-react-swc": "^3.10.0", + "analytics": "^0.8.16", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.1", "react-scroll-parallax": "^3.4.5", + "use-analytics": "^1.1.0", "vite": "^6.3.5", "vite-tsconfig-paths": "^5.1.4" }, @@ -37,8 +40,10 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/node": "^22.15.23", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", + "@types/use-analytics": "^0.0.3", "@typescript-eslint/eslint-plugin": "^8.33.0", "@typescript-eslint/parser": "^8.32.1", "@vitest/coverage-istanbul": "^3.1.4", diff --git a/src/App.test.tsx b/src/App.test.tsx index a64c386..2710b73 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,7 +1,14 @@ import { render, screen } from '@testing-library/react' +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { useAnalytics } from 'use-analytics' import App from './App' +// Mock the analytics module +vi.mock('use-analytics', () => ({ + useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() }, page: vi.fn() }), +})) + describe('App component', () => { test('renders MainPage without crashing', async () => { render() diff --git a/src/App.tsx b/src/App.tsx index c1aba46..a429bed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,53 +1,63 @@ import React from 'react' import { createBrowserRouter, RouterProvider } from 'react-router-dom' -import { Backdrop, CircularProgress } from '@mui/material' -import { ErrorPage } from 'components/pages' +import ErrorPage from 'components/pages/ErrorPage/index.js' +import RootLayout from 'components/templates/RouteTracker/index.js' +import Loading from 'components/molecules/LoadingScreen/index.js' -const MainPage = React.lazy(() => import('components/pages/MainPage')) -const ProjectsPage = React.lazy(() => import('components/pages/ProjectsPage')) -const CurriculumPage = React.lazy(() => import('components/pages/CurriculumPage')) -const ContactPage = React.lazy(() => import('components/pages/ContactPage')) - -const Loading = () => ( - - - -) +const MainPage = React.lazy(() => import('components/pages/MainPage/index.js')) +const ProjectsPage = React.lazy(() => import('components/pages/ProjectsPage/index.js')) +const CurriculumPage = React.lazy(() => import('components/pages/CurriculumPage/index.js')) +const ContactPage = React.lazy(() => import('components/pages/ContactPage/index.js')) +const PrivacyPage = React.lazy(() => import('components/pages/PrivacyPage/index.js')) const router = createBrowserRouter([ { path: '/', - element: ( - }> - - - ), + element: , errorElement: , - }, - { - path: '/projects', - element: ( - }> - - - ), - }, - { - path: '/curriculum', - element: ( - }> - - - ), - }, - { - path: '/contacts', - element: ( - }> - - - ), + children: [ + { + index: true, + element: ( + }> + + + ), + }, + { + path: 'projects', + element: ( + }> + + + ), + }, + { + path: 'curriculum', + element: ( + }> + + + ), + }, + { + path: 'contacts', + element: ( + }> + + + ), + }, + { + path: 'privacy', + element: ( + }> + + + ), + }, + ], }, ]) diff --git a/src/components/atoms/ListItemText/index.tsx b/src/components/atoms/ListItemText/index.tsx new file mode 100644 index 0000000..5f94dc2 --- /dev/null +++ b/src/components/atoms/ListItemText/index.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { ListItem, Typography, ListItemProps } from '@mui/material' + +interface ListItemTextProps extends ListItemProps { + title?: string +} +const ListItemText: React.FC = ({ title, children, ...props }) => ( + + {title && ( + + {title}{' '} + + )} + {children} + +) + +export default ListItemText diff --git a/src/components/molecules/CookieConsent/CookieConsent.test.tsx b/src/components/molecules/CookieConsent/CookieConsent.test.tsx new file mode 100644 index 0000000..42e707c --- /dev/null +++ b/src/components/molecules/CookieConsent/CookieConsent.test.tsx @@ -0,0 +1,64 @@ +import { render, screen, fireEvent } from '@testing-library/react' +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { useAnalytics } from 'use-analytics' + +import CookieConsent from './index.js' + +const analytics = { plugins: { enable: vi.fn(), disable: vi.fn() } } + +// Mock the analytics module +vi.mock('use-analytics', () => ({ + useAnalytics: () => analytics, +})) + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value + }), + clear: vi.fn(() => { + store = {} + }), + } +})() + +Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + +describe('CookieConsent', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorageMock.clear() + }) + + it('renders the cookie consent dialog when no consent is stored', () => { + render() + + expect(screen.getByText('Cookie Preferences')).toBeInTheDocument() + expect(screen.getByText(/We use cookies to improve your browsing experience/)).toBeInTheDocument() + expect(screen.getByText('Accept All')).toBeInTheDocument() + expect(screen.getByText('Reject All')).toBeInTheDocument() + }) + + it('accepts all cookies when clicking Accept All', () => { + render() + + fireEvent.click(screen.getByText('Accept All')) + + expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ analytics: true, necessary: true })) + expect(analytics.plugins.disable).not.toHaveBeenCalled() + expect(analytics.plugins.enable).toHaveBeenCalled() + }) + + it('rejects analytics cookies when clicking Reject All', () => { + render() + + fireEvent.click(screen.getByText('Reject All')) + + expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ analytics: false, necessary: true })) + expect(analytics.plugins.enable).not.toHaveBeenCalled() + expect(analytics.plugins.disable).toHaveBeenCalled() + }) +}) diff --git a/src/components/molecules/CookieConsent/index.tsx b/src/components/molecules/CookieConsent/index.tsx new file mode 100644 index 0000000..1b598ab --- /dev/null +++ b/src/components/molecules/CookieConsent/index.tsx @@ -0,0 +1,100 @@ +import { useState, useEffect, useCallback } from 'react' +import { Box, Button, Link, Stack, Typography, Unstable_TrapFocus as TrapFocus, Paper, Fade } from '@mui/material' +import { useAnalytics } from 'use-analytics' + +import { ANALYTICS_PLUGINS, COOKIE_CONSENT_KEY } from 'components/utils/analytics/index.js' + +interface Consent { + necessary: boolean // Always true, but included for completeness + analytics: boolean +} + +const CookieConsent = () => { + const [open, setOpen] = useState(false) + const analytics = useAnalytics() + + useEffect(() => { + // Check if user has already given consent + const savedConsent = localStorage.getItem(COOKIE_CONSENT_KEY) + + if (!savedConsent) { + setOpen(true) + } else { + const parsedConsent = JSON.parse(savedConsent) as Consent + if (parsedConsent.analytics) { + analytics.plugins.enable(ANALYTICS_PLUGINS) + } + } + }, [analytics]) + + const handleAcceptAll = useCallback(() => { + localStorage.setItem(COOKIE_CONSENT_KEY, JSON.stringify({ analytics: true, necessary: true })) + analytics.plugins.enable(ANALYTICS_PLUGINS) + setOpen(false) + }, [analytics.plugins]) + + const handleRejectAll = useCallback(() => { + localStorage.setItem(COOKIE_CONSENT_KEY, JSON.stringify({ analytics: false, necessary: true })) + analytics.plugins.disable(ANALYTICS_PLUGINS) + setOpen(false) + }, [analytics.plugins]) + + return ( + <> + + + ({ + position: 'fixed', + bottom: 0, + left: '50%', + transform: 'translate(-50%, 0)', + width: theme.breakpoints.values.sm, + maxWidth: `calc(100% - ${theme.spacing(2)})`, + mb: theme.spacing(1), + p: theme.spacing(3), + zIndex: 9999, + })}> + + + Cookie Preferences + + + We use cookies to improve your browsing experience and analyze our traffic. + By clicking "Accept All", you consent to our use of cookies. + + For more details, please see our{' '} + + Privacy Policy + + . + + + + + + + Customize + + + + + + + + + ) +} + +export default CookieConsent diff --git a/src/components/molecules/CookiePreferences/CookiePreferences.test.tsx b/src/components/molecules/CookiePreferences/CookiePreferences.test.tsx new file mode 100644 index 0000000..ef3115b --- /dev/null +++ b/src/components/molecules/CookiePreferences/CookiePreferences.test.tsx @@ -0,0 +1,89 @@ +import { screen, fireEvent } from '@testing-library/react' +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { useAnalytics } from 'use-analytics' + +import { renderWithRouter as render } from 'components/utils/tests/index.js' + +import CookiePreferences from './index.js' + +// Mock the analytics module +const analytics = { plugins: { enable: vi.fn(), disable: vi.fn() } } +vi.mock('use-analytics', () => ({ + useAnalytics: () => analytics, +})) + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value + }), + clear: vi.fn(() => { + store = {} + }), + } +})() + +Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + +describe('CookiePreferences', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorageMock.clear() + }) + + it('accepts all cookies when clicking Accept All', () => { + render() + + expect(screen.getByText('Cookie Preferences')).toBeInTheDocument() + + fireEvent.click(screen.getByText('Accept All')) + fireEvent.click(screen.getByText('Save Preferences')) + + expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ necessary: true, analytics: true })) + expect(analytics.plugins.disable).not.toHaveBeenCalled() + expect(analytics.plugins.enable).toHaveBeenCalled() + }) + + it('rejects analytics cookies when clicking Reject All', () => { + render() + + expect(screen.getByText('Cookie Preferences')).toBeInTheDocument() + + fireEvent.click(screen.getByText('Reject All')) + fireEvent.click(screen.getByText('Save Preferences')) + + expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ necessary: true, analytics: false })) + expect(analytics.plugins.enable).not.toHaveBeenCalled() + expect(analytics.plugins.disable).toHaveBeenCalled() + }) + + it('toggles analytics cookies when clicking the Analytics button', () => { + render() + + expect(screen.getByText('Cookie Preferences')).toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('analytics-cookies-toggle')) + fireEvent.click(screen.getByText('Save Preferences')) + + expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ necessary: true, analytics: true })) + expect(analytics.plugins.disable).not.toHaveBeenCalled() + expect(analytics.plugins.enable).toHaveBeenCalled() + }) + + it('renders the cookie consent in the right state when consent in local storage', () => { + localStorageMock.getItem.mockReturnValueOnce(JSON.stringify({ necessary: true, analytics: true })) + render() + + expect(screen.getByText('Cookie Preferences')).toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('analytics-cookies-toggle')) + fireEvent.click(screen.getByText('Save Preferences')) + + expect(localStorageMock.setItem).toHaveBeenCalledWith('m-tartari-cookie-consent', JSON.stringify({ necessary: true, analytics: false })) + expect(analytics.plugins.enable).not.toHaveBeenCalled() + expect(analytics.plugins.disable).toHaveBeenCalled() + }) +}) diff --git a/src/components/molecules/CookiePreferences/index.tsx b/src/components/molecules/CookiePreferences/index.tsx new file mode 100644 index 0000000..0fe4bec --- /dev/null +++ b/src/components/molecules/CookiePreferences/index.tsx @@ -0,0 +1,141 @@ +import React from 'react' +import { useState, useEffect, useCallback } from 'react' +import { Box, Button, Link, Stack, Switch, Typography, SnackbarCloseReason, Snackbar, Alert } from '@mui/material' +import { useAnalytics } from 'use-analytics' + +import { ANALYTICS_PLUGINS, COOKIE_CONSENT_KEY } from 'components/utils/analytics/index.js' + +interface CookieConsentState { + necessary: boolean // Always true, but included for completeness + analytics: boolean +} + +export type CookieConsentProps = { + consentDisplayDefaultOpen?: boolean +} + +const CookieConsent: React.FC = ({ consentDisplayDefaultOpen = true }) => { + const analytics = useAnalytics() + const [consent, setConsent] = useState({ + necessary: true, + analytics: false, + }) + const [open, setOpen] = React.useState(false) + + const handleClose = (_event: React.SyntheticEvent | Event, reason?: SnackbarCloseReason) => { + if (reason === 'clickaway') { + return + } + setOpen(false) + } + + useEffect(() => { + // Check if user has already given consent + const savedConsent = localStorage.getItem(COOKIE_CONSENT_KEY) + + if (savedConsent) { + const parsedConsent = JSON.parse(savedConsent) as CookieConsentState + setConsent(parsedConsent) + } + }, [analytics.plugins, consentDisplayDefaultOpen]) + + const handleSave = useCallback(() => { + localStorage.setItem(COOKIE_CONSENT_KEY, JSON.stringify(consent)) + + // Initialize GA if analytics consent was given + if (consent.analytics) { + analytics.plugins.enable(ANALYTICS_PLUGINS) + } else { + analytics.plugins.disable(ANALYTICS_PLUGINS) + } + setOpen(true) + }, [consent, analytics.plugins]) + + const handleChange = useCallback((type: keyof CookieConsentState) => { + if (type === 'necessary') { + return + } + + setConsent(prev => ({ + ...prev, + [type]: !prev[type], + })) + }, []) + + return ( + + + Cookie Preferences + + + Please set your preferences below. You can change these settings at any time in the future. +
+ For more details, please see our{' '} + + Privacy Policy + + . +
+ + ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + my: theme.spacing(2), + p: theme.spacing(4), + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + })}> + + + + + + + + + Necessary Cookies + + + + + These cookies are essential for the website to function properly and cannot be disabled. + + + + + + + Analytics Cookies + + handleChange('analytics')} + color="default" + /> + + + These cookies help us understand how visitors interact with our website, helping us improve our services. + + + + + + + + Preferences saved! + + +
+ ) +} + +export default CookieConsent diff --git a/src/components/molecules/Footer/Footer.test.tsx b/src/components/molecules/Footer/Footer.test.tsx new file mode 100644 index 0000000..ac1656a --- /dev/null +++ b/src/components/molecules/Footer/Footer.test.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { screen } from '@testing-library/react' +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { useAnalytics } from 'use-analytics' + +import { renderWithRouter as render } from 'components/utils/tests/index.js' + +import Footer from './index.js' + +// Mock the analytics module +const analytics = { plugins: { enable: vi.fn(), disable: vi.fn() } } +vi.mock('use-analytics', () => ({ + useAnalytics: () => analytics, +})) + +describe('Footer', () => { + it('renders the Footer', async () => { + render(