✨ feat(analytics): add cookie consent dialog
This commit is contained in:
@@ -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}>
|
||||
© {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}>
|
||||
& 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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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: '80em',
|
||||
width: theme => theme.breakpoints.values.lg,
|
||||
...props.sx,
|
||||
}}>
|
||||
{children}
|
||||
</Container>
|
||||
<Footer />
|
||||
<Footer showCookieConsent={showCookieConsent} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user