misc + bento layout + SectionParcours (mostly done)

This commit is contained in:
Thomas Fransolet 2026-07-14 17:11:32 +02:00
parent e3fbd06e71
commit 5fd6e60b7d
29 changed files with 4570 additions and 1469 deletions

1484
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,8 @@
"dev": "next dev", "dev": "next dev",
"dev:https": "next dev --experimental-https", "dev:https": "next dev --experimental-https",
"build": "next build", "build": "next build",
"start": "next start" "start": "next start",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
@ -27,6 +28,7 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5",
"vitest": "^3"
} }
} }

View File

@ -1,6 +1,7 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getInstanceBySlug, getConfiguration } from '@/lib/api/client' import { getInstanceBySlug, getConfiguration } from '@/lib/api/client'
import { resolveColors } from '@/lib/theme' import { resolveColors } from '@/lib/theme'
import SplashScreen from '@/components/SplashScreen'
export default async function ConfigLayout({ export default async function ConfigLayout({
children, children,
@ -20,6 +21,12 @@ export default async function ConfigLayout({
} }
const theme = resolveColors(instance, config) const theme = resolveColors(instance, config)
const loaderImageUrl = config.loaderImageUrl ?? instance.loaderImageUrl
return <div style={theme as React.CSSProperties}>{children}</div> return (
<div style={theme as React.CSSProperties}>
{loaderImageUrl && <SplashScreen imageUrl={loaderImageUrl} configId={configId} />}
{children}
</div>
)
} }

View File

@ -1,5 +1,5 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getInstanceBySlug, getConfiguration, getSections, getGuidedPaths, getGuidedPathsForGame } from '@/lib/api/client' import { getInstanceBySlug, getConfiguration, getSections, getGuidedPaths, getGuidedPathsForParcours } from '@/lib/api/client'
import type { SectionDTO } from '@/lib/api/types' import type { SectionDTO } from '@/lib/api/types'
// Section components // Section components
@ -15,6 +15,7 @@ import WebSection from '@/components/sections/WebSection'
import QuizSection from '@/components/sections/QuizSection' import QuizSection from '@/components/sections/QuizSection'
import GameSection from '@/components/sections/GameSection' import GameSection from '@/components/sections/GameSection'
import EventSection from '@/components/sections/EventSection' import EventSection from '@/components/sections/EventSection'
import ParcoursSection from '@/components/sections/ParcoursSection'
import TrackedSection from '@/components/TrackedSection' import TrackedSection from '@/components/TrackedSection'
export default async function SectionPage({ export default async function SectionPage({
@ -40,21 +41,11 @@ export default async function SectionPage({
sections.flatMap((s) => s.menu?.sections ?? []).find((s) => s.id === sectionId) sections.flatMap((s) => s.menu?.sections ?? []).find((s) => s.id === sectionId)
if (!section || section.isActive === false) notFound() if (!section || section.isActive === false) notFound()
// For Map sections, eagerly load the guided paths server-side and merge into the DTO // Parcours sections expose guided paths via their own endpoint
if (section.type === 'Map' && section.map) { if (section.type === 'Parcours' && section.parcours) {
try { try {
const paths = await getGuidedPaths(section.id, instance.publicApiKey!) const paths = await getGuidedPathsForParcours(section.id, instance.publicApiKey!)
section.map = { ...section.map, guidedPaths: paths } section.parcours = { ...section.parcours, guidedPaths: paths }
} catch {
// Silently ignore — Map still works without paths
}
}
// Escape Games also expose guided paths
if (section.type === 'Game' && section.gameType === 'Escape') {
try {
const paths = await getGuidedPathsForGame(section.id, instance.publicApiKey!)
section.guidedPaths = paths
} catch { } catch {
// Silently ignore // Silently ignore
} }
@ -86,6 +77,7 @@ if (!section || section.isActive === false) notFound()
case 'Quiz': content = <QuizSection {...props} />; break case 'Quiz': content = <QuizSection {...props} />; break
case 'Game': content = <GameSection {...props} />; break case 'Game': content = <GameSection {...props} />; break
case 'Event': content = <EventSection {...props} />; break case 'Event': content = <EventSection {...props} />; break
case 'Parcours': content = <ParcoursSection {...props} />; break
case 'Web': content = <WebSection {...props} />; break case 'Web': content = <WebSection {...props} />; break
default: default:
content = ( content = (

View File

@ -1,4 +1,4 @@
import { getInstanceBySlug, getConfigurations } from '@/lib/api/client' import { getInstanceBySlug, getConfigurationLinks } from '@/lib/api/client'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import ConfigurationGrid from '@/components/ConfigurationGrid' import ConfigurationGrid from '@/components/ConfigurationGrid'
import HomeHero from '@/components/HomeHero' import HomeHero from '@/components/HomeHero'
@ -11,16 +11,16 @@ export default async function HomePage({
}) { }) {
const { slug } = await params const { slug } = await params
let instance, configurations let instance, items
try { try {
instance = await getInstanceBySlug(slug) instance = await getInstanceBySlug(slug)
configurations = await getConfigurations(instance.id, instance.publicApiKey!) items = await getConfigurationLinks(instance.applicationInstanceId!, instance.publicApiKey!)
} catch { } catch {
notFound() notFound()
} }
const featuredEvent = instance.sectionEventDTO const featuredEvent = instance.sectionEventDTO
const languages = [...new Set(configurations.flatMap((c) => c.languages ?? []))] const languages = [...new Set(items.flatMap((i) => i.configuration.languages ?? []))]
return ( return (
<> <>
@ -32,7 +32,13 @@ export default async function HomePage({
configurationId={featuredEvent?.configurationId ?? undefined} configurationId={featuredEvent?.configurationId ?? undefined}
/> />
)} )}
<ConfigurationGrid configurations={configurations} slug={slug} languages={languages} /> <ConfigurationGrid
items={items}
slug={slug}
languages={languages}
instancePrimaryColor={instance.primaryColor}
instanceSecondaryColor={instance.secondaryColor}
/>
<QRScannerButton slug={slug} /> <QRScannerButton slug={slug} />
</> </>
) )

View File

@ -5,76 +5,79 @@ import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import type { ConfigurationDTO } from '@/lib/api/types' import type { WeightedConfiguration } from '@/lib/api/client'
interface Props { interface Props {
configurations: ConfigurationDTO[] items: WeightedConfiguration[]
slug: string slug: string
languages: string[] languages: string[]
instancePrimaryColor?: string
instanceSecondaryColor?: string
} }
export default function ConfigurationGrid({ configurations, slug, languages }: Props) { // Chaque section porte ses propres spans (col × row) — la taille joue sur la
// largeur ET la hauteur. On borne à 1..2 pour rester dans une grille lisible.
function clampSpan(v: number | undefined): number {
if (!v || v < 1) return 1
return v > 2 ? 2 : v
}
export default function ConfigurationGrid({ items, slug, languages, instancePrimaryColor }: Props) {
const { language, setAvailableLanguages } = useVisitor() const { language, setAvailableLanguages } = useVisitor()
const active = configurations.filter((c) => !c.isOffline)
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages(languages) }, [languages])
return ( return (
<main className="min-h-screen" style={{ background: 'var(--color-background)' }}> <main className="min-h-screen" style={{ background: 'var(--color-background)' }}>
<div className="p-4 columns-2 gap-3"> <div
{active.map((config) => ( className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 px-4 pt-6 pb-10"
style={{ gridAutoRows: '200px', gridAutoFlow: 'dense' }}
>
{items.map((item) => {
const config = item.configuration
const col = clampSpan(item.colSpan)
const row = clampSpan(item.rowSpan)
const fallbackColor = config.primaryColor ?? instancePrimaryColor ?? '#28415a'
return (
<Link <Link
key={config.id} key={config.id}
href={`/${slug}/${config.id}`} href={`/${slug}/${config.id}`}
className="break-inside-avoid mb-3 block rounded-2xl overflow-hidden relative" className="group relative rounded-2xl overflow-hidden block shadow-[0_2px_6px_rgba(0,0,0,0.22)] transition-[transform,box-shadow] duration-300 ease-out hover:-translate-y-1 hover:shadow-[0_12px_30px_rgba(0,0,0,0.30)] active:scale-[0.985] active:translate-y-0 motion-reduce:transition-none motion-reduce:hover:transform-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
style={{ style={{
background: 'var(--color-surface)', gridColumn: `span ${col}`,
boxShadow: '0 2px 6px rgba(0,0,0,0.22)', gridRow: `span ${row}`,
background: config.imageSource
? 'var(--color-surface)'
: `color-mix(in srgb, ${fallbackColor} 65%, white)`,
}} }}
> >
{config.imageSource ? ( {config.imageSource && (
<div className="relative w-full" style={{ minHeight: 120 }}>
<Image <Image
src={config.imageSource} src={config.imageSource}
alt={tPlain(config.title, language)} alt={tPlain(config.title, language)}
fill fill
className="object-cover" className="object-cover transition-transform duration-500 ease-out group-hover:scale-105 motion-reduce:group-hover:scale-100"
sizes="(max-width: 768px) 50vw, 33vw" sizes="(max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
/> />
)}
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.72) 0%, transparent 40%)' }} style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.72) 0%, transparent 45%)' }}
/> />
<div <div
className="absolute bottom-2 left-3 right-7 text-white text-sm font-semibold leading-tight [&_p]:m-0 [&_p]:leading-tight" className="absolute bottom-2 left-3 right-8 text-white text-sm font-semibold leading-tight line-clamp-2 [text-shadow:_0_1px_3px_rgba(0,0,0,0.45)] [&_p]:m-0 [&_p]:leading-tight"
dangerouslySetInnerHTML={{ __html: t(config.title, language) }} dangerouslySetInnerHTML={{ __html: t(config.title, language) }}
/> />
<svg <svg
className="absolute bottom-2 right-2" className="absolute bottom-2 right-2 transition-transform duration-300 ease-out group-hover:translate-x-0.5 motion-reduce:group-hover:transform-none"
width="18" height="18" viewBox="0 0 24 24" fill="white" width="18" height="18" viewBox="0 0 24 24" fill="white"
> >
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" /> <path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg> </svg>
</div>
) : (
<div className="p-4 relative" style={{ minHeight: 80 }}>
<div
className="text-sm font-semibold pr-6 [&_p]:m-0"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(config.title, language) }}
/>
<svg
className="absolute bottom-2 right-2"
width="18" height="18" viewBox="0 0 24 24"
fill="currentColor"
style={{ color: 'var(--color-text-muted)' }}
>
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</div>
)}
</Link> </Link>
))} )
})}
</div> </div>
</main> </main>
) )

View File

@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { useBack } from '@/hooks/useBack' import { useBack } from '@/hooks/useBack'
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain, stripHtml } from '@/lib/i18n' import { t, tPlain, stripHtml } from '@/lib/i18n'
import type { SectionDTO, TranslationDTO } from '@/lib/api/types' import type { SectionDTO, TranslationDTO } from '@/lib/api/types'
@ -54,15 +55,13 @@ export default function SectionList({
onClick={back} onClick={back}
className="absolute flex items-center justify-center" className="absolute flex items-center justify-center"
style={{ style={{
top: 44, left: 10, width: 44, height: 44, top: 'max(env(safe-area-inset-top), 16px)', left: 16, width: 40, height: 40,
background: 'rgba(0,0,0,0.35)', background: 'rgba(255,255,255,0.2)',
backdropFilter: 'blur(8px)', backdropFilter: 'blur(8px)',
borderRadius: '50%', borderRadius: 16,
}} }}
> >
<svg width="23" height="23" viewBox="0 0 24 24" fill="white"> <ChevronLeftIcon color="white" size={20} />
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button> </button>
</div> </div>

View File

@ -0,0 +1,39 @@
'use client'
import { useEffect, useState } from 'react'
export default function SplashScreen({ imageUrl, configId }: { imageUrl: string; configId: string }) {
const [phase, setPhase] = useState<'init' | 'visible' | 'fading' | 'gone'>('init')
useEffect(() => {
const key = `splash_shown_${configId}`
if (sessionStorage.getItem(key)) {
setPhase('gone')
return
}
sessionStorage.setItem(key, '1')
setPhase('visible')
const fade = setTimeout(() => setPhase('fading'), 1800)
const gone = setTimeout(() => setPhase('gone'), 2300)
return () => { clearTimeout(fade); clearTimeout(gone) }
}, [configId])
if (phase === 'init' || phase === 'gone') return null
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
background: '#000',
opacity: phase === 'fading' ? 0 : 1,
transition: 'opacity 0.5s ease',
pointerEvents: phase === 'fading' ? 'none' : 'auto',
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={imageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
)
}

View File

@ -96,25 +96,23 @@ export default function AgendaSection({ section, configId, languages, apiKey }:
} }
return ( return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}> <div className="min-h-screen flex flex-col" style={{ background: '#F6F3EE' }}>
<AppBar title={tPlain(section.title, language)} onBack={back} /> <AppBar title={tPlain(section.title, language)} onBack={back} />
{/* Month selector */} {/* Month selector */}
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--color-border)' }}> <div className="flex justify-center px-4 py-3">
<button onClick={prevMonth} className="p-1"> <div style={{ display: 'flex', alignItems: 'center', gap: 4, background: '#fff', border: '1px solid #EFEAE2', borderRadius: 14, padding: 4, boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-primary)' }}> <button onClick={prevMonth} style={{ width: 36, height: 36, borderRadius: 10, border: 'none', cursor: 'pointer', background: 'color-mix(in srgb, var(--color-primary) 10%, white)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2.4"><path d="M15 6l-6 6 6 6"/></svg>
</svg>
</button> </button>
<span className="font-semibold text-sm" style={{ color: 'var(--color-text)' }}> <span style={{ color: '#1E2A33', fontWeight: 700, fontSize: 14, minWidth: 150, textAlign: 'center' }}>
{formatMonthHeader(selectedYear, selectedMonth, language)} {formatMonthHeader(selectedYear, selectedMonth, language)}
</span> </span>
<button onClick={nextMonth} className="p-1"> <button onClick={nextMonth} style={{ width: 36, height: 36, borderRadius: 10, border: 'none', cursor: 'pointer', background: 'color-mix(in srgb, var(--color-primary) 10%, white)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-primary)' }}> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2.4"><path d="M9 6l6 6-6 6"/></svg>
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</svg>
</button> </button>
</div> </div>
</div>
{/* Events grid */} {/* Events grid */}
<main className="flex-1 overflow-y-auto p-3"> <main className="flex-1 overflow-y-auto p-3">
@ -139,8 +137,8 @@ export default function AgendaSection({ section, configId, languages, apiKey }:
}) })
} }
}} }}
className="rounded-2xl overflow-hidden text-left flex flex-col" className="overflow-hidden text-left flex flex-col"
style={{ background: 'var(--color-surface)', boxShadow: '0 2px 8px rgba(0,0,0,0.10)' }} style={{ background: '#fff', borderRadius: 20, border: '1px solid #EFEAE2', boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }}
> >
{/* Image */} {/* Image */}
<div className="relative w-full aspect-video"> <div className="relative w-full aspect-video">
@ -152,7 +150,7 @@ export default function AgendaSection({ section, configId, languages, apiKey }:
{event.dateFrom && ( {event.dateFrom && (
<div <div
className="absolute bottom-0 right-0 flex items-center gap-1.5 px-2.5 py-1.5 text-sm font-bold whitespace-nowrap" className="absolute bottom-0 right-0 flex items-center gap-1.5 px-2.5 py-1.5 text-sm font-bold whitespace-nowrap"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)', borderTopLeftRadius: 10 }} style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)', borderTopLeftRadius: 8 }}
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ flexShrink: 0 }}> <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ flexShrink: 0 }}>
<path d="M19 3h-1V1h-2v2H8V1H6v2H5a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 18H5V8h14v13z"/> <path d="M19 3h-1V1h-2v2H8V1H6v2H5a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 18H5V8h14v13z"/>
@ -208,7 +206,7 @@ function EventDetail({
const videoResource = event.videoResource?.url const videoResource = event.videoResource?.url
return ( return (
<div className="fixed inset-0 z-50" style={{ background: 'var(--color-background)' }}> <div className="fixed inset-0 z-50" style={{ background: '#F6F3EE' }}>
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<AppBar title={img ? '' : tPlain(event.label, language)} onBack={onClose} overlay={!!img} /> <AppBar title={img ? '' : tPlain(event.label, language)} onBack={onClose} overlay={!!img} />
{img && ( {img && (
@ -233,12 +231,12 @@ function EventDetail({
)} )}
{event.dateFrom && ( {event.dateFrom && (
<div <div
className="flex items-center gap-3 p-3 rounded-xl" className="flex items-center gap-3 p-3"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }} style={{ background: '#fff', border: '1px solid #EFEAE2', borderRadius: 16, boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }}
> >
<div <div
className="shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" className="shrink-0 flex items-center justify-center"
style={{ background: 'var(--color-primary-light)' }} style={{ width: 40, height: 40, borderRadius: 10, background: 'color-mix(in srgb, var(--color-primary) 12%, white)' }}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--color-primary)' }}> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--color-primary)' }}>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" /> <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
@ -300,7 +298,7 @@ function EventDetail({
{/* Mini map */} {/* Mini map */}
{coords && ( {coords && (
<div className="rounded-xl overflow-hidden" style={{ height: 180, border: '1px solid var(--color-border)' }}> <div className="overflow-hidden" style={{ height: 180, borderRadius: 16, border: '1px solid #EFEAE2' }}>
<EventMiniMap lat={coords.lat} lng={coords.lng} /> <EventMiniMap lat={coords.lat} lng={coords.lng} />
</div> </div>
)} )}
@ -311,11 +309,11 @@ function EventDetail({
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`} href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex items-center gap-3 p-3 rounded-xl" className="flex items-center gap-3 p-3"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }} style={{ background: '#fff', border: '1px solid #EFEAE2', borderRadius: 16, boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }}
> >
<Icon name="place" /> <Icon name="place" />
<span className="text-sm flex-1" style={{ color: 'var(--color-text)' }}>{address}</span> <span className="text-sm flex-1" style={{ color: '#1E2A33' }}>{address}</span>
</a> </a>
)} )}
@ -325,8 +323,8 @@ function EventDetail({
{event.phone && ( {event.phone && (
<a <a
href={`tel:${event.phone}`} href={`tel:${event.phone}`}
className="flex items-center gap-3 p-3 rounded-xl" className="flex items-center gap-3 p-3"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }} style={{ background: '#fff', border: '1px solid #EFEAE2', borderRadius: 16, boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)', color: 'var(--color-primary)' }}
> >
<Icon name="phone" /> <Icon name="phone" />
<span className="text-sm">{event.phone}</span> <span className="text-sm">{event.phone}</span>
@ -335,8 +333,8 @@ function EventDetail({
{event.email && ( {event.email && (
<a <a
href={`mailto:${event.email}`} href={`mailto:${event.email}`}
className="flex items-center gap-3 p-3 rounded-xl" className="flex items-center gap-3 p-3"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }} style={{ background: '#fff', border: '1px solid #EFEAE2', borderRadius: 16, boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)', color: 'var(--color-primary)' }}
> >
<Icon name="email" /> <Icon name="email" />
<span className="text-sm truncate">{event.email}</span> <span className="text-sm truncate">{event.email}</span>
@ -347,8 +345,8 @@ function EventDetail({
href={event.website.startsWith('http') ? event.website : `https://${event.website}`} href={event.website.startsWith('http') ? event.website : `https://${event.website}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex items-center gap-3 p-3 rounded-xl" className="flex items-center gap-3 p-3"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }} style={{ background: '#fff', border: '1px solid #EFEAE2', borderRadius: 16, boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)', color: 'var(--color-primary)' }}
> >
<Icon name="web" /> <Icon name="web" />
<span className="text-sm truncate">{event.website}</span> <span className="text-sm truncate">{event.website}</span>

View File

@ -69,7 +69,7 @@ export default function ArticleSection({ section, configId, languages }: Props)
const htmlContent = t(article?.content, language) const htmlContent = t(article?.content, language)
return ( return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}> <div className="min-h-screen flex flex-col" style={{ background: '#F6F3EE' }}>
<AppBar title={tPlain(section.title, language)} onBack={back} /> <AppBar title={tPlain(section.title, language)} onBack={back} />
<main ref={scrollRef} className="flex-1 overflow-y-auto pb-24"> <main ref={scrollRef} className="flex-1 overflow-y-auto pb-24">
@ -98,36 +98,36 @@ export default function ArticleSection({ section, configId, languages }: Props)
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)} onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)}
onEnded={() => setIsPlaying(false)} onEnded={() => setIsPlaying(false)}
/> />
<div <div style={{ position: 'fixed', left: 0, right: 0, bottom: 0, padding: '14px 16px 16px', background: 'linear-gradient(to top, #F6F3EE 78%, transparent)' }}>
className="fixed bottom-0 left-0 right-0 px-4 py-3 flex items-center gap-3" <div style={{ borderRadius: 16, overflow: 'hidden', background: '#fff', border: '1px solid #E8E3DA', boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }}>
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }} <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: '#1E2A33', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
> >
<button onClick={toggleAudio} className="shrink-0">
{isPlaying ? ( {isPlaying ? (
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
) : ( ) : (
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg>
<path d="M8 5v14l11-7z" />
</svg>
)} )}
</button> </button>
<div className="flex-1"> <div style={{ flex: 1, minWidth: 0 }}>
<div className="w-full h-1 rounded-full" style={{ background: 'var(--color-on-primary)', opacity: 0.3 }}> <span style={{ color: '#6B7B86', fontSize: 11, fontWeight: 700, letterSpacing: '0.5px' }}>GUIDE AUDIO</span>
<div <div style={{ marginTop: 5 }}>
className="h-1 rounded-full" <WaveformDecor currentTime={currentTime} duration={duration} />
style={{
background: 'var(--color-on-primary)',
width: duration ? `${(currentTime / duration) * 100}%` : '0%',
}}
/>
</div> </div>
</div> </div>
<span className="text-xs shrink-0"> <span style={{ color: '#6B7B86', fontSize: 12.5, fontWeight: 600, flexShrink: 0 }}>
{formatTime(currentTime)} / {formatTime(duration)} {duration > 0 ? formatTime(duration) : '--:--'}
</span> </span>
</div> </div>
{duration > 0 && (
<div style={{ height: 3, background: '#F0EBE3' }}>
<div style={{ height: '100%', width: `${(currentTime / duration) * 100}%`, background: 'var(--color-primary)', transition: 'width 0.3s linear' }} />
</div>
)}
</div>
</div>
</> </>
)} )}
</div> </div>
@ -152,16 +152,16 @@ function ImageCarousel({ contents, language }: { contents: NonNullable<SectionDT
<button <button
onClick={() => setIndex((i) => Math.max(0, i - 1))} onClick={() => setIndex((i) => Math.max(0, i - 1))}
disabled={index === 0} disabled={index === 0}
className="absolute left-2 top-1/2 -translate-y-1/2 bg-black/40 text-white rounded-full p-1 disabled:opacity-20" 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: index === 0 ? 0.3 : 1 }}
> >
<svg width="20" height="20" 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> <svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button> </button>
<button <button
onClick={() => setIndex((i) => Math.min(contents.length - 1, i + 1))} onClick={() => setIndex((i) => Math.min(contents.length - 1, i + 1))}
disabled={index === contents.length - 1} disabled={index === contents.length - 1}
className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/40 text-white rounded-full p-1 disabled:opacity-20" 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: index === contents.length - 1 ? 0.3 : 1 }}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button> </button>
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-1"> <div className="absolute bottom-2 left-0 right-0 flex justify-center gap-1">
{contents.map((_, i) => ( {contents.map((_, i) => (
@ -174,6 +174,20 @@ function ImageCarousel({ contents, language }: { contents: NonNullable<SectionDT
) )
} }
function WaveformDecor({ currentTime, duration }: { currentTime: number; duration: number }) {
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 ? 'var(--color-primary)' : 'rgba(100,110,120,0.2)'} />
))}
</svg>
)
}
function formatTime(s: number): string { function formatTime(s: number): string {
if (!s || isNaN(s)) return '0:00' if (!s || isNaN(s)) return '0:00'
const m = Math.floor(s / 60) const m = Math.floor(s / 60)

View File

@ -7,6 +7,7 @@ import dynamic from 'next/dynamic'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import LanguageSelector from '@/components/ui/LanguageSelector' import LanguageSelector from '@/components/ui/LanguageSelector'
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
import type { SectionDTO, ProgrammeBlock, MapAnnotationDTO, GuidedPathDTO } from '@/lib/api/types' import type { SectionDTO, ProgrammeBlock, MapAnnotationDTO, GuidedPathDTO } from '@/lib/api/types'
const EventMap = dynamic(() => import('./event/EventMap'), { const EventMap = dynamic(() => import('./event/EventMap'), {
@ -108,13 +109,11 @@ export default function EventSection({ section, slug, configId, languages }: Pro
<div className="relative z-10 flex items-center justify-between px-3 py-3" style={{ paddingTop: 'max(env(safe-area-inset-top), 12px)' }}> <div className="relative z-10 flex items-center justify-between px-3 py-3" style={{ paddingTop: 'max(env(safe-area-inset-top), 12px)' }}>
<button <button
onClick={back} onClick={back}
className="w-10 h-10 rounded-full flex items-center justify-center" className="w-10 h-10 rounded-2xl flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(8px)' }} style={{ background: 'rgba(255,255,255,0.2)', backdropFilter: 'blur(8px)' }}
aria-label="Retour" aria-label="Retour"
> >
<svg width="22" height="22" viewBox="0 0 24 24" fill="white"> <ChevronLeftIcon color="white" size={20} />
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button> </button>
<LanguageSelector overlay /> <LanguageSelector overlay />
</div> </div>

View File

@ -2,13 +2,13 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useBack } from '@/hooks/useBack' import { useBack } from '@/hooks/useBack'
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { t } from '@/lib/i18n'
import type { SectionDTO, TranslationDTO } from '@/lib/api/types' import type { SectionDTO, TranslationDTO } from '@/lib/api/types'
import PuzzleGame from './game/PuzzleGame' import PuzzleGame from './game/PuzzleGame'
import SlidingPuzzle from './game/SlidingPuzzle' import SlidingPuzzle from './game/SlidingPuzzle'
import MessageDialog from './game/MessageDialog' import MessageDialog from './game/MessageDialog'
import EscapeProgression from './game/EscapeProgression'
import { trackEvent } from '@/lib/stats' import { trackEvent } from '@/lib/stats'
interface Props { interface Props {
@ -18,12 +18,11 @@ interface Props {
languages: string[] languages: string[]
} }
type GameKind = 'Puzzle' | 'SlidingPuzzle' | 'Escape' | 'Unknown' type GameKind = 'Puzzle' | 'SlidingPuzzle' | 'Unknown'
function detectKind(raw?: string): GameKind { function detectKind(raw?: string): GameKind {
if (!raw) return 'Unknown' if (!raw) return 'Unknown'
if (raw === 'SlidingPuzzle') return 'SlidingPuzzle' if (raw === 'SlidingPuzzle') return 'SlidingPuzzle'
if (raw === 'Escape') return 'Escape'
if (raw === 'Puzzle') return 'Puzzle' if (raw === 'Puzzle') return 'Puzzle'
return 'Unknown' return 'Unknown'
} }
@ -94,13 +93,11 @@ export default function GameSection({ section, configId, languages }: Props) {
<div className="relative z-10 flex items-center gap-2 px-3 py-3" style={{ paddingTop: 'max(env(safe-area-inset-top), 12px)' }}> <div className="relative z-10 flex items-center gap-2 px-3 py-3" style={{ paddingTop: 'max(env(safe-area-inset-top), 12px)' }}>
<button <button
onClick={back} onClick={back}
className="w-10 h-10 rounded-full flex items-center justify-center" className="w-10 h-10 rounded-2xl flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(8px)' }} style={{ background: 'rgba(255,255,255,0.2)', backdropFilter: 'blur(8px)' }}
aria-label="Retour" aria-label="Retour"
> >
<svg width="22" height="22" viewBox="0 0 24 24" fill="white"> <ChevronLeftIcon color="white" size={20} />
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button> </button>
</div> </div>
@ -133,13 +130,6 @@ export default function GameSection({ section, configId, languages }: Props) {
onWin={handleWin} onWin={handleWin}
/> />
)} )}
{kind === 'Escape' && (
(section.guidedPaths?.length ?? 0) > 0 ? (
<EscapeProgression paths={section.guidedPaths!} language={language} />
) : (
<EscapeStub title={tPlain(section.title, language)} description={t(section.description, language)} />
)
)}
{kind === 'Unknown' && ( {kind === 'Unknown' && (
<div className="flex-1 flex items-center justify-center text-sm text-center p-6" style={{ color: 'var(--color-text-muted)' }}> <div className="flex-1 flex items-center justify-center text-sm text-center p-6" style={{ color: 'var(--color-text-muted)' }}>
Type de jeu inconnu. Type de jeu inconnu.
@ -221,29 +211,3 @@ export default function GameSection({ section, configId, languages }: Props) {
</div> </div>
) )
} }
function EscapeStub({ title, description }: { title: string; description: string }) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center gap-4">
<div
className="w-20 h-20 rounded-full flex items-center justify-center"
style={{ background: 'var(--color-primary-light)' }}
>
<svg width="42" height="42" viewBox="0 0 24 24" fill="var(--color-primary)">
<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-lg font-bold" style={{ color: 'var(--color-text)' }}>{title}</div>
{description && (
<div
className="text-sm max-w-md [&_p]:m-0"
style={{ color: 'var(--color-text-muted)' }}
dangerouslySetInnerHTML={{ __html: description }}
/>
)}
<div className="text-sm mt-4 px-4 py-2 rounded-full" style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-text-muted)' }}>
Parcours guidé bientôt disponible.
</div>
</div>
)
}

View File

@ -2,16 +2,12 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useBack } from '@/hooks/useBack' import { useBack } from '@/hooks/useBack'
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO, GeoPointDTO, GuidedPathDTO, GuidedStepDTO } from '@/lib/api/types' import type { SectionDTO, GeoPointDTO } from '@/lib/api/types'
import { useGeolocation } from '@/hooks/useGeolocation'
import { haversineMeters, formatDistance } from '@/lib/geo'
import { trackEvent } from '@/lib/stats' import { trackEvent } from '@/lib/stats'
import StepTimer from './map/StepTimer'
import StepQuiz from './map/StepQuiz'
import Toast from './map/Toast'
import './map/map.css' import './map/map.css'
const LeafletMap = dynamic(() => import('./map/LeafletMap'), { const LeafletMap = dynamic(() => import('./map/LeafletMap'), {
@ -42,100 +38,12 @@ export default function MapSection({ section, configId, languages }: Props) {
const hasData = !!map && (map.points?.length ?? 0) > 0 const hasData = !!map && (map.points?.length ?? 0) > 0
const points = useMemo(() => map?.points ?? [], [map]) const points = useMemo(() => map?.points ?? [], [map])
const categories = useMemo(() => map?.categories ?? [], [map]) const categories = useMemo(() => map?.categories ?? [], [map])
const guidedPaths = useMemo(() => map?.guidedPaths ?? [], [map])
const [mode, setMode] = useState<Mode>('map') const [mode, setMode] = useState<Mode>('map')
const [selectedId, setSelectedId] = useState<number | null>(null) const [selectedId, setSelectedId] = useState<number | null>(null)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [activeCats, setActiveCats] = useState<Set<number>>(new Set()) const [activeCats, setActiveCats] = useState<Set<number>>(new Set())
const [filterOpen, setFilterOpen] = useState(false) 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) => const norm = (s: string) =>
s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
@ -186,10 +94,8 @@ export default function MapSection({ section, configId, languages }: Props) {
className="absolute top-0 left-0 right-0 flex items-center gap-2 px-3 py-2 z-[1000]" 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)' }} 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"> <button onClick={back} className="w-10 h-10 rounded-2xl flex items-center justify-center" aria-label="Retour">
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"> <ChevronLeftIcon size={20} />
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button> </button>
<span className="text-base font-semibold flex-1 truncate px-2">{tPlain(section.title, language)}</span> <span className="text-base font-semibold flex-1 truncate px-2">{tPlain(section.title, language)}</span>
</div> </div>
@ -211,7 +117,7 @@ export default function MapSection({ section, configId, languages }: Props) {
zoom={zoom} zoom={zoom}
selectedId={selectedId} selectedId={selectedId}
onSelect={(id) => { onSelect={(id) => {
setSelectedId(id); setActiveStepId(null) setSelectedId(id)
const p = points.find((pp) => pp.id === id) const p = points.find((pp) => pp.id === id)
if (instanceId && p) { if (instanceId && p) {
trackEvent({ trackEvent({
@ -222,12 +128,6 @@ export default function MapSection({ section, configId, languages }: Props) {
} }
}} }}
primaryColor={primaryColor} 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 <PointList
@ -248,13 +148,11 @@ export default function MapSection({ section, configId, languages }: Props) {
> >
<button <button
onClick={back} onClick={back}
className="w-10 h-10 rounded-full flex items-center justify-center" className="w-10 h-10 rounded-2xl flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)' }} style={{ background: 'rgba(255,255,255,0.2)', backdropFilter: 'blur(8px)' }}
aria-label="Retour" aria-label="Retour"
> >
<svg width="22" height="22" viewBox="0 0 24 24" fill="white"> <ChevronLeftIcon color="white" size={20} />
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button> </button>
<span <span
className="text-base font-semibold flex-1 truncate px-2" className="text-base font-semibold flex-1 truncate px-2"
@ -262,23 +160,6 @@ export default function MapSection({ section, configId, languages }: Props) {
> >
{tPlain(section.title, language)} {tPlain(section.title, language)}
</span> </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 <button
onClick={() => setFilterOpen((v) => !v)} onClick={() => setFilterOpen((v) => !v)}
className="w-10 h-10 rounded-full flex items-center justify-center relative" className="w-10 h-10 rounded-full flex items-center justify-center relative"
@ -401,442 +282,14 @@ export default function MapSection({ section, configId, languages }: Props) {
</button> </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) */} {/* Detail sheet (POI) */}
{selected && ( {selected && (
<PointDetail point={selected} language={language} onClose={() => setSelectedId(null)} /> <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> </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 ───────────────────────────────────────────────────────── // ── Point list view ─────────────────────────────────────────────────────────
function PointList({ function PointList({
points, language, categories, onSelect, points, language, categories, onSelect,
@ -1036,62 +489,3 @@ function Icon({ name }: { name: 'phone' | 'email' | 'web' | 'schedule' | 'euro'
</svg> </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>
)
}

View File

@ -44,14 +44,14 @@ export default function MenuSection({ section, slug, configId, languages }: Prop
}) })
return ( return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}> <div className="min-h-screen flex flex-col" style={{ background: '#F6F3EE' }}>
<AppBar title={tPlain(section.title, language)} onBack={back} /> <AppBar title={tPlain(section.title, language)} onBack={back} />
{/* Search */} {/* Search */}
<div className="px-4 pt-3 pb-2"> <div className="px-4 pt-3 pb-2">
<div <div
className="flex items-center gap-2 rounded-xl px-3 py-2" className="flex items-center gap-2 px-3 py-2"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }} style={{ background: '#fff', border: '1px solid #E2DCD2', borderRadius: 14 }}
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-text-muted)' }}> <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-text-muted)' }}>
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 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"/> <path d="M15.5 14h-.79l-.28-.27A6.471 6.471 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"/>
@ -84,8 +84,8 @@ export default function MenuSection({ section, slug, configId, languages }: Prop
key={sub.id} key={sub.id}
href={`/${slug}/${configId}/sections/${sub.id}`} href={`/${slug}/${configId}/sections/${sub.id}`}
onClick={() => handleItemClick(sub)} onClick={() => handleItemClick(sub)}
className="rounded-2xl overflow-hidden block relative" className="block relative overflow-hidden"
style={{ background: 'var(--color-surface)', minHeight: 120 }} style={{ background: '#fff', minHeight: 120, borderRadius: 20, border: '1px solid #EFEAE2', boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }}
> >
{sub.imageSource ? ( {sub.imageSource ? (
<div className="relative w-full h-32"> <div className="relative w-full h-32">
@ -103,7 +103,7 @@ export default function MenuSection({ section, slug, configId, languages }: Prop
<div className="p-4 relative" style={{ minHeight: 80 }}> <div className="p-4 relative" style={{ minHeight: 80 }}>
<div <div
className="text-sm font-semibold pr-6 [&_p]:m-0" className="text-sm font-semibold pr-6 [&_p]:m-0"
style={{ color: 'var(--color-text)' }} style={{ color: '#1E2A33' }}
dangerouslySetInnerHTML={{ __html: t(sub.title, language) }} dangerouslySetInnerHTML={{ __html: t(sub.title, language) }}
/> />
<svg className="absolute bottom-2 right-2" width="15" height="15" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-text-muted)' }}> <svg className="absolute bottom-2 right-2" width="15" height="15" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-text-muted)' }}>

File diff suppressed because it is too large Load Diff

View File

@ -51,7 +51,7 @@ export default function QuizSection({ section, configId, languages }: Props) {
if (questions.length === 0) { if (questions.length === 0) {
return ( return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}> <div className="min-h-screen flex flex-col" style={{ background: '#F6F3EE' }}>
<AppBar title={tPlain(section.title, language)} onBack={back} /> <AppBar title={tPlain(section.title, language)} onBack={back} />
<div className="flex-1 flex items-center justify-center text-sm p-8 text-center" style={{ color: 'var(--color-text-muted)' }}> <div className="flex-1 flex items-center justify-center text-sm p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>
Aucune question disponible. Aucune question disponible.
@ -106,7 +106,7 @@ export default function QuizSection({ section, configId, languages }: Props) {
} }
const levelText = t(getLevel(), language) const levelText = t(getLevel(), language)
return ( return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}> <div className="min-h-screen flex flex-col" style={{ background: '#F6F3EE' }}>
<AppBar title={tPlain(section.title, language)} onBack={back} /> <AppBar title={tPlain(section.title, language)} onBack={back} />
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6"> <div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
<div <div
@ -127,11 +127,13 @@ export default function QuizSection({ section, configId, languages }: Props) {
{levelText && ( {levelText && (
<div <div
className="w-full rounded-2xl px-5 py-4 text-sm text-center [&_p]:m-0" className="w-full px-5 py-4 text-sm text-center [&_p]:m-0"
style={{ style={{
background: 'var(--color-surface)', background: '#fff',
color: 'var(--color-text)', color: '#1E2A33',
border: '1px solid var(--color-border)', border: '1px solid #EFEAE2',
borderRadius: 16,
boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)',
}} }}
dangerouslySetInnerHTML={{ __html: levelText }} dangerouslySetInnerHTML={{ __html: levelText }}
/> />
@ -140,19 +142,15 @@ export default function QuizSection({ section, configId, languages }: Props) {
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<button <button
onClick={restart} onClick={restart}
className="w-full py-3 rounded-2xl font-semibold text-sm" className="w-full font-bold"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }} style={{ padding: 16, borderRadius: 18, border: 'none', background: 'var(--color-primary)', color: 'var(--color-on-primary)', fontSize: 16, cursor: 'pointer' }}
> >
Recommencer Recommencer
</button> </button>
<button <button
onClick={() => { setCurrentIndex(0); setPhase('review') }} onClick={() => { setCurrentIndex(0); setPhase('review') }}
className="w-full py-3 rounded-2xl font-semibold text-sm" className="w-full font-bold"
style={{ style={{ padding: 16, borderRadius: 18, border: '1.5px solid #E2DCD2', background: '#fff', color: '#46555F', fontSize: 16, cursor: 'pointer' }}
background: 'var(--color-surface)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
}}
> >
Voir les réponses Voir les réponses
</button> </button>
@ -176,7 +174,7 @@ export default function QuizSection({ section, configId, languages }: Props) {
return ( return (
<> <>
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}> <div className="min-h-screen flex flex-col" style={{ background: '#F6F3EE' }}>
<AppBar <AppBar
title={isReview ? tPlain(section.title, language) : undefined} title={isReview ? tPlain(section.title, language) : undefined}
onBack={isReview ? () => { setCurrentIndex(0); setPhase('result') } : back} onBack={isReview ? () => { setCurrentIndex(0); setPhase('result') } : back}
@ -251,15 +249,27 @@ export default function QuizSection({ section, configId, languages }: Props) {
<button <button
key={response.id ?? i} key={response.id ?? i}
onClick={() => !isReview && chosen === undefined && selectAnswer(currentIndex, i)} onClick={() => !isReview && chosen === undefined && selectAnswer(currentIndex, i)}
className="w-full rounded-2xl px-4 py-3 text-sm font-medium text-left [&_p]:m-0 transition-colors" className="w-full text-left [&_p]:m-0 transition-colors"
style={{ style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '13px 14px', borderRadius: 16,
background: bg, background: bg,
color: textColor, color: textColor,
boxShadow: '0 1px 4px rgba(0,0,0,0.1)', border: `1.5px solid ${isReview && response.isCorrect ? '#2E9E6B' : isReview && isSelected ? '#D14343' : isSelected ? 'var(--color-primary)' : '#E2DCD2'}`,
boxShadow: (!isReview && !isSelected) ? '0 6px 16px -12px rgba(0,0,0,0.3)' : 'none',
cursor: isReview || chosen !== undefined ? 'default' : 'pointer', cursor: isReview || chosen !== undefined ? 'default' : 'pointer',
minHeight: 52, minHeight: 52,
}} }}
> >
<span style={{
width: 30, height: 30, borderRadius: 9, flexShrink: 0,
background: (isReview && response.isCorrect) || (isReview && isSelected) || (!isReview && isSelected) ? 'rgba(255,255,255,0.22)' : '#E8E3DA',
color: (isReview && response.isCorrect) || (isReview && isSelected) || (!isReview && isSelected) ? '#fff' : '#54636E',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 800, fontSize: 13,
}}>
{String.fromCharCode(65 + i)}
</span>
{responseResource?.url && ( {responseResource?.url && (
isAudio(responseResource) ? ( isAudio(responseResource) ? (
<div <div
@ -285,48 +295,49 @@ export default function QuizSection({ section, configId, languages }: Props) {
) )
)} )}
{responseText && ( {responseText && (
<div dangerouslySetInnerHTML={{ __html: responseText }} /> <div style={{ flex: 1, minWidth: 0 }} dangerouslySetInnerHTML={{ __html: responseText }} />
)} )}
</button> </button>
) )
})} })}
</div> </div>
{/* Counter + navigation */} {/* Segmented progress + counter */}
<div className="flex items-center justify-between pt-1"> <div style={{ display: 'flex', alignItems: 'center', gap: 10, paddingTop: 4 }}>
{isReview && (
<button <button
onClick={() => currentIndex > 0 && setCurrentIndex(currentIndex - 1)} onClick={() => currentIndex > 0 && setCurrentIndex(currentIndex - 1)}
disabled={currentIndex === 0} disabled={currentIndex === 0}
className="w-10 h-10 rounded-full flex items-center justify-center transition-opacity" style={{ width: 36, height: 36, borderRadius: 10, border: 'none', cursor: 'pointer', flexShrink: 0, background: currentIndex === 0 ? '#E8E3DA' : 'color-mix(in srgb, var(--color-primary) 12%, white)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
style={{ background: 'var(--color-primary)', opacity: currentIndex === 0 ? 0.3 : 1 }}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="white"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2.4"><path d="M15 6l-6 6 6 6"/></svg>
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button> </button>
)}
<span className="text-2xl font-bold" style={{ color: 'var(--color-text)' }}> <div style={{ flex: 1, display: 'flex', gap: 4 }}>
{currentIndex + 1}/{totalQuestions} {questions.map((_, i) => (
<div key={i} style={{ flex: 1, height: 6, borderRadius: 3, background: i <= currentIndex ? 'var(--color-primary)' : '#E2DCD2' }} />
))}
</div>
<span style={{ padding: '6px 12px', borderRadius: 11, fontSize: 13, fontWeight: 700, background: 'color-mix(in srgb, var(--color-primary) 12%, white)', color: 'var(--color-primary)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{currentIndex + 1} / {totalQuestions}
</span> </span>
{isReview && (
<button <button
onClick={() => canGoNext && !isLast && setCurrentIndex(currentIndex + 1)} onClick={() => canGoNext && !isLast && setCurrentIndex(currentIndex + 1)}
disabled={!canGoNext || isLast} disabled={!canGoNext || isLast}
className="w-10 h-10 rounded-full flex items-center justify-center transition-opacity" style={{ width: 36, height: 36, borderRadius: 10, border: 'none', cursor: 'pointer', flexShrink: 0, background: (!canGoNext || isLast) ? '#E8E3DA' : 'color-mix(in srgb, var(--color-primary) 12%, white)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
style={{ background: 'var(--color-primary)', opacity: !canGoNext || isLast ? 0.3 : 1 }}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="white"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2.4"><path d="M9 6l6 6-6 6"/></svg>
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</svg>
</button> </button>
)}
</div> </div>
{/* Restart button (review only) */} {/* Restart button (review only) */}
{isReview && ( {isReview && (
<button <button
onClick={restart} onClick={restart}
className="w-full py-3 rounded-2xl font-semibold text-sm" className="w-full font-bold"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }} style={{ padding: 16, borderRadius: 18, border: '1.5px solid #E2DCD2', background: '#fff', color: '#46555F', fontSize: 15, cursor: 'pointer' }}
> >
Recommencer Recommencer
</button> </button>
@ -404,11 +415,11 @@ function ZoomIcon() {
function getAnswerBg(isReview: boolean, isSelected: boolean, isCorrect?: boolean): string { function getAnswerBg(isReview: boolean, isSelected: boolean, isCorrect?: boolean): string {
if (!isReview) { if (!isReview) {
return isSelected ? 'var(--color-primary)' : 'white' return isSelected ? 'var(--color-primary)' : '#fff'
} }
if (isCorrect) return '#4caf50' if (isCorrect) return '#2E9E6B'
if (isSelected) return '#f44336' if (isSelected) return '#D14343'
return '#f5f5f5' return '#fff'
} }
function getLabelEntry(labels: TranslationAndResourceDTO[] | undefined, lang: string) { function getLabelEntry(labels: TranslationAndResourceDTO[] | undefined, lang: string) {

View File

@ -2,6 +2,7 @@
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { useBack } from '@/hooks/useBack' import { useBack } from '@/hooks/useBack'
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { tPlain } from '@/lib/i18n' import { tPlain } from '@/lib/i18n'
import type { SectionDTO } from '@/lib/api/types' import type { SectionDTO } from '@/lib/api/types'
@ -57,15 +58,13 @@ export default function VideoSection({ section, slug, configId, languages }: Pro
<button <button
onClick={back} onClick={back}
style={{ style={{
width: 36, height: 36, borderRadius: '50%', width: 40, height: 40, borderRadius: 16,
background: 'rgba(255,255,255,0.15)', background: 'rgba(255,255,255,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
border: 'none', cursor: 'pointer', flexShrink: 0, border: 'none', cursor: 'pointer', flexShrink: 0,
}} }}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="white"> <ChevronLeftIcon color="white" size={20} />
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button> </button>
<span style={{ color: 'white', fontWeight: 600, fontSize: 15, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ color: 'white', fontWeight: 600, fontSize: 15, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{title || 'Vidéo'} {title || 'Vidéo'}

View File

@ -2,6 +2,7 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useBack } from '@/hooks/useBack' import { useBack } from '@/hooks/useBack'
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { tPlain } from '@/lib/i18n' import { tPlain } from '@/lib/i18n'
import type { SectionDTO } from '@/lib/api/types' import type { SectionDTO } from '@/lib/api/types'
@ -40,15 +41,13 @@ export default function WebSection({ section, slug, configId, languages }: Props
<button <button
onClick={back} onClick={back}
style={{ style={{
width: 36, height: 36, borderRadius: '50%', width: 40, height: 40, borderRadius: 16,
background: 'rgba(255,255,255,0.15)', background: 'rgba(255,255,255,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
border: 'none', cursor: 'pointer', flexShrink: 0, border: 'none', cursor: 'pointer', flexShrink: 0,
}} }}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="white"> <ChevronLeftIcon color="white" size={20} />
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button> </button>
<span style={{ color: 'white', fontWeight: 600, fontSize: 15, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ color: 'white', fontWeight: 600, fontSize: 15, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{title || 'Web'} {title || 'Web'}

View File

@ -1,379 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { t, tPlain } from '@/lib/i18n'
import { useGeolocation } from '@/hooks/useGeolocation'
import { haversineMeters, formatDistance } from '@/lib/geo'
import StepTimer from '../map/StepTimer'
import StepQuiz from '../map/StepQuiz'
import Toast from '../map/Toast'
import MessageDialog from './MessageDialog'
import type { GuidedPathDTO, GuidedStepDTO } from '@/lib/api/types'
interface Props {
paths: GuidedPathDTO[]
language: string
}
export default function EscapeProgression({ paths, language }: Props) {
const [activePathId, setActivePathId] = useState<string | null>(paths.length === 1 ? paths[0].id : null)
const activePath = paths.find((p) => p.id === activePathId) ?? null
const activeSteps = useMemo(
() => [...(activePath?.steps ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
[activePath]
)
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 [showEnd, setShowEnd] = useState(false)
const [geoKey, setGeoKey] = useState(0)
useEffect(() => {
setCompletedSteps(new Set())
setQuizPassedSteps(new Set())
setTimerExpiredSteps(new Set())
setZoneNotifiedSteps(new Set())
setShowEnd(false)
setToastMessage(null)
}, [activePathId])
const currentStepId = useMemo(() => {
if (!activePath) return null
return activeSteps.find((s) => !completedSteps.has(s.id))?.id ?? null
}, [activePath, activeSteps, completedSteps])
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])
const geo = useGeolocation(!!activePath, geoKey)
// 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(`Zone atteinte : « ${title} »`)
setZoneNotifiedSteps((prev) => {
const next = new Set(prev)
next.add(currentStepId)
return next
})
}
}, [activePath, currentStepId, activeSteps, geo.lat, geo.lng, zoneNotifiedSteps, language])
function markComplete(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) setShowEnd(true)
}
// Path picker
if (!activePath) {
return (
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-3">
<div className="text-sm text-center mb-2" style={{ color: 'var(--color-text-muted)' }}>
Choisis un parcours pour démarrer.
</div>
{paths.map((p) => (
<button
key={p.id}
onClick={() => setActivePathId(p.id)}
className="flex items-center gap-3 p-4 rounded-2xl text-left"
style={{ background: 'var(--color-surface)', border: '1px solid 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)' }}>
{p.steps?.length ?? 0} étape{(p.steps?.length ?? 0) > 1 ? 's' : ''}
{p.isLinear && ' · linéaire'}
</div>
</div>
</button>
))}
</div>
)
}
return (
<div className="flex-1 overflow-y-auto p-3 pb-20">
<div className="flex items-center justify-between mb-3 px-1">
<div className="text-xs font-semibold" style={{ color: 'var(--color-text-muted)' }}>
{completedSteps.size} / {activeSteps.length} étapes
</div>
{paths.length > 1 && (
<button
onClick={() => setActivePathId(null)}
className="text-xs font-medium"
style={{ color: 'var(--color-primary)' }}
>
Changer de parcours
</button>
)}
</div>
<div className="flex flex-col gap-3">
{visibleSteps.map((step, i) => (
<EscapeStepCard
key={step.id}
step={step}
index={activeSteps.findIndex((s) => s.id === step.id)}
total={activeSteps.length}
language={language}
geo={geo}
isCurrent={step.id === currentStepId}
isCompleted={completedSteps.has(step.id)}
quizPassed={quizPassedSteps.has(step.id)}
timerExpired={timerExpiredSteps.has(step.id)}
requireSuccess={!!activePath.requireSuccessToAdvance}
onQuizResult={(passed) => {
if (passed) {
setQuizPassedSteps((prev) => {
const next = new Set(prev)
next.add(step.id)
return next
})
}
}}
onTimerExpired={() => {
setTimerExpiredSteps((prev) => {
const next = new Set(prev)
next.add(step.id)
return next
})
}}
onRetryGeo={() => setGeoKey((k) => k + 1)}
onMarkComplete={() => markComplete(step.id)}
/>
))}
</div>
{toastMessage && <Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />}
<MessageDialog
open={showEnd}
title="Bravo !"
html={`Tu as terminé le parcours « ${tPlain(activePath.title, language) || 'parcours'} ».`}
icon={
<div
className="w-16 h-16 rounded-full flex items-center justify-center"
style={{ background: 'var(--color-primary-light)' }}
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="var(--color-primary)">
<path d="M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v1c0 2.55 1.92 4.63 4.39 4.94A5.01 5.01 0 0 0 11 15.9V18H9v2h6v-2h-2v-2.1a5.01 5.01 0 0 0 3.61-2.96C19.08 12.63 21 10.55 21 8V7c0-1.1-.9-2-2-2zM5 8V7h2v3.82C5.84 10.4 5 9.3 5 8zm14 0c0 1.3-.84 2.4-2 2.82V7h2v1z" />
</svg>
</div>
}
onClose={() => { setShowEnd(false); setActivePathId(null) }}
primaryAction={{ label: 'Recommencer', onClick: () => { setCompletedSteps(new Set()); setQuizPassedSteps(new Set()); setTimerExpiredSteps(new Set()); setZoneNotifiedSteps(new Set()); setShowEnd(false) } }}
secondaryAction={{ label: 'Retour', onClick: () => { setShowEnd(false); setActivePathId(null) } }}
/>
</div>
)
}
function EscapeStepCard({
step, index, total, language, geo,
isCurrent, isCompleted, quizPassed, timerExpired, requireSuccess,
onQuizResult, onTimerExpired, onRetryGeo, onMarkComplete,
}: {
step: GuidedStepDTO
index: number
total: number
language: string
geo: ReturnType<typeof useGeolocation>
isCurrent: boolean
isCompleted: boolean
quizPassed: boolean
timerExpired: boolean
requireSuccess: boolean
onQuizResult: (passed: boolean) => void
onTimerExpired: () => void
onRetryGeo: () => void
onMarkComplete: () => void
}) {
const radius = step.zoneRadiusMeters ?? 0
const coords = step.geometry?.coordinates
const distance = geo.lat != null && geo.lng != null && coords
? haversineMeters({ lat: geo.lat, lng: geo.lng }, { lat: coords[1], lng: coords[0] })
: 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'
const quizOk = !hasQuiz || quizPassed || !requireSuccess
const zoneOk = radius === 0 || inZone || !requireSuccess || geoUnavailable
const lockOk = !isLocked || !requireSuccess || quizPassed || inZone || geoUnavailable
const canAdvance = quizOk && zoneOk && lockOk
const stateColor = isCompleted ? '#16a34a' : isCurrent ? 'var(--color-primary)' : 'var(--color-border)'
return (
<div
className="rounded-2xl overflow-hidden"
style={{
background: 'var(--color-surface)',
border: `2px solid ${stateColor}`,
opacity: !isCurrent && !isCompleted ? 0.6 : 1,
}}
>
{step.imageUrl && (
<div className="relative" style={{ height: 140 }}>
{/* 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.5), transparent)' }} />
<div
className="absolute top-2 left-2 px-2 py-0.5 rounded-full text-[11px] font-bold"
style={{ background: stateColor, color: 'white' }}
>
Étape {index + 1} / {total}
{isCompleted && ' · ✓'}
</div>
</div>
)}
<div className="p-4 flex flex-col gap-3">
{!step.imageUrl && (
<div className="flex items-center gap-2">
<span
className="px-2 py-0.5 rounded-full text-[11px] font-bold"
style={{ background: stateColor, color: 'white' }}
>
Étape {index + 1} / {total}
{isCompleted && ' · ✓'}
</span>
</div>
)}
<div
className="text-base font-bold [&_p]:m-0"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(step.title, language) || `Étape ${index + 1}` }}
/>
{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="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 === 'denied' && (
<button onClick={onRetryGeo} className="text-xs underline" style={{ color: '#b91c1c' }}>
Localisation refusée réessayer
</button>
)}
</div>
)}
{isLocked && !isCompleted && (
<div className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full self-start" style={{ background: '#fef3c7', color: '#92400e' }}>
<span>🔒</span>
{quizPassed || inZone ? 'Étape déverrouillée' : 'Étape verrouillée'}
</div>
)}
{hasTimer && isCurrent && !isCompleted && (
<div>
<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>
<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>
)}
{isCurrent && !isCompleted && (
<button
onClick={onMarkComplete}
disabled={!canAdvance}
className="w-full py-3 rounded-2xl font-semibold text-sm mt-1"
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',
}}
>
{index === total - 1 ? 'Terminer' : 'Marquer terminée'}
</button>
)}
{isCompleted && (
<div className="text-xs text-center py-1.5 rounded-full" style={{ background: '#d1fae5', color: '#065f46' }}>
Étape terminée
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,71 @@
'use client'
import { useState } from 'react'
import type { QuestionDTO } from '@/lib/api/types'
import PuzzleGame from './PuzzleGame'
import SlidingPuzzle from './SlidingPuzzle'
interface Props {
question: QuestionDTO
solved: boolean
onSolved: () => void
accentColor: string
isGame: boolean
}
export default function QuestionPuzzle({ question, solved, onSolved, accentColor, isGame }: Props) {
const [showHint, setShowHint] = useState(false)
const imageUrl = question.puzzleImage?.url
const rows = Math.max(2, question.puzzleRows ?? 3)
const cols = Math.max(2, question.puzzleCols ?? 3)
if (!imageUrl) {
return (
<div className="text-sm" style={{ color: isGame ? '#8a8068' : '#6B7B86' }}>
Image du puzzle manquante.
</div>
)
}
return (
<div
className="relative"
style={{
height: 280, borderRadius: 14, overflow: 'hidden',
background: isGame ? 'rgba(255,255,255,0.04)' : '#F0EBE3',
}}
>
{solved ? (
<div className="w-full h-full flex flex-col items-center justify-center gap-2">
<div
className="w-full h-full absolute inset-0"
style={{ backgroundImage: `url(${imageUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' }}
/>
<div className="absolute inset-0" style={{ background: 'rgba(0,0,0,0.35)' }} />
<span className="relative text-sm font-semibold" style={{ color: '#fff' }}>Puzzle résolu !</span>
</div>
) : question.isSlidingPuzzle ? (
<SlidingPuzzle imageUrl={imageUrl} rows={rows} cols={cols} showHint={showHint} onWin={onSolved} />
) : (
<PuzzleGame imageUrl={imageUrl} rows={rows} cols={cols} showHint={showHint} onWin={onSolved} />
)}
{!solved && (
<button
onClick={() => setShowHint((v) => !v)}
className="absolute bottom-2 right-2 w-9 h-9 rounded-full flex items-center justify-center"
style={{
background: showHint ? accentColor : (isGame ? 'rgba(255,255,255,0.1)' : '#fff'),
color: showHint ? (isGame ? '#1a1305' : '#fff') : (isGame ? '#D4AF37' : '#54636E'),
boxShadow: '0 2px 8px rgba(0,0,0,0.2)', border: 'none', cursor: 'pointer',
}}
aria-label="Aide"
title="Afficher / masquer l'aperçu"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17a5 5 0 1 1 0-10 5 5 0 0 1 0 10zm0-8a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" />
</svg>
</button>
)}
</div>
)
}

View File

@ -1,10 +1,11 @@
'use client' 'use client'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { MapContainer, TileLayer, Marker, Polyline, useMap } from 'react-leaflet' import { MapContainer, TileLayer, Marker, Polyline, Polygon, useMap } from 'react-leaflet'
import L from 'leaflet' import L from 'leaflet'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
import type { GeoPointDTO, CategorieDTO, GuidedStepDTO } from '@/lib/api/types' import type { GeoPointDTO, CategorieDTO, GuidedStepDTO } from '@/lib/api/types'
import { getStepGeometryCenter, getStepGeometryShape } from '@/lib/geo'
interface PathStep { interface PathStep {
id: string id: string
@ -69,6 +70,12 @@ function stepIcon(order: number, primaryColor: string, state: StepState, selecte
}) })
} }
function stepColor(state: StepState, primaryColor: string): string {
if (state === 'completed') return '#16a34a'
if (state === 'locked' || state === 'future') return '#9ca3af'
return primaryColor
}
function userIcon() { function userIcon() {
return L.divIcon({ return L.divIcon({
className: 'mim-user-wrapper', className: 'mim-user-wrapper',
@ -122,20 +129,37 @@ export default function LeafletMap({
const stepCoords: PathStep[] = useMemo(() => { const stepCoords: PathStep[] = useMemo(() => {
if (!pathSteps || pathSteps.length === 0) return [] if (!pathSteps || pathSteps.length === 0) return []
return pathSteps return pathSteps
.filter((s) => s.geometry?.coordinates && s.geometry.coordinates.length >= 2) .flatMap((s) => {
.map((s) => ({ const pt = getStepGeometryCenter(s.geometry)
id: s.id, if (!pt) return []
order: s.order ?? 0, return [{ id: s.id, order: s.order ?? 0, lat: pt.lat, lng: pt.lng }]
lat: s.geometry!.coordinates![1], })
lng: s.geometry!.coordinates![0],
}))
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
}, [pathSteps]) }, [pathSteps])
const polylinePositions: [number, number][] = stepCoords.map((s) => [s.lat, s.lng]) const polylinePositions: [number, number][] = stepCoords.map((s) => [s.lat, s.lng])
// When a path is loaded, fit map to all step bounds. Otherwise fly to selected POI. const stepShapes = useMemo(() => {
const fitBounds: [number, number][] | null = stepCoords.length > 1 ? polylinePositions : null if (!pathSteps) return []
return pathSteps.flatMap((s) => {
const shape = getStepGeometryShape(s.geometry)
return shape ? [{ id: s.id, shape }] : []
})
}, [pathSteps])
const stepState = (id: string): StepState => {
if (completedStepIds?.has(id)) return 'completed'
if (id === currentStepId) return 'current'
if (pathSteps?.find((x) => x.id === id)?.isStepLocked) return 'locked'
return 'future'
}
// When a path is loaded, fit map to all step bounds (representative points + real shapes).
// Otherwise fly to selected POI.
const fitBounds: [number, number][] | null = useMemo(() => {
const positions = [...polylinePositions, ...stepShapes.flatMap((s) => s.shape.positions)]
return positions.length > 1 ? positions : null
}, [polylinePositions, stepShapes])
return ( return (
<MapContainer <MapContainer
@ -168,6 +192,31 @@ export default function LeafletMap({
) )
})} })}
{/* Real LineString/Polygon shape of steps whose geometry isn't a single point */}
{stepShapes.map(({ id, shape }) => {
const state = stepState(id)
const color = stepColor(state, primaryColor)
const opacity = state === 'future' || state === 'locked' ? 0.5 : 0.9
if (shape.type === 'LineString') {
return (
<Polyline
key={`shape-${id}`}
positions={shape.positions}
pathOptions={{ color, weight: 5, opacity, lineCap: 'round', lineJoin: 'round' }}
eventHandlers={{ click: () => onSelectStep?.(id) }}
/>
)
}
return (
<Polygon
key={`shape-${id}`}
positions={shape.positions}
pathOptions={{ color, weight: 3, opacity, fillOpacity: 0.15 }}
eventHandlers={{ click: () => onSelectStep?.(id) }}
/>
)
})}
{/* Path polyline + numbered step markers */} {/* Path polyline + numbered step markers */}
{polylinePositions.length > 1 && ( {polylinePositions.length > 1 && (
<Polyline <Polyline
@ -182,19 +231,13 @@ export default function LeafletMap({
}} }}
/> />
)} )}
{stepCoords.map((s) => { {stepCoords.map((s, idx) => {
let state: StepState = 'future' const state = stepState(s.id)
if (completedStepIds?.has(s.id)) state = 'completed'
else if (s.id === currentStepId) state = 'current'
else {
const stepDef = pathSteps?.find((x) => x.id === s.id)
if (stepDef?.isStepLocked) state = 'locked'
}
return ( return (
<Marker <Marker
key={`s-${s.id}`} key={`s-${s.id}`}
position={[s.lat, s.lng]} position={[s.lat, s.lng]}
icon={stepIcon(s.order, primaryColor, state, s.id === selectedStepId)} icon={stepIcon(idx + 1, primaryColor, state, s.id === selectedStepId)}
eventHandlers={{ click: () => onSelectStep?.(s.id) }} eventHandlers={{ click: () => onSelectStep?.(s.id) }}
zIndexOffset={state === 'current' ? 2000 : 1000} zIndexOffset={state === 'current' ? 2000 : 1000}
/> />

View File

@ -1,107 +0,0 @@
'use client'
import { useState } from 'react'
import { t } from '@/lib/i18n'
import type { QuestionDTO } from '@/lib/api/types'
interface Props {
questions: QuestionDTO[]
language: string
onResult: (passed: boolean, score: number) => void
}
export default function StepQuiz({ questions, language, onResult }: Props) {
const sorted = [...questions].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const [answers, setAnswers] = useState<Record<number, number>>({})
const [submitted, setSubmitted] = useState(false)
function selectAnswer(qi: number, ai: number) {
if (submitted) return
setAnswers((prev) => ({ ...prev, [qi]: ai }))
}
function submit() {
let correct = 0
sorted.forEach((q, i) => {
const responses = [...(q.responses ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const chosen = answers[i]
if (chosen != null && responses[chosen]?.isCorrect) correct++
})
const score = sorted.length > 0 ? (correct / sorted.length) * 100 : 0
setSubmitted(true)
onResult(score === 100, score)
}
function retry() {
setAnswers({})
setSubmitted(false)
}
const allAnswered = sorted.every((_, i) => answers[i] != null)
return (
<div className="flex flex-col gap-3">
{sorted.map((q, qi) => {
const responses = [...(q.responses ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
return (
<div key={q.id} className="rounded-xl p-3" style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
<div
className="text-sm font-medium mb-2 [&_p]:m-0"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(q.label, language) }}
/>
<div className="flex flex-col gap-1.5">
{responses.map((r, ri) => {
const selected = answers[qi] === ri
let bg = 'var(--color-background)'
let textColor = 'var(--color-text)'
let border = '1px solid var(--color-border)'
if (submitted) {
if (r.isCorrect) { bg = '#d1fae5'; textColor = '#065f46'; border = '1px solid #16a34a' }
else if (selected) { bg = '#fee2e2'; textColor = '#991b1b'; border = '1px solid #dc2626' }
} else if (selected) {
bg = 'var(--color-primary)'
textColor = 'var(--color-on-primary)'
border = 'none'
}
return (
<button
key={r.id}
onClick={() => selectAnswer(qi, ri)}
disabled={submitted}
className="w-full text-left px-3 py-2 rounded-lg text-sm [&_p]:m-0"
style={{ background: bg, color: textColor, border, cursor: submitted ? 'default' : 'pointer' }}
dangerouslySetInnerHTML={{ __html: t(r.label, language) }}
/>
)
})}
</div>
</div>
)
})}
{!submitted ? (
<button
onClick={submit}
disabled={!allAnswered}
className="w-full py-2.5 rounded-xl font-semibold text-sm"
style={{
background: allAnswered ? 'var(--color-primary)' : 'var(--color-surface)',
color: allAnswered ? 'var(--color-on-primary)' : 'var(--color-text-muted)',
border: allAnswered ? 'none' : '1px solid var(--color-border)',
cursor: allAnswered ? 'pointer' : 'not-allowed',
}}
>
Valider mes réponses
</button>
) : (
<button
onClick={retry}
className="w-full py-2.5 rounded-xl font-semibold text-sm"
style={{ background: 'var(--color-surface)', color: 'var(--color-text)', border: '1px solid var(--color-border)' }}
>
Réessayer
</button>
)}
</div>
)
}

View File

@ -1,11 +1,8 @@
'use client' 'use client'
import { useEffect, useState } from 'react'
interface Props { interface Props {
totalSeconds: number totalSeconds: number
active: boolean remainingSeconds: number
onExpired?: () => void
} }
function format(s: number): string { function format(s: number): string {
@ -14,37 +11,15 @@ function format(s: number): string {
return `${m}:${r.toString().padStart(2, '0')}` return `${m}:${r.toString().padStart(2, '0')}`
} }
export default function StepTimer({ totalSeconds, active, onExpired }: Props) { export default function StepTimer({ totalSeconds, remainingSeconds }: Props) {
const [remaining, setRemaining] = useState(totalSeconds) const pct = totalSeconds > 0 ? (remainingSeconds / totalSeconds) * 100 : 0
useEffect(() => {
setRemaining(totalSeconds)
}, [totalSeconds])
useEffect(() => {
if (!active) return
if (remaining <= 0) return
const id = setInterval(() => {
setRemaining((s) => {
if (s <= 1) {
clearInterval(id)
onExpired?.()
return 0
}
return s - 1
})
}, 1000)
return () => clearInterval(id)
}, [active, remaining > 0, onExpired])
const pct = totalSeconds > 0 ? (remaining / totalSeconds) * 100 : 0
const color = pct > 50 ? '#16a34a' : pct > 25 ? '#f59e0b' : '#dc2626' const color = pct > 50 ? '#16a34a' : pct > 25 ? '#f59e0b' : '#dc2626'
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span style={{ color: 'var(--color-text-muted)' }}>Temps restant</span> <span style={{ color: 'var(--color-text-muted)' }}>Temps restant</span>
<span className="font-mono font-bold" style={{ color }}>{format(remaining)}</span> <span className="font-mono font-bold" style={{ color }}>{format(remainingSeconds)}</span>
</div> </div>
<div className="h-2 rounded-full overflow-hidden" style={{ background: 'var(--color-border)' }}> <div className="h-2 rounded-full overflow-hidden" style={{ background: 'var(--color-border)' }}>
<div <div

View File

@ -1,37 +0,0 @@
'use client'
import { useEffect } from 'react'
export default function Toast({
message, onDismiss, duration = 3500,
}: {
message: string
onDismiss: () => void
duration?: number
}) {
useEffect(() => {
const id = setTimeout(onDismiss, duration)
return () => clearTimeout(id)
}, [duration, onDismiss])
return (
<div
className="absolute left-1/2 z-[2500] flex items-center gap-2 px-4 py-3 rounded-2xl text-sm font-semibold"
style={{
top: 'calc(env(safe-area-inset-top, 0px) + 70px)',
transform: 'translateX(-50%)',
background: '#16a34a',
color: 'white',
boxShadow: '0 8px 20px rgba(0,0,0,0.3)',
animation: 'mim-toast-in 0.3s ease',
maxWidth: 'calc(100vw - 32px)',
}}
>
<style>{`@keyframes mim-toast-in { from { transform: translate(-50%, -20px); opacity: 0; } to { transform: translate(-50%, 0); opacity: 1; } }`}</style>
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{message}
</div>
)
}

View File

@ -2,6 +2,7 @@
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import LanguageSelector from '@/components/ui/LanguageSelector' import LanguageSelector from '@/components/ui/LanguageSelector'
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
interface Props { interface Props {
title?: string title?: string
@ -24,16 +25,14 @@ export default function AppBar({ title, onBack, overlay }: Props) {
{onBack && ( {onBack && (
<button <button
onClick={onBack} onClick={onBack}
className="shrink-0 flex items-center justify-center rounded-full hover:opacity-80 transition-opacity" className="shrink-0 flex items-center justify-center rounded-2xl hover:opacity-80 transition-opacity"
style={overlay style={overlay
? { background: 'rgba(0,0,0,0.35)', backdropFilter: 'blur(8px)', padding: 6 } ? { width: 40, height: 40, background: 'rgba(255,255,255,0.2)', backdropFilter: 'blur(8px)' }
: { background: 'var(--color-primary-light)', color: 'var(--color-primary)', padding: 6 } : { width: 40, height: 40, background: 'var(--color-primary-light)', color: 'var(--color-primary)' }
} }
aria-label="Retour" aria-label="Retour"
> >
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"> <ChevronLeftIcon color={overlay ? 'white' : 'var(--color-primary)'} size={20} />
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button> </button>
)} )}
<span <span

View File

@ -0,0 +1,12 @@
interface Props {
color?: string
size?: number
}
export default function ChevronLeftIcon({ color = 'currentColor', size = 22 }: Props) {
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>
)
}

View File

@ -1,4 +1,4 @@
import type { ApplicationInstanceDTO, ConfigurationDTO, SectionDTO, GuidedPathDTO } from './types' import type { ApplicationInstanceDTO, AppConfigurationLinkDTO, ConfigurationDTO, SectionDTO, GuidedPathDTO, ParcoursDTO } from './types'
const BASE_URL = process.env.NEXT_PUBLIC_API_URL const BASE_URL = process.env.NEXT_PUBLIC_API_URL
@ -48,7 +48,6 @@ function normalizeSectionDTO(raw: any): SectionDTO {
centerLatitude: raw.centerLatitude, centerLatitude: raw.centerLatitude,
centerLongitude: raw.centerLongitude, centerLongitude: raw.centerLongitude,
isParcours: raw.isParcours, isParcours: raw.isParcours,
guidedPaths: raw.guidedPaths,
} }
break break
case 'Article': case 'Article':
@ -115,7 +114,6 @@ function normalizeSectionDTO(raw: any): SectionDTO {
base.puzzleImage = raw.puzzleImage base.puzzleImage = raw.puzzleImage
base.messageDebut = raw.messageDebut base.messageDebut = raw.messageDebut
base.messageFin = raw.messageFin base.messageFin = raw.messageFin
base.guidedPaths = raw.guidedPaths
break break
case 'Event': case 'Event':
base.event = { base.event = {
@ -127,6 +125,15 @@ function normalizeSectionDTO(raw: any): SectionDTO {
programme: raw.programme ?? raw.Programme, programme: raw.programme ?? raw.Programme,
} }
break break
case 'Parcours': {
const parcours: ParcoursDTO = {
showMap: raw.showMap,
baseSectionMapId: raw.baseSectionMapId,
guidedPaths: raw.guidedPaths,
}
base.parcours = parcours
break
}
} }
return base return base
@ -138,14 +145,31 @@ export async function getInstanceBySlug(slug: string): Promise<ApplicationInstan
return { return {
...webInstance, ...webInstance,
id: raw.id, id: raw.id,
applicationInstanceId: webInstance.id,
publicApiKey: raw.publicApiKey, publicApiKey: raw.publicApiKey,
webSlug: raw.webSlug, webSlug: raw.webSlug,
label: raw.name, label: raw.name,
} }
} }
export async function getConfigurations(instanceId: string, apiKey: string): Promise<ConfigurationDTO[]> { export interface WeightedConfiguration {
return apiFetch(`/api/configuration?instanceId=${instanceId}`, apiKey) configuration: ConfigurationDTO
order?: number
colSpan: number
rowSpan: number
}
export async function getConfigurationLinks(applicationInstanceId: string, apiKey: string): Promise<WeightedConfiguration[]> {
const links: AppConfigurationLinkDTO[] = await apiFetch(`/api/ApplicationInstance/${applicationInstanceId}/application-link`, apiKey)
return links
.filter((link) => link.isActive && link.configuration)
.map((link) => ({
configuration: link.configuration!,
order: link.order,
colSpan: link.gridColSpan ?? 1,
rowSpan: link.gridRowSpan ?? 1,
}))
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
} }
export async function getConfiguration(configId: string, apiKey: string): Promise<ConfigurationDTO> { export async function getConfiguration(configId: string, apiKey: string): Promise<ConfigurationDTO> {
@ -165,12 +189,28 @@ export async function getAgendaEvents(sectionId: string, language: string, apiKe
return res.json() return res.json()
} }
export async function getGuidedPaths(sectionMapId: string, apiKey: string): Promise<GuidedPathDTO[]> { function normalizeGuidedPaths(raw: any[]): GuidedPathDTO[] {
return apiFetch(`/api/SectionMap/${sectionMapId}/GuidedPath`, apiKey) return raw.map((path) => ({
...path,
steps: path.steps?.map((step: any) => ({
...step,
quizQuestions: step.quizQuestions?.map((q: any) => ({
...q,
responses: q.responses?.map((r: any) => ({
...r,
isCorrect: r.isGood,
})),
})),
})),
}))
} }
// Escape games also expose guided paths. The backend uses the same SectionMap endpoint export async function getGuidedPaths(sectionMapId: string, apiKey: string): Promise<GuidedPathDTO[]> {
// since GuidedPath is a polymorphic entity attached via parent ID. const raw: any[] = await apiFetch(`/api/SectionMap/${sectionMapId}/guided-path`, apiKey)
export async function getGuidedPathsForGame(sectionGameId: string, apiKey: string): Promise<GuidedPathDTO[]> { return normalizeGuidedPaths(raw)
return apiFetch(`/api/SectionMap/${sectionGameId}/GuidedPath`, apiKey) }
export async function getGuidedPathsForParcours(sectionParcoursId: string, apiKey: string): Promise<GuidedPathDTO[]> {
const raw: any[] = await apiFetch(`/api/SectionParcours/${sectionParcoursId}/guided-path`, apiKey)
return normalizeGuidedPaths(raw)
} }

View File

@ -19,6 +19,7 @@ export interface ContentDTO {
export interface ApplicationInstanceDTO { export interface ApplicationInstanceDTO {
id: string id: string
applicationInstanceId?: string
label?: string label?: string
primaryColor?: string primaryColor?: string
secondaryColor?: string secondaryColor?: string
@ -45,6 +46,16 @@ export interface ConfigurationDTO {
sections?: SectionDTO[] sections?: SectionDTO[]
} }
export interface AppConfigurationLinkDTO {
id: string
configurationId?: string
configuration?: ConfigurationDTO
order?: number
isActive?: boolean
gridColSpan?: number
gridRowSpan?: number
}
export type SectionType = export type SectionType =
| 'Map' | 'Map'
| 'Slider' | 'Slider'
@ -59,6 +70,7 @@ export type SectionType =
| 'Agenda' | 'Agenda'
| 'Weather' | 'Weather'
| 'Event' | 'Event'
| 'Parcours'
export interface SectionDTO { export interface SectionDTO {
id: string id: string
@ -83,13 +95,12 @@ export interface SectionDTO {
map?: MapDTO map?: MapDTO
agenda?: AgendaDTO agenda?: AgendaDTO
// Game fields (GameDTO extends SectionDTO → flat) // Game fields (GameDTO extends SectionDTO → flat)
gameType?: 'Puzzle' | 'SlidingPuzzle' | 'Escape' gameType?: 'Puzzle' | 'SlidingPuzzle'
rows?: number rows?: number
cols?: number cols?: number
puzzleImage?: ResourceDTO puzzleImage?: ResourceDTO
messageDebut?: TranslationAndResourceDTO[] messageDebut?: TranslationAndResourceDTO[]
messageFin?: TranslationAndResourceDTO[] messageFin?: TranslationAndResourceDTO[]
guidedPaths?: GuidedPathDTO[]
// PDF fields // PDF fields
pdfs?: OrderedTranslationAndResourceDTO[] pdfs?: OrderedTranslationAndResourceDTO[]
menu?: MenuDTO menu?: MenuDTO
@ -98,6 +109,7 @@ export interface SectionDTO {
weather?: WeatherDTO weather?: WeatherDTO
web?: WebDTO web?: WebDTO
event?: EventDTO event?: EventDTO
parcours?: ParcoursDTO
} }
export interface MapAnnotationDTO { export interface MapAnnotationDTO {
@ -157,6 +169,11 @@ export interface QuestionDTO {
responses?: QuestionResponseDTO[] responses?: QuestionResponseDTO[]
imageBackgroundResourceUrl?: string imageBackgroundResourceUrl?: string
order?: number order?: number
validationQuestionType?: number // 0 = Simple, 1 = QCM, 2 = Puzzle
puzzleImage?: ResourceDTO
puzzleRows?: number
puzzleCols?: number
isSlidingPuzzle?: boolean
} }
export interface QuizDTO { export interface QuizDTO {
@ -169,7 +186,7 @@ export interface QuizDTO {
export interface GeometryDTO { export interface GeometryDTO {
type?: string type?: string
coordinates?: number[] coordinates?: unknown // Point: [x,y] | LineString: [x,y][] | Polygon: [x,y][][]
} }
export interface GeoPointDTO { export interface GeoPointDTO {
@ -205,6 +222,11 @@ export interface MapDTO {
centerLatitude?: string centerLatitude?: string
centerLongitude?: string centerLongitude?: string
isParcours?: boolean isParcours?: boolean
}
export interface ParcoursDTO {
showMap?: boolean
baseSectionMapId?: string
guidedPaths?: GuidedPathDTO[] guidedPaths?: GuidedPathDTO[]
} }
@ -216,6 +238,11 @@ export interface GuidedPathDTO {
requireSuccessToAdvance?: boolean requireSuccessToAdvance?: boolean
hideNextStepsUntilComplete?: boolean hideNextStepsUntilComplete?: boolean
order?: number order?: number
estimatedDurationMinutes?: number
imageUrl?: string
isGameMode?: boolean
gameMessageDebut?: TranslationAndResourceDTO[]
gameMessageFin?: TranslationAndResourceDTO[]
steps?: GuidedStepDTO[] steps?: GuidedStepDTO[]
} }
@ -226,15 +253,17 @@ export interface GuidedStepDTO {
title?: TranslationDTO[] title?: TranslationDTO[]
description?: TranslationDTO[] description?: TranslationDTO[]
geometry?: GeometryDTO geometry?: GeometryDTO
isGeoTriggered?: boolean
zoneRadiusMeters?: number zoneRadiusMeters?: number
imageUrl?: string imageUrl?: string
triggerGeoPointId?: number
isHiddenInitially?: boolean isHiddenInitially?: boolean
isStepTimer?: boolean isStepTimer?: boolean
isStepLocked?: boolean isStepLocked?: boolean
timerSeconds?: number timerSeconds?: number
timerExpiredMessage?: TranslationDTO[] timerExpiredMessage?: TranslationDTO[]
quizQuestions?: QuestionDTO[] quizQuestions?: QuestionDTO[]
audioIds?: TranslationDTO[]
contents?: ContentDTO[]
} }
export interface EventAgendaAddressDTO { export interface EventAgendaAddressDTO {

View File

@ -1,3 +1,53 @@
import type { GeometryDTO } from './api/types'
function toLatLngPairs(points: unknown): [number, number][] {
if (!Array.isArray(points)) return []
return (points as number[][])
.filter((p) => Array.isArray(p) && typeof p[0] === 'number' && typeof p[1] === 'number')
.map(([lat, lng]) => [lat, lng])
}
function averageLatLng(points: unknown): { lat: number; lng: number } | null {
const valid = toLatLngPairs(points)
if (valid.length === 0) return null
const sum = valid.reduce((acc, [lat, lng]) => ({ lat: acc.lat + lat, lng: acc.lng + lng }), { lat: 0, lng: 0 })
return { lat: sum.lat / valid.length, lng: sum.lng / valid.length }
}
export type StepGeometryShape =
| { type: 'LineString'; positions: [number, number][] }
| { type: 'Polygon'; positions: [number, number][] }
// Real LineString/Polygon shape for a step's geometry, for drawing on the map (as opposed
// to getStepGeometryCenter, which collapses it to a single representative point).
export function getStepGeometryShape(geometry: GeometryDTO | undefined): StepGeometryShape | null {
const coords = geometry?.coordinates
if (!Array.isArray(coords)) return null
if (geometry?.type === 'LineString') {
const positions = toLatLngPairs(coords)
return positions.length > 1 ? { type: 'LineString', positions } : null
}
if (geometry?.type === 'Polygon') {
const positions = toLatLngPairs((coords as number[][][])[0])
return positions.length > 2 ? { type: 'Polygon', positions } : null
}
return null
}
// GuidedStep geometry coordinates are stored as [lat, lng] pairs (not GeoJSON [lng, lat]),
// matching manager-app's map_geometry_picker. LineString/Polygon are reduced to their centroid.
export function getStepGeometryCenter(geometry: GeometryDTO | undefined): { lat: number; lng: number } | null {
const coords = geometry?.coordinates
if (!Array.isArray(coords) || coords.length === 0) return null
if (geometry?.type === 'LineString') return averageLatLng(coords)
if (geometry?.type === 'Polygon') return averageLatLng((coords as number[][][])[0])
const [lat, lng] = coords as number[]
return typeof lat === 'number' && typeof lng === 'number' ? { lat, lng } : null
}
export function haversineMeters( export function haversineMeters(
a: { lat: number; lng: number }, a: { lat: number; lng: number },
b: { lat: number; lng: number } b: { lat: number; lng: number }