Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b38bfa4e45 | |||
| 1f5db3b5db |
@@ -4,7 +4,7 @@ name: Deployment (Staging)
|
||||
on:
|
||||
# Runs on pushes targeting the default branch
|
||||
push:
|
||||
branches: ['main']
|
||||
branches: ["main"]
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
@@ -20,8 +20,8 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
node-version: "lts/*"
|
||||
cache: "npm"
|
||||
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
@@ -44,6 +44,9 @@ jobs:
|
||||
if [[ -n "${{ secrets.VITE_BC_EMAIL }}" ]]; then
|
||||
echo "VITE_BC_EMAIL=${{ secrets.VITE_BC_EMAIL }}" >> .env
|
||||
fi
|
||||
if [[ -n "${{ secrets.VITE_EV_CARTO_KEY }}" ]]; then
|
||||
echo "VITE_EV_CARTO_KEY=${{ secrets.VITE_EV_CARTO_KEY }}" >> .env
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --omit=dev
|
||||
|
||||
Generated
+51
@@ -18,8 +18,10 @@
|
||||
"@mui/material": "^7.0.0",
|
||||
"@vitejs/plugin-react-swc": "^4.2.3",
|
||||
"analytics": "^0.8.19",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"react-scroll-parallax": "^3.5.0",
|
||||
"use-analytics": "^1.1.0",
|
||||
@@ -31,6 +33,7 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
@@ -1761,6 +1764,17 @@
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
||||
@@ -2415,6 +2429,13 @@
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -2422,6 +2443,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
|
||||
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
|
||||
@@ -4184,6 +4215,12 @@
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -4652,6 +4689,20 @@
|
||||
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
|
||||
|
||||
@@ -29,8 +29,10 @@
|
||||
"@mui/material": "^7.0.0",
|
||||
"@vitejs/plugin-react-swc": "^4.2.3",
|
||||
"analytics": "^0.8.19",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"react-scroll-parallax": "^3.5.0",
|
||||
"use-analytics": "^1.1.0",
|
||||
@@ -42,6 +44,7 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
|
||||
@@ -8,6 +8,7 @@ import Loading from 'components/molecules/LoadingScreen/index.js'
|
||||
const MainPage = React.lazy(() => import('components/pages/MainPage/index.js'))
|
||||
const PeoplePage = React.lazy(() => import('components/pages/PeoplePage/index.js'))
|
||||
const ActivitiesPage = React.lazy(() => import('components/pages/ActivitiesPage/index.js'))
|
||||
const EventsPage = React.lazy(() => import('components/pages/EventsPage/index.js'))
|
||||
const ContactPage = React.lazy(() => import('components/pages/ContactPage/index.js'))
|
||||
const PrivacyPage = React.lazy(() => import('components/pages/PrivacyPage/index.js'))
|
||||
const BusinessCardPage = React.lazy(() => import('components/pages/BusinessCardPage/index.js'))
|
||||
@@ -42,6 +43,14 @@ const router = createBrowserRouter([
|
||||
</React.Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'events',
|
||||
element: (
|
||||
<React.Suspense fallback={<Loading />}>
|
||||
<EventsPage />
|
||||
</React.Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'contacts',
|
||||
element: (
|
||||
|
||||
@@ -101,6 +101,11 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({ drawerWidth = 240, window
|
||||
<ListItemText primary="Personaggi" />
|
||||
</DrawerButton>
|
||||
</ListItem>
|
||||
<ListItem key="Eventi" disablePadding>
|
||||
<DrawerButton component={RouterLink} to="/events">
|
||||
<ListItemText primary="Eventi" />
|
||||
</DrawerButton>
|
||||
</ListItem>
|
||||
<ListItem key="Contatti" disablePadding>
|
||||
<DrawerButton component={RouterLink} to="/contacts">
|
||||
<ListItemText primary="Contatti" />
|
||||
@@ -163,9 +168,9 @@ const CustomToolbar: React.FC<CustomToolbarProps> = ({ drawerWidth = 240, window
|
||||
Compagnia d'Arme del Santo Luca
|
||||
</AppBarLink>
|
||||
<Stack component="div" direction="row" spacing={2} sx={{ display: { xs: 'none', md: 'flex', alignItems: 'center' } }}>
|
||||
<AppBarLink to="/">Home</AppBarLink>
|
||||
<AppBarLink to="/activities">Attività</AppBarLink>
|
||||
<AppBarLink to="/people">Personaggi</AppBarLink>
|
||||
<AppBarLink to="/events">Eventi</AppBarLink>
|
||||
<Button component={RouterLink} to="/contacts" color="primary" variant="contained" sx={{ textTransform: 'none' }}>
|
||||
<Typography variant="h6">Contattaci</Typography>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, Link, Stack, Typography } from '@mui/material'
|
||||
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'
|
||||
import LocationOnIcon from '@mui/icons-material/LocationOn'
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
|
||||
|
||||
import type { Event } from './types.js'
|
||||
|
||||
interface EventDialogProps {
|
||||
event: Event | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const formatDateRange = (date: Date, endDate?: Date): string => {
|
||||
const fmt = (d: Date) => d.toLocaleDateString('it-IT', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
if (!endDate) return fmt(date)
|
||||
if (date.getMonth() === endDate.getMonth() && date.getFullYear() === endDate.getFullYear()) {
|
||||
return `${date.getDate()}–${endDate.getDate()} ${date.toLocaleDateString('it-IT', { month: 'long', year: 'numeric' })}`
|
||||
}
|
||||
return `${fmt(date)} – ${fmt(endDate)}`
|
||||
}
|
||||
|
||||
const EventDialog: React.FC<EventDialogProps> = ({ event, onClose }) => {
|
||||
if (!event) return null
|
||||
|
||||
const osmUrl = `https://www.openstreetmap.org/?mlat=${event.location.lat}&mlon=${event.location.lng}#map=15/${event.location.lat}/${event.location.lng}`
|
||||
|
||||
return (
|
||||
<Dialog open={!!event} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
{event.image && <Box component="img" src={event.image} alt={event.title} sx={{ width: '100%', objectFit: 'cover' }} />}
|
||||
<DialogTitle>
|
||||
<Typography variant="h5" component="span">
|
||||
{event.details?.title ?? event.title}
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={1} mb={2}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<LocationOnIcon fontSize="small" color="primary" />
|
||||
<Typography variant="body2">
|
||||
{event.location.name}, {event.location.city}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<CalendarMonthIcon fontSize="small" color="primary" />
|
||||
<Typography variant="body2">{formatDateRange(event.date, event.endDate)}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{event.details?.description && (
|
||||
<>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{event.details.description}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{event.details?.howToReach && (
|
||||
<>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{event.details.howToReach}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2, justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" sx={{ minWidth: 0 }}>
|
||||
{event.link && (
|
||||
<Chip
|
||||
component={Link}
|
||||
href={event.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
label="Sito ufficiale"
|
||||
icon={<OpenInNewIcon />}
|
||||
clickable
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
component={Link}
|
||||
href={event.location.link || osmUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
label="Apri sulla mappa"
|
||||
icon={<LocationOnIcon />}
|
||||
clickable
|
||||
/>
|
||||
</Stack>
|
||||
<Button onClick={onClose} variant="contained" sx={{ justifySelf: 'flex-end' }}>
|
||||
Chiudi
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default EventDialog
|
||||
@@ -0,0 +1,58 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
|
||||
import ListView from './ListView.js'
|
||||
import type { Event } from './types.js'
|
||||
|
||||
const makeEvent = (id: string, title: string, date: string, city = 'Ferrara'): Event => ({
|
||||
id,
|
||||
title,
|
||||
date: new Date(date),
|
||||
location: { name: 'Piazza', city, lat: 44, lng: 11 },
|
||||
})
|
||||
|
||||
describe('Events list', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-10-19T12:00:00'))
|
||||
})
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('separates events and sorts upcoming ascending and past descending', () => {
|
||||
render(
|
||||
<ListView
|
||||
events={[
|
||||
makeEvent('past-old', 'Past old', '2026-01-01'),
|
||||
makeEvent('future-late', 'Future late', '2026-12-01'),
|
||||
makeEvent('past-new', 'Past new', '2026-10-01'),
|
||||
makeEvent('future-soon', 'Future soon', '2026-11-01'),
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const upcoming = within(screen.getByRole('region', { name: 'prossimi eventi' }))
|
||||
const past = within(screen.getByRole('region', { name: 'eventi passati' }))
|
||||
expect(upcoming.getAllByRole('heading', { level: 2 }).map(node => node.textContent)).toEqual(['Future soon', 'Future late'])
|
||||
expect(past.getAllByRole('heading', { level: 2 }).map(node => node.textContent)).toEqual(['Past new', 'Past old'])
|
||||
})
|
||||
|
||||
it('renders date ranges and invokes onSelect for the clicked card', () => {
|
||||
const onSelect = vi.fn()
|
||||
const event = { ...makeEvent('range', 'Long medieval event', '2026-11-01', 'San Martino'), endDate: new Date('2026-11-03') }
|
||||
render(<ListView events={[event]} onSelect={onSelect} />)
|
||||
|
||||
expect(screen.getByText(/1-3 novembre 2026/)).toBeInTheDocument()
|
||||
expect(screen.getByText('San Martino')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('heading', { name: 'Long medieval event' }))
|
||||
expect(onSelect).toHaveBeenCalledWith(event)
|
||||
})
|
||||
|
||||
it('uses a single fluid column on small screens', () => {
|
||||
render(<ListView events={[makeEvent('one', 'A very long event title that must wrap', '2026-11-01')]} onSelect={vi.fn()} />)
|
||||
|
||||
const card = screen.getByRole('heading', { name: /very long event/ }).closest('.MuiCard-root')
|
||||
expect(card).toHaveStyle({ width: '100%', minWidth: '0', boxSizing: 'border-box' })
|
||||
expect(screen.getByRole('region', { name: 'prossimi eventi' })).toHaveStyle({ width: '100%', minWidth: '0' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import React from 'react'
|
||||
import { Box, Card, CardActionArea, CardContent, CardMedia, Chip, Stack, Typography } from '@mui/material'
|
||||
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'
|
||||
import LocationOnIcon from '@mui/icons-material/LocationOn'
|
||||
import { InfoOutline } from '@mui/icons-material'
|
||||
|
||||
import type { Event } from './types.js'
|
||||
|
||||
interface ListViewProps {
|
||||
events: Event[]
|
||||
onSelect: (event: Event) => void
|
||||
}
|
||||
|
||||
const formatDateRange = (date: Date, endDate?: Date): string => {
|
||||
const fmt = (d: Date) => d.toLocaleDateString('it-IT', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
if (!endDate) return fmt(date)
|
||||
if (date.getMonth() === endDate.getMonth() && date.getFullYear() === endDate.getFullYear()) {
|
||||
return `${date.getDate()}-${endDate.getDate()} ${date.toLocaleDateString('it-IT', { month: 'long', year: 'numeric' })}`
|
||||
}
|
||||
return `${fmt(date)} - ${fmt(endDate)}`
|
||||
}
|
||||
|
||||
const isUpcoming = (date: Date) => date >= new Date()
|
||||
|
||||
const EventCard: React.FC<{ event: Event; onSelect: (event: Event) => void }> = ({ event, onSelect }) => {
|
||||
return (
|
||||
<Card
|
||||
key={event.id}
|
||||
elevation={2}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
opacity: isUpcoming(event.date) ? 1 : 0.6,
|
||||
// Cards must be allowed to shrink on phones. The previous fixed
|
||||
// width (30% of the `lg` breakpoint) was wider than small screens.
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
<CardActionArea
|
||||
onClick={() => onSelect(event)}
|
||||
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch', justifyContent: 'flex-end' }}>
|
||||
{event.image && <CardMedia component="img" height={160} image={event.image} alt={event.title} />}
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" mb={1}>
|
||||
<Typography variant="h6" component="h2" sx={{ flexGrow: 1, minWidth: 0, mr: 1, overflowWrap: 'anywhere' }}>
|
||||
{event.title}
|
||||
</Typography>
|
||||
{!isUpcoming(event.date) && <Chip label="Passato" size="small" color="default" />}
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ minWidth: 0 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5} mb={0.5}>
|
||||
<CalendarMonthIcon fontSize="small" color="primary" />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ overflowWrap: 'anywhere' }}>
|
||||
{formatDateRange(event.date, event.endDate)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||||
<LocationOnIcon fontSize="small" color="primary" />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ overflowWrap: 'anywhere' }}>
|
||||
{event.location.city}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<InfoOutline sx={{ color: 'text.secondary', flexShrink: 0, ml: 1 }} />
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const ListView: React.FC<ListViewProps> = ({ events, onSelect }) => {
|
||||
const eventGroups = React.useMemo(() => {
|
||||
const groups: { upcoming: Event[]; past: Event[] } = { upcoming: [], past: [] }
|
||||
|
||||
for (const event of events) {
|
||||
if (isUpcoming(event.date)) {
|
||||
groups.upcoming.push(event)
|
||||
} else {
|
||||
groups.past.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
groups.upcoming.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||
groups.past.sort((a, b) => b.date.getTime() - a.date.getTime())
|
||||
|
||||
return groups
|
||||
}, [events])
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4, width: '100%', minWidth: 0, pb: 4 }}>
|
||||
{eventGroups.upcoming.length > 0 && (
|
||||
<>
|
||||
<Typography variant="h5" component="h2" textAlign="center" mt={4} mb={2}>
|
||||
Prossimi eventi
|
||||
</Typography>
|
||||
<Box
|
||||
role="region"
|
||||
aria-label="prossimi eventi"
|
||||
sx={{
|
||||
display: 'grid',
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
gridTemplateColumns: { xs: 'minmax(0, 1fr)', sm: 'repeat(2, minmax(0, 1fr))', lg: 'repeat(3, minmax(0, 1fr))' },
|
||||
gap: 3,
|
||||
}}>
|
||||
{eventGroups.upcoming.map(event => (
|
||||
<EventCard key={event.id} event={event} onSelect={onSelect} />
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{eventGroups.past.length > 0 && (
|
||||
<>
|
||||
<Typography variant="h5" component="h3" textAlign="center" mt={4} mb={2}>
|
||||
Eventi passati
|
||||
</Typography>
|
||||
<Box
|
||||
role="region"
|
||||
aria-label="eventi passati"
|
||||
sx={{
|
||||
display: 'grid',
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
gridTemplateColumns: { xs: 'minmax(0, 1fr)', sm: 'repeat(2, minmax(0, 1fr))', lg: 'repeat(3, minmax(0, 1fr))' },
|
||||
gap: 3,
|
||||
}}>
|
||||
{eventGroups.past.map(event => (
|
||||
<EventCard key={event.id} event={event} onSelect={onSelect} />
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListView
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react'
|
||||
import { Box, Button, Chip, Stack, Typography, useTheme } from '@mui/material'
|
||||
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'
|
||||
import LocationOnIcon from '@mui/icons-material/LocationOn'
|
||||
import InfoIcon from '@mui/icons-material/InfoOutline'
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'
|
||||
import { divIcon } from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
import type { Event } from './types.js'
|
||||
|
||||
/** Creates a numbered teardrop pin SVG as a Leaflet divIcon */
|
||||
const makeNumberedIcon = (n: number, color: string, isPast: boolean) => {
|
||||
const fill = isPast ? '#9e9e9e' : color
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="42" viewBox="0 0 32 42">
|
||||
<path d="M16 0 C7.163 0 0 7.163 0 16 C0 28 16 42 16 42 C16 42 32 28 32 16 C32 7.163 24.837 0 16 0 Z"
|
||||
fill="${fill}" stroke="white" stroke-width="1.5"/>
|
||||
<text x="16" y="20" font-family="sans-serif" font-size="13" font-weight="bold"
|
||||
fill="white" text-anchor="middle" dominant-baseline="middle">${n}</text>
|
||||
</svg>`
|
||||
return divIcon({
|
||||
html: svg,
|
||||
className: '',
|
||||
iconSize: [32, 42],
|
||||
iconAnchor: [16, 42],
|
||||
popupAnchor: [0, -44],
|
||||
})
|
||||
}
|
||||
|
||||
interface MapViewProps {
|
||||
events: Event[]
|
||||
onSelect: (event: Event) => void
|
||||
}
|
||||
|
||||
const formatDateRange = (date: Date, endDate?: Date): string => {
|
||||
const fmt = (d: Date) => d.toLocaleDateString('it-IT', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
if (!endDate) return fmt(date)
|
||||
if (date.getMonth() === endDate.getMonth() && date.getFullYear() === endDate.getFullYear()) {
|
||||
return `${date.getDate()}–${endDate.getDate()} ${date.toLocaleDateString('it-IT', { month: 'long', year: 'numeric' })}`
|
||||
}
|
||||
return `${fmt(date)} – ${fmt(endDate)}`
|
||||
}
|
||||
|
||||
const isUpcoming = (date: Date) => date >= new Date()
|
||||
|
||||
const CENTER: [number, number] = [43.5, 12.5] // roughly central Italy
|
||||
|
||||
const MapView: React.FC<MapViewProps> = ({ events, onSelect }) => {
|
||||
const theme = useTheme()
|
||||
const primaryColor = theme.palette.primary.main
|
||||
const today = new Date()
|
||||
|
||||
const sorted = [...events].sort((a, b) => b.date.getTime() - a.date.getTime())
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
px: { xs: 2, md: 4, lg: 0 },
|
||||
pb: 4,
|
||||
'& .leaflet-container': {
|
||||
height: { xs: 420, md: 560 },
|
||||
width: '100%',
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
},
|
||||
}}>
|
||||
<MapContainer center={CENTER} zoom={6} scrollWheelZoom style={{ height: 560, width: '100%' }}>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CARTO</a>'
|
||||
url={`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png?key=${import.meta.env.VITE_EV_CARTO_KEY}`}
|
||||
/>
|
||||
{sorted.map((event, idx) => (
|
||||
<Marker
|
||||
key={event.id}
|
||||
position={[event.location.lat, event.location.lng]}
|
||||
icon={makeNumberedIcon(sorted.length - idx, primaryColor, event.date < today)}>
|
||||
<Popup>
|
||||
<Stack justifyContent="flex-end" gap={0.5}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={1}>
|
||||
<Typography variant="h6" component="h2" sx={{ flexGrow: 1 }}>
|
||||
{event.title}
|
||||
</Typography>
|
||||
{!isUpcoming(event.date) && <Chip label="Passato" size="small" color="default" />}
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
<LocationOnIcon fontSize="small" />
|
||||
<Typography variant="caption">
|
||||
{event.location.name}
|
||||
<br /> {event.location.city}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center" gap={0.5} mb={0.5}>
|
||||
<CalendarMonthIcon fontSize="small" />
|
||||
<Typography variant="caption">{formatDateRange(event.date, event.endDate)}</Typography>
|
||||
</Stack>
|
||||
<Button startIcon={<InfoIcon />} size="small" variant="contained" onClick={() => onSelect(event)}>
|
||||
Dettagli
|
||||
</Button>
|
||||
</Stack>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default MapView
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Event } from './types.js'
|
||||
|
||||
export const events: Event[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: '4 Passi nel Medioevo',
|
||||
date: new Date('2026-05-03'),
|
||||
endDate: new Date('2026-05-04'),
|
||||
location: {
|
||||
name: 'Castello dei Ronchi',
|
||||
city: 'Crevalcore (BO)',
|
||||
lat: 44.7559844,
|
||||
lng: 11.1382366,
|
||||
link: 'https://maps.app.goo.gl/eqNB13PpJ4G7Sq2DA',
|
||||
},
|
||||
link: 'https://www.4passinelmedioevo.com/',
|
||||
image: 'https://www.4passinelmedioevo.com/wp-content/uploads/2026/04/cropped-565125388_1208800397957599_3322632688455654651_n-scaled-1.jpg',
|
||||
details: {
|
||||
description:
|
||||
"La Compagnia d'Arme del Santo Luca partecipa al tradizionale Palio di Ferrara con uno spettacolo di combattimento storico e una mostra della vita civile medievale.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Armi e Bagabli',
|
||||
date: new Date('2026-03-21'),
|
||||
endDate: new Date('2026-03-22'),
|
||||
location: {
|
||||
name: 'Piacenza Expo',
|
||||
city: 'Piacenza (PC)',
|
||||
lat: 45.0423607,
|
||||
lng: 9.75375,
|
||||
link: 'https://maps.app.goo.gl/FQAtyyK5zCZyaGzx9',
|
||||
},
|
||||
link: 'https://armiebagagli.org/',
|
||||
image:
|
||||
'https://scontent-fco2-1.xx.fbcdn.net/v/t39.30808-6/687039739_1615155693950230_1523012821653618722_n.jpg?_nc_cat=110&ccb=1-7&_nc_sid=2a1932&_nc_ohc=301psXNiznUQ7kNvwFIU2A_&_nc_oc=AdqgQ3sUtKe-X02NMO-pOaTgFocqAEc4rep8ZTUFvGN7e1sij1OdLeErW01aLp6NJKE&_nc_zt=23&_nc_ht=scontent-fco2-1.xx&_nc_gid=vfSFXIAfYJiRdDTqz1le-g&_nc_ss=7b2a8&oh=00_Af5QgkkEnukOrBqB9EiDmBbgNtXI4_Qi3WZ8eNzsg1MwZA&oe=69FFD320',
|
||||
details: {
|
||||
description:
|
||||
'La più grande manifestazione italiana per operatori e appassionati della Rievocazione Storica, presso il quartiere fieristico di Piacenza, appuntamento imperdibile per chiunque operi nel settore storico/rievocativo.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'trecentesca',
|
||||
title: 'Trecentesca',
|
||||
date: new Date('2026-05-16'),
|
||||
endDate: new Date('2026-05-17'),
|
||||
location: {
|
||||
name: 'Abbazia di Morimondo',
|
||||
city: 'Morimondo (MI)',
|
||||
lat: 45.351915,
|
||||
lng: 8.954823,
|
||||
link: 'https://maps.app.goo.gl/oV97GdTZejNjpneD8',
|
||||
},
|
||||
link: 'https://www.abbaziamorimondo.it/trecentesca-2026',
|
||||
image:
|
||||
'https://scontent-fco2-1.xx.fbcdn.net/v/t39.30808-6/645894440_26176732645298731_6691381800434721321_n.jpg?_nc_cat=104&ccb=1-7&_nc_sid=25d718&_nc_ohc=EIa29pGGa5QQ7kNvwHIRuQt&_nc_oc=AdpEUIYfsBiSh8HpwsT5KkFdSYvlZYkUOCOUl9DYdEQ9PTbGR0uaDmVjI5HKy6cugb4&_nc_zt=23&_nc_ht=scontent-fco2-1.xx&_nc_gid=EcW53pTVT3DWReZnsh_SSw&_nc_ss=7b2a8&oh=00_Af5duE5CS05V2-KRwthjoxoib2SIeP18zu5obSxr1R6J7g&oe=69FFBB66',
|
||||
},
|
||||
{
|
||||
id: 'Palio dello Sparviero - Voci dal Medioevo di Trambaque',
|
||||
title: 'Palio dello Sparviero',
|
||||
date: new Date('2026-06-20'),
|
||||
location: {
|
||||
name: 'Castello di San Martino della Vaneza',
|
||||
city: 'Cervarese Santa Croce (PD)',
|
||||
lat: 45.4145888,
|
||||
lng: 11.7110493,
|
||||
link: 'https://maps.app.goo.gl/j1QwPm2CYL24vMQz6',
|
||||
},
|
||||
link: 'https://www.paliodellosparviero.it/',
|
||||
image: 'https://www.paliodellosparviero.it/wp-content/uploads/2021/06/e3c1afa4-630c-480f-acdd-0a5685c5c0ae.jpg',
|
||||
details: {
|
||||
title: 'Palio dello Sparviero - Voci dal Medioevo di Trambaque',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Giostra del Borgo',
|
||||
date: new Date('2026-06-13'),
|
||||
location: {
|
||||
name: 'Ippodromo',
|
||||
city: 'Ferrara',
|
||||
lat: 44.82578,
|
||||
lng: 11.612924,
|
||||
link: 'https://maps.app.goo.gl/mqgVWmm8crAz7ofUA',
|
||||
},
|
||||
image:
|
||||
'https://scontent-fco2-1.xx.fbcdn.net/v/t39.30808-6/495026144_998527592485792_333972602704298657_n.jpg?_nc_cat=111&ccb=1-7&_nc_sid=2a1932&_nc_ohc=V-Rj6Zro94UQ7kNvwGL_LTQ&_nc_oc=AdqbGYdKAgeN0MyVzbsnRbz31W9qqEIyJMzesfZl9r0f33UcVC-00gGtdM62Y6eLZso&_nc_zt=23&_nc_ht=scontent-fco2-1.xx&_nc_gid=XaR2VZ5CeSlQhvYnG2Ofbg&_nc_ss=7b2a8&oh=00_Af6CgCkib7vEwAVeuiPwlTTjkQrBeeLqzpE2ZkxzdWmxDA&oe=69FFB224',
|
||||
link: 'https://www.quintana.it',
|
||||
},
|
||||
{
|
||||
id: 'Mantova Medievale',
|
||||
title: 'Mantova Medievale',
|
||||
date: new Date('2026-11-07'),
|
||||
endDate: new Date('2026-11-08'),
|
||||
location: {
|
||||
name: 'Lungolago Gonzaga',
|
||||
city: 'Mantova',
|
||||
lat: 45.160947,
|
||||
lng: 10.799786,
|
||||
link: 'https://maps.app.goo.gl/QF5UjjLb5HRYgLxg8',
|
||||
},
|
||||
link: 'http://www.mantovamedievale.it/',
|
||||
image:
|
||||
'https://scontent-fco2-1.xx.fbcdn.net/v/t39.30808-6/475772202_928196622760603_187755049466880515_n.jpg?_nc_cat=108&ccb=1-7&_nc_sid=2a1932&_nc_ohc=GFNIVf-3cKoQ7kNvwHWpMoA&_nc_oc=AdqCNjkyT6mYI9D8sIsmAgfQdXtrVMNOthjth3dfcGCvVgxVSJSG_SVWroEcBPbyI7k&_nc_zt=23&_nc_ht=scontent-fco2-1.xx&_nc_gid=nD82EIE8zEzhzr8oQIF78A&_nc_ss=7b2a8&oh=00_Af6Jg9HJBlMy--pZ0eQp_QZ18k1b-hSmMDp-zUC6ID5Yrw&oe=69FFE2CC',
|
||||
},
|
||||
{
|
||||
id: 'Autunno Ducale nelle Terre Estensi',
|
||||
title: 'Autunno Ducale nelle Terre Estensi',
|
||||
date: new Date('2026-10-24'),
|
||||
endDate: new Date('2026-10-25'),
|
||||
location: {
|
||||
name: 'Ferrara',
|
||||
city: 'Ferrara',
|
||||
lat: 44.5,
|
||||
lng: 11.37,
|
||||
link: 'https://maps.app.goo.gl/TYLWP51PvjCjsxdw6',
|
||||
},
|
||||
link: 'https://www.prolocoferrara.org/category/eventi/autunno-ducale/',
|
||||
image: 'https://www.prolocoferrara.org/wp-content/uploads/2026/07/locandina-AUTUNNO-DUCALE-2026.jpg',
|
||||
details: {
|
||||
description:
|
||||
"La Compagnia d'Arme del Santo Luca partecipa al tradizionale Palio di Ferrara con uno spettacolo di combattimento storico e una mostra della vita civile medievale.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Usi & Costumi',
|
||||
title: 'Usi & Costumi',
|
||||
date: new Date('2026-11-07'),
|
||||
endDate: new Date('2026-11-08'),
|
||||
location: {
|
||||
name: 'Ferrara Fiere',
|
||||
city: 'Ferrara',
|
||||
lat: 44.8141398,
|
||||
lng: 11.584555,
|
||||
link: 'https://maps.app.goo.gl/q6MgoYrjm1uGxrrD8',
|
||||
},
|
||||
link: 'https://www.usiecostumi.org/',
|
||||
image: 'https://www.usiecostumi.org/wp-content/uploads/2024/04/usi-e-costumi-box.jpg',
|
||||
details: {
|
||||
description: 'La prima grande fiera internazionale, dedicata alla tradizione come strumento al servizio del turismo sostenibile.',
|
||||
},
|
||||
},
|
||||
//
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
import { Box, Tab, Tabs, Typography } from '@mui/material'
|
||||
import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted'
|
||||
import MapIcon from '@mui/icons-material/Map'
|
||||
|
||||
import { Page } from 'components/templates/index.js'
|
||||
import type { Event } from './types.js'
|
||||
import { events } from './events.js'
|
||||
import EventDialog from './EventDialog.js'
|
||||
import ListView from './ListView.js'
|
||||
|
||||
const MapView = React.lazy(() => import('./MapView.js'))
|
||||
|
||||
type ViewTab = 'list' | 'map'
|
||||
|
||||
const EventsPage = () => {
|
||||
const [tab, setTab] = React.useState<ViewTab>('list')
|
||||
// const [selectedTab, setSelectedTab] = React.useState<ViewTab>('list')
|
||||
const [selectedEvent, setSelectedEvent] = React.useState<Event | null>(null)
|
||||
|
||||
return (
|
||||
<Page
|
||||
title="Eventi"
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: theme => theme.breakpoints.values.lg,
|
||||
boxSizing: 'border-box',
|
||||
px: { xs: 2, sm: 3, md: 4 },
|
||||
mx: 'auto',
|
||||
}}>
|
||||
<Typography>
|
||||
Vieni a trovarci! Partecipiamo a rievocazioni storiche, fiere e manifestazioni in tutta Italia, portando la nostra passione per il Medioevo in
|
||||
ogni angolo del paese. Consulta il calendario per non perdere le nostre prossime avventure!
|
||||
</Typography>
|
||||
<Box>
|
||||
<Tabs value={tab} onChange={(_, v: ViewTab) => setTab(v)} centered aria-label="vista eventi">
|
||||
<Tab icon={<FormatListBulletedIcon />} iconPosition="start" label="Lista" value="list" />
|
||||
<Tab icon={<MapIcon />} iconPosition="start" label="Mappa" value="map" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{tab === 'list' && <ListView events={events} onSelect={setSelectedEvent} />}
|
||||
{tab === 'map' && (
|
||||
<React.Suspense fallback={null}>
|
||||
<MapView events={events} onSelect={setSelectedEvent} />
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
<EventDialog event={selectedEvent} onClose={() => setSelectedEvent(null)} />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export default EventsPage
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface EventLocation {
|
||||
name: string
|
||||
city: string
|
||||
lat: number
|
||||
lng: number
|
||||
link?: string
|
||||
}
|
||||
|
||||
export interface EventDetails {
|
||||
title?: string
|
||||
description?: string
|
||||
howToReach?: string
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
id: string
|
||||
title: string
|
||||
date: Date
|
||||
endDate?: Date
|
||||
location: EventLocation
|
||||
details?: EventDetails
|
||||
image?: string
|
||||
link?: string
|
||||
}
|
||||
Reference in New Issue
Block a user