visitapp-web/src/components/sections/EventSection.tsx
2026-05-07 16:50:19 +02:00

437 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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