misc + bento layout + SectionParcours (mostly done)
This commit is contained in:
parent
e3fbd06e71
commit
5fd6e60b7d
1484
package-lock.json
generated
1484
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,8 @@
|
||||
"dev": "next dev",
|
||||
"dev:https": "next dev --experimental-https",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"start": "next start",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
@ -27,6 +28,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getInstanceBySlug, getConfiguration } from '@/lib/api/client'
|
||||
import { resolveColors } from '@/lib/theme'
|
||||
import SplashScreen from '@/components/SplashScreen'
|
||||
|
||||
export default async function ConfigLayout({
|
||||
children,
|
||||
@ -20,6 +21,12 @@ export default async function ConfigLayout({
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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'
|
||||
|
||||
// Section components
|
||||
@ -15,6 +15,7 @@ import WebSection from '@/components/sections/WebSection'
|
||||
import QuizSection from '@/components/sections/QuizSection'
|
||||
import GameSection from '@/components/sections/GameSection'
|
||||
import EventSection from '@/components/sections/EventSection'
|
||||
import ParcoursSection from '@/components/sections/ParcoursSection'
|
||||
import TrackedSection from '@/components/TrackedSection'
|
||||
|
||||
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)
|
||||
if (!section || section.isActive === false) notFound()
|
||||
|
||||
// For Map sections, eagerly load the guided paths server-side and merge into the DTO
|
||||
if (section.type === 'Map' && section.map) {
|
||||
// Parcours sections expose guided paths via their own endpoint
|
||||
if (section.type === 'Parcours' && section.parcours) {
|
||||
try {
|
||||
const paths = await getGuidedPaths(section.id, instance.publicApiKey!)
|
||||
section.map = { ...section.map, 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
|
||||
const paths = await getGuidedPathsForParcours(section.id, instance.publicApiKey!)
|
||||
section.parcours = { ...section.parcours, guidedPaths: paths }
|
||||
} catch {
|
||||
// Silently ignore
|
||||
}
|
||||
@ -85,8 +76,9 @@ if (!section || section.isActive === false) notFound()
|
||||
case 'Weather': content = <WeatherSection {...props} />; break
|
||||
case 'Quiz': content = <QuizSection {...props} />; break
|
||||
case 'Game': content = <GameSection {...props} />; break
|
||||
case 'Event': content = <EventSection {...props} />; break
|
||||
case 'Web': content = <WebSection {...props} />; break
|
||||
case 'Event': content = <EventSection {...props} />; break
|
||||
case 'Parcours': content = <ParcoursSection {...props} />; break
|
||||
case 'Web': content = <WebSection {...props} />; break
|
||||
default:
|
||||
content = (
|
||||
<div className="p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { getInstanceBySlug, getConfigurations } from '@/lib/api/client'
|
||||
import { getInstanceBySlug, getConfigurationLinks } from '@/lib/api/client'
|
||||
import { notFound } from 'next/navigation'
|
||||
import ConfigurationGrid from '@/components/ConfigurationGrid'
|
||||
import HomeHero from '@/components/HomeHero'
|
||||
@ -11,16 +11,16 @@ export default async function HomePage({
|
||||
}) {
|
||||
const { slug } = await params
|
||||
|
||||
let instance, configurations
|
||||
let instance, items
|
||||
try {
|
||||
instance = await getInstanceBySlug(slug)
|
||||
configurations = await getConfigurations(instance.id, instance.publicApiKey!)
|
||||
items = await getConfigurationLinks(instance.applicationInstanceId!, instance.publicApiKey!)
|
||||
} catch {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const featuredEvent = instance.sectionEventDTO
|
||||
const languages = [...new Set(configurations.flatMap((c) => c.languages ?? []))]
|
||||
const languages = [...new Set(items.flatMap((i) => i.configuration.languages ?? []))]
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -32,7 +32,13 @@ export default async function HomePage({
|
||||
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} />
|
||||
</>
|
||||
)
|
||||
|
||||
@ -5,76 +5,79 @@ import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
import type { ConfigurationDTO } from '@/lib/api/types'
|
||||
import type { WeightedConfiguration } from '@/lib/api/client'
|
||||
|
||||
interface Props {
|
||||
configurations: ConfigurationDTO[]
|
||||
items: WeightedConfiguration[]
|
||||
slug: 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 active = configurations.filter((c) => !c.isOffline)
|
||||
|
||||
useEffect(() => { setAvailableLanguages(languages) }, [languages])
|
||||
|
||||
return (
|
||||
<main className="min-h-screen" style={{ background: 'var(--color-background)' }}>
|
||||
<div className="p-4 columns-2 gap-3">
|
||||
{active.map((config) => (
|
||||
<Link
|
||||
key={config.id}
|
||||
href={`/${slug}/${config.id}`}
|
||||
className="break-inside-avoid mb-3 block rounded-2xl overflow-hidden relative"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.22)',
|
||||
}}
|
||||
>
|
||||
{config.imageSource ? (
|
||||
<div className="relative w-full" style={{ minHeight: 120 }}>
|
||||
<div
|
||||
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
|
||||
key={config.id}
|
||||
href={`/${slug}/${config.id}`}
|
||||
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={{
|
||||
gridColumn: `span ${col}`,
|
||||
gridRow: `span ${row}`,
|
||||
background: config.imageSource
|
||||
? 'var(--color-surface)'
|
||||
: `color-mix(in srgb, ${fallbackColor} 65%, white)`,
|
||||
}}
|
||||
>
|
||||
{config.imageSource && (
|
||||
<Image
|
||||
src={config.imageSource}
|
||||
alt={tPlain(config.title, language)}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 50vw, 33vw"
|
||||
className="object-cover transition-transform duration-500 ease-out group-hover:scale-105 motion-reduce:group-hover:scale-100"
|
||||
sizes="(max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.72) 0%, transparent 40%)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-2 left-3 right-7 text-white text-sm font-semibold leading-tight [&_p]:m-0 [&_p]:leading-tight"
|
||||
dangerouslySetInnerHTML={{ __html: t(config.title, language) }}
|
||||
/>
|
||||
<svg
|
||||
className="absolute bottom-2 right-2"
|
||||
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" />
|
||||
</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>
|
||||
))}
|
||||
)}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.72) 0%, transparent 45%)' }}
|
||||
/>
|
||||
<div
|
||||
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) }}
|
||||
/>
|
||||
<svg
|
||||
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"
|
||||
>
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
||||
</svg>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain, stripHtml } from '@/lib/i18n'
|
||||
import type { SectionDTO, TranslationDTO } from '@/lib/api/types'
|
||||
@ -54,15 +55,13 @@ export default function SectionList({
|
||||
onClick={back}
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: 44, left: 10, width: 44, height: 44,
|
||||
background: 'rgba(0,0,0,0.35)',
|
||||
top: 'max(env(safe-area-inset-top), 16px)', left: 16, width: 40, height: 40,
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
borderRadius: '50%',
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<svg width="23" height="23" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
<ChevronLeftIcon color="white" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
39
src/components/SplashScreen.tsx
Normal file
39
src/components/SplashScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -96,24 +96,22 @@ export default function AgendaSection({ section, configId, languages, apiKey }:
|
||||
}
|
||||
|
||||
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} />
|
||||
|
||||
{/* Month selector */}
|
||||
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||
<button onClick={prevMonth} className="p-1">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-primary)' }}>
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span className="font-semibold text-sm" style={{ color: 'var(--color-text)' }}>
|
||||
{formatMonthHeader(selectedYear, selectedMonth, language)}
|
||||
</span>
|
||||
<button onClick={nextMonth} className="p-1">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--color-primary)' }}>
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex justify-center px-4 py-3">
|
||||
<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)' }}>
|
||||
<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' }}>
|
||||
<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>
|
||||
</button>
|
||||
<span style={{ color: '#1E2A33', fontWeight: 700, fontSize: 14, minWidth: 150, textAlign: 'center' }}>
|
||||
{formatMonthHeader(selectedYear, selectedMonth, language)}
|
||||
</span>
|
||||
<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="none" stroke="var(--color-primary)" strokeWidth="2.4"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events grid */}
|
||||
@ -139,8 +137,8 @@ export default function AgendaSection({ section, configId, languages, apiKey }:
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="rounded-2xl overflow-hidden text-left flex flex-col"
|
||||
style={{ background: 'var(--color-surface)', boxShadow: '0 2px 8px rgba(0,0,0,0.10)' }}
|
||||
className="overflow-hidden text-left flex flex-col"
|
||||
style={{ background: '#fff', borderRadius: 20, border: '1px solid #EFEAE2', boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }}
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative w-full aspect-video">
|
||||
@ -152,7 +150,7 @@ export default function AgendaSection({ section, configId, languages, apiKey }:
|
||||
{event.dateFrom && (
|
||||
<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"
|
||||
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 }}>
|
||||
<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
|
||||
|
||||
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">
|
||||
<AppBar title={img ? '' : tPlain(event.label, language)} onBack={onClose} overlay={!!img} />
|
||||
{img && (
|
||||
@ -233,12 +231,12 @@ function EventDetail({
|
||||
)}
|
||||
{event.dateFrom && (
|
||||
<div
|
||||
className="flex items-center gap-3 p-3 rounded-xl"
|
||||
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
|
||||
className="flex items-center gap-3 p-3"
|
||||
style={{ background: '#fff', border: '1px solid #EFEAE2', borderRadius: 16, boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }}
|
||||
>
|
||||
<div
|
||||
className="shrink-0 w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{ background: 'var(--color-primary-light)' }}
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
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)' }}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
@ -300,7 +298,7 @@ function EventDetail({
|
||||
|
||||
{/* Mini map */}
|
||||
{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} />
|
||||
</div>
|
||||
)}
|
||||
@ -311,11 +309,11 @@ function EventDetail({
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-3 p-3 rounded-xl"
|
||||
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
|
||||
className="flex items-center gap-3 p-3"
|
||||
style={{ background: '#fff', border: '1px solid #EFEAE2', borderRadius: 16, boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@ -325,8 +323,8 @@ function EventDetail({
|
||||
{event.phone && (
|
||||
<a
|
||||
href={`tel:${event.phone}`}
|
||||
className="flex items-center gap-3 p-3 rounded-xl"
|
||||
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }}
|
||||
className="flex items-center gap-3 p-3"
|
||||
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" />
|
||||
<span className="text-sm">{event.phone}</span>
|
||||
@ -335,8 +333,8 @@ function EventDetail({
|
||||
{event.email && (
|
||||
<a
|
||||
href={`mailto:${event.email}`}
|
||||
className="flex items-center gap-3 p-3 rounded-xl"
|
||||
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }}
|
||||
className="flex items-center gap-3 p-3"
|
||||
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" />
|
||||
<span className="text-sm truncate">{event.email}</span>
|
||||
@ -347,8 +345,8 @@ function EventDetail({
|
||||
href={event.website.startsWith('http') ? event.website : `https://${event.website}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-3 p-3 rounded-xl"
|
||||
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }}
|
||||
className="flex items-center gap-3 p-3"
|
||||
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" />
|
||||
<span className="text-sm truncate">{event.website}</span>
|
||||
|
||||
@ -69,7 +69,7 @@ export default function ArticleSection({ section, configId, languages }: Props)
|
||||
const htmlContent = t(article?.content, language)
|
||||
|
||||
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} />
|
||||
|
||||
<main ref={scrollRef} className="flex-1 overflow-y-auto pb-24">
|
||||
@ -98,35 +98,35 @@ export default function ArticleSection({ section, configId, languages }: Props)
|
||||
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 px-4 py-3 flex items-center gap-3"
|
||||
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
|
||||
>
|
||||
<button onClick={toggleAudio} className="shrink-0">
|
||||
{isPlaying ? (
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="w-full h-1 rounded-full" style={{ background: 'var(--color-on-primary)', opacity: 0.3 }}>
|
||||
<div
|
||||
className="h-1 rounded-full"
|
||||
style={{
|
||||
background: 'var(--color-on-primary)',
|
||||
width: duration ? `${(currentTime / duration) * 100}%` : '0%',
|
||||
}}
|
||||
/>
|
||||
<div style={{ position: 'fixed', left: 0, right: 0, bottom: 0, padding: '14px 16px 16px', background: 'linear-gradient(to top, #F6F3EE 78%, transparent)' }}>
|
||||
<div style={{ borderRadius: 16, overflow: 'hidden', background: '#fff', border: '1px solid #E8E3DA', boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }}>
|
||||
<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' }}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg>
|
||||
)}
|
||||
</button>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ color: '#6B7B86', fontSize: 11, fontWeight: 700, letterSpacing: '0.5px' }}>GUIDE AUDIO</span>
|
||||
<div style={{ marginTop: 5 }}>
|
||||
<WaveformDecor currentTime={currentTime} duration={duration} />
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ color: '#6B7B86', fontSize: 12.5, fontWeight: 600, flexShrink: 0 }}>
|
||||
{duration > 0 ? formatTime(duration) : '--:--'}
|
||||
</span>
|
||||
</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>
|
||||
<span className="text-xs shrink-0">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@ -152,16 +152,16 @@ function ImageCarousel({ contents, language }: { contents: NonNullable<SectionDT
|
||||
<button
|
||||
onClick={() => setIndex((i) => Math.max(0, i - 1))}
|
||||
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
|
||||
onClick={() => setIndex((i) => Math.min(contents.length - 1, i + 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>
|
||||
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-1">
|
||||
{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 {
|
||||
if (!s || isNaN(s)) return '0:00'
|
||||
const m = Math.floor(s / 60)
|
||||
|
||||
@ -7,6 +7,7 @@ import dynamic from 'next/dynamic'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
import LanguageSelector from '@/components/ui/LanguageSelector'
|
||||
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
|
||||
import type { SectionDTO, ProgrammeBlock, MapAnnotationDTO, GuidedPathDTO } from '@/lib/api/types'
|
||||
|
||||
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)' }}>
|
||||
<button
|
||||
onClick={back}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center"
|
||||
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(8px)' }}
|
||||
className="w-10 h-10 rounded-2xl flex items-center justify-center"
|
||||
style={{ background: 'rgba(255,255,255,0.2)', backdropFilter: 'blur(8px)' }}
|
||||
aria-label="Retour"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
<ChevronLeftIcon color="white" size={20} />
|
||||
</button>
|
||||
<LanguageSelector overlay />
|
||||
</div>
|
||||
|
||||
@ -2,13 +2,13 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
|
||||
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 PuzzleGame from './game/PuzzleGame'
|
||||
import SlidingPuzzle from './game/SlidingPuzzle'
|
||||
import MessageDialog from './game/MessageDialog'
|
||||
import EscapeProgression from './game/EscapeProgression'
|
||||
import { trackEvent } from '@/lib/stats'
|
||||
|
||||
interface Props {
|
||||
@ -18,12 +18,11 @@ interface Props {
|
||||
languages: string[]
|
||||
}
|
||||
|
||||
type GameKind = 'Puzzle' | 'SlidingPuzzle' | 'Escape' | 'Unknown'
|
||||
type GameKind = 'Puzzle' | 'SlidingPuzzle' | 'Unknown'
|
||||
|
||||
function detectKind(raw?: string): GameKind {
|
||||
if (!raw) return 'Unknown'
|
||||
if (raw === 'SlidingPuzzle') return 'SlidingPuzzle'
|
||||
if (raw === 'Escape') return 'Escape'
|
||||
if (raw === 'Puzzle') return 'Puzzle'
|
||||
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)' }}>
|
||||
<button
|
||||
onClick={back}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center"
|
||||
style={{ background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(8px)' }}
|
||||
className="w-10 h-10 rounded-2xl flex items-center justify-center"
|
||||
style={{ background: 'rgba(255,255,255,0.2)', backdropFilter: 'blur(8px)' }}
|
||||
aria-label="Retour"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
<ChevronLeftIcon color="white" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -133,13 +130,6 @@ export default function GameSection({ section, configId, languages }: Props) {
|
||||
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' && (
|
||||
<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.
|
||||
@ -221,29 +211,3 @@ export default function GameSection({ section, configId, languages }: Props) {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,16 +2,12 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
import type { SectionDTO, GeoPointDTO, GuidedPathDTO, GuidedStepDTO } from '@/lib/api/types'
|
||||
import { useGeolocation } from '@/hooks/useGeolocation'
|
||||
import { haversineMeters, formatDistance } from '@/lib/geo'
|
||||
import type { SectionDTO, GeoPointDTO } from '@/lib/api/types'
|
||||
import { trackEvent } from '@/lib/stats'
|
||||
import StepTimer from './map/StepTimer'
|
||||
import StepQuiz from './map/StepQuiz'
|
||||
import Toast from './map/Toast'
|
||||
import './map/map.css'
|
||||
|
||||
const LeafletMap = dynamic(() => import('./map/LeafletMap'), {
|
||||
@ -42,100 +38,12 @@ export default function MapSection({ section, configId, languages }: Props) {
|
||||
const hasData = !!map && (map.points?.length ?? 0) > 0
|
||||
const points = useMemo(() => map?.points ?? [], [map])
|
||||
const categories = useMemo(() => map?.categories ?? [], [map])
|
||||
const guidedPaths = useMemo(() => map?.guidedPaths ?? [], [map])
|
||||
|
||||
const [mode, setMode] = useState<Mode>('map')
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeCats, setActiveCats] = useState<Set<number>>(new Set())
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [pathListOpen, setPathListOpen] = useState(false)
|
||||
const [activePathId, setActivePathId] = useState<string | null>(null)
|
||||
const [activeStepId, setActiveStepId] = useState<string | null>(null)
|
||||
|
||||
const activePath = guidedPaths.find((p) => p.id === activePathId) ?? null
|
||||
const activeSteps = useMemo(
|
||||
() => [...(activePath?.steps ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
[activePath]
|
||||
)
|
||||
const activeStep = activeSteps.find((s) => s.id === activeStepId) ?? null
|
||||
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set())
|
||||
const [quizPassedSteps, setQuizPassedSteps] = useState<Set<string>>(new Set())
|
||||
const [timerExpiredSteps, setTimerExpiredSteps] = useState<Set<string>>(new Set())
|
||||
const [zoneNotifiedSteps, setZoneNotifiedSteps] = useState<Set<string>>(new Set())
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const [showEndModal, setShowEndModal] = useState(false)
|
||||
const [geoEnabledKey, setGeoEnabledKey] = useState(0)
|
||||
|
||||
// Reset progression when path changes
|
||||
useEffect(() => {
|
||||
setCompletedSteps(new Set())
|
||||
setQuizPassedSteps(new Set())
|
||||
setTimerExpiredSteps(new Set())
|
||||
setZoneNotifiedSteps(new Set())
|
||||
setShowEndModal(false)
|
||||
setToastMessage(null)
|
||||
}, [activePathId])
|
||||
|
||||
// First non-completed step in order = current
|
||||
const currentStepId = useMemo(() => {
|
||||
if (!activePath) return null
|
||||
const next = activeSteps.find((s) => !completedSteps.has(s.id))
|
||||
return next?.id ?? null
|
||||
}, [activePath, activeSteps, completedSteps])
|
||||
|
||||
// Hide future steps if path requires it; also hide isHiddenInitially steps not yet current/completed
|
||||
const visibleSteps = useMemo(() => {
|
||||
if (!activePath) return []
|
||||
let list = activeSteps
|
||||
if (activePath.hideNextStepsUntilComplete) {
|
||||
const idx = currentStepId ? activeSteps.findIndex((s) => s.id === currentStepId) : activeSteps.length
|
||||
list = activeSteps.slice(0, idx + 1)
|
||||
}
|
||||
return list.filter((s) => {
|
||||
if (!s.isHiddenInitially) return true
|
||||
return completedSteps.has(s.id) || s.id === currentStepId
|
||||
})
|
||||
}, [activePath, activeSteps, currentStepId, completedSteps])
|
||||
|
||||
function markStepComplete(stepId: string) {
|
||||
setCompletedSteps((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(stepId)
|
||||
return next
|
||||
})
|
||||
const idx = activeSteps.findIndex((s) => s.id === stepId)
|
||||
if (idx === activeSteps.length - 1) {
|
||||
setShowEndModal(true)
|
||||
setActiveStepId(null)
|
||||
} else {
|
||||
const nextStep = activeSteps[idx + 1]
|
||||
if (nextStep) setActiveStepId(nextStep.id)
|
||||
}
|
||||
}
|
||||
|
||||
const geo = useGeolocation(!!activePath, geoEnabledKey)
|
||||
|
||||
// Toast on first entry into the current step's zone
|
||||
useEffect(() => {
|
||||
if (!activePath || !currentStepId) return
|
||||
const step = activeSteps.find((s) => s.id === currentStepId)
|
||||
if (!step) return
|
||||
const radius = step.zoneRadiusMeters ?? 0
|
||||
const coords = step.geometry?.coordinates
|
||||
if (radius <= 0 || !coords || geo.lat == null || geo.lng == null) return
|
||||
const dist = haversineMeters({ lat: geo.lat, lng: geo.lng }, { lat: coords[1], lng: coords[0] })
|
||||
if (dist <= radius && !zoneNotifiedSteps.has(currentStepId)) {
|
||||
const title = tPlain(step.title, language) || 'cette étape'
|
||||
setToastMessage(`Vous êtes arrivé à « ${title} »`)
|
||||
setZoneNotifiedSteps((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(currentStepId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [activePath, currentStepId, activeSteps, geo.lat, geo.lng, zoneNotifiedSteps, language])
|
||||
|
||||
const norm = (s: string) =>
|
||||
s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||
@ -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]"
|
||||
style={{ background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))', color: 'var(--color-on-primary)' }}
|
||||
>
|
||||
<button onClick={back} className="w-10 h-10 rounded-full flex items-center justify-center" aria-label="Retour">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
<button onClick={back} className="w-10 h-10 rounded-2xl flex items-center justify-center" aria-label="Retour">
|
||||
<ChevronLeftIcon size={20} />
|
||||
</button>
|
||||
<span className="text-base font-semibold flex-1 truncate px-2">{tPlain(section.title, language)}</span>
|
||||
</div>
|
||||
@ -211,7 +117,7 @@ export default function MapSection({ section, configId, languages }: Props) {
|
||||
zoom={zoom}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => {
|
||||
setSelectedId(id); setActiveStepId(null)
|
||||
setSelectedId(id)
|
||||
const p = points.find((pp) => pp.id === id)
|
||||
if (instanceId && p) {
|
||||
trackEvent({
|
||||
@ -222,12 +128,6 @@ export default function MapSection({ section, configId, languages }: Props) {
|
||||
}
|
||||
}}
|
||||
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
|
||||
@ -248,13 +148,11 @@ export default function MapSection({ section, configId, languages }: Props) {
|
||||
>
|
||||
<button
|
||||
onClick={back}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center"
|
||||
style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)' }}
|
||||
className="w-10 h-10 rounded-2xl flex items-center justify-center"
|
||||
style={{ background: 'rgba(255,255,255,0.2)', backdropFilter: 'blur(8px)' }}
|
||||
aria-label="Retour"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
<ChevronLeftIcon color="white" size={20} />
|
||||
</button>
|
||||
<span
|
||||
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)}
|
||||
</span>
|
||||
{guidedPaths.length > 0 && (
|
||||
<button
|
||||
onClick={() => setPathListOpen(true)}
|
||||
className="h-10 rounded-full flex items-center gap-1.5 px-3 font-semibold text-xs"
|
||||
style={{
|
||||
background: activePath ? 'var(--color-primary)' : 'rgba(0,0,0,0.6)',
|
||||
color: 'white',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
aria-label="Parcours"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M20 4h-4.18A2.99 2.99 0 0 0 13 2c-1.3 0-2.4.84-2.82 2H6c-1.1 0-2 .9-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6c0-1.1-.9-2-2-2zm-7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm-2 14l-4-4 1.41-1.41L11 15.17l5.59-5.59L18 11l-7 7z" />
|
||||
</svg>
|
||||
{activePath ? `Étape ${(activeSteps.findIndex((s) => s.id === activeStepId) + 1) || 1}/${activeSteps.length}` : `Parcours (${guidedPaths.length})`}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setFilterOpen((v) => !v)}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center relative"
|
||||
@ -401,442 +282,14 @@ export default function MapSection({ section, configId, languages }: Props) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Quitter parcours button (when active) */}
|
||||
{activePath && !pathListOpen && !activeStep && !selected && (
|
||||
<button
|
||||
onClick={() => { setActivePathId(null); setActiveStepId(null) }}
|
||||
className="absolute bottom-6 left-4 rounded-full flex items-center gap-2 px-4 py-3 font-semibold text-sm z-[1000]"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-text)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</svg>
|
||||
Quitter le parcours
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Detail sheet (POI) */}
|
||||
{selected && (
|
||||
<PointDetail point={selected} language={language} onClose={() => setSelectedId(null)} />
|
||||
)}
|
||||
|
||||
{/* Step detail sheet (parcours) */}
|
||||
{activeStep && activePath && (
|
||||
<StepDetail
|
||||
step={activeStep}
|
||||
stepIndex={activeSteps.findIndex((s) => s.id === activeStep.id)}
|
||||
totalSteps={activeSteps.length}
|
||||
language={language}
|
||||
geo={geo}
|
||||
isCurrent={activeStep.id === currentStepId}
|
||||
isCompleted={completedSteps.has(activeStep.id)}
|
||||
quizPassed={quizPassedSteps.has(activeStep.id)}
|
||||
timerExpired={timerExpiredSteps.has(activeStep.id)}
|
||||
requireSuccess={!!activePath.requireSuccessToAdvance}
|
||||
isLinear={!!activePath.isLinear}
|
||||
onQuizResult={(passed) => {
|
||||
if (passed) {
|
||||
setQuizPassedSteps((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(activeStep.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}}
|
||||
onTimerExpired={() => {
|
||||
setTimerExpiredSteps((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(activeStep.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
onRetryGeo={() => setGeoEnabledKey((k) => k + 1)}
|
||||
onClose={() => setActiveStepId(null)}
|
||||
onMarkComplete={() => markStepComplete(activeStep.id)}
|
||||
onPrev={() => {
|
||||
const i = activeSteps.findIndex((s) => s.id === activeStep.id)
|
||||
if (i > 0) setActiveStepId(activeSteps[i - 1].id)
|
||||
}}
|
||||
onNext={() => {
|
||||
const i = activeSteps.findIndex((s) => s.id === activeStep.id)
|
||||
if (i < activeSteps.length - 1) setActiveStepId(activeSteps[i + 1].id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toast (entry into zone) */}
|
||||
{toastMessage && (
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
)}
|
||||
|
||||
{/* End of path modal */}
|
||||
{showEndModal && activePath && (
|
||||
<EndOfPathModal
|
||||
pathTitle={tPlain(activePath.title, language) || 'Parcours'}
|
||||
stepCount={activeSteps.length}
|
||||
onRestart={() => {
|
||||
setCompletedSteps(new Set())
|
||||
setShowEndModal(false)
|
||||
if (activeSteps[0]) setActiveStepId(activeSteps[0].id)
|
||||
}}
|
||||
onLeave={() => {
|
||||
setActivePathId(null)
|
||||
setActiveStepId(null)
|
||||
setShowEndModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Path list bottom sheet */}
|
||||
{pathListOpen && (
|
||||
<PathListSheet
|
||||
paths={guidedPaths}
|
||||
activePathId={activePathId}
|
||||
language={language}
|
||||
onSelect={(id) => {
|
||||
setActivePathId(id)
|
||||
setActiveStepId(null)
|
||||
setSelectedId(null)
|
||||
setPathListOpen(false)
|
||||
}}
|
||||
onLeave={() => {
|
||||
setActivePathId(null)
|
||||
setActiveStepId(null)
|
||||
setPathListOpen(false)
|
||||
}}
|
||||
onClose={() => setPathListOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Path list bottom sheet ──────────────────────────────────────────────────
|
||||
function PathListSheet({
|
||||
paths, activePathId, language, onSelect, onLeave, onClose,
|
||||
}: {
|
||||
paths: GuidedPathDTO[]
|
||||
activePathId: string | null
|
||||
language: string
|
||||
onSelect: (id: string) => void
|
||||
onLeave: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="absolute inset-0 z-[1500]" style={{ background: 'rgba(0,0,0,0.3)' }} onClick={onClose} />
|
||||
<div
|
||||
className="absolute left-0 right-0 bottom-0 z-[1600] rounded-t-3xl overflow-hidden"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
maxHeight: '70%',
|
||||
boxShadow: '0 -8px 24px rgba(0,0,0,0.2)',
|
||||
animation: 'mim-slide-up 0.25s ease',
|
||||
}}
|
||||
>
|
||||
<style>{`@keyframes mim-slide-up { from { transform: translateY(100%); } to { transform: translateY(0); } }`}</style>
|
||||
<div className="flex justify-center pt-2 pb-1">
|
||||
<div style={{ width: 40, height: 4, borderRadius: 2, background: 'var(--color-border)' }} />
|
||||
</div>
|
||||
<div className="px-5 pt-2 pb-3 flex items-center justify-between">
|
||||
<div className="text-base font-bold" style={{ color: 'var(--color-text)' }}>Parcours</div>
|
||||
{activePathId && (
|
||||
<button onClick={onLeave} className="text-xs font-medium" style={{ color: 'var(--color-primary)' }}>
|
||||
Quitter le parcours
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 pb-5 flex flex-col gap-2" style={{ maxHeight: 'calc(70vh - 80px)' }}>
|
||||
{paths.map((p) => {
|
||||
const active = p.id === activePathId
|
||||
const stepCount = p.steps?.length ?? 0
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onSelect(p.id)}
|
||||
className="flex items-center gap-3 p-3 rounded-2xl text-left transition-colors"
|
||||
style={{
|
||||
background: active ? 'var(--color-primary-light)' : 'var(--color-surface)',
|
||||
border: `1.5px solid ${active ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-sm font-semibold [&_p]:m-0"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
dangerouslySetInnerHTML={{ __html: t(p.title, language) || 'Parcours' }}
|
||||
/>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{stepCount} étape{stepCount > 1 ? 's' : ''}
|
||||
{p.isLinear && ' · linéaire'}
|
||||
</div>
|
||||
</div>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--color-text-muted)">
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Step detail bottom sheet ────────────────────────────────────────────────
|
||||
function StepDetail({
|
||||
step, stepIndex, totalSteps, language, geo,
|
||||
isCurrent, isCompleted, quizPassed, timerExpired,
|
||||
requireSuccess, isLinear,
|
||||
onQuizResult, onTimerExpired, onRetryGeo,
|
||||
onClose, onMarkComplete, onPrev, onNext,
|
||||
}: {
|
||||
step: GuidedStepDTO
|
||||
stepIndex: number
|
||||
totalSteps: number
|
||||
language: string
|
||||
geo: ReturnType<typeof useGeolocation>
|
||||
isCurrent: boolean
|
||||
isCompleted: boolean
|
||||
quizPassed: boolean
|
||||
timerExpired: boolean
|
||||
requireSuccess: boolean
|
||||
isLinear: boolean
|
||||
onQuizResult: (passed: boolean) => void
|
||||
onTimerExpired: () => void
|
||||
onRetryGeo: () => void
|
||||
onClose: () => void
|
||||
onMarkComplete: () => void
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
}) {
|
||||
const hasPrev = stepIndex > 0 && !isLinear
|
||||
const hasNext = stepIndex < totalSteps - 1 && !isLinear
|
||||
|
||||
const stepCoords = step.geometry?.coordinates
|
||||
const stepLat = stepCoords?.[1]
|
||||
const stepLng = stepCoords?.[0]
|
||||
const radius = step.zoneRadiusMeters ?? 0
|
||||
const distance = geo.lat != null && geo.lng != null && stepLat != null && stepLng != null
|
||||
? haversineMeters({ lat: geo.lat, lng: geo.lng }, { lat: stepLat, lng: stepLng })
|
||||
: null
|
||||
const inZone = distance != null && radius > 0 && distance <= radius
|
||||
|
||||
const hasQuiz = (step.quizQuestions?.length ?? 0) > 0
|
||||
const hasTimer = !!step.isStepTimer && (step.timerSeconds ?? 0) > 0
|
||||
const isLocked = !!step.isStepLocked
|
||||
const geoUnavailable = geo.status === 'denied' || geo.status === 'unavailable'
|
||||
|
||||
// canAdvance composé :
|
||||
// - quiz : si présent + requireSuccess → doit être passé
|
||||
// - zone : si présente + requireSuccess → doit être dans la zone (sauf si GPS indispo)
|
||||
// - lock : si verrouillé + requireSuccess → doit avoir au moins une preuve (quiz ou zone)
|
||||
const quizOk = !hasQuiz || quizPassed || !requireSuccess
|
||||
const zoneOk = radius === 0 || inZone || !requireSuccess || geoUnavailable
|
||||
const lockOk = !isLocked || !requireSuccess || quizPassed || inZone || geoUnavailable
|
||||
const canAdvance = quizOk && zoneOk && lockOk
|
||||
return (
|
||||
<>
|
||||
<div className="absolute inset-0 z-[1500]" style={{ background: 'rgba(0,0,0,0.3)' }} onClick={onClose} />
|
||||
<div
|
||||
className="absolute left-0 right-0 bottom-0 z-[1600] rounded-t-3xl overflow-hidden"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
maxHeight: '78%',
|
||||
boxShadow: '0 -8px 24px rgba(0,0,0,0.2)',
|
||||
animation: 'mim-slide-up 0.25s ease',
|
||||
}}
|
||||
>
|
||||
<div className="relative" style={{ height: 160, background: 'var(--color-primary)' }}>
|
||||
{step.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={step.imageUrl} alt="" className="absolute inset-0 w-full h-full object-cover" />
|
||||
)}
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.7), rgba(0,0,0,0.1))' }} />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 w-9 h-9 rounded-full flex items-center justify-center"
|
||||
style={{ background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(6px)' }}
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className="absolute top-3 left-3 px-2.5 py-1 rounded-full text-xs font-bold"
|
||||
style={{ background: 'rgba(255,255,255,0.95)', color: 'var(--color-primary)' }}
|
||||
>
|
||||
Étape {stepIndex + 1} / {totalSteps}
|
||||
</div>
|
||||
<div
|
||||
className="absolute bottom-3 left-4 right-12 text-white text-lg font-bold [&_p]:m-0"
|
||||
style={{ textShadow: '0 2px 6px rgba(0,0,0,0.6)' }}
|
||||
dangerouslySetInnerHTML={{ __html: t(step.title, language) || 'Étape' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto px-5 py-4" style={{ maxHeight: 'calc(78vh - 160px - 64px)' }}>
|
||||
{t(step.description, language) && (
|
||||
<div
|
||||
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
dangerouslySetInnerHTML={{ __html: t(step.description, language) }}
|
||||
/>
|
||||
)}
|
||||
{radius > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2 items-center">
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full"
|
||||
style={{
|
||||
background: inZone ? '#d1fae5' : 'var(--color-primary-light)',
|
||||
color: inZone ? '#065f46' : 'var(--color-primary)',
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z" />
|
||||
</svg>
|
||||
{inZone
|
||||
? 'Vous êtes dans la zone'
|
||||
: distance != null
|
||||
? `À ${formatDistance(distance)} de la zone (${radius} m)`
|
||||
: `Zone à atteindre · ${radius} m`}
|
||||
</div>
|
||||
{geo.status === 'requesting' && (
|
||||
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Localisation en cours…</span>
|
||||
)}
|
||||
{geo.status === 'denied' && (
|
||||
<button
|
||||
onClick={onRetryGeo}
|
||||
className="text-xs underline"
|
||||
style={{ color: '#b91c1c' }}
|
||||
>
|
||||
Localisation refusée — réessayer
|
||||
</button>
|
||||
)}
|
||||
{geo.status === 'unavailable' && (
|
||||
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>GPS indisponible</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLocked && !isCompleted && (
|
||||
<div className="mt-3 inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full" style={{ background: '#fef3c7', color: '#92400e' }}>
|
||||
<span>🔒</span>
|
||||
{quizPassed || inZone ? 'Étape déverrouillée' : 'Étape verrouillée — validez la condition'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasTimer && isCurrent && !isCompleted && (
|
||||
<div className="mt-4">
|
||||
<StepTimer
|
||||
totalSeconds={step.timerSeconds!}
|
||||
active={!timerExpired}
|
||||
onExpired={onTimerExpired}
|
||||
/>
|
||||
{timerExpired && t(step.timerExpiredMessage, language) && (
|
||||
<div
|
||||
className="mt-2 text-xs p-2 rounded-lg [&_p]:m-0"
|
||||
style={{ background: '#fee2e2', color: '#991b1b' }}
|
||||
dangerouslySetInnerHTML={{ __html: t(step.timerExpiredMessage, language) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasQuiz && isCurrent && !isCompleted && (
|
||||
<div className="mt-4">
|
||||
<div className="text-xs font-semibold mb-2 uppercase tracking-wide" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Défi
|
||||
</div>
|
||||
<StepQuiz
|
||||
key={step.id}
|
||||
questions={step.quizQuestions!}
|
||||
language={language}
|
||||
onResult={onQuizResult}
|
||||
/>
|
||||
{quizPassed && (
|
||||
<div className="mt-2 text-xs text-center py-1.5 rounded-full" style={{ background: '#d1fae5', color: '#065f46' }}>
|
||||
✓ Défi réussi
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 px-4 pb-4 pt-2 border-t" style={{ borderColor: 'var(--color-border)' }}>
|
||||
{isCompleted && (
|
||||
<div className="text-xs text-center py-1.5 rounded-full" style={{ background: '#d1fae5', color: '#065f46' }}>
|
||||
✓ Étape terminée
|
||||
</div>
|
||||
)}
|
||||
{isCurrent && !isCompleted && (
|
||||
<button
|
||||
onClick={onMarkComplete}
|
||||
disabled={!canAdvance}
|
||||
className="w-full py-3 rounded-2xl font-semibold text-sm"
|
||||
style={{
|
||||
background: canAdvance ? 'var(--color-primary)' : 'var(--color-surface)',
|
||||
color: canAdvance ? 'var(--color-on-primary)' : 'var(--color-text-muted)',
|
||||
border: canAdvance ? 'none' : '1px solid var(--color-border)',
|
||||
cursor: canAdvance ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
title={!canAdvance ? 'Approchez de la zone pour valider' : undefined}
|
||||
>
|
||||
{stepIndex === totalSteps - 1 ? 'Terminer le parcours' : 'Marquer terminée'}
|
||||
</button>
|
||||
)}
|
||||
{!isLinear && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={!hasPrev}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-semibold flex items-center justify-center gap-1"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-border)',
|
||||
opacity: hasPrev ? 1 : 0.4,
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" /></svg>
|
||||
Précédente
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!hasNext}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-semibold flex items-center justify-center gap-1"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-border)',
|
||||
opacity: hasNext ? 1 : 0.4,
|
||||
}}
|
||||
>
|
||||
Suivante
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Point list view ─────────────────────────────────────────────────────────
|
||||
function PointList({
|
||||
points, language, categories, onSelect,
|
||||
@ -1036,62 +489,3 @@ function Icon({ name }: { name: 'phone' | 'email' | 'web' | 'schedule' | 'euro'
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -44,14 +44,14 @@ export default function MenuSection({ section, slug, configId, languages }: Prop
|
||||
})
|
||||
|
||||
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} />
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-xl px-3 py-2"
|
||||
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
|
||||
className="flex items-center gap-2 px-3 py-2"
|
||||
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)' }}>
|
||||
<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}
|
||||
href={`/${slug}/${configId}/sections/${sub.id}`}
|
||||
onClick={() => handleItemClick(sub)}
|
||||
className="rounded-2xl overflow-hidden block relative"
|
||||
style={{ background: 'var(--color-surface)', minHeight: 120 }}
|
||||
className="block relative overflow-hidden"
|
||||
style={{ background: '#fff', minHeight: 120, borderRadius: 20, border: '1px solid #EFEAE2', boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }}
|
||||
>
|
||||
{sub.imageSource ? (
|
||||
<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="text-sm font-semibold pr-6 [&_p]:m-0"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
style={{ color: '#1E2A33' }}
|
||||
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)' }}>
|
||||
|
||||
2497
src/components/sections/ParcoursSection.tsx
Normal file
2497
src/components/sections/ParcoursSection.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -51,7 +51,7 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
|
||||
if (questions.length === 0) {
|
||||
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} />
|
||||
<div className="flex-1 flex items-center justify-center text-sm p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Aucune question disponible.
|
||||
@ -106,7 +106,7 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
}
|
||||
const levelText = t(getLevel(), language)
|
||||
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} />
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
|
||||
<div
|
||||
@ -127,11 +127,13 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
|
||||
{levelText && (
|
||||
<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={{
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: '#fff',
|
||||
color: '#1E2A33',
|
||||
border: '1px solid #EFEAE2',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: levelText }}
|
||||
/>
|
||||
@ -140,19 +142,15 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<button
|
||||
onClick={restart}
|
||||
className="w-full py-3 rounded-2xl font-semibold text-sm"
|
||||
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
|
||||
className="w-full font-bold"
|
||||
style={{ padding: 16, borderRadius: 18, border: 'none', background: 'var(--color-primary)', color: 'var(--color-on-primary)', fontSize: 16, cursor: 'pointer' }}
|
||||
>
|
||||
Recommencer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setCurrentIndex(0); setPhase('review') }}
|
||||
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)',
|
||||
}}
|
||||
className="w-full font-bold"
|
||||
style={{ padding: 16, borderRadius: 18, border: '1.5px solid #E2DCD2', background: '#fff', color: '#46555F', fontSize: 16, cursor: 'pointer' }}
|
||||
>
|
||||
Voir les réponses
|
||||
</button>
|
||||
@ -176,7 +174,7 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
|
||||
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={isReview ? tPlain(section.title, language) : undefined}
|
||||
onBack={isReview ? () => { setCurrentIndex(0); setPhase('result') } : back}
|
||||
@ -251,15 +249,27 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
<button
|
||||
key={response.id ?? 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={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '13px 14px', borderRadius: 16,
|
||||
background: bg,
|
||||
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',
|
||||
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 && (
|
||||
isAudio(responseResource) ? (
|
||||
<div
|
||||
@ -285,48 +295,49 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
)
|
||||
)}
|
||||
{responseText && (
|
||||
<div dangerouslySetInnerHTML={{ __html: responseText }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }} dangerouslySetInnerHTML={{ __html: responseText }} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Counter + navigation */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<button
|
||||
onClick={() => currentIndex > 0 && setCurrentIndex(currentIndex - 1)}
|
||||
disabled={currentIndex === 0}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center transition-opacity"
|
||||
style={{ background: 'var(--color-primary)', opacity: currentIndex === 0 ? 0.3 : 1 }}
|
||||
>
|
||||
<svg width="20" height="20" 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>
|
||||
|
||||
<span className="text-2xl font-bold" style={{ color: 'var(--color-text)' }}>
|
||||
{currentIndex + 1}/{totalQuestions}
|
||||
{/* Segmented progress + counter */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, paddingTop: 4 }}>
|
||||
{isReview && (
|
||||
<button
|
||||
onClick={() => currentIndex > 0 && setCurrentIndex(currentIndex - 1)}
|
||||
disabled={currentIndex === 0}
|
||||
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' }}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
)}
|
||||
<div style={{ flex: 1, display: 'flex', gap: 4 }}>
|
||||
{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>
|
||||
|
||||
<button
|
||||
onClick={() => canGoNext && !isLast && setCurrentIndex(currentIndex + 1)}
|
||||
disabled={!canGoNext || isLast}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center transition-opacity"
|
||||
style={{ background: 'var(--color-primary)', opacity: !canGoNext || isLast ? 0.3 : 1 }}
|
||||
>
|
||||
<svg width="20" height="20" 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>
|
||||
{isReview && (
|
||||
<button
|
||||
onClick={() => canGoNext && !isLast && setCurrentIndex(currentIndex + 1)}
|
||||
disabled={!canGoNext || isLast}
|
||||
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' }}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restart button (review only) */}
|
||||
{isReview && (
|
||||
<button
|
||||
onClick={restart}
|
||||
className="w-full py-3 rounded-2xl font-semibold text-sm"
|
||||
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
|
||||
className="w-full font-bold"
|
||||
style={{ padding: 16, borderRadius: 18, border: '1.5px solid #E2DCD2', background: '#fff', color: '#46555F', fontSize: 15, cursor: 'pointer' }}
|
||||
>
|
||||
Recommencer
|
||||
</button>
|
||||
@ -404,11 +415,11 @@ function ZoomIcon() {
|
||||
|
||||
function getAnswerBg(isReview: boolean, isSelected: boolean, isCorrect?: boolean): string {
|
||||
if (!isReview) {
|
||||
return isSelected ? 'var(--color-primary)' : 'white'
|
||||
return isSelected ? 'var(--color-primary)' : '#fff'
|
||||
}
|
||||
if (isCorrect) return '#4caf50'
|
||||
if (isSelected) return '#f44336'
|
||||
return '#f5f5f5'
|
||||
if (isCorrect) return '#2E9E6B'
|
||||
if (isSelected) return '#D14343'
|
||||
return '#fff'
|
||||
}
|
||||
|
||||
function getLabelEntry(labels: TranslationAndResourceDTO[] | undefined, lang: string) {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { tPlain } from '@/lib/i18n'
|
||||
import type { SectionDTO } from '@/lib/api/types'
|
||||
@ -57,15 +58,13 @@ export default function VideoSection({ section, slug, configId, languages }: Pro
|
||||
<button
|
||||
onClick={back}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
width: 40, height: 40, borderRadius: 16,
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: 'none', cursor: 'pointer', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
<ChevronLeftIcon color="white" size={20} />
|
||||
</button>
|
||||
<span style={{ color: 'white', fontWeight: 600, fontSize: 15, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{title || 'Vidéo'}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { tPlain } from '@/lib/i18n'
|
||||
import type { SectionDTO } from '@/lib/api/types'
|
||||
@ -40,15 +41,13 @@ export default function WebSection({ section, slug, configId, languages }: Props
|
||||
<button
|
||||
onClick={back}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
width: 40, height: 40, borderRadius: 16,
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: 'none', cursor: 'pointer', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
<ChevronLeftIcon color="white" size={20} />
|
||||
</button>
|
||||
<span style={{ color: 'white', fontWeight: 600, fontSize: 15, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{title || 'Web'}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
71
src/components/sections/game/QuestionPuzzle.tsx
Normal file
71
src/components/sections/game/QuestionPuzzle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
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 'leaflet/dist/leaflet.css'
|
||||
import type { GeoPointDTO, CategorieDTO, GuidedStepDTO } from '@/lib/api/types'
|
||||
import { getStepGeometryCenter, getStepGeometryShape } from '@/lib/geo'
|
||||
|
||||
interface PathStep {
|
||||
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() {
|
||||
return L.divIcon({
|
||||
className: 'mim-user-wrapper',
|
||||
@ -122,20 +129,37 @@ export default function LeafletMap({
|
||||
const stepCoords: PathStep[] = useMemo(() => {
|
||||
if (!pathSteps || pathSteps.length === 0) return []
|
||||
return pathSteps
|
||||
.filter((s) => s.geometry?.coordinates && s.geometry.coordinates.length >= 2)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
order: s.order ?? 0,
|
||||
lat: s.geometry!.coordinates![1],
|
||||
lng: s.geometry!.coordinates![0],
|
||||
}))
|
||||
.flatMap((s) => {
|
||||
const pt = getStepGeometryCenter(s.geometry)
|
||||
if (!pt) return []
|
||||
return [{ id: s.id, order: s.order ?? 0, lat: pt.lat, lng: pt.lng }]
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}, [pathSteps])
|
||||
|
||||
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 fitBounds: [number, number][] | null = stepCoords.length > 1 ? polylinePositions : null
|
||||
const stepShapes = useMemo(() => {
|
||||
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 (
|
||||
<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 */}
|
||||
{polylinePositions.length > 1 && (
|
||||
<Polyline
|
||||
@ -182,19 +231,13 @@ export default function LeafletMap({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{stepCoords.map((s) => {
|
||||
let state: StepState = 'future'
|
||||
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'
|
||||
}
|
||||
{stepCoords.map((s, idx) => {
|
||||
const state = stepState(s.id)
|
||||
return (
|
||||
<Marker
|
||||
key={`s-${s.id}`}
|
||||
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) }}
|
||||
zIndexOffset={state === 'current' ? 2000 : 1000}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -1,11 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
totalSeconds: number
|
||||
active: boolean
|
||||
onExpired?: () => void
|
||||
remainingSeconds: number
|
||||
}
|
||||
|
||||
function format(s: number): string {
|
||||
@ -14,37 +11,15 @@ function format(s: number): string {
|
||||
return `${m}:${r.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function StepTimer({ totalSeconds, active, onExpired }: Props) {
|
||||
const [remaining, setRemaining] = useState(totalSeconds)
|
||||
|
||||
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
|
||||
export default function StepTimer({ totalSeconds, remainingSeconds }: Props) {
|
||||
const pct = totalSeconds > 0 ? (remainingSeconds / totalSeconds) * 100 : 0
|
||||
const color = pct > 50 ? '#16a34a' : pct > 25 ? '#f59e0b' : '#dc2626'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<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 className="h-2 rounded-full overflow-hidden" style={{ background: 'var(--color-border)' }}>
|
||||
<div
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import LanguageSelector from '@/components/ui/LanguageSelector'
|
||||
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
@ -24,16 +25,14 @@ export default function AppBar({ title, onBack, overlay }: Props) {
|
||||
{onBack && (
|
||||
<button
|
||||
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
|
||||
? { background: 'rgba(0,0,0,0.35)', backdropFilter: 'blur(8px)', padding: 6 }
|
||||
: { background: 'var(--color-primary-light)', color: 'var(--color-primary)', padding: 6 }
|
||||
? { width: 40, height: 40, background: 'rgba(255,255,255,0.2)', backdropFilter: 'blur(8px)' }
|
||||
: { width: 40, height: 40, background: 'var(--color-primary-light)', color: 'var(--color-primary)' }
|
||||
}
|
||||
aria-label="Retour"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
<ChevronLeftIcon color={overlay ? 'white' : 'var(--color-primary)'} size={20} />
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
|
||||
12
src/components/ui/ChevronLeftIcon.tsx
Normal file
12
src/components/ui/ChevronLeftIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -48,7 +48,6 @@ function normalizeSectionDTO(raw: any): SectionDTO {
|
||||
centerLatitude: raw.centerLatitude,
|
||||
centerLongitude: raw.centerLongitude,
|
||||
isParcours: raw.isParcours,
|
||||
guidedPaths: raw.guidedPaths,
|
||||
}
|
||||
break
|
||||
case 'Article':
|
||||
@ -115,7 +114,6 @@ function normalizeSectionDTO(raw: any): SectionDTO {
|
||||
base.puzzleImage = raw.puzzleImage
|
||||
base.messageDebut = raw.messageDebut
|
||||
base.messageFin = raw.messageFin
|
||||
base.guidedPaths = raw.guidedPaths
|
||||
break
|
||||
case 'Event':
|
||||
base.event = {
|
||||
@ -127,6 +125,15 @@ function normalizeSectionDTO(raw: any): SectionDTO {
|
||||
programme: raw.programme ?? raw.Programme,
|
||||
}
|
||||
break
|
||||
case 'Parcours': {
|
||||
const parcours: ParcoursDTO = {
|
||||
showMap: raw.showMap,
|
||||
baseSectionMapId: raw.baseSectionMapId,
|
||||
guidedPaths: raw.guidedPaths,
|
||||
}
|
||||
base.parcours = parcours
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return base
|
||||
@ -138,14 +145,31 @@ export async function getInstanceBySlug(slug: string): Promise<ApplicationInstan
|
||||
return {
|
||||
...webInstance,
|
||||
id: raw.id,
|
||||
applicationInstanceId: webInstance.id,
|
||||
publicApiKey: raw.publicApiKey,
|
||||
webSlug: raw.webSlug,
|
||||
label: raw.name,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfigurations(instanceId: string, apiKey: string): Promise<ConfigurationDTO[]> {
|
||||
return apiFetch(`/api/configuration?instanceId=${instanceId}`, apiKey)
|
||||
export interface WeightedConfiguration {
|
||||
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> {
|
||||
@ -165,12 +189,28 @@ export async function getAgendaEvents(sectionId: string, language: string, apiKe
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function getGuidedPaths(sectionMapId: string, apiKey: string): Promise<GuidedPathDTO[]> {
|
||||
return apiFetch(`/api/SectionMap/${sectionMapId}/GuidedPath`, apiKey)
|
||||
function normalizeGuidedPaths(raw: any[]): GuidedPathDTO[] {
|
||||
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
|
||||
// since GuidedPath is a polymorphic entity attached via parent ID.
|
||||
export async function getGuidedPathsForGame(sectionGameId: string, apiKey: string): Promise<GuidedPathDTO[]> {
|
||||
return apiFetch(`/api/SectionMap/${sectionGameId}/GuidedPath`, apiKey)
|
||||
export async function getGuidedPaths(sectionMapId: string, apiKey: string): Promise<GuidedPathDTO[]> {
|
||||
const raw: any[] = await apiFetch(`/api/SectionMap/${sectionMapId}/guided-path`, apiKey)
|
||||
return normalizeGuidedPaths(raw)
|
||||
}
|
||||
|
||||
export async function getGuidedPathsForParcours(sectionParcoursId: string, apiKey: string): Promise<GuidedPathDTO[]> {
|
||||
const raw: any[] = await apiFetch(`/api/SectionParcours/${sectionParcoursId}/guided-path`, apiKey)
|
||||
return normalizeGuidedPaths(raw)
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ export interface ContentDTO {
|
||||
|
||||
export interface ApplicationInstanceDTO {
|
||||
id: string
|
||||
applicationInstanceId?: string
|
||||
label?: string
|
||||
primaryColor?: string
|
||||
secondaryColor?: string
|
||||
@ -45,6 +46,16 @@ export interface ConfigurationDTO {
|
||||
sections?: SectionDTO[]
|
||||
}
|
||||
|
||||
export interface AppConfigurationLinkDTO {
|
||||
id: string
|
||||
configurationId?: string
|
||||
configuration?: ConfigurationDTO
|
||||
order?: number
|
||||
isActive?: boolean
|
||||
gridColSpan?: number
|
||||
gridRowSpan?: number
|
||||
}
|
||||
|
||||
export type SectionType =
|
||||
| 'Map'
|
||||
| 'Slider'
|
||||
@ -59,6 +70,7 @@ export type SectionType =
|
||||
| 'Agenda'
|
||||
| 'Weather'
|
||||
| 'Event'
|
||||
| 'Parcours'
|
||||
|
||||
export interface SectionDTO {
|
||||
id: string
|
||||
@ -83,13 +95,12 @@ export interface SectionDTO {
|
||||
map?: MapDTO
|
||||
agenda?: AgendaDTO
|
||||
// Game fields (GameDTO extends SectionDTO → flat)
|
||||
gameType?: 'Puzzle' | 'SlidingPuzzle' | 'Escape'
|
||||
gameType?: 'Puzzle' | 'SlidingPuzzle'
|
||||
rows?: number
|
||||
cols?: number
|
||||
puzzleImage?: ResourceDTO
|
||||
messageDebut?: TranslationAndResourceDTO[]
|
||||
messageFin?: TranslationAndResourceDTO[]
|
||||
guidedPaths?: GuidedPathDTO[]
|
||||
// PDF fields
|
||||
pdfs?: OrderedTranslationAndResourceDTO[]
|
||||
menu?: MenuDTO
|
||||
@ -98,6 +109,7 @@ export interface SectionDTO {
|
||||
weather?: WeatherDTO
|
||||
web?: WebDTO
|
||||
event?: EventDTO
|
||||
parcours?: ParcoursDTO
|
||||
}
|
||||
|
||||
export interface MapAnnotationDTO {
|
||||
@ -157,6 +169,11 @@ export interface QuestionDTO {
|
||||
responses?: QuestionResponseDTO[]
|
||||
imageBackgroundResourceUrl?: string
|
||||
order?: number
|
||||
validationQuestionType?: number // 0 = Simple, 1 = QCM, 2 = Puzzle
|
||||
puzzleImage?: ResourceDTO
|
||||
puzzleRows?: number
|
||||
puzzleCols?: number
|
||||
isSlidingPuzzle?: boolean
|
||||
}
|
||||
|
||||
export interface QuizDTO {
|
||||
@ -169,7 +186,7 @@ export interface QuizDTO {
|
||||
|
||||
export interface GeometryDTO {
|
||||
type?: string
|
||||
coordinates?: number[]
|
||||
coordinates?: unknown // Point: [x,y] | LineString: [x,y][] | Polygon: [x,y][][]
|
||||
}
|
||||
|
||||
export interface GeoPointDTO {
|
||||
@ -205,6 +222,11 @@ export interface MapDTO {
|
||||
centerLatitude?: string
|
||||
centerLongitude?: string
|
||||
isParcours?: boolean
|
||||
}
|
||||
|
||||
export interface ParcoursDTO {
|
||||
showMap?: boolean
|
||||
baseSectionMapId?: string
|
||||
guidedPaths?: GuidedPathDTO[]
|
||||
}
|
||||
|
||||
@ -216,6 +238,11 @@ export interface GuidedPathDTO {
|
||||
requireSuccessToAdvance?: boolean
|
||||
hideNextStepsUntilComplete?: boolean
|
||||
order?: number
|
||||
estimatedDurationMinutes?: number
|
||||
imageUrl?: string
|
||||
isGameMode?: boolean
|
||||
gameMessageDebut?: TranslationAndResourceDTO[]
|
||||
gameMessageFin?: TranslationAndResourceDTO[]
|
||||
steps?: GuidedStepDTO[]
|
||||
}
|
||||
|
||||
@ -226,15 +253,17 @@ export interface GuidedStepDTO {
|
||||
title?: TranslationDTO[]
|
||||
description?: TranslationDTO[]
|
||||
geometry?: GeometryDTO
|
||||
isGeoTriggered?: boolean
|
||||
zoneRadiusMeters?: number
|
||||
imageUrl?: string
|
||||
triggerGeoPointId?: number
|
||||
isHiddenInitially?: boolean
|
||||
isStepTimer?: boolean
|
||||
isStepLocked?: boolean
|
||||
timerSeconds?: number
|
||||
timerExpiredMessage?: TranslationDTO[]
|
||||
quizQuestions?: QuestionDTO[]
|
||||
audioIds?: TranslationDTO[]
|
||||
contents?: ContentDTO[]
|
||||
}
|
||||
|
||||
export interface EventAgendaAddressDTO {
|
||||
|
||||
@ -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(
|
||||
a: { lat: number; lng: number },
|
||||
b: { lat: number; lng: number }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user