'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, GeoPointDTO, GuidedPathDTO, GuidedStepDTO } from '@/lib/api/types' import { useGeolocation } from '@/hooks/useGeolocation' import { haversineMeters, formatDistance } from '@/lib/geo' import { trackEvent } from '@/lib/stats' import StepTimer from './map/StepTimer' import StepQuiz from './map/StepQuiz' import Toast from './map/Toast' import './map/map.css' const LeafletMap = dynamic(() => import('./map/LeafletMap'), { ssr: false, loading: () => (
Chargement de la carte…
), }) interface Props { section: SectionDTO slug: string configId: string languages: string[] } type Mode = 'map' | 'list' export default function MapSection({ section, configId, languages }: Props) { const { language, setAvailableLanguages, instanceId } = useVisitor() const router = useRouter() useEffect(() => { setAvailableLanguages([]) }, [languages]) const map = section.map const hasData = !!map && (map.points?.length ?? 0) > 0 const points = useMemo(() => map?.points ?? [], [map]) const categories = useMemo(() => map?.categories ?? [], [map]) const guidedPaths = useMemo(() => map?.guidedPaths ?? [], [map]) const [mode, setMode] = useState('map') const [selectedId, setSelectedId] = useState(null) const [search, setSearch] = useState('') const [activeCats, setActiveCats] = useState>(new Set()) const [filterOpen, setFilterOpen] = useState(false) const [pathListOpen, setPathListOpen] = useState(false) const [activePathId, setActivePathId] = useState(null) const [activeStepId, setActiveStepId] = useState(null) const activePath = guidedPaths.find((p) => p.id === activePathId) ?? null const activeSteps = useMemo( () => [...(activePath?.steps ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)), [activePath] ) const activeStep = activeSteps.find((s) => s.id === activeStepId) ?? null const [completedSteps, setCompletedSteps] = useState>(new Set()) const [quizPassedSteps, setQuizPassedSteps] = useState>(new Set()) const [timerExpiredSteps, setTimerExpiredSteps] = useState>(new Set()) const [zoneNotifiedSteps, setZoneNotifiedSteps] = useState>(new Set()) const [toastMessage, setToastMessage] = useState(null) const [showEndModal, setShowEndModal] = useState(false) const [geoEnabledKey, setGeoEnabledKey] = useState(0) // Reset progression when path changes useEffect(() => { setCompletedSteps(new Set()) setQuizPassedSteps(new Set()) setTimerExpiredSteps(new Set()) setZoneNotifiedSteps(new Set()) setShowEndModal(false) setToastMessage(null) }, [activePathId]) // First non-completed step in order = current const currentStepId = useMemo(() => { if (!activePath) return null const next = activeSteps.find((s) => !completedSteps.has(s.id)) return next?.id ?? null }, [activePath, activeSteps, completedSteps]) // Hide future steps if path requires it; also hide isHiddenInitially steps not yet current/completed const visibleSteps = useMemo(() => { if (!activePath) return [] let list = activeSteps if (activePath.hideNextStepsUntilComplete) { const idx = currentStepId ? activeSteps.findIndex((s) => s.id === currentStepId) : activeSteps.length list = activeSteps.slice(0, idx + 1) } return list.filter((s) => { if (!s.isHiddenInitially) return true return completedSteps.has(s.id) || s.id === currentStepId }) }, [activePath, activeSteps, currentStepId, completedSteps]) function markStepComplete(stepId: string) { setCompletedSteps((prev) => { const next = new Set(prev) next.add(stepId) return next }) const idx = activeSteps.findIndex((s) => s.id === stepId) if (idx === activeSteps.length - 1) { setShowEndModal(true) setActiveStepId(null) } else { const nextStep = activeSteps[idx + 1] if (nextStep) setActiveStepId(nextStep.id) } } const geo = useGeolocation(!!activePath, geoEnabledKey) // Toast on first entry into the current step's zone useEffect(() => { if (!activePath || !currentStepId) return const step = activeSteps.find((s) => s.id === currentStepId) if (!step) return const radius = step.zoneRadiusMeters ?? 0 const coords = step.geometry?.coordinates if (radius <= 0 || !coords || geo.lat == null || geo.lng == null) return const dist = haversineMeters({ lat: geo.lat, lng: geo.lng }, { lat: coords[1], lng: coords[0] }) if (dist <= radius && !zoneNotifiedSteps.has(currentStepId)) { const title = tPlain(step.title, language) || 'cette étape' setToastMessage(`Vous êtes arrivé à « ${title} »`) setZoneNotifiedSteps((prev) => { const next = new Set(prev) next.add(currentStepId) return next }) } }, [activePath, currentStepId, activeSteps, geo.lat, geo.lng, zoneNotifiedSteps, language]) const norm = (s: string) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() const filteredPoints = useMemo(() => { const q = norm(search.trim()) return points.filter((p) => { if (activeCats.size > 0 && (p.categorieId == null || !activeCats.has(p.categorieId))) return false if (!q) return true const title = norm(tPlain(p.title, language)) return title.includes(q) }) }, [points, activeCats, search, language]) const center: [number, number] = useMemo(() => { const lat = parseFloat(map?.centerLatitude ?? '') const lng = parseFloat(map?.centerLongitude ?? '') if (!isNaN(lat) && !isNaN(lng)) return [lat, lng] const first = points.find((p) => p.geometry?.coordinates) if (first?.geometry?.coordinates) { return [first.geometry.coordinates[1], first.geometry.coordinates[0]] } return [50.5, 4.5] }, [map, points]) const zoom = map?.zoom ?? 14 const selected = points.find((p) => p.id === selectedId) ?? null const [primaryColor, setPrimaryColor] = useState('#3a6ea5') useEffect(() => { const c = getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim() if (c) setPrimaryColor(c) }, []) function toggleCat(id: number) { setActiveCats((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } if (!hasData) { return (
{tPlain(section.title, language)}
Aucun lieu à afficher sur la carte.
) } return (
{/* Map / List */} {mode === 'map' ? ( { setSelectedId(id); setActiveStepId(null) const p = points.find((pp) => pp.id === id) if (instanceId && p) { trackEvent({ instanceId, configurationId: configId, sectionId: section.id, eventType: 'MapPoiTap', language, metadata: JSON.stringify({ geoPointId: p.id, title: tPlain(p.title, language) }), }) } }} primaryColor={primaryColor} pathSteps={visibleSteps} selectedStepId={activeStepId} onSelectStep={(id) => { setActiveStepId(id); setSelectedId(null) }} currentStepId={currentStepId} completedStepIds={completedSteps} userPosition={geo.lat != null && geo.lng != null ? { lat: geo.lat, lng: geo.lng } : null} /> ) : ( { setSelectedId(id); setMode('map') }} /> )} {/* Top dark AppBar */}
{tPlain(section.title, language)} {guidedPaths.length > 0 && ( )}
{/* Filter panel */} {filterOpen && (
setSearch(e.target.value)} placeholder="Rechercher un lieu…" className="flex-1 bg-transparent text-sm outline-none" style={{ color: '#1a1a1a' }} /> {search && ( )}
{categories.length > 0 && (
{categories.map((cat) => { const active = activeCats.has(cat.id) const color = cat.color || primaryColor return ( ) })}
)}
{filteredPoints.length} lieu{filteredPoints.length > 1 ? 'x' : ''} {(activeCats.size > 0 || search) && ( )}
)} {/* Bottom-right toggle */} {map?.isListViewEnabled && ( )} {/* Quitter parcours button (when active) */} {activePath && !pathListOpen && !activeStep && !selected && ( )} {/* Detail sheet (POI) */} {selected && ( setSelectedId(null)} /> )} {/* Step detail sheet (parcours) */} {activeStep && activePath && ( s.id === activeStep.id)} totalSteps={activeSteps.length} language={language} geo={geo} isCurrent={activeStep.id === currentStepId} isCompleted={completedSteps.has(activeStep.id)} quizPassed={quizPassedSteps.has(activeStep.id)} timerExpired={timerExpiredSteps.has(activeStep.id)} requireSuccess={!!activePath.requireSuccessToAdvance} isLinear={!!activePath.isLinear} onQuizResult={(passed) => { if (passed) { setQuizPassedSteps((prev) => { const next = new Set(prev) next.add(activeStep.id) return next }) } }} onTimerExpired={() => { setTimerExpiredSteps((prev) => { const next = new Set(prev) next.add(activeStep.id) return next }) }} onRetryGeo={() => setGeoEnabledKey((k) => k + 1)} onClose={() => setActiveStepId(null)} onMarkComplete={() => markStepComplete(activeStep.id)} onPrev={() => { const i = activeSteps.findIndex((s) => s.id === activeStep.id) if (i > 0) setActiveStepId(activeSteps[i - 1].id) }} onNext={() => { const i = activeSteps.findIndex((s) => s.id === activeStep.id) if (i < activeSteps.length - 1) setActiveStepId(activeSteps[i + 1].id) }} /> )} {/* Toast (entry into zone) */} {toastMessage && ( setToastMessage(null)} /> )} {/* End of path modal */} {showEndModal && activePath && ( { setCompletedSteps(new Set()) setShowEndModal(false) if (activeSteps[0]) setActiveStepId(activeSteps[0].id) }} onLeave={() => { setActivePathId(null) setActiveStepId(null) setShowEndModal(false) }} /> )} {/* Path list bottom sheet */} {pathListOpen && ( { setActivePathId(id) setActiveStepId(null) setSelectedId(null) setPathListOpen(false) }} onLeave={() => { setActivePathId(null) setActiveStepId(null) setPathListOpen(false) }} onClose={() => setPathListOpen(false)} /> )}
) } // ── Path list bottom sheet ────────────────────────────────────────────────── function PathListSheet({ paths, activePathId, language, onSelect, onLeave, onClose, }: { paths: GuidedPathDTO[] activePathId: string | null language: string onSelect: (id: string) => void onLeave: () => void onClose: () => void }) { return ( <>
Parcours
{activePathId && ( )}
{paths.map((p) => { const active = p.id === activePathId const stepCount = p.steps?.length ?? 0 return ( ) })}
) } // ── Step detail bottom sheet ──────────────────────────────────────────────── function StepDetail({ step, stepIndex, totalSteps, language, geo, isCurrent, isCompleted, quizPassed, timerExpired, requireSuccess, isLinear, onQuizResult, onTimerExpired, onRetryGeo, onClose, onMarkComplete, onPrev, onNext, }: { step: GuidedStepDTO stepIndex: number totalSteps: number language: string geo: ReturnType isCurrent: boolean isCompleted: boolean quizPassed: boolean timerExpired: boolean requireSuccess: boolean isLinear: boolean onQuizResult: (passed: boolean) => void onTimerExpired: () => void onRetryGeo: () => void onClose: () => void onMarkComplete: () => void onPrev: () => void onNext: () => void }) { const hasPrev = stepIndex > 0 && !isLinear const hasNext = stepIndex < totalSteps - 1 && !isLinear const stepCoords = step.geometry?.coordinates const stepLat = stepCoords?.[1] const stepLng = stepCoords?.[0] const radius = step.zoneRadiusMeters ?? 0 const distance = geo.lat != null && geo.lng != null && stepLat != null && stepLng != null ? haversineMeters({ lat: geo.lat, lng: geo.lng }, { lat: stepLat, lng: stepLng }) : null const inZone = distance != null && radius > 0 && distance <= radius const hasQuiz = (step.quizQuestions?.length ?? 0) > 0 const hasTimer = !!step.isStepTimer && (step.timerSeconds ?? 0) > 0 const isLocked = !!step.isStepLocked const geoUnavailable = geo.status === 'denied' || geo.status === 'unavailable' // canAdvance composé : // - quiz : si présent + requireSuccess → doit être passé // - zone : si présente + requireSuccess → doit être dans la zone (sauf si GPS indispo) // - lock : si verrouillé + requireSuccess → doit avoir au moins une preuve (quiz ou zone) const quizOk = !hasQuiz || quizPassed || !requireSuccess const zoneOk = radius === 0 || inZone || !requireSuccess || geoUnavailable const lockOk = !isLocked || !requireSuccess || quizPassed || inZone || geoUnavailable const canAdvance = quizOk && zoneOk && lockOk return ( <>
{step.imageUrl && ( // eslint-disable-next-line @next/next/no-img-element )}
Étape {stepIndex + 1} / {totalSteps}
{t(step.description, language) && (
)} {radius > 0 && (
{inZone ? 'Vous êtes dans la zone' : distance != null ? `À ${formatDistance(distance)} de la zone (${radius} m)` : `Zone à atteindre · ${radius} m`}
{geo.status === 'requesting' && ( Localisation en cours… )} {geo.status === 'denied' && ( )} {geo.status === 'unavailable' && ( GPS indisponible )}
)} {isLocked && !isCompleted && (
🔒 {quizPassed || inZone ? 'Étape déverrouillée' : 'Étape verrouillée — validez la condition'}
)} {hasTimer && isCurrent && !isCompleted && (
{timerExpired && t(step.timerExpiredMessage, language) && (
)}
)} {hasQuiz && isCurrent && !isCompleted && (
Défi
{quizPassed && (
✓ Défi réussi
)}
)}
{isCompleted && (
✓ Étape terminée
)} {isCurrent && !isCompleted && ( )} {!isLinear && (
)}
) } // ── Point list view ───────────────────────────────────────────────────────── function PointList({ points, language, categories, onSelect, }: { points: GeoPointDTO[] language: string categories: { id: number; color?: string }[] onSelect: (id: number) => void }) { const colorOf = (id?: number) => { if (id == null) return 'var(--color-primary)' return categories.find((c) => c.id === id)?.color || 'var(--color-primary)' } return (
{points.length === 0 && (
Aucun lieu à afficher.
)} {points.map((p) => ( ))}
) } // ── Detail sheet ──────────────────────────────────────────────────────────── function PointDetail({ point, language, onClose, }: { point: GeoPointDTO language: string onClose: () => void }) { const phone = tPlain(point.phone, language) const email = tPlain(point.email, language) const site = tPlain(point.site, language) const prices = t(point.prices, language) const schedules = t(point.schedules, language) return ( <>
{/* Header image */}
{point.imageUrl && ( // eslint-disable-next-line @next/next/no-img-element )}
{/* Body */}
{t(point.description, language) && (
)} {schedules && ( )} {prices && ( )} {(phone || email || site) && (
{phone && ( {phone} )} {email && ( {email} )} {site && ( {site} )}
)}
) } function InfoBlock({ icon, label, html }: { icon: 'schedule' | 'euro'; label: string; html: string }) { return (
{label}
) } function Icon({ name }: { name: 'phone' | 'email' | 'web' | 'schedule' | 'euro' }) { const paths: Record = { 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', schedule: 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z', euro: 'M15 18.5A6.48 6.48 0 0 1 9.24 15H15v-2H8.58a6.6 6.6 0 0 1 0-2H15V9H9.24A6.49 6.49 0 0 1 15 5.5c1.61 0 3.09.59 4.23 1.57L21 5.3A8.96 8.96 0 0 0 15 3a9 9 0 0 0-8.55 6H3v2h2.05c-.03.33-.05.66-.05 1s.02.67.05 1H3v2h3.45a9 9 0 0 0 8.55 6c2.3 0 4.41-.87 6-2.3l-1.78-1.77c-1.13.98-2.6 1.57-4.22 1.57z', } return ( ) } // ── End of path modal ────────────────────────────────────────────────────── function EndOfPathModal({ pathTitle, stepCount, onRestart, onLeave, }: { pathTitle: string stepCount: number onRestart: () => void onLeave: () => void }) { return (
Bravo !
Tu as terminé le parcours « {pathTitle} » ({stepCount} étape{stepCount > 1 ? 's' : ''}).
) }