visitapp-web/src/components/sections/AgendaSection.tsx
2026-05-07 15:28:48 +02:00

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>
)
}