1098 lines
45 KiB
TypeScript
1098 lines
45 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, 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 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<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={() => router.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={() => router.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>
|
|
)
|
|
}
|