334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import Image from 'next/image'
|
|
import dynamic from 'next/dynamic'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useVisitor } from '@/context/VisitorContext'
|
|
import { t, tPlain } from '@/lib/i18n'
|
|
import type { SectionDTO, EventAgendaDTO } from '@/lib/api/types'
|
|
import AppBar from '@/components/ui/AppBar'
|
|
import { trackEvent } from '@/lib/stats'
|
|
|
|
const EventMiniMap = dynamic(() => import('./agenda/EventMiniMap'), {
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="w-full h-full flex items-center justify-center text-xs" style={{ background: '#e8eef3', color: 'var(--color-text-muted)' }}>
|
|
Chargement…
|
|
</div>
|
|
),
|
|
})
|
|
|
|
interface Props {
|
|
section: SectionDTO
|
|
slug: string
|
|
configId: string
|
|
languages: string[]
|
|
}
|
|
|
|
function formatMonthHeader(year: number, month: number, lang: string): string {
|
|
const name = new Date(year, month, 1).toLocaleDateString(lang.toLowerCase(), { month: 'long' })
|
|
return name.charAt(0).toUpperCase() + name.slice(1) + ' ' + year
|
|
}
|
|
|
|
function eventImage(e: EventAgendaDTO): string | undefined {
|
|
return e.resource?.url
|
|
}
|
|
|
|
function eventCoords(e: EventAgendaDTO): { lat: number; lng: number } | null {
|
|
const c = e.address?.geometry?.coordinates
|
|
if (!Array.isArray(c) || c.length < 2) return null
|
|
return { lat: c[1], lng: c[0] }
|
|
}
|
|
|
|
function fullAddress(e: EventAgendaDTO): string {
|
|
const a = e.address
|
|
if (!a) return ''
|
|
return [
|
|
[a.streetNumber, a.streetName].filter(Boolean).join(' '),
|
|
[a.postCode, a.city].filter(Boolean).join(' '),
|
|
a.country,
|
|
].filter(Boolean).join(', ')
|
|
}
|
|
|
|
function youtubeEmbedUrl(idOrUrl: string): string {
|
|
if (idOrUrl.includes('youtube.com') || idOrUrl.includes('youtu.be')) {
|
|
const m = idOrUrl.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/)
|
|
if (m) return `https://www.youtube.com/embed/${m[1]}`
|
|
return idOrUrl
|
|
}
|
|
return `https://www.youtube.com/embed/${idOrUrl}`
|
|
}
|
|
|
|
export default function AgendaSection({ section, configId, languages }: Props) {
|
|
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
|
const router = useRouter()
|
|
const events = section.agenda?.events ?? []
|
|
|
|
useEffect(() => { setAvailableLanguages(languages) }, [languages])
|
|
|
|
const now = new Date()
|
|
const [selectedMonth, setSelectedMonth] = useState(now.getMonth())
|
|
const [selectedYear, setSelectedYear] = useState(now.getFullYear())
|
|
const [selected, setSelected] = useState<EventAgendaDTO | null>(null)
|
|
|
|
const filtered = events.filter((e) => {
|
|
if (!e.dateFrom) return false
|
|
const d = new Date(e.dateFrom)
|
|
return d.getMonth() === selectedMonth && d.getFullYear() === selectedYear
|
|
})
|
|
|
|
function prevMonth() {
|
|
if (selectedMonth === 0) { setSelectedMonth(11); setSelectedYear(y => y - 1) }
|
|
else setSelectedMonth(m => m - 1)
|
|
}
|
|
function nextMonth() {
|
|
if (selectedMonth === 11) { setSelectedMonth(0); setSelectedYear(y => y + 1) }
|
|
else setSelectedMonth(m => m + 1)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
|
|
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
|
|
|
|
{/* Month selector */}
|
|
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--color-border)' }}>
|
|
<button onClick={prevMonth} className="p-1">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-primary)' }}>
|
|
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
|
</svg>
|
|
</button>
|
|
<span className="font-semibold text-sm" style={{ color: 'var(--color-text)' }}>
|
|
{formatMonthHeader(selectedYear, selectedMonth, language)}
|
|
</span>
|
|
<button onClick={nextMonth} className="p-1">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-primary)' }}>
|
|
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Events grid */}
|
|
<main className="flex-1 overflow-y-auto p-3">
|
|
{filtered.length === 0 ? (
|
|
<p className="text-center py-12 text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
|
Aucun événement ce mois-ci
|
|
</p>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{filtered.map((event) => {
|
|
const img = eventImage(event)
|
|
return (
|
|
<button
|
|
key={event.id}
|
|
onClick={() => {
|
|
setSelected(event)
|
|
if (instanceId) {
|
|
trackEvent({
|
|
instanceId, configurationId: configId, sectionId: section.id,
|
|
eventType: 'AgendaEventTap', language,
|
|
metadata: JSON.stringify({ eventAgendaId: event.id, title: tPlain(event.label, language) }),
|
|
})
|
|
}
|
|
}}
|
|
className="rounded-2xl overflow-hidden text-left flex flex-col"
|
|
style={{ background: 'var(--color-surface)', boxShadow: '0 2px 8px rgba(0,0,0,0.10)' }}
|
|
>
|
|
{/* Image */}
|
|
<div className="relative w-full aspect-video">
|
|
{img ? (
|
|
<Image src={img} alt="" fill className="object-cover" sizes="50vw" />
|
|
) : (
|
|
<div className="w-full h-full" style={{ background: 'var(--color-primary-light)' }} />
|
|
)}
|
|
{event.dateFrom && (
|
|
<div
|
|
className="absolute bottom-0 right-0 flex items-center gap-1.5 px-2.5 py-1.5 text-sm font-bold whitespace-nowrap"
|
|
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)', borderTopLeftRadius: 10 }}
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ flexShrink: 0 }}>
|
|
<path d="M19 3h-1V1h-2v2H8V1H6v2H5a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 18H5V8h14v13z"/>
|
|
</svg>
|
|
{new Date(event.dateFrom).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'short' })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Title */}
|
|
<div className="p-3 flex-1 flex items-center justify-center">
|
|
<p
|
|
className="text-sm font-semibold text-center w-full [&_p]:m-0 line-clamp-3"
|
|
style={{ color: 'var(--color-text)' }}
|
|
dangerouslySetInnerHTML={{ __html: t(event.label, language) }}
|
|
/>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* Event detail popup */}
|
|
{selected && (
|
|
<EventDetail
|
|
event={selected}
|
|
language={language}
|
|
onClose={() => setSelected(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EventDetail({
|
|
event, language, onClose,
|
|
}: {
|
|
event: EventAgendaDTO
|
|
language: string
|
|
onClose: () => void
|
|
}) {
|
|
const img = eventImage(event)
|
|
const coords = eventCoords(event)
|
|
const address = fullAddress(event)
|
|
const yt = event.idVideoYoutube
|
|
const videoLink = event.videoLink
|
|
const videoResource = event.videoResource?.url
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex flex-col" style={{ background: 'var(--color-background)' }}>
|
|
<AppBar title={tPlain(event.label, language)} onBack={onClose} />
|
|
<div className="flex-1 overflow-y-auto">
|
|
{img && (
|
|
<div className="relative w-full" style={{ height: 220 }}>
|
|
<Image src={img} alt="" fill className="object-cover" sizes="100vw" />
|
|
</div>
|
|
)}
|
|
<div className="p-4 flex flex-col gap-4">
|
|
{event.dateFrom && (
|
|
<div
|
|
className="self-start px-3 py-1.5 rounded-full text-xs font-bold"
|
|
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
|
>
|
|
{new Date(event.dateFrom).toLocaleDateString(language.toLowerCase(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
|
{event.dateTo && event.dateTo !== event.dateFrom && (
|
|
<> → {new Date(event.dateTo).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{t(event.description, language) && (
|
|
<div
|
|
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"
|
|
style={{ color: 'var(--color-text)' }}
|
|
dangerouslySetInnerHTML={{ __html: t(event.description, language) }}
|
|
/>
|
|
)}
|
|
|
|
{/* Video */}
|
|
{(yt || videoLink || videoResource) && (
|
|
<div className="rounded-xl overflow-hidden" style={{ background: '#000', aspectRatio: '16/9' }}>
|
|
{yt ? (
|
|
<iframe
|
|
src={youtubeEmbedUrl(yt)}
|
|
title="Vidéo"
|
|
className="w-full h-full"
|
|
allowFullScreen
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
/>
|
|
) : videoLink ? (
|
|
videoLink.includes('youtube') || videoLink.includes('youtu.be') ? (
|
|
<iframe
|
|
src={youtubeEmbedUrl(videoLink)}
|
|
title="Vidéo"
|
|
className="w-full h-full"
|
|
allowFullScreen
|
|
/>
|
|
) : (
|
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
|
<video src={videoLink} controls className="w-full h-full" />
|
|
)
|
|
) : videoResource ? (
|
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
|
<video src={videoResource} controls className="w-full h-full" />
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{/* Mini map */}
|
|
{coords && (
|
|
<div className="rounded-xl overflow-hidden" style={{ height: 180, border: '1px solid var(--color-border)' }}>
|
|
<EventMiniMap lat={coords.lat} lng={coords.lng} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Address */}
|
|
{address && (
|
|
<a
|
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="flex items-center gap-3 p-3 rounded-xl"
|
|
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
|
|
>
|
|
<Icon name="place" />
|
|
<span className="text-sm flex-1" style={{ color: 'var(--color-text)' }}>{address}</span>
|
|
</a>
|
|
)}
|
|
|
|
{/* Contact */}
|
|
{(event.phone || event.email || event.website) && (
|
|
<div className="flex flex-col gap-2">
|
|
{event.phone && (
|
|
<a
|
|
href={`tel:${event.phone}`}
|
|
className="flex items-center gap-3 p-3 rounded-xl"
|
|
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }}
|
|
>
|
|
<Icon name="phone" />
|
|
<span className="text-sm">{event.phone}</span>
|
|
</a>
|
|
)}
|
|
{event.email && (
|
|
<a
|
|
href={`mailto:${event.email}`}
|
|
className="flex items-center gap-3 p-3 rounded-xl"
|
|
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }}
|
|
>
|
|
<Icon name="email" />
|
|
<span className="text-sm truncate">{event.email}</span>
|
|
</a>
|
|
)}
|
|
{event.website && (
|
|
<a
|
|
href={event.website.startsWith('http') ? event.website : `https://${event.website}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="flex items-center gap-3 p-3 rounded-xl"
|
|
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }}
|
|
>
|
|
<Icon name="web" />
|
|
<span className="text-sm truncate">{event.website}</span>
|
|
</a>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Icon({ name }: { name: 'phone' | 'email' | 'web' | 'place' }) {
|
|
const paths: Record<string, string> = {
|
|
phone: 'M6.62 10.79a15.05 15.05 0 0 0 6.59 6.59l2.2-2.2a1 1 0 0 1 1.05-.24c1.12.37 2.33.57 3.57.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57a1 1 0 0 1-.25 1.05l-2.2 2.17z',
|
|
email: 'M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z',
|
|
web: 'M12 2A10 10 0 1 0 22 12 10 10 0 0 0 12 2zm6.93 6h-2.95a15.65 15.65 0 0 0-1.38-3.56A8 8 0 0 1 18.92 8zM12 4a14 14 0 0 1 1.81 4h-3.62A14 14 0 0 1 12 4zM4.26 14a8 8 0 0 1 0-4h3.38a16.5 16.5 0 0 0 0 4zm.82 2h2.95a15.65 15.65 0 0 0 1.38 3.56A8 8 0 0 1 5.08 16zm2.95-8H5.08a8 8 0 0 1 4.33-3.56A15.65 15.65 0 0 0 8.05 8zM12 20a14 14 0 0 1-1.81-4h3.62A14 14 0 0 1 12 20zm2.34-6h-4.68a14.6 14.6 0 0 1 0-4h4.68a14.6 14.6 0 0 1 0 4zm.25 5.56A15.65 15.65 0 0 0 15.97 16h2.95a8 8 0 0 1-4.33 3.56zM16.36 14a16.5 16.5 0 0 0 0-4h3.38a8 8 0 0 1 0 4z',
|
|
place: 'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z',
|
|
}
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d={paths[name]} />
|
|
</svg>
|
|
)
|
|
}
|