✨ 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'
|
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 { renderWithRouter as render } from 'components/utils/tests/index.js'
|
||||||
import ContactPage from './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())
|
vi.stubGlobal('open', vi.fn())
|
||||||
describe('ContactPage', () => {
|
describe('ContactPage', () => {
|
||||||
test('renders without crashing and displays the correct contact methods', () => {
|
test('renders without crashing and displays the correct contact methods', () => {
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { screen } from '@testing-library/react'
|
import { screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
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 { renderWithRouter as render } from 'components/utils/tests'
|
||||||
|
|
||||||
import CurriculumPage from '.'
|
import CurriculumPage from './index.js'
|
||||||
|
|
||||||
vi.stubGlobal('open', vi.fn())
|
vi.stubGlobal('open', vi.fn())
|
||||||
|
vi.mock('use-analytics', () => ({
|
||||||
|
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
|
||||||
|
}))
|
||||||
|
|
||||||
describe('Curriculum component', () => {
|
describe('Curriculum component', () => {
|
||||||
const open = vi.spyOn(window, 'open')
|
const open = vi.spyOn(window, 'open')
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { screen, cleanup, render } from '@testing-library/react'
|
import { screen, cleanup, render } from '@testing-library/react'
|
||||||
import { RouterProvider, createMemoryRouter } from 'react-router-dom'
|
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
|
// Tests
|
||||||
describe('Renders ErrorPage correctly', async () => {
|
describe('Renders ErrorPage correctly', async () => {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { isRouteErrorResponse, useRouteError } from 'react-router-dom'
|
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 ErrorPage: React.FC = () => {
|
||||||
const error: unknown = useRouteError()
|
const error: unknown = useRouteError()
|
||||||
return (
|
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">
|
<Typography variant="h1" color="primary.main">
|
||||||
Oops!
|
Oops!
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -14,7 +16,7 @@ const ErrorPage: React.FC = () => {
|
|||||||
{error.status} {error.data}
|
{error.status} {error.data}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { screen } from '@testing-library/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'
|
import { renderWithRouter as render } from 'components/utils/tests/index.js'
|
||||||
|
|
||||||
import MainPage from '.'
|
import MainPage from '.'
|
||||||
|
|
||||||
|
// Mock the analytics module
|
||||||
|
vi.mock('use-analytics', () => ({
|
||||||
|
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
|
||||||
|
}))
|
||||||
|
|
||||||
// Tests
|
// Tests
|
||||||
describe('Main page', () => {
|
describe('Main page', () => {
|
||||||
it('Should render the page correctly on desktop', async () => {
|
it('Should render the page correctly on desktop', async () => {
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { screen } from '@testing-library/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 { renderWithRouter as render } from 'components/utils/tests/index.js'
|
||||||
import ProjectsPage from '.'
|
import ProjectsPage from '.'
|
||||||
|
|
||||||
|
// Mock the analytics module
|
||||||
|
vi.mock('use-analytics', () => ({
|
||||||
|
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.stubGlobal('open', vi.fn())
|
vi.stubGlobal('open', vi.fn())
|
||||||
describe('ProjectsPage component', () => {
|
describe('ProjectsPage component', () => {
|
||||||
test('renders without crashing and displays the correct project titles', () => {
|
test('renders without crashing and displays the correct project titles', () => {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { screen } from '@testing-library/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'
|
import { renderWithRouter as render } from 'components/utils/tests/index.js'
|
||||||
|
|
||||||
import Page from '.'
|
import Page from '.'
|
||||||
|
|
||||||
|
// Mock the analytics module
|
||||||
|
vi.mock('use-analytics', () => ({
|
||||||
|
useAnalytics: () => ({ plugins: { enable: vi.fn(), disable: vi.fn() } }),
|
||||||
|
}))
|
||||||
|
|
||||||
describe('Page component', () => {
|
describe('Page component', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
title: 'Test Page',
|
title: 'Test Page',
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ import { Box, BoxProps, Container, Divider, Typography } from '@mui/material'
|
|||||||
|
|
||||||
import { Footer, Toolbar } from 'components/molecules'
|
import { Footer, Toolbar } from 'components/molecules'
|
||||||
|
|
||||||
|
interface PageProps extends BoxProps {
|
||||||
|
showCookieConsent?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React function component that implements the default page template.
|
* React function component that implements the default page template.
|
||||||
* @see BoxProps from '@mui/material' for props
|
* @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(() => {
|
React.useEffect(() => {
|
||||||
if (title) {
|
if (title) {
|
||||||
document.title = `Michele Tartari - ${title}`
|
document.title = `Michele Tartari - ${title}`
|
||||||
@@ -52,12 +56,12 @@ const Page: React.FC<BoxProps> = ({ title, children, ...props }) => {
|
|||||||
minHeight: '80vh',
|
minHeight: '80vh',
|
||||||
mx: 'auto',
|
mx: 'auto',
|
||||||
maxWidth: { xs: '100%', md: 'calc(100% - 4em)' },
|
maxWidth: { xs: '100%', md: 'calc(100% - 4em)' },
|
||||||
width: '80em',
|
width: theme => theme.breakpoints.values.lg,
|
||||||
...props.sx,
|
...props.sx,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
<Footer />
|
<Footer showCookieConsent={showCookieConsent} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user