437 lines
18 KiB
TypeScript
437 lines
18 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
import dynamic from 'next/dynamic'
|
||
import { useVisitor } from '@/context/VisitorContext'
|
||
import { t, tPlain } from '@/lib/i18n'
|
||
import type { SectionDTO, ProgrammeBlock, MapAnnotationDTO, GuidedPathDTO } from '@/lib/api/types'
|
||
|
||
const EventMap = dynamic(() => import('./event/EventMap'), {
|
||
ssr: false,
|
||
loading: () => (
|
||
<div className="w-full h-full flex items-center justify-center" style={{ background: '#e8eef3' }}>
|
||
<div className="text-sm" style={{ color: 'var(--color-text-muted)' }}>Chargement de la carte…</div>
|
||
</div>
|
||
),
|
||
})
|
||
|
||
interface Props {
|
||
section: SectionDTO
|
||
slug: string
|
||
configId: string
|
||
languages: string[]
|
||
}
|
||
|
||
function formatDateRange(start?: string, end?: string): string {
|
||
if (!start) return ''
|
||
const d1 = new Date(start)
|
||
const d2 = end ? new Date(end) : null
|
||
const fmt = (d: Date) => `${d.getDate().toString().padStart(2, '0')}/${(d.getMonth() + 1).toString().padStart(2, '0')}`
|
||
if (!d2 || d1.toDateString() === d2.toDateString()) return fmt(d1)
|
||
if (d1.getFullYear() === d2.getFullYear()) return `${fmt(d1)} → ${fmt(d2)}/${d2.getFullYear()}`
|
||
return `${fmt(d1)}/${d1.getFullYear()} → ${fmt(d2)}/${d2.getFullYear()}`
|
||
}
|
||
|
||
function formatTime(iso?: string): string {
|
||
if (!iso) return ''
|
||
const d = new Date(iso)
|
||
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
|
||
}
|
||
|
||
export default function EventSection({ section, slug, configId, languages }: Props) {
|
||
const { language, setAvailableLanguages } = useVisitor()
|
||
const router = useRouter()
|
||
|
||
useEffect(() => { setAvailableLanguages([]) }, [languages])
|
||
|
||
const event = section.event
|
||
const programme = useMemo(
|
||
() => [...(event?.programme ?? [])].sort((a, b) => {
|
||
const ta = a.startTime ? new Date(a.startTime).getTime() : 0
|
||
const tb = b.startTime ? new Date(b.startTime).getTime() : 0
|
||
return ta - tb
|
||
}),
|
||
[event]
|
||
)
|
||
const globalAnnotations = useMemo(() => event?.globalMapAnnotations ?? [], [event])
|
||
|
||
const activeBlock = useMemo<ProgrammeBlock | null>(() => {
|
||
const now = Date.now()
|
||
return programme.find((b) => {
|
||
if (!b.startTime || !b.endTime) return false
|
||
const s = new Date(b.startTime).getTime()
|
||
const e = new Date(b.endTime).getTime()
|
||
return now >= s && now <= e
|
||
}) ?? null
|
||
}, [programme])
|
||
|
||
const [selectedBlock, setSelectedBlock] = useState<ProgrammeBlock | null>(null)
|
||
const [mapFullscreen, setMapFullscreen] = useState(false)
|
||
|
||
const [primaryColor, setPrimaryColor] = useState('#3a6ea5')
|
||
useEffect(() => {
|
||
const c = getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim()
|
||
if (c) setPrimaryColor(c)
|
||
}, [])
|
||
|
||
const centerLat = section.latitude ?? 50.85
|
||
const centerLng = section.longitude ?? 4.35
|
||
|
||
const hasMap = globalAnnotations.length > 0 || !!event?.baseSectionMapId
|
||
const hasPaths = (section.event as { guidedPaths?: GuidedPathDTO[] } | undefined)?.guidedPaths?.length
|
||
const paths: GuidedPathDTO[] = (section.event as { guidedPaths?: GuidedPathDTO[] } | undefined)?.guidedPaths ?? []
|
||
|
||
const description = t(section.description, language)
|
||
|
||
return (
|
||
<div style={{ position: 'fixed', inset: 0, background: 'var(--color-background)', overflowY: 'auto' }}>
|
||
{/* Hero */}
|
||
<div
|
||
className="relative"
|
||
style={{
|
||
height: '52vh',
|
||
minHeight: 280,
|
||
background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{section.imageSource && (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={section.imageSource} alt="" className="absolute inset-0 w-full h-full object-cover" />
|
||
)}
|
||
<div className="absolute inset-0" style={{ background: 'linear-gradient(to bottom, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.05) 40%, rgba(0,0,0,0.7) 100%)' }} />
|
||
|
||
<div className="relative z-10 flex items-center px-3 py-3" style={{ paddingTop: 'max(env(safe-area-inset-top), 12px)' }}>
|
||
<button
|
||
onClick={() => router.back()}
|
||
className="w-10 h-10 rounded-full flex items-center justify-center"
|
||
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(8px)' }}
|
||
aria-label="Retour"
|
||
>
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
|
||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="absolute bottom-5 left-5 right-5 flex flex-col gap-2">
|
||
{(event?.startDate || event?.endDate) && (
|
||
<span
|
||
className="self-start px-3 py-1 rounded-full text-xs font-bold"
|
||
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)', boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }}
|
||
>
|
||
{formatDateRange(event?.startDate, event?.endDate)}
|
||
</span>
|
||
)}
|
||
<h1
|
||
className="text-white text-2xl font-bold leading-tight [&_p]:m-0"
|
||
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
|
||
dangerouslySetInnerHTML={{ __html: t(section.title, language) || 'Événement' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Programme */}
|
||
{programme.length > 0 && (
|
||
<Section title="Programme">
|
||
<div className="flex flex-col">
|
||
{programme.map((block, i) => {
|
||
const isActive = activeBlock?.id === block.id
|
||
return (
|
||
<button
|
||
key={block.id ?? i}
|
||
onClick={() => setSelectedBlock(block)}
|
||
className="flex gap-3 text-left"
|
||
>
|
||
{/* Time + dot column */}
|
||
<div className="flex flex-col items-center" style={{ width: 56 }}>
|
||
<div className="text-xs font-bold" style={{ color: isActive ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>
|
||
{formatTime(block.startTime)}
|
||
</div>
|
||
<div
|
||
className="w-3 h-3 rounded-full mt-1"
|
||
style={{
|
||
background: isActive ? 'var(--color-primary)' : 'var(--color-border)',
|
||
boxShadow: isActive ? `0 0 0 4px ${primaryColor}33` : undefined,
|
||
}}
|
||
/>
|
||
{i < programme.length - 1 && (
|
||
<div className="flex-1 w-px mt-1" style={{ background: 'var(--color-border)', minHeight: 30 }} />
|
||
)}
|
||
</div>
|
||
|
||
{/* Block content */}
|
||
<div
|
||
className="flex-1 mb-3 rounded-xl p-3"
|
||
style={{
|
||
background: isActive ? 'var(--color-primary-light)' : 'var(--color-surface)',
|
||
border: `1px solid ${isActive ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||
}}
|
||
>
|
||
<div className="flex items-start justify-between gap-2 mb-1">
|
||
<div
|
||
className="text-sm font-semibold flex-1 [&_p]:m-0"
|
||
style={{ color: 'var(--color-text)' }}
|
||
dangerouslySetInnerHTML={{ __html: t(block.title, language) || 'Bloc' }}
|
||
/>
|
||
{isActive && (
|
||
<span
|
||
className="text-[10px] font-bold px-2 py-0.5 rounded-full flex-shrink-0"
|
||
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
|
||
>
|
||
EN COURS
|
||
</span>
|
||
)}
|
||
</div>
|
||
{(block.startTime && block.endTime) && (
|
||
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||
{formatTime(block.startTime)} – {formatTime(block.endTime)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
{/* Carte */}
|
||
{hasMap && (
|
||
<Section title="Carte">
|
||
<button
|
||
onClick={() => setMapFullscreen(true)}
|
||
className="block w-full rounded-2xl overflow-hidden relative"
|
||
style={{ height: 220, border: '1px solid var(--color-border)' }}
|
||
>
|
||
<EventMap
|
||
globalAnnotations={globalAnnotations}
|
||
blockAnnotations={activeBlock?.mapAnnotations ?? []}
|
||
centerLat={centerLat}
|
||
centerLng={centerLng}
|
||
zoom={14}
|
||
primaryColor={primaryColor}
|
||
/>
|
||
<div
|
||
className="absolute top-2 right-2 px-2.5 py-1 rounded-full text-xs font-semibold flex items-center gap-1"
|
||
style={{ background: 'rgba(255,255,255,0.95)', color: 'var(--color-text)' }}
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M3 5v4h2V5h4V3H5c-1.1 0-2 .9-2 2zm2 10H3v4c0 1.1.9 2 2 2h4v-2H5v-4zm14 4h-4v2h4c1.1 0 2-.9 2-2v-4h-2v4zm0-16h-4v2h4v4h2V5c0-1.1-.9-2-2-2z" />
|
||
</svg>
|
||
Plein écran
|
||
</div>
|
||
</button>
|
||
{activeBlock && (activeBlock.mapAnnotations?.length ?? 0) > 0 && (
|
||
<div className="mt-2 text-xs flex items-center gap-1.5" style={{ color: 'var(--color-text-muted)' }}>
|
||
<span className="w-2 h-2 rounded-full" style={{ background: '#f97316' }} />
|
||
Annotations du bloc en cours en orange
|
||
</div>
|
||
)}
|
||
</Section>
|
||
)}
|
||
|
||
{/* Parcours */}
|
||
{hasPaths && (
|
||
<Section title="Parcours">
|
||
<div className="text-xs mb-2" style={{ color: 'var(--color-text-muted)' }}>
|
||
{paths.length} parcours disponible{paths.length > 1 ? 's' : ''}
|
||
</div>
|
||
<div className="flex gap-2 overflow-x-auto pb-2 -mx-4 px-4">
|
||
{paths.map((p) => (
|
||
<button
|
||
key={p.id}
|
||
onClick={() => {
|
||
// Navigate to the SectionMap page if event has baseSectionMapId, otherwise stay
|
||
if (event?.baseSectionMapId) {
|
||
router.push(`/${slug}/${configId}/sections/${event.baseSectionMapId}`)
|
||
}
|
||
}}
|
||
className="flex-shrink-0 flex flex-col gap-2 p-3 rounded-2xl text-left"
|
||
style={{
|
||
width: 180,
|
||
background: 'var(--color-surface)',
|
||
border: '1px solid var(--color-border)',
|
||
}}
|
||
>
|
||
<div
|
||
className="w-10 h-10 rounded-xl flex items-center justify-center"
|
||
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
|
||
>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3z" />
|
||
</svg>
|
||
</div>
|
||
<div
|
||
className="text-sm font-semibold line-clamp-2 [&_p]:m-0"
|
||
style={{ color: 'var(--color-text)' }}
|
||
dangerouslySetInnerHTML={{ __html: t(p.title, language) || 'Parcours' }}
|
||
/>
|
||
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||
{p.steps?.length ?? 0} étape{(p.steps?.length ?? 0) > 1 ? 's' : ''}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
{/* À propos */}
|
||
{description && (
|
||
<Section title="À propos">
|
||
<div
|
||
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"
|
||
style={{ color: 'var(--color-text)' }}
|
||
dangerouslySetInnerHTML={{ __html: description }}
|
||
/>
|
||
</Section>
|
||
)}
|
||
|
||
<div className="h-12" />
|
||
|
||
{/* Block detail bottom sheet */}
|
||
{selectedBlock && (
|
||
<BlockDetailSheet
|
||
block={selectedBlock}
|
||
language={language}
|
||
onClose={() => setSelectedBlock(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* Fullscreen map overlay */}
|
||
{mapFullscreen && (
|
||
<div className="fixed inset-0 z-[2000]" style={{ background: 'var(--color-background)' }}>
|
||
<EventMap
|
||
globalAnnotations={globalAnnotations}
|
||
blockAnnotations={activeBlock?.mapAnnotations ?? []}
|
||
centerLat={centerLat}
|
||
centerLng={centerLng}
|
||
zoom={14}
|
||
primaryColor={primaryColor}
|
||
/>
|
||
<div
|
||
className="absolute top-0 left-0 right-0 flex items-center px-3 py-3 z-[10]"
|
||
style={{
|
||
background: 'linear-gradient(to bottom, rgba(0,0,0,0.55), rgba(0,0,0,0))',
|
||
paddingTop: 'max(env(safe-area-inset-top), 12px)',
|
||
}}
|
||
>
|
||
<button
|
||
onClick={() => setMapFullscreen(false)}
|
||
className="w-10 h-10 rounded-full flex items-center justify-center"
|
||
style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)' }}
|
||
aria-label="Fermer"
|
||
>
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
|
||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||
</svg>
|
||
</button>
|
||
<span
|
||
className="ml-3 text-base font-semibold flex-1 truncate"
|
||
style={{ color: 'white', textShadow: '0 1px 4px rgba(0,0,0,0.6)' }}
|
||
>
|
||
{tPlain(section.title, language)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<section className="px-4 pt-5">
|
||
<h2 className="text-sm font-bold uppercase tracking-wider mb-3" style={{ color: 'var(--color-text-muted)' }}>
|
||
{title}
|
||
</h2>
|
||
{children}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function BlockDetailSheet({
|
||
block, language, onClose,
|
||
}: {
|
||
block: ProgrammeBlock
|
||
language: string
|
||
onClose: () => void
|
||
}) {
|
||
return (
|
||
<>
|
||
<div className="fixed inset-0 z-[1500]" style={{ background: 'rgba(0,0,0,0.4)' }} onClick={onClose} />
|
||
<div
|
||
className="fixed left-0 right-0 bottom-0 z-[1600] rounded-t-3xl overflow-hidden flex flex-col"
|
||
style={{
|
||
background: 'var(--color-surface)',
|
||
maxHeight: '78%',
|
||
boxShadow: '0 -8px 24px rgba(0,0,0,0.2)',
|
||
animation: 'mim-slide-up 0.25s ease',
|
||
}}
|
||
>
|
||
<style>{`@keyframes mim-slide-up { from { transform: translateY(100%); } to { transform: translateY(0); } }`}</style>
|
||
<div className="flex justify-center pt-2 pb-1">
|
||
<div style={{ width: 40, height: 4, borderRadius: 2, background: 'var(--color-border)' }} />
|
||
</div>
|
||
<div className="px-5 pb-3 flex items-start justify-between gap-3">
|
||
<div
|
||
className="text-lg font-bold flex-1 [&_p]:m-0"
|
||
style={{ color: 'var(--color-text)' }}
|
||
dangerouslySetInnerHTML={{ __html: t(block.title, language) || 'Bloc' }}
|
||
/>
|
||
<button onClick={onClose} className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0" style={{ background: 'var(--color-background)' }}>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="var(--color-text)">
|
||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
{(block.startTime && block.endTime) && (
|
||
<div className="px-5 pb-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||
{formatTime(block.startTime)} – {formatTime(block.endTime)}
|
||
</div>
|
||
)}
|
||
<div className="overflow-y-auto px-5 pb-5 flex flex-col gap-4">
|
||
{t(block.description, language) && (
|
||
<div
|
||
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"
|
||
style={{ color: 'var(--color-text)' }}
|
||
dangerouslySetInnerHTML={{ __html: t(block.description, language) }}
|
||
/>
|
||
)}
|
||
{(block.mapAnnotations?.length ?? 0) > 0 && (
|
||
<div>
|
||
<div className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--color-text-muted)' }}>
|
||
Lieux concernés
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
{block.mapAnnotations!.map((a, i) => (
|
||
<div
|
||
key={a.id ?? i}
|
||
className="flex items-center gap-2 p-2 rounded-lg"
|
||
style={{ background: 'var(--color-background)' }}
|
||
>
|
||
<div
|
||
className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
|
||
style={{ background: '#f97316' }}
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
|
||
<path d="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" />
|
||
</svg>
|
||
</div>
|
||
<div
|
||
className="text-sm flex-1 [&_p]:m-0"
|
||
style={{ color: 'var(--color-text)' }}
|
||
dangerouslySetInnerHTML={{ __html: t(a.label, language) || 'Lieu' }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|