✨ feat(analytics): add PrivacyPolicy page

This commit is contained in:
2025-05-28 10:35:44 +00:00
parent 2547572e1c
commit 585aed9233
8 changed files with 447 additions and 4 deletions
+13 -4
View File
@@ -4,10 +4,11 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import ErrorPage from 'components/pages/ErrorPage/index.js'
import RootLayout from 'components/templates/RouteTracker/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 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 Loading = () => (
<Backdrop open={true} aria-label="loading-screen">
@@ -53,6 +54,14 @@ const router = createBrowserRouter([
</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,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,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
@@ -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