diff --git a/src/App.tsx b/src/App.tsx index 047dccd..d2f4f42 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = () => ( @@ -53,6 +54,14 @@ const router = createBrowserRouter([ ), }, + { + 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/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/PrivacyPolicy/PrivacyPolicy.test.tsx b/src/components/molecules/PrivacyPolicy/PrivacyPolicy.test.tsx new file mode 100644 index 0000000..0f434c9 --- /dev/null +++ b/src/components/molecules/PrivacyPolicy/PrivacyPolicy.test.tsx @@ -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( + + + + ) + expect(screen.getByText('Privacy Policy')).toBeInTheDocument() + expect(screen.getByText('Google Analytics')).toBeInTheDocument() + expect(screen.getByText('Your Rights')).toBeInTheDocument() + expect(screen.getByText('Contact Us')).toBeInTheDocument() + }) +}) diff --git a/src/components/molecules/PrivacyPolicy/index.tsx b/src/components/molecules/PrivacyPolicy/index.tsx new file mode 100644 index 0000000..3405e50 --- /dev/null +++ b/src/components/molecules/PrivacyPolicy/index.tsx @@ -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 ( + + + Privacy Policy + + + Last updated: May 27, 2025 + + + + Introduction + + + 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. + + + + The Data We Collect About You + + + We collect and process the following information when you visit our website: + + + + 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. + + includes information about how you use our website, products, and services. + + + + Cookies + + + 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. + + + We use the following types of 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. + + + 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. + + + + + Google Analytics + + + 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. + + + We have implemented the following measures to be GDPR compliant: + + + We have obtained your consent before processing your data through cookies + We have implemented IP address anonymization + We have disabled data sharing with Google + We have set the data retention period to the minimum required for our analysis + + + 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{' '} + + https://tools.google.com/dlpage/gaoptout + + . + + + + Your Rights + + + Under the General Data Protection Regulation (GDPR), you have various rights in relation to your personal data, including: + + + + The right to access your personal data + The right to rectification of your personal data + The right to erasure of your personal data + The right to restrict processing of your personal data + The right to data portability + The right to object to processing of your personal data + + + + Contact Us + + + If you have any questions about this privacy policy or our privacy practices, please contact us at{' '} + + privacy@m-tartari.eu + + . + + + ) +} + +export default PrivacyPolicy diff --git a/src/components/pages/PrivacyPage/PrivacyPage.test.tsx b/src/components/pages/PrivacyPage/PrivacyPage.test.tsx new file mode 100644 index 0000000..13e46d9 --- /dev/null +++ b/src/components/pages/PrivacyPage/PrivacyPage.test.tsx @@ -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() + 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 + }) +}) diff --git a/src/components/pages/PrivacyPage/index.tsx b/src/components/pages/PrivacyPage/index.tsx new file mode 100644 index 0000000..c8111fa --- /dev/null +++ b/src/components/pages/PrivacyPage/index.tsx @@ -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 ( + + + + + + ) +} + +export default PrivacyPage