✨ feat(analytics): add cookie consent dialog

This commit is contained in:
2025-05-28 10:32:29 +00:00
parent e0f90d5d87
commit 2547572e1c
14 changed files with 302 additions and 34 deletions
@@ -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,30 @@
import React from 'react'
import { screen } from '@testing-library/react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import { renderWithRouter as render } from 'components/utils/tests/index.js'
import Footer from './index.js'
// Mock the analytics module
const analytics = { plugins: { enable: vi.fn(), disable: vi.fn() } }
vi.mock('use-analytics', () => ({
useAnalytics: () => analytics,
}))
describe('Footer', () => {
it('renders the Footer', async () => {
render(<Footer />)
expect(screen.getAllByText('Privacy Policy')).toHaveLength(2) // link and dialog
expect(screen.getAllByText('Cookie Preferences')).toHaveLength(2) // link and dialog
})
it('does not show the dialog if the `showCookieConsent` prop is false', () => {
render(<Footer showCookieConsent={false} />)
expect(screen.getAllByText('Privacy Policy')).toHaveLength(1) // link only
expect(screen.getAllByText('Cookie Preferences')).toHaveLength(1) // link only
})
})
@@ -1,21 +0,0 @@
import { Box, Stack, Typography } from '@mui/material'
import { EmailIconLink, GitHubIconLink, LinkedInIconLink } from 'components/atoms'
const Footer = () => {
const year = new Date().getFullYear()
return (
<Box sx={{ bgcolor: 'background.paper', p: 6, display: 'flex', alignItems: 'center', flexDirection: 'column' }} component="footer">
<Stack direction="row" gap={2}>
<LinkedInIconLink fontSize="large" href="https://www.linkedin.com/in/m-tartari/" />
<GitHubIconLink fontSize="large" href="https://www.github.com/m-tartari/" />
<EmailIconLink fontSize="large" href="mailto:info@m-tartari.eu" />
</Stack>
<Typography variant="subtitle2" align="center" color="text.secondary" component="em" mt={3}>
&copy; {year} Michele Tartari. All rights reserved.
</Typography>
</Box>
)
}
export default Footer
-1
View File
@@ -1 +0,0 @@
export { default } from './Footer'
+52
View File
@@ -0,0 +1,52 @@
import React from 'react'
import { Link as RouterLink } from 'react-router-dom'
import { Box, Link, Stack, Typography } from '@mui/material'
import PolicyIcon from '@mui/icons-material/Policy'
import CookieIcon from '@mui/icons-material/Cookie'
import { EmailIconLink, GitHubIconLink, LinkedInIconLink } from 'components/atoms'
import CookieConsent from 'components/molecules/CookieConsent/index.js'
type FooterProps = {
showCookieConsent?: boolean
}
const Footer: React.FC<FooterProps> = ({ showCookieConsent = true }) => {
const year = new Date().getFullYear()
return (
<Box sx={{ bgcolor: 'background.paper', p: 6, display: 'flex', alignItems: 'center', flexDirection: 'column' }} component="footer">
<Stack direction="row" gap={2}>
<LinkedInIconLink fontSize="large" href="https://www.linkedin.com/in/m-tartari/" />
<GitHubIconLink fontSize="large" href="https://www.github.com/m-tartari/" />
<EmailIconLink fontSize="large" href="mailto:info@m-tartari.eu" />
</Stack>
<Typography variant="subtitle2" align="center" color="text.secondary" component="em" mt={3}>
& copy; {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
@@ -1,8 +1,15 @@
import { screen } from '@testing-library/react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import { renderWithRouter as render } from 'components/utils/tests/index.js'
import ContactPage from './index.js'
// Mock the analytics module
vi.mock('use-analytics', () => ({
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
}))
vi.stubGlobal('open', vi.fn())
describe('ContactPage', () => {
test('renders without crashing and displays the correct contact methods', () => {
@@ -1,11 +1,16 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import { renderWithRouter as render } from 'components/utils/tests'
import CurriculumPage from '.'
import CurriculumPage from './index.js'
vi.stubGlobal('open', vi.fn())
vi.mock('use-analytics', () => ({
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
}))
describe('Curriculum component', () => {
const open = vi.spyOn(window, 'open')
@@ -1,7 +1,14 @@
import { screen, cleanup, render } from '@testing-library/react'
import { RouterProvider, createMemoryRouter } from 'react-router-dom'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import ErrorPage from '.'
import ErrorPage from './index.js'
// Mock the analytics module
vi.mock('use-analytics', () => ({
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
}))
// Tests
describe('Renders ErrorPage correctly', async () => {
+5 -3
View File
@@ -1,10 +1,12 @@
import { isRouteErrorResponse, useRouteError } from 'react-router-dom'
import { Box, Typography } from '@mui/material'
import { Typography } from '@mui/material'
import { Page } from 'components/templates/index.js'
const ErrorPage: React.FC = () => {
const error: unknown = useRouteError()
return (
<Box display="flex" flexDirection="column" justifyContent="center" alignItems="center" minHeight="100vh">
<Page sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', minHeight: '90vh' }}>
<Typography variant="h1" color="primary.main">
Oops!
</Typography>
@@ -14,7 +16,7 @@ const ErrorPage: React.FC = () => {
{error.status} {error.data}
</Typography>
)}
</Box>
</Page>
)
}
@@ -1,9 +1,15 @@
import { screen } from '@testing-library/react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import { renderWithRouter as render } from 'components/utils/tests'
import { renderWithRouter as render } from 'components/utils/tests/index.js'
import MainPage from '.'
// Mock the analytics module
vi.mock('use-analytics', () => ({
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
}))
// Tests
describe('Main page', () => {
it('Should render the page correctly on desktop', async () => {
@@ -1,8 +1,15 @@
import { screen } from '@testing-library/react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import { renderWithRouter as render } from 'components/utils/tests/index.js'
import ProjectsPage from '.'
// Mock the analytics module
vi.mock('use-analytics', () => ({
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
}))
vi.stubGlobal('open', vi.fn())
describe('ProjectsPage component', () => {
test('renders without crashing and displays the correct project titles', () => {
+8 -2
View File
@@ -1,9 +1,15 @@
import { screen } from '@testing-library/react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { useAnalytics } from 'use-analytics'
import { renderWithRouter as render } from 'components/utils/tests'
import { renderWithRouter as render } from 'components/utils/tests/index.js'
import Page from '.'
// Mock the analytics module
vi.mock('use-analytics', () => ({
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
}))
describe('Page component', () => {
const defaultProps = {
title: 'Test Page',
+7 -3
View File
@@ -3,11 +3,15 @@ import { Box, BoxProps, Container, Divider, Typography } from '@mui/material'
import { Footer, Toolbar } from 'components/molecules'
interface PageProps extends BoxProps {
showCookieConsent?: boolean
}
/**
* React function component that implements the default page template.
* @see BoxProps from '@mui/material' for props
*/
const Page: React.FC<BoxProps> = ({ title, children, ...props }) => {
const Page: React.FC<PageProps> = ({ title, children, showCookieConsent, ...props }) => {
React.useEffect(() => {
if (title) {
document.title = `Michele Tartari - ${title}`
@@ -52,12 +56,12 @@ const Page: React.FC<BoxProps> = ({ title, children, ...props }) => {
minHeight: '80vh',
mx: 'auto',
maxWidth: { xs: '100%', md: 'calc(100% - 4em)' },
width: '80em',
width: theme => theme.breakpoints.values.lg,
...props.sx,
}}>
{children}
</Container>
<Footer />
<Footer showCookieConsent={showCookieConsent} />
</Box>
)
}