2026-05-09 00:22:28 +02:00

1098 lines
45 KiB
TypeScript

'use client'
import { useEffect, useMemo, useState } from 'react'
import { useBack } from '@/hooks/useBack'
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: () => (
<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[]
}
type Mode = 'map' | 'list'
export default function MapSection({ section, configId, languages }: Props) {
const { language, setAvailableLanguages, instanceId } = useVisitor()
const back = useBack()
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<Mode>('map')
const [selectedId, setSelectedId] = useState<number | null>(null)
const [search, setSearch] = useState('')
const [activeCats, setActiveCats] = useState<Set<number>>(new Set())
const [filterOpen, setFilterOpen] = useState(false)
const [pathListOpen, setPathListOpen] = useState(false)
const [activePathId, setActivePathId] = useState<string | null>(null)
const [activeStepId, setActiveStepId] = useState<string | null>(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<Set<string>>(new Set())
const [quizPassedSteps, setQuizPassedSteps] = useState<Set<string>>(new Set())
const [timerExpiredSteps, setTimerExpiredSteps] = useState<Set<string>>(new Set())
const [zoneNotifiedSteps, setZoneNotifiedSteps] = useState<Set<string>>(new Set())
const [toastMessage, setToastMessage] = useState<string | null>(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 (
<div style={{ position: 'fixed', inset: 0, background: 'var(--color-background)' }} className="flex flex-col">
<div
className="absolute top-0 left-0 right-0 flex items-center gap-2 px-3 py-2 z-[1000]"
style={{ background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))', color: 'var(--color-on-primary)' }}
>
<button onClick={back} className="w-10 h-10 rounded-full flex items-center justify-center" aria-label="Retour">
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
<span className="text-base font-semibold flex-1 truncate px-2">{tPlain(section.title, language)}</span>
</div>
<div className="flex-1 flex items-center justify-center text-sm text-center p-8" style={{ color: 'var(--color-text-muted)' }}>
Aucun lieu à afficher sur la carte.
</div>
</div>
)
}
return (
<div style={{ position: 'fixed', inset: 0, background: '#e8eef3' }}>
{/* Map / List */}
{mode === 'map' ? (
<LeafletMap
points={filteredPoints}
categories={categories}
center={center}
zoom={zoom}
selectedId={selectedId}
onSelect={(id) => {
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}
/>
) : (
<PointList
points={filteredPoints}
language={language}
categories={categories}
onSelect={(id) => { setSelectedId(id); setMode('map') }}
/>
)}
{/* Top dark AppBar */}
<div
className="absolute top-0 left-0 right-0 flex items-center gap-2 px-3 py-2 z-[1000]"
style={{
background: 'linear-gradient(to bottom, rgba(0,0,0,0.55), rgba(0,0,0,0))',
paddingTop: 'max(env(safe-area-inset-top), 10px)',
}}
>
<button
onClick={back}
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="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>
<span
className="text-base font-semibold flex-1 truncate px-2"
style={{ color: 'white', textShadow: '0 1px 4px rgba(0,0,0,0.6)' }}
>
{tPlain(section.title, language)}
</span>
{guidedPaths.length > 0 && (
<button
onClick={() => setPathListOpen(true)}
className="h-10 rounded-full flex items-center gap-1.5 px-3 font-semibold text-xs"
style={{
background: activePath ? 'var(--color-primary)' : 'rgba(0,0,0,0.6)',
color: 'white',
backdropFilter: 'blur(8px)',
}}
aria-label="Parcours"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="white">
<path d="M20 4h-4.18A2.99 2.99 0 0 0 13 2c-1.3 0-2.4.84-2.82 2H6c-1.1 0-2 .9-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6c0-1.1-.9-2-2-2zm-7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm-2 14l-4-4 1.41-1.41L11 15.17l5.59-5.59L18 11l-7 7z" />
</svg>
{activePath ? `Étape ${(activeSteps.findIndex((s) => s.id === activeStepId) + 1) || 1}/${activeSteps.length}` : `Parcours (${guidedPaths.length})`}
</button>
)}
<button
onClick={() => setFilterOpen((v) => !v)}
className="w-10 h-10 rounded-full flex items-center justify-center relative"
style={{ background: filterOpen ? 'var(--color-primary)' : 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)' }}
aria-label="Filtres"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
<path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z" />
</svg>
{activeCats.size > 0 && (
<span
className="absolute top-0 right-0 w-5 h-5 rounded-full text-[10px] font-bold flex items-center justify-center"
style={{ background: 'white', color: 'var(--color-primary)' }}
>
{activeCats.size}
</span>
)}
</button>
</div>
{/* Filter panel */}
{filterOpen && (
<div
className="absolute top-16 left-3 right-3 rounded-2xl p-3 z-[1000] flex flex-col gap-3"
style={{
background: 'rgba(255,255,255,0.97)',
backdropFilter: 'blur(12px)',
boxShadow: '0 8px 24px rgba(0,0,0,0.18)',
maxHeight: 'calc(100vh - 96px)',
}}
>
<div className="flex items-center gap-2 px-3 py-2 rounded-full" style={{ background: '#f1f3f5' }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#666">
<path d="M15.5 14h-.79l-.28-.27A6.5 6.5 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Rechercher un lieu…"
className="flex-1 bg-transparent text-sm outline-none"
style={{ color: '#1a1a1a' }}
/>
{search && (
<button onClick={() => setSearch('')} aria-label="Effacer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#999">
<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>
{categories.length > 0 && (
<div className="flex flex-wrap gap-2 overflow-y-auto" style={{ maxHeight: 260 }}>
{categories.map((cat) => {
const active = activeCats.has(cat.id)
const color = cat.color || primaryColor
return (
<button
key={cat.id}
onClick={() => toggleCat(cat.id)}
className="px-3 py-1.5 rounded-full text-xs font-medium flex items-center gap-1.5 transition-all"
style={{
background: active ? color : 'white',
color: active ? 'white' : '#333',
border: `1.5px solid ${color}`,
}}
>
<span
className="w-2 h-2 rounded-full"
style={{ background: active ? 'white' : color }}
/>
{tPlain(cat.label, language) || `Cat. ${cat.id}`}
</button>
)
})}
</div>
)}
<div className="flex items-center justify-between text-xs" style={{ color: '#666' }}>
<span>{filteredPoints.length} lieu{filteredPoints.length > 1 ? 'x' : ''}</span>
{(activeCats.size > 0 || search) && (
<button
onClick={() => { setActiveCats(new Set()); setSearch('') }}
className="font-medium"
style={{ color: 'var(--color-primary)' }}
>
Réinitialiser
</button>
)}
</div>
</div>
)}
{/* Bottom-right toggle */}
{map?.isListViewEnabled && (
<button
onClick={() => setMode((m) => (m === 'map' ? 'list' : 'map'))}
className="absolute bottom-6 right-4 rounded-full flex items-center gap-2 px-4 py-3 font-semibold text-sm z-[1000]"
style={{
background: 'var(--color-primary)',
color: 'var(--color-on-primary)',
boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
}}
>
{mode === 'map' ? (
<>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
</svg>
Liste
</>
) : (
<>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z" />
</svg>
Carte
</>
)}
</button>
)}
{/* Quitter parcours button (when active) */}
{activePath && !pathListOpen && !activeStep && !selected && (
<button
onClick={() => { setActivePathId(null); setActiveStepId(null) }}
className="absolute bottom-6 left-4 rounded-full flex items-center gap-2 px-4 py-3 font-semibold text-sm z-[1000]"
style={{
background: 'var(--color-surface)',
color: 'var(--color-text)',
boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
border: '1px solid var(--color-border)',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<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>
Quitter le parcours
</button>
)}
{/* Detail sheet (POI) */}
{selected && (
<PointDetail point={selected} language={language} onClose={() => setSelectedId(null)} />
)}
{/* Step detail sheet (parcours) */}
{activeStep && activePath && (
<StepDetail
step={activeStep}
stepIndex={activeSteps.findIndex((s) => 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 && (
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
)}
{/* End of path modal */}
{showEndModal && activePath && (
<EndOfPathModal
pathTitle={tPlain(activePath.title, language) || 'Parcours'}
stepCount={activeSteps.length}
onRestart={() => {
setCompletedSteps(new Set())
setShowEndModal(false)
if (activeSteps[0]) setActiveStepId(activeSteps[0].id)
}}
onLeave={() => {
setActivePathId(null)
setActiveStepId(null)
setShowEndModal(false)
}}
/>
)}
{/* Path list bottom sheet */}
{pathListOpen && (
<PathListSheet
paths={guidedPaths}
activePathId={activePathId}
language={language}
onSelect={(id) => {
setActivePathId(id)
setActiveStepId(null)
setSelectedId(null)
setPathListOpen(false)
}}
onLeave={() => {
setActivePathId(null)
setActiveStepId(null)
setPathListOpen(false)
}}
onClose={() => setPathListOpen(false)}
/>
)}
</div>
)
}
// ── 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 (
<>
<div className="absolute inset-0 z-[1500]" style={{ background: 'rgba(0,0,0,0.3)' }} onClick={onClose} />
<div
className="absolute left-0 right-0 bottom-0 z-[1600] rounded-t-3xl overflow-hidden"
style={{
background: 'var(--color-surface)',
maxHeight: '70%',
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 pt-2 pb-3 flex items-center justify-between">
<div className="text-base font-bold" style={{ color: 'var(--color-text)' }}>Parcours</div>
{activePathId && (
<button onClick={onLeave} className="text-xs font-medium" style={{ color: 'var(--color-primary)' }}>
Quitter le parcours
</button>
)}
</div>
<div className="overflow-y-auto px-3 pb-5 flex flex-col gap-2" style={{ maxHeight: 'calc(70vh - 80px)' }}>
{paths.map((p) => {
const active = p.id === activePathId
const stepCount = p.steps?.length ?? 0
return (
<button
key={p.id}
onClick={() => onSelect(p.id)}
className="flex items-center gap-3 p-3 rounded-2xl text-left transition-colors"
style={{
background: active ? 'var(--color-primary-light)' : 'var(--color-surface)',
border: `1.5px solid ${active ? 'var(--color-primary)' : 'var(--color-border)'}`,
}}
>
<div
className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
<svg width="22" height="22" 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="flex-1 min-w-0">
<div
className="text-sm font-semibold [&_p]:m-0"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(p.title, language) || 'Parcours' }}
/>
<div className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
{stepCount} étape{stepCount > 1 ? 's' : ''}
{p.isLinear && ' · linéaire'}
</div>
</div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--color-text-muted)">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</button>
)
})}
</div>
</div>
</>
)
}
// ── 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<typeof useGeolocation>
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 (
<>
<div className="absolute inset-0 z-[1500]" style={{ background: 'rgba(0,0,0,0.3)' }} onClick={onClose} />
<div
className="absolute left-0 right-0 bottom-0 z-[1600] rounded-t-3xl overflow-hidden"
style={{
background: 'var(--color-surface)',
maxHeight: '78%',
boxShadow: '0 -8px 24px rgba(0,0,0,0.2)',
animation: 'mim-slide-up 0.25s ease',
}}
>
<div className="relative" style={{ height: 160, background: 'var(--color-primary)' }}>
{step.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={step.imageUrl} alt="" className="absolute inset-0 w-full h-full object-cover" />
)}
<div className="absolute inset-0" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.7), rgba(0,0,0,0.1))' }} />
<button
onClick={onClose}
className="absolute top-3 right-3 w-9 h-9 rounded-full flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(6px)' }}
aria-label="Fermer"
>
<svg width="20" height="20" 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>
<div
className="absolute top-3 left-3 px-2.5 py-1 rounded-full text-xs font-bold"
style={{ background: 'rgba(255,255,255,0.95)', color: 'var(--color-primary)' }}
>
Étape {stepIndex + 1} / {totalSteps}
</div>
<div
className="absolute bottom-3 left-4 right-12 text-white text-lg font-bold [&_p]:m-0"
style={{ textShadow: '0 2px 6px rgba(0,0,0,0.6)' }}
dangerouslySetInnerHTML={{ __html: t(step.title, language) || 'Étape' }}
/>
</div>
<div className="overflow-y-auto px-5 py-4" style={{ maxHeight: 'calc(78vh - 160px - 64px)' }}>
{t(step.description, language) && (
<div
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(step.description, language) }}
/>
)}
{radius > 0 && (
<div className="mt-3 flex flex-wrap gap-2 items-center">
<div
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full"
style={{
background: inZone ? '#d1fae5' : 'var(--color-primary-light)',
color: inZone ? '#065f46' : 'var(--color-primary)',
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<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>
{inZone
? 'Vous êtes dans la zone'
: distance != null
? `À ${formatDistance(distance)} de la zone (${radius} m)`
: `Zone à atteindre · ${radius} m`}
</div>
{geo.status === 'requesting' && (
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Localisation en cours</span>
)}
{geo.status === 'denied' && (
<button
onClick={onRetryGeo}
className="text-xs underline"
style={{ color: '#b91c1c' }}
>
Localisation refusée réessayer
</button>
)}
{geo.status === 'unavailable' && (
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>GPS indisponible</span>
)}
</div>
)}
{isLocked && !isCompleted && (
<div className="mt-3 inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full" style={{ background: '#fef3c7', color: '#92400e' }}>
<span>🔒</span>
{quizPassed || inZone ? 'Étape déverrouillée' : 'Étape verrouillée — validez la condition'}
</div>
)}
{hasTimer && isCurrent && !isCompleted && (
<div className="mt-4">
<StepTimer
totalSeconds={step.timerSeconds!}
active={!timerExpired}
onExpired={onTimerExpired}
/>
{timerExpired && t(step.timerExpiredMessage, language) && (
<div
className="mt-2 text-xs p-2 rounded-lg [&_p]:m-0"
style={{ background: '#fee2e2', color: '#991b1b' }}
dangerouslySetInnerHTML={{ __html: t(step.timerExpiredMessage, language) }}
/>
)}
</div>
)}
{hasQuiz && isCurrent && !isCompleted && (
<div className="mt-4">
<div className="text-xs font-semibold mb-2 uppercase tracking-wide" style={{ color: 'var(--color-text-muted)' }}>
Défi
</div>
<StepQuiz
key={step.id}
questions={step.quizQuestions!}
language={language}
onResult={onQuizResult}
/>
{quizPassed && (
<div className="mt-2 text-xs text-center py-1.5 rounded-full" style={{ background: '#d1fae5', color: '#065f46' }}>
Défi réussi
</div>
)}
</div>
)}
</div>
<div className="flex flex-col gap-2 px-4 pb-4 pt-2 border-t" style={{ borderColor: 'var(--color-border)' }}>
{isCompleted && (
<div className="text-xs text-center py-1.5 rounded-full" style={{ background: '#d1fae5', color: '#065f46' }}>
Étape terminée
</div>
)}
{isCurrent && !isCompleted && (
<button
onClick={onMarkComplete}
disabled={!canAdvance}
className="w-full py-3 rounded-2xl font-semibold text-sm"
style={{
background: canAdvance ? 'var(--color-primary)' : 'var(--color-surface)',
color: canAdvance ? 'var(--color-on-primary)' : 'var(--color-text-muted)',
border: canAdvance ? 'none' : '1px solid var(--color-border)',
cursor: canAdvance ? 'pointer' : 'not-allowed',
}}
title={!canAdvance ? 'Approchez de la zone pour valider' : undefined}
>
{stepIndex === totalSteps - 1 ? 'Terminer le parcours' : 'Marquer terminée'}
</button>
)}
{!isLinear && (
<div className="flex items-center justify-between gap-2">
<button
onClick={onPrev}
disabled={!hasPrev}
className="flex-1 py-2.5 rounded-xl text-sm font-semibold flex items-center justify-center gap-1"
style={{
background: 'var(--color-surface)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
opacity: hasPrev ? 1 : 0.4,
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" /></svg>
Précédente
</button>
<button
onClick={onNext}
disabled={!hasNext}
className="flex-1 py-2.5 rounded-xl text-sm font-semibold flex items-center justify-center gap-1"
style={{
background: 'var(--color-surface)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
opacity: hasNext ? 1 : 0.4,
}}
>
Suivante
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" /></svg>
</button>
</div>
)}
</div>
</div>
</>
)
}
// ── 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 (
<div className="absolute inset-0 overflow-y-auto pt-20 pb-24 px-3" style={{ background: 'var(--color-background)' }}>
<div className="flex flex-col gap-2">
{points.length === 0 && (
<div className="text-center text-sm py-12" style={{ color: 'var(--color-text-muted)' }}>
Aucun lieu à afficher.
</div>
)}
{points.map((p) => (
<button
key={p.id}
onClick={() => onSelect(p.id)}
className="flex items-center gap-3 p-3 rounded-2xl text-left"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
{p.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={p.imageUrl} alt="" className="w-14 h-14 rounded-xl object-cover flex-shrink-0" />
) : (
<div
className="w-14 h-14 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ background: colorOf(p.categorieId) }}
>
<svg width="22" height="22" 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="flex-1 min-w-0">
<div
className="text-sm font-semibold truncate [&_p]:m-0"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(p.title, language) }}
/>
<div
className="text-xs line-clamp-2 [&_p]:m-0 mt-0.5"
style={{ color: 'var(--color-text-muted)' }}
dangerouslySetInnerHTML={{ __html: t(p.description, language) }}
/>
</div>
</button>
))}
</div>
</div>
)
}
// ── 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 (
<>
<div
className="absolute inset-0 z-[1500]"
style={{ background: 'rgba(0,0,0,0.3)' }}
onClick={onClose}
/>
<div
className="absolute left-0 right-0 bottom-0 z-[1600] rounded-t-3xl overflow-hidden"
style={{
background: 'var(--color-surface)',
maxHeight: '80%',
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>
{/* Header image */}
<div className="relative" style={{ height: 180, background: 'var(--color-primary)' }}>
{point.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={point.imageUrl} alt="" className="absolute inset-0 w-full h-full object-cover" />
)}
<div className="absolute inset-0" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.65), rgba(0,0,0,0.1))' }} />
<button
onClick={onClose}
className="absolute top-3 right-3 w-9 h-9 rounded-full flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(6px)' }}
aria-label="Fermer"
>
<svg width="20" height="20" 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>
<div
className="absolute bottom-3 left-4 right-12 text-white text-xl font-bold [&_p]:m-0"
style={{ textShadow: '0 2px 6px rgba(0,0,0,0.6)' }}
dangerouslySetInnerHTML={{ __html: t(point.title, language) }}
/>
</div>
{/* Body */}
<div className="overflow-y-auto px-5 py-4 flex flex-col gap-4" style={{ maxHeight: 'calc(80vh - 180px)' }}>
{t(point.description, language) && (
<div
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(point.description, language) }}
/>
)}
{schedules && (
<InfoBlock icon="schedule" label="Horaires" html={schedules} />
)}
{prices && (
<InfoBlock icon="euro" label="Tarifs" html={prices} />
)}
{(phone || email || site) && (
<div className="flex flex-col gap-2 pt-2 border-t" style={{ borderColor: 'var(--color-border)' }}>
{phone && (
<a href={`tel:${phone}`} className="flex items-center gap-3 text-sm" style={{ color: 'var(--color-primary)' }}>
<Icon name="phone" />
<span>{phone}</span>
</a>
)}
{email && (
<a href={`mailto:${email}`} className="flex items-center gap-3 text-sm" style={{ color: 'var(--color-primary)' }}>
<Icon name="email" />
<span className="truncate">{email}</span>
</a>
)}
{site && (
<a
href={site.startsWith('http') ? site : `https://${site}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-3 text-sm"
style={{ color: 'var(--color-primary)' }}
>
<Icon name="web" />
<span className="truncate">{site}</span>
</a>
)}
</div>
)}
</div>
</div>
</>
)
}
function InfoBlock({ icon, label, html }: { icon: 'schedule' | 'euro'; label: string; html: string }) {
return (
<div className="flex gap-3">
<div className="w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0" style={{ background: 'var(--color-primary-light)' }}>
<Icon name={icon} />
</div>
<div className="flex-1">
<div className="text-xs font-semibold uppercase tracking-wide mb-1" style={{ color: 'var(--color-text-muted)' }}>{label}</div>
<div
className="text-sm [&_p]:m-0 [&_p+p]:mt-1"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
</div>
)
}
function Icon({ name }: { name: 'phone' | 'email' | 'web' | 'schedule' | 'euro' }) {
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',
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 (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d={paths[name]} />
</svg>
)
}
// ── End of path modal ──────────────────────────────────────────────────────
function EndOfPathModal({
pathTitle, stepCount, onRestart, onLeave,
}: {
pathTitle: string
stepCount: number
onRestart: () => void
onLeave: () => void
}) {
return (
<div className="absolute inset-0 z-[2000] flex items-center justify-center p-6" style={{ background: 'rgba(0,0,0,0.55)' }}>
<div
className="w-full max-w-md rounded-3xl overflow-hidden"
style={{
background: 'var(--color-surface)',
boxShadow: '0 20px 50px rgba(0,0,0,0.4)',
animation: 'mim-pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
>
<style>{`@keyframes mim-pop-in { from { transform: scale(0.85); opacity: 0; } to { transform: scale(1); opacity: 1; } }`}</style>
<div className="px-6 pt-6 pb-2 flex flex-col items-center gap-3">
<div
className="w-20 h-20 rounded-full flex items-center justify-center"
style={{ background: '#d1fae5' }}
>
<svg width="44" height="44" viewBox="0 0 24 24" fill="#16a34a">
<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-3zm-2 14l-4-4 1.41-1.41L10 13.17l6.59-6.59L18 8l-8 8z" />
</svg>
</div>
<div className="text-xl font-bold text-center" style={{ color: 'var(--color-text)' }}>Bravo !</div>
<div className="text-sm text-center" style={{ color: 'var(--color-text-muted)' }}>
Tu as terminé le parcours « {pathTitle} » ({stepCount} étape{stepCount > 1 ? 's' : ''}).
</div>
</div>
<div className="flex flex-col gap-2 px-5 pb-5 pt-3">
<button
onClick={onLeave}
className="w-full py-3 rounded-2xl font-semibold text-sm"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
Retour à la carte
</button>
<button
onClick={onRestart}
className="w-full py-3 rounded-2xl font-semibold text-sm"
style={{
background: 'var(--color-surface)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
}}
>
Recommencer
</button>
</div>
</div>
</div>
)
}