Thomas Fransolet a5a8ecdb20 Documentation interne MyInfoMate / Unov
Import initial de la documentation : statut, roadmap, plans V1/V2,
specs verticales (creche, sport), audits securite, plan de test,
analyse concurrentielle et maquettes de design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:17:01 +02:00

1525 lines
58 KiB
TypeScript

'use client'
import { useEffect, useRef, useState } from 'react'
import { useBack } from '@/hooks/useBack'
import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n'
import LanguageSelector from '@/components/ui/LanguageSelector'
import dynamic from 'next/dynamic'
import './map/map.css'
import type { SectionDTO, GuidedPathDTO, GuidedStepDTO, QuestionDTO, TranslationAndResourceDTO, ContentDTO } from '@/lib/api/types'
const LeafletMap = dynamic(() => import('./map/LeafletMap'), {
ssr: false,
loading: () => (
<div style={{ width: '100%', height: '100%', background: '#e8eef3', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ fontSize: 13, color: '#6B7B86' }}>Chargement de la carte</span>
</div>
),
})
interface Props {
section: SectionDTO
slug: string
configId: string
languages: string[]
apiKey: string
}
type View = 'list' | 'start' | 'progress' | 'end'
export default function ParcoursSection({ section, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor()
const back = useBack()
useEffect(() => { setAvailableLanguages(languages) }, [languages])
const parcours = section.parcours
const paths: GuidedPathDTO[] = [...(parcours?.guidedPaths ?? [])].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0)
)
const isGame = parcours?.isGameMode === true
const [view, setView] = useState<View>('list')
const [activePath, setActivePath] = useState<GuidedPathDTO | null>(null)
const [stepIndex, setStepIndex] = useState(0)
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set())
const [primaryColor, setPrimaryColor] = useState('#264863')
useEffect(() => {
const c = getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim()
if (c) setPrimaryColor(c)
}, [])
const sectionTitle = tPlain(section.title, language)
const sectionDesc = t(section.description, language)
const steps: GuidedStepDTO[] = activePath
? [...(activePath.steps ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
: []
const currentStep = steps[stepIndex] ?? null
function selectPath(path: GuidedPathDTO) {
setActivePath(path)
setStepIndex(0)
setCompletedSteps(new Set())
setView('start')
}
function startPath() {
setView('progress')
}
function nextStep() {
if (currentStep) setCompletedSteps((s) => new Set(s).add(currentStep.id))
if (stepIndex < steps.length - 1) {
setStepIndex((i) => i + 1)
} else {
setView('end')
}
}
function prevStep() {
if (stepIndex > 0) setStepIndex((i) => i - 1)
}
function restart() {
setView('list')
setActivePath(null)
setStepIndex(0)
setCompletedSteps(new Set())
}
const gameIntro = tRaw(parcours?.gameMessageDebut, language)
// ── Render ─────────────────────────────────────────────────────────────────
const stepsHaveGeo = steps.some((s) => s.geometry?.coordinates?.length === 2)
const useMapView = section.parcours?.showMap === true && stepsHaveGeo
if (view === 'progress' && activePath && currentStep) {
if (useMapView) {
return (
<ParcoursMapProgression
path={activePath}
steps={steps}
stepIndex={stepIndex}
completedSteps={completedSteps}
primaryColor={primaryColor}
isGame={isGame}
language={language}
sectionLat={section.latitude}
sectionLng={section.longitude}
onNext={nextStep}
onPrev={prevStep}
onBack={() => setView('list')}
/>
)
}
return (
<ProgressView
path={activePath}
steps={steps}
stepIndex={stepIndex}
completedSteps={completedSteps}
primaryColor={primaryColor}
isGame={isGame}
language={language}
onNext={nextStep}
onPrev={prevStep}
onBack={() => setView('list')}
/>
)
}
if (view === 'end' && activePath) {
return (
<EndView
path={activePath}
steps={steps}
isGame={isGame}
primaryColor={primaryColor}
language={language}
gameOutro={tRaw(parcours?.gameMessageFin, language)}
onBack={restart}
/>
)
}
return (
<div style={{ position: 'fixed', inset: 0, background: '#F6F3EE', overflowY: 'auto' }}>
{/* Hero */}
<div
className="relative"
style={{
height: '44vw',
minHeight: 200,
maxHeight: 300,
background: `linear-gradient(150deg, color-mix(in srgb, ${primaryColor} 80%, white), ${primaryColor})`,
overflow: 'hidden',
}}
>
{section.imageSource && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={section.imageSource}
alt={sectionTitle}
className="absolute inset-0 w-full h-full object-cover"
style={{ mixBlendMode: 'overlay', opacity: 0.55 }}
/>
)}
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(to bottom, transparent 40%, rgba(0,0,0,0.45))' }}
/>
{/* Back */}
<button
onClick={back}
className="absolute top-4 left-4 flex items-center justify-center rounded-2xl"
style={{
width: 40,
height: 40,
background: 'rgba(255,255,255,0.2)',
backdropFilter: 'blur(8px)',
border: 'none',
cursor: 'pointer',
}}
>
<ChevronLeft color="white" />
</button>
{/* Language */}
<div className="absolute top-4 right-4">
<LanguageSelector />
</div>
{/* Info overlay */}
<div className="absolute left-5 right-5 bottom-5">
{paths.length > 0 && (
<span
className="inline-flex items-center mb-2"
style={{
padding: '5px 10px',
borderRadius: 8,
background: 'rgba(255,255,255,0.92)',
color: primaryColor,
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.4px',
}}
>
{paths.length} parcours
</span>
)}
<h1 style={{ color: '#fff', fontSize: 'clamp(20px, 5vw, 26px)', fontWeight: 600, letterSpacing: '-0.5px', lineHeight: 1.1, margin: 0 }}>
{sectionTitle}
</h1>
{sectionDesc && (
<p
className="mt-1"
style={{ color: 'rgba(255,255,255,0.85)', fontSize: 13.5, lineHeight: 1.4, margin: '6px 0 0', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}
dangerouslySetInnerHTML={{ __html: sectionDesc }}
/>
)}
</div>
</div>
{/* Path list */}
<div style={{ padding: '0 0 32px' }}>
{paths.length === 0 ? (
<div className="flex flex-col items-center justify-center" style={{ padding: '48px 32px', color: '#6B7B86' }}>
<RouteIcon size={40} />
<p className="mt-3" style={{ fontSize: 15 }}>Aucun parcours disponible</p>
</div>
) : (
<>
<p style={{ padding: '16px 20px 4px', color: '#1E2A33', fontSize: 18, fontWeight: 600, letterSpacing: '-0.3px', margin: 0 }}>
Choisir un parcours
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: '8px 16px 0' }}>
{paths.map((path) => (
<PathCard
key={path.id}
path={path}
isGame={isGame}
primaryColor={primaryColor}
language={language}
onClick={() => selectPath(path)}
/>
))}
</div>
</>
)}
</div>
{/* Start sheet (bottom drawer) */}
{view === 'start' && activePath && (
<StartSheet
path={activePath}
isGame={isGame}
primaryColor={primaryColor}
language={language}
gameIntro={gameIntro}
onStart={startPath}
onClose={() => setView('list')}
/>
)}
</div>
)
}
// ── Map-based progression ─────────────────────────────────────────────────────
function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primaryColor, isGame, language, sectionLat, sectionLng, onNext, onPrev, onBack }: {
path: GuidedPathDTO
steps: GuidedStepDTO[]
stepIndex: number
completedSteps: Set<string>
primaryColor: string
isGame: boolean
language: string
sectionLat?: number
sectionLng?: number
onNext: () => void
onPrev: () => void
onBack: () => void
}) {
const currentStep = steps[stepIndex]
const [selectedStepId, setSelectedStepId] = useState<string | null>(currentStep?.id ?? null)
// Follow current step when it changes
useEffect(() => {
setSelectedStepId(currentStep?.id ?? null)
}, [currentStep?.id])
// Center: current step coords, or section coords, or Paris fallback
const center: [number, number] = (() => {
const cs = steps[stepIndex]
if (cs?.geometry?.coordinates?.length === 2) {
return [cs.geometry.coordinates[1], cs.geometry.coordinates[0]]
}
const firstWithGeo = steps.find((s) => s.geometry?.coordinates?.length === 2)
if (firstWithGeo) return [firstWithGeo.geometry!.coordinates![1], firstWithGeo.geometry!.coordinates![0]]
return [sectionLat ?? 48.86, sectionLng ?? 2.35]
})()
const stepTitle = tPlain(currentStep?.title, language)
const stepDesc = t(currentStep?.description, language)
const accentColor = isGame ? '#D4AF37' : primaryColor
return (
<div style={{ position: 'fixed', inset: 0 }}>
{/* Map fills screen */}
<div style={{ position: 'absolute', inset: 0 }}>
<LeafletMap
points={[]}
categories={[]}
center={center}
zoom={15}
selectedId={null}
onSelect={() => {}}
primaryColor={primaryColor}
pathSteps={steps}
selectedStepId={selectedStepId}
onSelectStep={setSelectedStepId}
currentStepId={currentStep?.id ?? null}
completedStepIds={completedSteps}
/>
</div>
{/* Top header */}
<div
style={{
position: 'absolute', top: 0, left: 0, right: 0, zIndex: 1000,
padding: '12px 14px',
display: 'flex', alignItems: 'center', gap: 10,
background: 'rgba(255,255,255,0.92)',
backdropFilter: 'blur(10px)',
borderBottom: '1px solid rgba(0,0,0,0.06)',
}}
>
<button
onClick={onBack}
style={{
width: 40, height: 40, borderRadius: 12, border: 'none', cursor: 'pointer', flexShrink: 0,
background: 'rgba(255,255,255,0.85)', boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<ChevronLeft color="#1E2A33" />
</button>
<div
style={{
flex: 1, height: 40, borderRadius: 12,
background: 'rgba(255,255,255,0.85)', boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 8px 0 14px',
}}
>
<span style={{ fontSize: 15, fontWeight: 600, color: '#1E2A33' }}>
{tPlain(path.title, language)}
</span>
<span
style={{
padding: '5px 10px', borderRadius: 9,
background: `color-mix(in srgb, ${accentColor} 12%, white)`,
color: accentColor, fontSize: 12, fontWeight: 700, whiteSpace: 'nowrap',
}}
>
{stepIndex + 1} / {steps.length}
</span>
</div>
</div>
{/* Bottom sheet — current step */}
<div
style={{
position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 1000,
background: '#fff',
borderRadius: '22px 22px 0 0',
boxShadow: '0 -12px 32px rgba(20,30,40,0.18)',
paddingBottom: 'max(18px, env(safe-area-inset-bottom))',
}}
>
{/* Handle */}
<div style={{ display: 'flex', justifyContent: 'center', padding: '10px 0 4px' }}>
<div style={{ width: 40, height: 5, borderRadius: 3, background: '#E2DCD2' }} />
</div>
{/* Step card */}
<button
onClick={() => setSelectedStepId(currentStep?.id ?? null)}
style={{
display: 'flex', gap: 14, padding: '4px 18px 0',
background: 'none', border: 'none', cursor: 'pointer', width: '100%', textAlign: 'left',
}}
>
{/* Step thumb */}
<div
style={{
width: 72, height: 72, borderRadius: 16, flexShrink: 0, position: 'relative', overflow: 'hidden',
background: `linear-gradient(150deg, color-mix(in srgb, ${accentColor} 60%, white), ${accentColor})`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{currentStep?.imageUrl
// eslint-disable-next-line @next/next/no-img-element
? <img src={currentStep.imageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <RouteIcon color="rgba(255,255,255,0.8)" size={28} />
}
<div
style={{
position: 'absolute', left: 4, bottom: 4,
width: 22, height: 22, borderRadius: 7,
background: accentColor, border: '2px solid #fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: isGame ? '#1a1305' : '#fff', fontWeight: 800, fontSize: 11,
}}
>
{stepIndex + 1}
</div>
</div>
{/* Step info */}
<div style={{ flex: 1, minWidth: 0, paddingTop: 2 }}>
<span style={{ color: accentColor, fontSize: 11, fontWeight: 700, letterSpacing: '0.5px' }}>
ÉTAPE EN COURS
</span>
<p style={{ margin: '3px 0 0', color: '#1E2A33', fontSize: 18, fontWeight: 600, lineHeight: 1.15 }}>
{stepTitle}
</p>
{stepDesc && (
<p
style={{
margin: '3px 0 0', color: '#6B7B86', fontSize: 13, lineHeight: 1.4,
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}
dangerouslySetInnerHTML={{ __html: stepDesc }}
/>
)}
</div>
</button>
{/* Progress bar */}
<div style={{ padding: '12px 18px 0' }}>
<div style={{ height: 6, borderRadius: 4, background: '#EEE9E0', overflow: 'hidden' }}>
<div
style={{
height: '100%', borderRadius: 4,
width: `${Math.round((stepIndex / Math.max(steps.length - 1, 1)) * 100)}%`,
background: accentColor, transition: 'width 0.4s ease',
}}
/>
</div>
</div>
{/* Action buttons */}
<div style={{ display: 'flex', gap: 11, padding: '12px 18px 0' }}>
{stepIndex > 0 && (
<button
onClick={onPrev}
style={{
flex: 1, border: '1.5px solid #E2DCD2', background: '#fff',
padding: '14px', borderRadius: 14,
color: '#46555F', fontSize: 14, fontWeight: 700, cursor: 'pointer',
}}
>
Étape précédente
</button>
)}
<button
onClick={onNext}
style={{
flex: stepIndex > 0 ? 1.4 : 1, border: 'none', cursor: 'pointer',
background: isGame ? 'linear-gradient(180deg, #E7C765, #D4AF37)' : accentColor,
color: isGame ? '#1a1305' : '#fff',
padding: '14px', borderRadius: 14, fontSize: 14, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7,
}}
>
<FlagIcon color="currentColor" size={14} />
{stepIndex < steps.length - 1 ? 'Étape suivante' : 'Terminer'}
</button>
</div>
</div>
</div>
)
}
// ── Sub-components ────────────────────────────────────────────────────────────
function PathCard({ path, isGame, primaryColor, language, onClick }: {
path: GuidedPathDTO
isGame: boolean
primaryColor: string
language: string
onClick: () => void
}) {
const title = tPlain(path.title, language)
const desc = tPlain(path.description, language)
const stepCount = path.steps?.length ?? 0
return (
<button
onClick={onClick}
style={{
display: 'flex',
alignItems: 'stretch',
background: '#fff',
border: '1px solid #EFEAE2',
borderRadius: 20,
overflow: 'hidden',
cursor: 'pointer',
boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)',
textAlign: 'left',
padding: 0,
width: '100%',
}}
>
{/* Thumb */}
<div
style={{
width: 88,
minHeight: 88,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: isGame
? 'linear-gradient(150deg, #1a2436, #0B1018)'
: `linear-gradient(150deg, color-mix(in srgb, ${primaryColor} 70%, white), ${primaryColor})`,
}}
>
{isGame
? <LockIcon size={28} color="#D4AF37" />
: <RouteIcon size={28} color="rgba(255,255,255,0.9)" />
}
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0, padding: '12px 12px 12px 13px' }}>
<span
style={{
display: 'inline-block',
padding: '4px 8px',
borderRadius: 7,
fontSize: 10.5,
fontWeight: 700,
letterSpacing: '0.4px',
background: isGame ? 'rgba(212,175,55,0.15)' : `color-mix(in srgb, ${primaryColor} 12%, white)`,
color: isGame ? '#D4AF37' : primaryColor,
}}
>
{isGame ? 'ESCAPE GAME' : 'DÉCOUVERTE'}
</span>
<p style={{ margin: '6px 0 0', color: '#1E2A33', fontSize: 16, fontWeight: 600, lineHeight: 1.2 }}>
{title || 'Parcours sans titre'}
</p>
{desc && (
<p style={{ margin: '3px 0 0', color: '#6B7B86', fontSize: 12.5, lineHeight: 1.35,
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{desc}
</p>
)}
<div className="flex items-center gap-3 mt-1.5" style={{ color: '#6B7B86', fontSize: 12, fontWeight: 600 }}>
<span className="flex items-center gap-1">
<FlagIcon size={11} /> {stepCount} étape{stepCount > 1 ? 's' : ''}
</span>
{path.estimatedDurationMinutes && (
<span className="flex items-center gap-1">
<ClockIcon size={11} /> ~{path.estimatedDurationMinutes} min
</span>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', paddingRight: 14 }}>
<ChevronRight color="#6B7B86" size={14} />
</div>
</button>
)
}
function StartSheet({ path, isGame, primaryColor, language, gameIntro, onStart, onClose }: {
path: GuidedPathDTO
isGame: boolean
primaryColor: string
language: string
gameIntro: string
onStart: () => void
onClose: () => void
}) {
const title = tPlain(path.title, language)
const desc = tPlain(path.description, language)
const stepCount = path.steps?.length ?? 0
return (
<>
{/* Backdrop */}
<div
onClick={onClose}
style={{ position: 'fixed', inset: 0, background: 'rgba(10,16,22,0.5)', backdropFilter: 'blur(3px)', zIndex: 40 }}
/>
{/* Sheet */}
<div
style={{
position: 'fixed', left: 0, right: 0, bottom: 0, zIndex: 50,
background: isGame
? 'radial-gradient(120% 90% at 50% 22%, #16202e, #0B1018 62%, #06090e)'
: '#F6F3EE',
borderRadius: '26px 26px 0 0',
boxShadow: '0 -20px 50px rgba(0,0,0,0.4)',
paddingBottom: 'max(28px, env(safe-area-inset-bottom))',
maxHeight: '85vh',
overflowY: 'auto',
}}
>
{/* Handle */}
<div className="flex justify-center pt-3 pb-1">
<div style={{ width: 40, height: 5, borderRadius: 3, background: isGame ? 'rgba(212,175,55,0.4)' : '#D8D2C8' }} />
</div>
{isGame ? (
/* ── Game start ── */
<div className="flex flex-col items-center text-center" style={{ padding: '16px 28px 0' }}>
<div
style={{
width: 90, height: 90, borderRadius: '50%',
border: '1.5px solid rgba(212,175,55,0.5)',
boxShadow: '0 0 40px rgba(212,175,55,0.2), inset 0 0 20px rgba(212,175,55,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 16,
}}
>
<LockIcon size={36} color="#D4AF37" />
</div>
<p style={{ color: '#D4AF37', fontSize: 11, fontWeight: 700, letterSpacing: 4, margin: 0 }}>ESCAPE GAME</p>
<h2 style={{ color: '#F4ECD8', fontSize: 'clamp(22px, 5vw, 28px)', fontWeight: 600, letterSpacing: '-0.5px', lineHeight: 1.1, margin: '10px 0 0' }}>
{title}
</h2>
{gameIntro && (
<p style={{ color: '#b9ac8c', fontSize: 15, fontStyle: 'italic', lineHeight: 1.6, margin: '14px 0 0' }}>
« {gameIntro} »
</p>
)}
<div className="flex items-center gap-2 mt-4" style={{ color: '#8a8068', fontSize: 13, fontWeight: 600 }}>
<span>{stepCount} énigme{stepCount > 1 ? 's' : ''}</span>
{path.estimatedDurationMinutes && <><span style={{ opacity: 0.4 }}>·</span><span>~{path.estimatedDurationMinutes} min</span></>}
</div>
<button
onClick={onStart}
style={{
marginTop: 24, width: '100%', border: 'none', cursor: 'pointer',
background: 'linear-gradient(180deg, #E7C765, #D4AF37)',
color: '#1a1305', padding: '17px', borderRadius: 16,
fontSize: 16, fontWeight: 800, letterSpacing: '0.3px',
boxShadow: '0 0 30px rgba(212,175,55,0.3), 0 12px 26px -8px rgba(0,0,0,0.6)',
}}
>
Démarrer l&apos;aventure
</button>
<p style={{ color: '#6f6655', fontSize: 12, marginTop: 10 }}>Activez le son pour une immersion totale</p>
</div>
) : (
/* ── Normal start ── */
<div style={{ padding: '8px 20px 0' }}>
{/* Mini-map sketch */}
<div
style={{
height: 140, borderRadius: 18, overflow: 'hidden', position: 'relative',
background: `linear-gradient(150deg, color-mix(in srgb, ${primaryColor} 70%, white), ${primaryColor})`,
marginBottom: 16,
}}
>
<MapSketch primaryColor={primaryColor} />
<div className="absolute left-3 top-3">
<span style={{
padding: '5px 10px', borderRadius: 8,
background: 'rgba(255,255,255,0.92)', color: primaryColor,
fontSize: 10, fontWeight: 700, letterSpacing: '0.4px',
}}>
DÉCOUVERTE LIBRE
</span>
</div>
<div className="absolute left-4 right-4 bottom-3">
<p style={{ color: '#fff', fontSize: 18, fontWeight: 700, margin: 0, letterSpacing: '-0.3px', lineHeight: 1.1 }}>
{title}
</p>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-4 mb-3" style={{ color: '#54636E', fontSize: 13, fontWeight: 600 }}>
<span className="flex items-center gap-1.5"><ClockIcon size={14} color={primaryColor} /> {path.estimatedDurationMinutes ? `~${path.estimatedDurationMinutes} min` : '—'}</span>
<span className="flex items-center gap-1.5"><FlagIcon size={14} color={primaryColor} /> {stepCount} étape{stepCount > 1 ? 's' : ''}</span>
</div>
{desc && (
<p style={{ color: '#46555F', fontSize: 15, lineHeight: 1.55, margin: '0 0 16px' }}>{desc}</p>
)}
<button
onClick={onStart}
style={{
width: '100%', border: 'none', cursor: 'pointer',
background: primaryColor, color: '#fff',
padding: '16px', borderRadius: 18,
fontSize: 16, fontWeight: 700,
}}
>
Commencer le parcours
</button>
</div>
)}
</div>
</>
)
}
function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, isGame, language, onNext, onPrev, onBack }: {
path: GuidedPathDTO
steps: GuidedStepDTO[]
stepIndex: number
completedSteps: Set<string>
primaryColor: string
isGame: boolean
language: string
onNext: () => void
onPrev: () => void
onBack: () => void
}) {
const step = steps[stepIndex]
const [quizAnswered, setQuizAnswered] = useState<Record<number, number>>({})
const [showQuizResult, setShowQuizResult] = useState(false)
const [quizPassed, setQuizPassed] = useState(false)
const [showChallenge, setShowChallenge] = useState(false)
const [isAudioPlaying, setIsAudioPlaying] = useState(false)
const [audioCurrentTime, setAudioCurrentTime] = useState(0)
const [audioDuration, setAudioDuration] = useState(0)
const audioRef = useRef<HTMLAudioElement>(null)
const quizQuestions = step?.quizQuestions ?? []
const hasQuiz = quizQuestions.length > 0
const requireSuccess = path.requireSuccessToAdvance === true
const canAdvance = !hasQuiz || !requireSuccess || quizPassed
const stepTitle = tPlain(step?.title, language)
const stepDesc = t(step?.description, language)
const audioUrl = step?.audioIds?.find((a) => a.language === language)?.value
?? step?.audioIds?.[0]?.value
const stepContents = [...(step?.contents ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const hasCarousel = stepContents.length > 0
const bgColor = isGame ? '#0B1018' : '#F6F3EE'
const mutedColor = isGame ? '#8a8068' : '#6B7B86'
const accentColor = isGame ? '#D4AF37' : primaryColor
useEffect(() => {
setQuizAnswered({})
setShowQuizResult(false)
setQuizPassed(false)
setShowChallenge(false)
setIsAudioPlaying(false)
setAudioCurrentTime(0)
}, [stepIndex])
function toggleAudio() {
if (!audioRef.current) return
if (isAudioPlaying) { audioRef.current.pause(); setIsAudioPlaying(false) }
else { audioRef.current.play(); setIsAudioPlaying(true) }
}
function submitQuiz() {
const allCorrect = quizQuestions.every((q) => {
const chosen = quizAnswered[q.id]
return q.responses?.find((r) => r.order === chosen)?.isCorrect === true
})
setQuizPassed(allCorrect)
setShowQuizResult(true)
}
return (
<div style={{ position: 'fixed', inset: 0, background: bgColor, overflowY: 'auto' }}>
{/* Header */}
<div
className="flex items-center gap-3 sticky top-0 z-20"
style={{
padding: '12px 14px',
background: isGame ? 'rgba(11,16,24,0.95)' : 'rgba(246,243,238,0.95)',
backdropFilter: 'blur(10px)',
borderBottom: `1px solid ${isGame ? 'rgba(212,175,55,0.15)' : '#E2DCD2'}`,
}}
>
<button
onClick={onBack}
style={{
width: 40, height: 40, borderRadius: 12, border: 'none', cursor: 'pointer', flexShrink: 0,
background: isGame ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.85)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<ChevronLeft color={isGame ? '#D4AF37' : '#1E2A33'} />
</button>
<div className="flex flex-1 gap-1 items-center">
{steps.map((s, i) => (
<div
key={s.id}
style={{
flex: 1, height: 5, borderRadius: 3,
background: i < stepIndex
? (isGame ? '#D4AF37' : '#2E9E6B')
: i === stepIndex
? accentColor
: (isGame ? 'rgba(255,255,255,0.1)' : '#E2DCD2'),
}}
/>
))}
</div>
<span
style={{
padding: '6px 12px', borderRadius: 11, fontSize: 13, fontWeight: 700,
background: isGame ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.85)',
color: isGame ? '#D4AF37' : '#46555F',
whiteSpace: 'nowrap',
}}
>
{stepIndex + 1} / {steps.length}
</span>
</div>
{/* Hero — carousel si contents, sinon dégradé */}
{hasCarousel ? (
<StepCarousel
contents={stepContents}
stepIndexKey={stepIndex}
isGame={isGame}
accentColor={accentColor}
language={language}
/>
) : (
<div
style={{
height: 220, position: 'relative', overflow: 'hidden',
background: isGame
? 'linear-gradient(150deg, #1a2436, #0B1018)'
: `linear-gradient(150deg, color-mix(in srgb, ${primaryColor} 60%, white), ${primaryColor})`,
}}
>
{step?.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={step.imageUrl}
alt={stepTitle}
className="absolute inset-0 w-full h-full object-cover"
style={{ opacity: 0.7 }}
/>
)}
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.5), transparent 50%)' }} />
<div className="absolute top-3 left-4">
<span style={{
padding: '6px 12px', borderRadius: 30,
background: isGame ? 'rgba(0,0,0,0.5)' : 'rgba(10,16,22,0.4)',
backdropFilter: 'blur(6px)', color: '#fff', fontSize: 12.5, fontWeight: 700,
}}>
Étape {stepIndex + 1}
</span>
</div>
<div className="absolute left-4 right-4 bottom-4">
<p style={{ color: '#fff', fontSize: 22, fontWeight: 600, margin: 0, letterSpacing: '-0.4px', lineHeight: 1.1 }}>
{stepTitle}
</p>
</div>
</div>
)}
{/* Contenu */}
<div style={{ padding: '20px 20px 140px' }}>
{/* Titre — uniquement en mode carousel (sinon il est dans le hero) */}
{hasCarousel && (
<p style={{ color: isGame ? '#F4ECD8' : '#1E2A33', fontSize: 22, fontWeight: 600, margin: '0 0 10px', letterSpacing: '-0.4px', lineHeight: 1.15 }}>
{stepTitle}
</p>
)}
{/* Label parcours */}
<span
style={{
display: 'inline-block', marginBottom: 14,
padding: '5px 11px', borderRadius: 8,
background: isGame ? 'rgba(212,175,55,0.12)' : `color-mix(in srgb, ${primaryColor} 10%, white)`,
color: accentColor, fontSize: 11.5, fontWeight: 700, letterSpacing: '0.3px',
}}
>
{tPlain(path.title, language).toUpperCase() || 'PARCOURS'}
</span>
{/* Lecteur audio */}
{audioUrl && (
<>
<audio
ref={audioRef}
src={audioUrl}
onTimeUpdate={() => setAudioCurrentTime(audioRef.current?.currentTime ?? 0)}
onLoadedMetadata={() => setAudioDuration(audioRef.current?.duration ?? 0)}
onEnded={() => setIsAudioPlaying(false)}
/>
<div
style={{
marginBottom: 16, borderRadius: 16, overflow: 'hidden',
background: isGame ? 'rgba(255,255,255,0.06)' : '#fff',
border: `1px solid ${isGame ? 'rgba(212,175,55,0.2)' : '#E8E3DA'}`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px' }}>
<button
onClick={toggleAudio}
style={{
width: 44, height: 44, borderRadius: '50%', border: 'none', cursor: 'pointer', flexShrink: 0,
background: isGame ? '#D4AF37' : '#1E2A33',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{isAudioPlaying
? <PauseIcon color={isGame ? '#1a1305' : '#fff'} size={18} />
: <PlayIcon color={isGame ? '#1a1305' : '#fff'} size={18} />
}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ color: mutedColor, fontSize: 11, fontWeight: 700, letterSpacing: '0.5px' }}>
GUIDE AUDIO
</span>
<div style={{ marginTop: 5 }}>
<WaveformDecor
currentTime={audioCurrentTime}
duration={audioDuration}
accentColor={accentColor}
/>
</div>
</div>
<span style={{ color: mutedColor, fontSize: 12.5, fontWeight: 600, flexShrink: 0 }}>
{audioDuration > 0 ? formatTime(audioDuration) : '--:--'}
</span>
</div>
{audioDuration > 0 && (
<div style={{ height: 3, background: isGame ? 'rgba(255,255,255,0.06)' : '#F0EBE3' }}>
<div
style={{
height: '100%',
width: `${(audioCurrentTime / audioDuration) * 100}%`,
background: accentColor,
transition: 'width 0.3s linear',
}}
/>
</div>
)}
</div>
</>
)}
{/* Description */}
{stepDesc && (
<div
style={{ color: isGame ? '#c8bfa8' : '#46555F', fontSize: 15.5, lineHeight: 1.65, marginBottom: 16 }}
dangerouslySetInnerHTML={{ __html: stepDesc }}
/>
)}
{/* Défi (quiz) — affiché uniquement quand togglé */}
{showChallenge && hasQuiz && (
<StepQuiz
questions={quizQuestions}
answered={quizAnswered}
showResult={showQuizResult}
quizPassed={quizPassed}
isGame={isGame}
accentColor={accentColor}
onAnswer={(qId, rOrder) => setQuizAnswered((a) => ({ ...a, [qId]: rOrder }))}
onSubmit={submitQuiz}
onRetry={() => { setQuizAnswered({}); setShowQuizResult(false); setQuizPassed(false) }}
/>
)}
</div>
{/* Barre du bas */}
<div
style={{
position: 'fixed', left: 0, right: 0, bottom: 0,
padding: 'max(14px, env(safe-area-inset-bottom)) 18px 18px',
background: isGame
? 'linear-gradient(to top, #0B1018 80%, transparent)'
: 'linear-gradient(to top, #F6F3EE 80%, transparent)',
display: 'flex', gap: 10,
}}
>
{stepIndex > 0 && (
<button
onClick={onPrev}
style={{
width: 50, height: 50, border: `1.5px solid ${isGame ? 'rgba(212,175,55,0.3)' : '#E2DCD2'}`,
borderRadius: 14, background: isGame ? 'rgba(255,255,255,0.06)' : '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0,
}}
>
<ChevronLeft color={isGame ? '#D4AF37' : '#54636E'} />
</button>
)}
{hasQuiz && (
<button
onClick={() => setShowChallenge((v) => !v)}
style={{
flex: 1, cursor: 'pointer',
border: `1.5px solid ${showChallenge ? accentColor : (isGame ? 'rgba(212,175,55,0.4)' : '#D4C9B8')}`,
borderRadius: 15, fontSize: 14, fontWeight: 700,
background: showChallenge
? (isGame ? 'rgba(212,175,55,0.12)' : `color-mix(in srgb, ${accentColor} 10%, white)`)
: (isGame ? 'rgba(255,255,255,0.04)' : '#fff'),
color: accentColor,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '14px 10px',
}}
>
<StarIcon color={accentColor} size={14} />
Voir le défi
</button>
)}
<button
onClick={canAdvance ? onNext : undefined}
disabled={!canAdvance}
style={{
flex: 1.4, border: 'none', cursor: canAdvance ? 'pointer' : 'not-allowed', opacity: canAdvance ? 1 : 0.5,
background: isGame ? 'linear-gradient(180deg, #E7C765, #D4AF37)' : accentColor,
color: isGame ? '#1a1305' : '#fff',
padding: '16px', borderRadius: 15, fontSize: 15, fontWeight: 700,
}}
>
{stepIndex < steps.length - 1 ? 'Étape suivante' : 'Terminer'}
</button>
</div>
</div>
)
}
function StepQuiz({ questions, answered, showResult, quizPassed, isGame, accentColor, onAnswer, onSubmit, onRetry }: {
questions: QuestionDTO[]
answered: Record<number, number>
showResult: boolean
quizPassed: boolean
isGame: boolean
accentColor: string
onAnswer: (qId: number, rOrder: number) => void
onSubmit: () => void
onRetry: () => void
}) {
const allAnswered = questions.every((q) => answered[q.id] !== undefined)
return (
<div
style={{
marginTop: 20, padding: '16px 16px', borderRadius: 20,
background: isGame ? 'rgba(255,255,255,0.04)' : '#fff',
border: `1px solid ${isGame ? 'rgba(212,175,55,0.2)' : '#ECE6DC'}`,
}}
>
{showResult ? (
<div className="text-center" style={{ padding: '8px 0' }}>
{quizPassed ? (
<>
<div
style={{
width: 64, height: 64, borderRadius: '50%', margin: '0 auto 12px',
background: 'rgba(46,158,107,0.14)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div style={{ width: 48, height: 48, borderRadius: '50%', background: '#2E9E6B', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CheckIcon color="#fff" />
</div>
</div>
<p style={{ color: isGame ? '#F4ECD8' : '#1E2A33', fontSize: 18, fontWeight: 600, margin: '0 0 6px' }}>Bien joué !</p>
<p style={{ color: isGame ? '#8a8068' : '#6B7B86', fontSize: 14 }}>Continuez votre parcours.</p>
</>
) : (
<>
<div
style={{
width: 64, height: 64, borderRadius: '50%', margin: '0 auto 12px',
background: 'rgba(209,67,67,0.12)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div style={{ width: 48, height: 48, borderRadius: '50%', background: '#D14343', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<XIcon color="#fff" />
</div>
</div>
<p style={{ color: isGame ? '#F4ECD8' : '#1E2A33', fontSize: 18, fontWeight: 600, margin: '0 0 6px' }}>Pas tout à fait</p>
<p style={{ color: isGame ? '#8a8068' : '#6B7B86', fontSize: 14, margin: '0 0 16px' }}>Observez bien les détails autour de vous.</p>
<button
onClick={onRetry}
style={{
border: 'none', cursor: 'pointer',
background: accentColor, color: isGame ? '#1a1305' : '#fff',
padding: '13px 28px', borderRadius: 14, fontSize: 15, fontWeight: 700,
}}
>
Réessayer
</button>
</>
)}
</div>
) : (
<>
{questions.map((q) => (
<div key={q.id} style={{ marginBottom: 16 }}>
<p style={{ color: isGame ? '#F4ECD8' : '#1E2A33', fontSize: 15, fontWeight: 600, margin: '0 0 10px', lineHeight: 1.4 }}>
{tPlainArr(q.label)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{(q.responses ?? []).map((r) => {
const isSelected = answered[q.id] === r.order
return (
<button
key={r.id}
onClick={() => onAnswer(q.id, r.order!)}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '13px 14px', borderRadius: 14, cursor: 'pointer',
border: `1.5px solid ${isSelected ? accentColor : (isGame ? 'rgba(255,255,255,0.1)' : '#E2DCD2')}`,
background: isSelected
? (isGame ? 'rgba(212,175,55,0.12)' : `color-mix(in srgb, ${accentColor} 10%, white)`)
: (isGame ? 'rgba(255,255,255,0.04)' : '#F9F6F1'),
textAlign: 'left',
}}
>
<span
style={{
width: 30, height: 30, borderRadius: 9, flexShrink: 0,
background: isSelected ? accentColor : (isGame ? 'rgba(255,255,255,0.08)' : '#E8E3DA'),
color: isSelected ? (isGame ? '#1a1305' : '#fff') : (isGame ? '#D4AF37' : '#54636E'),
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 800, fontSize: 13,
}}
>
{String.fromCharCode(65 + (r.order ?? 0))}
</span>
<span style={{ color: isGame ? '#F4ECD8' : '#1E2A33', fontSize: 15, fontWeight: 500 }}>
{tPlainArr(r.label)}
</span>
</button>
)
})}
</div>
</div>
))}
<button
onClick={onSubmit}
disabled={!allAnswered}
style={{
width: '100%', border: 'none', cursor: allAnswered ? 'pointer' : 'not-allowed',
opacity: allAnswered ? 1 : 0.5,
background: accentColor, color: isGame ? '#1a1305' : '#fff',
padding: '14px', borderRadius: 14, fontSize: 15, fontWeight: 700, marginTop: 4,
}}
>
Valider
</button>
</>
)}
</div>
)
}
function EndView({ path, steps, isGame, primaryColor, language, gameOutro, onBack }: {
path: GuidedPathDTO
steps: GuidedStepDTO[]
isGame: boolean
primaryColor: string
language: string
gameOutro: string
onBack: () => void
}) {
const accentColor = isGame ? '#D4AF37' : primaryColor
return (
<div
style={{
position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', textAlign: 'center',
padding: '0 34px',
background: isGame
? 'radial-gradient(120% 80% at 50% 18%, #1a2436, #0B1018 60%, #05070b)'
: 'radial-gradient(110% 70% at 50% 0%, #ffffff, #F6F3EE 60%)',
}}
>
{/* Emblem */}
<div
style={{
width: 110, height: 110, borderRadius: '50%', marginBottom: 24,
border: `1.5px solid ${isGame ? 'rgba(212,175,55,0.5)' : 'rgba(46,158,107,0.2)'}`,
boxShadow: isGame
? '0 0 60px rgba(212,175,55,0.35), inset 0 0 30px rgba(212,175,55,0.12)'
: '0 16px 32px -10px rgba(46,158,107,0.4)',
background: isGame ? 'transparent' : '#2E9E6B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{isGame
? <StarIcon size={50} color="#E7C765" />
: <CheckIcon size={40} color="#fff" strokeWidth={2.6} />
}
</div>
{isGame ? (
<>
<p style={{ color: '#D4AF37', fontSize: 11, fontWeight: 700, letterSpacing: 4, margin: '0 0 14px' }}>ÉNIGME RÉSOLUE</p>
<h2 style={{ color: '#F4ECD8', fontSize: 'clamp(26px, 6vw, 32px)', fontWeight: 600, letterSpacing: '-0.5px', lineHeight: 1.1, margin: '0 0 14px' }}>
Le secret est<br />à vous
</h2>
{gameOutro && (
<p style={{ color: '#b9ac8c', fontSize: 15, fontStyle: 'italic', lineHeight: 1.6, margin: '0 0 20px' }}>
« {gameOutro} »
</p>
)}
<div
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '10px 18px', borderRadius: 30,
border: '1px solid rgba(212,175,55,0.4)',
background: 'rgba(212,175,55,0.08)',
marginBottom: 28, color: '#E7C765', fontSize: 13.5, fontWeight: 700,
}}
>
<TrophyIcon size={16} /> Badge obtenu
</div>
<button
onClick={onBack}
style={{
width: '100%', border: 'none', cursor: 'pointer',
background: 'linear-gradient(180deg, #E7C765, #D4AF37)',
color: '#1a1305', padding: '17px', borderRadius: 16,
fontSize: 16, fontWeight: 800,
}}
>
Terminer l&apos;aventure
</button>
</>
) : (
<>
<h2 style={{ color: '#1E2A33', fontSize: 'clamp(24px, 5vw, 30px)', fontWeight: 600, letterSpacing: '-0.4px', margin: '0 0 10px' }}>
Parcours terminé
</h2>
<p style={{ color: '#6B7B86', fontSize: 15, lineHeight: 1.55, margin: '0 0 24px' }}>
Vous avez exploré {steps.length} étape{steps.length > 1 ? 's' : ''} de &laquo;&nbsp;{tPlain(path.title, language)}&nbsp;&raquo;.
</p>
{/* Stats */}
<div className="flex gap-3 mb-8">
<div style={{ padding: '14px 20px', borderRadius: 18, background: '#fff', border: '1px solid #EEE9E0' }}>
<p style={{ fontSize: 26, fontWeight: 600, color: primaryColor, margin: 0 }}>{steps.length}</p>
<p style={{ fontSize: 12, color: '#8A969F', fontWeight: 600, margin: '2px 0 0' }}>étapes</p>
</div>
{path.estimatedDurationMinutes && (
<div style={{ padding: '14px 20px', borderRadius: 18, background: '#fff', border: '1px solid #EEE9E0' }}>
<p style={{ fontSize: 26, fontWeight: 600, color: primaryColor, margin: 0 }}>
{path.estimatedDurationMinutes}<span style={{ fontSize: 14 }}> min</span>
</p>
<p style={{ fontSize: 12, color: '#8A969F', fontWeight: 600, margin: '2px 0 0' }}>estimé</p>
</div>
)}
</div>
<button
onClick={onBack}
style={{
width: '100%', border: 'none', cursor: 'pointer',
background: primaryColor, color: '#fff',
padding: '16px', borderRadius: 18,
fontSize: 15, fontWeight: 700,
}}
>
Retour à la section
</button>
</>
)}
</div>
)
}
// ── Step carousel ─────────────────────────────────────────────────────────────
function StepCarousel({ contents, stepIndexKey, isGame, accentColor, language }: {
contents: ContentDTO[]
stepIndexKey: number
isGame: boolean
accentColor: string
language: string
}) {
const [imgIndex, setImgIndex] = useState(0)
useEffect(() => { setImgIndex(0) }, [stepIndexKey])
const current = contents[imgIndex]
return (
<div style={{ height: 220, position: 'relative', overflow: 'hidden', background: isGame ? '#0a0e14' : '#1E2A33' }}>
{current?.resource?.url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={current.resource.url}
alt={tPlain(current.title, language)}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
)}
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.3), transparent 60%)' }} />
{/* Counter badge */}
<div style={{ position: 'absolute', top: 12, left: 14 }}>
<span style={{
padding: '5px 10px', borderRadius: 9,
background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(6px)',
color: '#fff', fontSize: 12.5, fontWeight: 700, letterSpacing: '0.3px',
}}>
🖼 {imgIndex + 1} / {contents.length}
</span>
</div>
{/* Arrows */}
{contents.length > 1 && (
<>
<button
onClick={() => setImgIndex((i) => Math.max(0, i - 1))}
disabled={imgIndex === 0}
style={{
position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
background: 'rgba(0,0,0,0.35)', border: 'none', cursor: 'pointer',
borderRadius: '50%', width: 32, height: 32,
display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: imgIndex === 0 ? 0.3 : 1,
}}
>
<ChevronLeft color="#fff" size={16} />
</button>
<button
onClick={() => setImgIndex((i) => Math.min(contents.length - 1, i + 1))}
disabled={imgIndex === contents.length - 1}
style={{
position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
background: 'rgba(0,0,0,0.35)', border: 'none', cursor: 'pointer',
borderRadius: '50%', width: 32, height: 32,
display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: imgIndex === contents.length - 1 ? 0.3 : 1,
}}
>
<ChevronRight color="#fff" size={16} />
</button>
{/* Dots */}
<div style={{ position: 'absolute', bottom: 10, left: 0, right: 0, display: 'flex', justifyContent: 'center', gap: 5 }}>
{contents.map((_, i) => (
<button
key={i}
onClick={() => setImgIndex(i)}
style={{
width: i === imgIndex ? 18 : 6, height: 6, borderRadius: 3,
background: i === imgIndex ? accentColor : 'rgba(255,255,255,0.45)',
border: 'none', cursor: 'pointer', padding: 0,
transition: 'width 0.2s ease',
}}
/>
))}
</div>
</>
)}
</div>
)
}
// ── Waveform decorative ────────────────────────────────────────────────────────
function WaveformDecor({ currentTime, duration, accentColor }: {
currentTime: number
duration: number
accentColor: string
}) {
const bars = [4, 8, 13, 10, 16, 11, 7, 14, 9, 5, 12, 8, 10, 15, 7, 11, 13, 6, 14, 9, 5, 12, 10, 7, 13]
const progress = duration > 0 ? currentTime / duration : 0
const total = bars.length * 5
return (
<svg width={total} height={18} viewBox={`0 0 ${total} 18`}>
{bars.map((h, i) => (
<rect
key={i}
x={i * 5}
y={(18 - h) / 2}
width={3}
height={h}
rx={1.5}
fill={i / bars.length <= progress ? accentColor : 'rgba(100,110,120,0.2)'}
/>
))}
</svg>
)
}
// ── Time formatter ─────────────────────────────────────────────────────────────
function formatTime(s: number): string {
if (!s || isNaN(s)) return '0:00'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, '0')}`
}
// ── Decorative map sketch ─────────────────────────────────────────────────────
function MapSketch({ primaryColor }: { primaryColor: string }) {
return (
<svg
viewBox="0 0 342 140"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.25 }}
preserveAspectRatio="xMidYMid slice"
>
<path d="-5 60 H120 L160 100 H360" stroke="rgba(255,255,255,0.3)" strokeWidth="9" fill="none" />
<path d="M40 -5 V75 L90 115 V145" stroke="rgba(255,255,255,0.25)" strokeWidth="7" fill="none" />
<path d="M40 65 L120 40 L210 80 L160 105 L290 88" stroke="rgba(255,255,255,0.8)" strokeWidth="2" strokeDasharray="3 7" strokeLinecap="round" fill="none" />
<circle cx="40" cy="65" r="5" fill="rgba(255,255,255,0.9)" />
<circle cx="290" cy="88" r="5" fill="#2E9E6B" />
</svg>
)
}
// ── i18n helpers ──────────────────────────────────────────────────────────────
function tRaw(list: TranslationAndResourceDTO[] | undefined, language: string): string {
if (!list?.length) return ''
return list.find((i) => i.language === language)?.value ?? list[0]?.value ?? ''
}
function tPlainArr(list: { language?: string; value?: string }[] | undefined): string {
if (!list?.length) return ''
return list[0]?.value ?? ''
}
// ── Inline SVG icons ──────────────────────────────────────────────────────────
function ChevronLeft({ color = 'currentColor', size = 18 }: { color?: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.4">
<path d="M15 6l-6 6 6 6" />
</svg>
)
}
function ChevronRight({ color = 'currentColor', size = 14 }: { color?: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.4">
<path d="M9 6l6 6-6 6" />
</svg>
)
}
function RouteIcon({ color = 'currentColor', size = 24 }: { color?: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.8">
<path d="M3 6l6-2 6 2 6-2v14l-6 2-6-2-6 2Z" /><path d="M9 4v14M15 6v14" />
</svg>
)
}
function FlagIcon({ color = 'currentColor', size = 12 }: { color?: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2">
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" /><line x1="4" y1="22" x2="4" y2="15" />
</svg>
)
}
function ClockIcon({ color = 'currentColor', size = 14 }: { color?: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2">
<circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" />
</svg>
)
}
function LockIcon({ color = 'currentColor', size = 28 }: { color?: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.8">
<rect x="5" y="11" width="14" height="9" rx="2" /><path d="M8 11V8a4 4 0 0 1 8 0v3" />
</svg>
)
}
function CheckIcon({ color = 'currentColor', size = 34, strokeWidth = 3 }: { color?: string; size?: number; strokeWidth?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={strokeWidth}>
<path d="M5 13l4 4L19 7" />
</svg>
)
}
function XIcon({ color = 'currentColor', size = 28 }: { color?: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.8">
<path d="M7 7l10 10M17 7L7 17" />
</svg>
)
}
function StarIcon({ color = 'currentColor', size = 50 }: { color?: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.4">
<path d="M12 2l2.4 5.6L20 8l-4 4 1.2 6L12 15.5 6.8 18 8 12 4 8l5.6-.4Z" />
</svg>
)
}
function TrophyIcon({ color = 'currentColor', size = 16 }: { color?: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.8">
<path d="M8 21h8M12 17v4M5 4h14v4a7 7 0 0 1-14 0Z" />
</svg>
)
}
function PlayIcon({ color = 'currentColor', size = 18 }: { color?: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<path d="M8 5v14l11-7z" />
</svg>
)
}
function PauseIcon({ color = 'currentColor', size = 18 }: { color?: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
)
}