✨ 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