From 931f35c818cc6eebd82f4feebc6e93d5a020a665 Mon Sep 17 00:00:00 2001
From: Thomas Fransolet
Date: Mon, 4 May 2026 14:23:50 +0200
Subject: [PATCH] Wip (sections event, game, map +qr code) to be tested !
---
CLAUDE.md | 7 +
guided-path-implementation.md | 255 ++++
package-lock.json | 67 +-
package.json | 6 +-
.../[configId]/sections/[sectionId]/page.tsx | 66 +-
src/app/[slug]/layout.tsx | 2 +-
src/app/[slug]/page.tsx | 16 +-
src/components/FeaturedEvent.tsx | 79 ++
src/components/QRScannerButton.tsx | 136 ++
src/components/TrackedSection.tsx | 14 +
src/components/sections/AgendaSection.tsx | 292 ++++-
src/components/sections/EventSection.tsx | 436 +++++++
src/components/sections/GameSection.tsx | 243 ++++
src/components/sections/MapSection.tsx | 1098 ++++++++++++++++-
src/components/sections/QuizSection.tsx | 17 +-
.../sections/agenda/EventMiniMap.tsx | 43 +
src/components/sections/event/EventMap.tsx | 115 ++
.../sections/game/EscapeProgression.tsx | 369 ++++++
.../sections/game/MessageDialog.tsx | 78 ++
src/components/sections/game/PuzzleGame.tsx | 202 +++
.../sections/game/SlidingPuzzle.tsx | 163 +++
src/components/sections/map/LeafletMap.tsx | 216 ++++
src/components/sections/map/StepQuiz.tsx | 107 ++
src/components/sections/map/StepTimer.tsx | 61 +
src/components/sections/map/Toast.tsx | 37 +
src/components/sections/map/map.css | 115 ++
src/context/VisitorContext.tsx | 16 +-
src/hooks/useGeolocation.ts | 57 +
src/hooks/useSectionTracking.ts | 36 +
src/lib/api/client.ts | 12 +-
src/lib/api/types.ts | 97 +-
src/lib/geo.ts | 20 +
src/lib/stats.ts | 64 +
todo-features.md | 141 +++
34 files changed, 4598 insertions(+), 85 deletions(-)
create mode 100644 guided-path-implementation.md
create mode 100644 src/components/FeaturedEvent.tsx
create mode 100644 src/components/QRScannerButton.tsx
create mode 100644 src/components/TrackedSection.tsx
create mode 100644 src/components/sections/EventSection.tsx
create mode 100644 src/components/sections/GameSection.tsx
create mode 100644 src/components/sections/agenda/EventMiniMap.tsx
create mode 100644 src/components/sections/event/EventMap.tsx
create mode 100644 src/components/sections/game/EscapeProgression.tsx
create mode 100644 src/components/sections/game/MessageDialog.tsx
create mode 100644 src/components/sections/game/PuzzleGame.tsx
create mode 100644 src/components/sections/game/SlidingPuzzle.tsx
create mode 100644 src/components/sections/map/LeafletMap.tsx
create mode 100644 src/components/sections/map/StepQuiz.tsx
create mode 100644 src/components/sections/map/StepTimer.tsx
create mode 100644 src/components/sections/map/Toast.tsx
create mode 100644 src/components/sections/map/map.css
create mode 100644 src/hooks/useGeolocation.ts
create mode 100644 src/hooks/useSectionTracking.ts
create mode 100644 src/lib/geo.ts
create mode 100644 src/lib/stats.ts
create mode 100644 todo-features.md
diff --git a/CLAUDE.md b/CLAUDE.md
index ddb5efb..c8f40bf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -87,6 +87,13 @@ Deux patterns, calqués sur `mymuseum-visitapp` :
**Full-screen** (`position: fixed; inset: 0` + dark AppBar) — contenu immersif :
- Video, Web, PDF, Map
+### Carte (MapSection)
+
+- Librairie : **Leaflet + react-leaflet**, dynamic-importé (`ssr: false`) car Leaflet a besoin de `window`
+- Tiles : **CartoDB Voyager** (gratuit, sans clé API, plus joli qu'OSM brut)
+- Markers : pins CSS custom colorés par `categorie.color` (pas d'icônes PNG comme en Flutter — on n'a pas `iconSource` dans le DTO web)
+- Équivalent Flutter : `flutter_map` (même rendu Leaflet sous le capot)
+
Pour les titres : toujours `tPlain()` dans les AppBar et attributs `alt`.
Pour les contenus longs : toujours `dangerouslySetInnerHTML` (Quill → HTML).
diff --git a/guided-path-implementation.md b/guided-path-implementation.md
new file mode 100644
index 0000000..9c1d703
--- /dev/null
+++ b/guided-path-implementation.md
@@ -0,0 +1,255 @@
+# GuidedPath — Implémentation visitapp-web
+
+Suivi d'implémentation du parcours guidé pour `visitapp-web`. Chaque phase peut être interrompue/reprise indépendamment.
+
+---
+
+## ✅ À tester (checklist complète)
+
+### Pré-requis
+- [ ] Avoir au moins une `SectionMap` avec quelques GeoPoints + au moins un parcours (idéalement plusieurs étapes)
+- [ ] Avoir au moins un `SectionGame` de type `Escape` avec un parcours
+- [ ] Tester depuis un device avec GPS (mobile ou navigateur desktop avec localisation activée)
+- [ ] Tester en HTTPS (la Geolocation API est bloquée en HTTP sauf sur `localhost`)
+
+### Mode Carte (SectionMap)
+
+**Affichage de base**
+- [ ] Sur une SectionMap **sans** parcours : aucun bouton "Parcours" visible
+- [ ] Sur une SectionMap **avec** parcours : bouton **"Parcours (N)"** dans la top bar
+- [ ] Tap sur le bouton → bottom sheet liste les parcours avec titre + nb étapes + badge "linéaire" si applicable
+
+**Sélection d'un parcours**
+- [ ] Tap sur un parcours dans la liste → la carte se recadre (flyToBounds) sur les étapes
+- [ ] Polyline pointillée colorée relie les étapes dans l'ordre
+- [ ] Markers étapes numérotés (1, 2, 3…) visibles
+- [ ] POI passent en opacity réduite (0.45)
+- [ ] Bouton change en **"Étape 1/N"**
+- [ ] Bouton **"Quitter le parcours"** apparaît en bottom-left
+
+**États visuels des markers étapes**
+- [ ] Étape **courante** : couleur primaire avec animation pulse
+- [ ] Étape **complétée** : vert avec ✓
+- [ ] Étape **future** : gris clair
+- [ ] Étape avec `isStepLocked` : gris avec 🔒
+- [ ] Tap sur un marker step → bottom sheet de détail s'ouvre
+
+**Marqueur utilisateur GPS**
+- [ ] Premier accès au parcours → demande de permission GPS du navigateur
+- [ ] Permission accordée → point bleu pulsant apparaît à votre position
+- [ ] Le point se déplace en live si vous bougez
+
+**StepDetail (bottom sheet d'une étape)**
+- [ ] Header avec image de l'étape + badge "Étape N/M"
+- [ ] Titre + description (HTML rendu)
+- [ ] Si `zoneRadiusMeters > 0` : badge "À X m de la zone" qui passe vert "Vous êtes dans la zone" quand vous approchez
+- [ ] Si GPS refusé : bouton "Localisation refusée — réessayer" (clic → re-demande la permission)
+- [ ] Boutons Précédent/Suivant **uniquement** en mode non-linéaire
+
+**Validation et progression**
+- [ ] Bouton **"Marquer terminée"** sur l'étape courante
+- [ ] Si `requireSuccessToAdvance` + zone non atteinte → bouton désactivé
+- [ ] Validation → étape passe en complétée + ouverture auto de la suivante
+- [ ] Dernière étape validée → modal **"Bravo !"** avec "Recommencer" / "Retour à la carte"
+- [ ] Étape déjà complétée → badge "✓ Étape terminée" affiché à la place du bouton
+
+**Quiz par étape**
+- [ ] Si `step.quizQuestions` non vide → bloc "Défi" avec QCM
+- [ ] Sélection des réponses → bouton "Valider mes réponses" s'active quand toutes répondues
+- [ ] Validation → feedback vert (correct) / rouge (mauvaise sélection) sur les choix
+- [ ] Si tout correct (100%) → badge "✓ Défi réussi"
+- [ ] Si pas 100% → bouton "Réessayer" (réinitialise le quiz)
+- [ ] Si `requireSuccessToAdvance` et quiz non passé → bouton "Marquer terminée" désactivé
+
+**Timer par étape**
+- [ ] Si `step.isStepTimer && step.timerSeconds > 0` → barre décompte affichée
+- [ ] Couleur barre : vert > 50%, orange 25-50%, rouge < 25%
+- [ ] Format temps : MM:SS
+- [ ] À expiration → message `timerExpiredMessage` affiché en rouge (si défini)
+
+**Toast d'entrée en zone**
+- [ ] Première fois que vous entrez dans la zone de l'étape courante → toast vert "Zone atteinte : « titre »"
+- [ ] Le toast disparaît après ~3.5s
+- [ ] Si vous quittez puis revenez dans la zone : pas de re-déclenchement (1× par étape)
+
+**Modes de parcours**
+- [ ] `isLinear = true` : pas de boutons Précédent/Suivant, progression strictement par validation
+- [ ] `isLinear = false` : navigation libre Précédent/Suivant entre étapes
+- [ ] `hideNextStepsUntilComplete = true` : seules les étapes complétées + courante visibles sur la carte
+- [ ] `isHiddenInitially` sur une étape : invisible tant qu'elle n'est pas devenue courante ou complétée
+
+**Lock**
+- [ ] `isStepLocked` + `requireSuccessToAdvance` → message "🔒 Étape verrouillée — validez la condition"
+- [ ] Validation (quiz passé OU dans la zone) → message "🔒 Étape déverrouillée"
+
+### Mode Game Escape (SectionGame type Escape)
+
+- [ ] Sur un Game type Escape **sans** parcours : stub "Parcours guidé bientôt disponible"
+- [ ] Avec parcours : `EscapeProgression` rendu (vue verticale, pas de carte)
+- [ ] Si plusieurs parcours : sélecteur initial avec liste de cartes
+- [ ] Si un seul parcours : auto-sélectionné
+- [ ] Compteur "N/M étapes" affiché en haut + bouton "Changer de parcours" (si plusieurs)
+- [ ] Étapes empilées verticalement (carte par étape)
+- [ ] Étape courante : bordure couleur primaire, étapes complétées vertes, futures grisées (opacity 0.6)
+- [ ] Quiz / Timer / Toast / GPS retry / lock fonctionnent identiquement au mode carte
+- [ ] Modal de fin avec "Recommencer" / "Retour"
+
+### Bugs connus à valider
+- [ ] **Endpoint Game Escape** : on utilise `/api/SectionMap/{id}/GuidedPath` pour les SectionGame aussi (hypothèse polymorphe). Si ça renvoie 404, le backend doit exposer `/api/SectionGame/{id}/GuidedPath` ou inclure `guidedPaths` dans le DTO `/api/Section/configuration/{id}/detail`
+
+---
+
+## Contexte
+
+- **Pas un type de Section standalone** : GuidedPath est une entité attachée à `SectionMap`, `SectionGame` (type Escape), ou `SectionEvent`.
+- **Endpoint backend** : `GET /api/SectionMap/{sectionMapId}/GuidedPath` retourne les parcours avec `steps + quizQuestions + triggerGeoPoint` eager-loadés.
+- **Référence Flutter** :
+ - `mymuseum-visitapp/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart` (mode carte, ~770 lignes)
+ - `mymuseum-visitapp/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart` (mode contenu, ~450 lignes)
+ - `mymuseum-visitapp/lib/Screens/Sections/GuidedPath/guided_path_list_sheet.dart` (sélecteur)
+ - `mymuseum-visitapp/lib/Screens/Sections/GuidedPath/guided_step_timer.dart` (widget timer)
+
+---
+
+## Phase 1 — Découverte et affichage statique sur la carte
+
+**Objectif** : permettre au visiteur de voir qu'il y a des parcours, choisir un parcours, et voir ses étapes tracées sur la carte (sans GPS, sans quiz, sans validation).
+
+### Fichiers touchés
+
+- `src/lib/api/types.ts` : ajout `GuidedPathDTO`, `GuidedStepDTO`, champ `guidedPaths?` sur `MapDTO`
+- `src/lib/api/client.ts` : nouvelle fonction `getGuidedPaths(sectionMapId, apiKey)`
+- `src/components/sections/MapSection.tsx` :
+ - fetch des parcours au mount (si `map.points` existe)
+ - bouton flottant "Parcours" en haut-droite (si parcours dispo)
+ - bottom sheet listant les parcours (titre + nb étapes)
+ - état `selectedPathId` qui filtre l'affichage carte
+- `src/components/sections/map/LeafletMap.tsx` :
+ - prop optionnelle `pathSteps?: { lat, lng, order, title }[]`
+ - rendu polyline reliant les étapes
+ - markers étapes numérotés (style différent des points POI)
+
+### États UX à couvrir
+
+- Aucun parcours dispo → bouton caché
+- Parcours dispo, aucun sélectionné → bouton "Parcours (N)" + ouverture liste au tap
+- Parcours sélectionné → polyline + steps numérotés sur la carte + bouton "Quitter le parcours"
+- Tap sur step → bottom sheet de l'étape (titre, image, description, badge ordre)
+
+### Hors scope Phase 1
+
+- GPS tracking
+- Quiz par étape
+- Timer
+- Lock / progression réelle
+- Page de progression dédiée (full-screen)
+
+### État
+
+- [x] Types (`GuidedPathDTO`, `GuidedStepDTO`, champ `guidedPaths` sur `MapDTO`)
+- [x] API client `getGuidedPaths(sectionMapId)`
+- [x] Pré-fetch server-side dans `app/[slug]/[configId]/sections/[sectionId]/page.tsx` (les paths sont injectés dans `section.map.guidedPaths` avant le render client)
+- [x] Bouton "Parcours" dans la top bar de `MapSection` (visible si paths > 0, change en "Étape N/M" quand parcours actif)
+- [x] Bottom sheet `PathListSheet` listant les parcours (titre + nb étapes + flag linéaire)
+- [x] Polyline pointillée + step markers numérotés stylés (CSS pulse sur sélection)
+- [x] Bottom sheet `StepDetail` (image header + titre + description + badge zone + nav précédent/suivant)
+- [x] Bouton "Quitter le parcours" en bottom-left quand un parcours est actif
+- [x] Auto-fit des bounds de la carte sur les étapes du parcours sélectionné
+- [x] POI dim (opacity 0.45) quand un parcours est actif pour mettre les étapes en avant
+
+---
+
+## Phase 2 — Progression complète (en cours)
+
+**Objectif** : reproduire `GuidedPathMapProgressionPage` du Flutter.
+
+### Fait
+
+- [x] `src/lib/geo.ts` : `haversineMeters()` + `formatDistance()`
+- [x] `src/hooks/useGeolocation.ts` : hook `watchPosition` avec états `idle | requesting | granted | denied | unavailable`
+- [x] Hook activé seulement quand un parcours est actif (économie de batterie)
+- [x] StepDetail affiche en live :
+ - Distance jusqu'à la zone (formatée m/km)
+ - Badge vert "Vous êtes dans la zone" si `distance <= zoneRadiusMeters`
+ - États dégradés : "Localisation en cours…", "Localisation refusée", "GPS indisponible"
+
+### Fait (suite — 2e itération)
+
+- [x] **Marqueurs étapes différenciés** dans `LeafletMap` :
+ - Étape courante : couleur primaire pulsante (animation `mim-step-current`), z-index 2000
+ - Étape complétée : vert (#16a34a) avec ✓
+ - Étape locked : gris avec 🔒
+ - Étape future : gris clair
+- [x] **Marqueur utilisateur** : point bleu pulsant (`mim-user`), position = `geo.lat/lng`, z-index 3000, non-interactif
+- [x] **Progression in-memory** dans `MapSection` :
+ - `completedSteps: Set` reset à chaque changement de parcours
+ - `currentStepId` = première étape non complétée dans l'ordre
+ - `markStepComplete()` : ajoute aux completed + avance auto vers la suivante (ou ouvre la modal de fin si dernière)
+- [x] **`hideNextStepsUntilComplete`** géré : `visibleSteps` slice à `currentStep + 1`, polyline et markers respectent
+- [x] **Conditions `canAdvance`** dans `StepDetail` :
+ - `!requireSuccess || radius === 0 || inZone || geo refusé/indispo`
+ - Bouton "Marquer terminée" / "Terminer le parcours" disabled sinon, avec tooltip explicatif
+- [x] **Mode non-linéaire** : nav Précédent/Suivant n'apparaît que si `!path.isLinear`. En linéaire, l'avancement est strictement par validation
+- [x] **Badge "✓ Étape terminée"** affiché si l'étape consultée est déjà complétée
+- [x] **Modal de fin** `EndOfPathModal` : bravo + boutons "Retour à la carte" / "Recommencer"
+
+### Fait (suite — 3e itération, finale Phase 2)
+
+- [x] **`StepTimer`** ([map/StepTimer.tsx](visitapp-web/src/components/sections/map/StepTimer.tsx)) : barre décroissante + MM:SS + couleurs vert/orange/rouge (≥50/≥25/<25%), callback `onExpired`
+- [x] **`StepQuiz`** ([map/StepQuiz.tsx](visitapp-web/src/components/sections/map/StepQuiz.tsx)) : QCM par question (réponses ordonnées), bouton Valider si toutes répondues, feedback vert/rouge sur les bonnes/mauvaises réponses après validation, bouton Réessayer, callback `(passed, score)`. Passe = 100%
+- [x] **`Toast`** ([map/Toast.tsx](visitapp-web/src/components/sections/map/Toast.tsx)) : toast vert qui apparaît en haut, auto-dismiss 3.5s, animation slide-in
+- [x] **Quiz / Timer / Toast intégrés dans `StepDetail`** :
+ - Quiz affiché si `step.quizQuestions` non vide ET étape courante non complétée
+ - Timer affiché si `step.isStepTimer && step.timerSeconds > 0` ET étape courante
+ - Toast déclenché à la première entrée dans la zone d'une étape (transition false→true)
+- [x] **Conditions `canAdvance` composées** :
+ ```
+ quizOk = !hasQuiz || quizPassed || !requireSuccess
+ zoneOk = radius === 0 || inZone || !requireSuccess || geoUnavailable
+ lockOk = !isLocked || !requireSuccess || quizPassed || inZone || geoUnavailable
+ canAdvance = quizOk && zoneOk && lockOk
+ ```
+- [x] **Lock dur** : `isStepLocked` impose au moins une preuve (quiz passé OU dans la zone) pour valider quand `requireSuccess`
+- [x] **`isHiddenInitially`** : filtré dans `visibleSteps` — uniquement affichées si complétée ou devenue courante
+- [x] **Retry permission GPS** : bouton "Localisation refusée — réessayer" dans le StepDetail, qui incrémente `geoEnabledKey` → re-mount du watcher
+- [x] **Hook `useGeolocation`** étendu pour accepter un `refreshKey` qui force le re-watch
+
+### Phase 2.bis — Game Escape (FAIT)
+
+- [x] `GameDTO.guidedPaths` ajouté aux types
+- [x] `getGuidedPathsForGame()` dans le client API (utilise le même endpoint `/api/SectionMap/{id}/GuidedPath` car la table `GuidedPath` est polymorphe via parent ID — ajustable si le backend expose un endpoint dédié)
+- [x] Server component pré-fetch les paths pour les sections Game de type Escape
+- [x] [`game/EscapeProgression.tsx`](visitapp-web/src/components/sections/game/EscapeProgression.tsx) — vue verticale sans carte, avec :
+ - Sélecteur de parcours si plusieurs (auto-sélection si un seul)
+ - Liste verticale des étapes (cartes empilées)
+ - Étape courante mise en valeur avec bordure primaire, étapes complétées en vert, étapes futures grisées
+ - Réutilise `StepTimer`, `StepQuiz`, `Toast`, `MessageDialog`
+ - Compteur "N/M étapes" en haut + bouton "Changer de parcours"
+ - Modal de fin avec Recommencer / Retour
+- [x] `GameSection` branche `EscapeProgression` quand `gameType === 'Escape'` ET `guidedPaths` non vide, sinon fallback `EscapeStub`
+
+---
+
+## État global
+
+**Tout le périmètre GuidedPath spécifié est implémenté** côté visitapp-web :
+- Mode carte (Map section) : sélection parcours, polyline + steps numérotés, marqueurs différenciés (courant/complété/locked/future), marqueur user GPS, progression in-memory, conditions composées, quiz/timer/lock/toast/retry GPS, modal de fin
+- Mode contenu (Game type Escape) : même logique sans la carte, vue empilée verticale
+
+**Points d'attention pour validation backend** :
+- L'endpoint `/api/SectionMap/{id}/GuidedPath` est utilisé pour Map ET Game Escape (hypothèse : table polymorphe via parent ID). À tester avec une section Game Escape réelle ; sinon le backend doit exposer `/api/SectionGame/{id}/GuidedPath` ou inclure `guidedPaths` directement dans le DTO retourné par `/api/Section/configuration/{id}/detail`
+- [ ] **Modal de fin de parcours** : à la dernière étape validée, afficher un dialog "Bravo !" + bouton retour
+- [ ] **Permission GPS** : si `geo.status === 'denied'`, afficher un overlay explicatif avec bouton "Réessayer" qui re-mount le hook (nécessite un `key` ou un `setEnabled(false); setEnabled(true)`)
+
+### Phase 2.bis — Mode Game Escape
+
+- [ ] Étendre `GameDTO` web avec `guidedPaths?: GuidedPathDTO[]`
+- [ ] Endpoint backend équivalent : à vérifier (probablement `GET /api/SectionGame/{id}/GuidedPath` ou inclus dans le DTO)
+- [ ] Page `GuidedPathContentProgressionPage` (sans carte, vue verticale)
+- [ ] Brancher depuis `GameSection` quand `gameType === 'Escape'` (remplacer le stub actuel `EscapeStub`)
+
+### Phase 2.bis — Mode Game Escape
+
+- Page `GuidedPathContentProgressionPage` (sans carte, vue verticale)
+- Brancher depuis `GameSection` quand `gameType === 'Escape'`
+- Nécessite ajout `guidedPaths?` sur `GameDTO`
diff --git a/package-lock.json b/package-lock.json
index da9bac1..8165a06 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,10 +8,14 @@
"name": "visitapp-web",
"version": "0.1.0",
"dependencies": {
+ "@types/leaflet": "^1.9.21",
"color2k": "^2.0.3",
+ "leaflet": "^1.9.4",
"next": "16.2.4",
+ "qr-scanner": "^1.4.2",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "react-leaflet": "^5.0.0"
},
"devDependencies": {
"@hey-api/openapi-ts": "^0.95.0",
@@ -861,6 +865,17 @@
"node": ">= 10"
}
},
+ "node_modules/@react-leaflet/core": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
+ "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
+ "license": "Hippocratic-2.1",
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1141,6 +1156,12 @@
"tailwindcss": "4.2.4"
}
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1148,6 +1169,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/leaflet": {
+ "version": "1.9.21",
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
+ "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
@@ -1158,6 +1188,12 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/offscreencanvas": {
+ "version": "2019.7.3",
+ "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
+ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -1612,6 +1648,12 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2126,6 +2168,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/qr-scanner": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz",
+ "integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/offscreencanvas": "^2019.6.4"
+ }
+ },
"node_modules/rc9": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
@@ -2158,6 +2209,20 @@
"react": "^19.2.4"
}
},
+ "node_modules/react-leaflet": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
+ "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
+ "license": "Hippocratic-2.1",
+ "dependencies": {
+ "@react-leaflet/core": "^3.0.0"
+ },
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
diff --git a/package.json b/package.json
index 92d1cd4..de86d76 100644
--- a/package.json
+++ b/package.json
@@ -8,10 +8,14 @@
"start": "next start"
},
"dependencies": {
+ "@types/leaflet": "^1.9.21",
"color2k": "^2.0.3",
+ "leaflet": "^1.9.4",
"next": "16.2.4",
+ "qr-scanner": "^1.4.2",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "react-leaflet": "^5.0.0"
},
"devDependencies": {
"@hey-api/openapi-ts": "^0.95.0",
diff --git a/src/app/[slug]/[configId]/sections/[sectionId]/page.tsx b/src/app/[slug]/[configId]/sections/[sectionId]/page.tsx
index 0434bf2..6b26323 100644
--- a/src/app/[slug]/[configId]/sections/[sectionId]/page.tsx
+++ b/src/app/[slug]/[configId]/sections/[sectionId]/page.tsx
@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation'
-import { getInstanceBySlug, getConfiguration, getSections } from '@/lib/api/client'
+import { getInstanceBySlug, getConfiguration, getSections, getGuidedPaths, getGuidedPathsForGame } from '@/lib/api/client'
import type { SectionDTO } from '@/lib/api/types'
// Section components
@@ -13,6 +13,9 @@ import PdfSection from '@/components/sections/PdfSection'
import WeatherSection from '@/components/sections/WeatherSection'
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 TrackedSection from '@/components/TrackedSection'
export default async function SectionPage({
params,
@@ -35,24 +38,63 @@ export default async function SectionPage({
const section: SectionDTO | undefined = 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) {
+ 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.game?.gameType?.toLowerCase().includes('escape')) {
+ try {
+ const paths = await getGuidedPathsForGame(section.id, instance.publicApiKey!)
+ section.game = { ...section.game, guidedPaths: paths }
+ } catch {
+ // Silently ignore
+ }
+ }
+
+ // Events also expose guided paths (same polymorphic endpoint)
+ if (section.type === 'Event' && section.event) {
+ try {
+ const paths = await getGuidedPaths(section.id, instance.publicApiKey!)
+ ;(section.event as { guidedPaths?: typeof paths }).guidedPaths = paths
+ } catch {
+ // Silently ignore
+ }
+ }
+
const props = { section, slug, configId, languages: config.languages ?? ['FR'] }
+ let content: React.ReactNode
switch (section.type) {
- case 'Article': return
- case 'Agenda': return
- case 'Menu': return
- case 'Slider': return
- case 'Video': return
- case 'Map': return
- case 'Pdf': return
- case 'Weather': return
- case 'Quiz': return
- case 'Web': return
+ case 'Article': content = ; break
+ case 'Agenda': content = ; break
+ case 'Menu': content = ; break
+ case 'Slider': content = ; break
+ case 'Video': content = ; break
+ case 'Map': content = ; break
+ case 'Pdf': content = ; break
+ case 'Weather': content = ; break
+ case 'Quiz': content = ; break
+ case 'Game': content = ; break
+ case 'Event': content = ; break
+ case 'Web': content = ; break
default:
- return (
+ content = (
Section de type « {section.type} » à venir.
)
}
+
+ return (
+
+ {content}
+
+ )
}
diff --git a/src/app/[slug]/layout.tsx b/src/app/[slug]/layout.tsx
index f08dc80..57dee21 100644
--- a/src/app/[slug]/layout.tsx
+++ b/src/app/[slug]/layout.tsx
@@ -22,7 +22,7 @@ export default async function SlugLayout({
const theme = resolveColors(instance)
return (
-
+
{children}
diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx
index 04f003d..21aa6c4 100644
--- a/src/app/[slug]/page.tsx
+++ b/src/app/[slug]/page.tsx
@@ -1,6 +1,8 @@
import { getInstanceBySlug, getConfigurations } from '@/lib/api/client'
import { notFound } from 'next/navigation'
import ConfigurationGrid from '@/components/ConfigurationGrid'
+import FeaturedEvent from '@/components/FeaturedEvent'
+import QRScannerButton from '@/components/QRScannerButton'
export default async function HomePage({
params,
@@ -17,5 +19,17 @@ export default async function HomePage({
notFound()
}
- return
+ const featuredEvent = instance.sectionEventDTO
+ // The featured event is rendered inside its parent configuration's context
+ const featuredConfigId = featuredEvent?.configurationId
+
+ return (
+ <>
+ {featuredEvent && featuredConfigId && (
+
+ )}
+
+
+ >
+ )
}
diff --git a/src/components/FeaturedEvent.tsx b/src/components/FeaturedEvent.tsx
new file mode 100644
index 0000000..cfa32b0
--- /dev/null
+++ b/src/components/FeaturedEvent.tsx
@@ -0,0 +1,79 @@
+'use client'
+
+import Link from 'next/link'
+import Image from 'next/image'
+import { useVisitor } from '@/context/VisitorContext'
+import { t, tPlain } from '@/lib/i18n'
+import type { SectionDTO } from '@/lib/api/types'
+
+interface Props {
+ event: SectionDTO
+ slug: string
+ configurationId: string
+}
+
+function formatDateRange(start?: string, end?: string, locale: string = 'fr'): string {
+ if (!start) return ''
+ const d1 = new Date(start)
+ const d2 = end ? new Date(end) : null
+ const opt: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' }
+ if (!d2 || d1.toDateString() === d2.toDateString()) {
+ return d1.toLocaleDateString(locale, opt)
+ }
+ return `${d1.toLocaleDateString(locale, opt)} → ${d2.toLocaleDateString(locale, opt)}`
+}
+
+export default function FeaturedEvent({ event, slug, configurationId }: Props) {
+ const { language } = useVisitor()
+ const start = event.event?.startDate
+ const end = event.event?.endDate
+ const dateLabel = formatDateRange(start, end, language.toLowerCase())
+
+ return (
+
+ {event.imageSource ? (
+
+ ) : (
+
+ )}
+
+
+
+ À la une
+
+
+
+ {dateLabel && (
+
+ {dateLabel}
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/QRScannerButton.tsx b/src/components/QRScannerButton.tsx
new file mode 100644
index 0000000..38afd2f
--- /dev/null
+++ b/src/components/QRScannerButton.tsx
@@ -0,0 +1,136 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useVisitor } from '@/context/VisitorContext'
+import { trackEvent } from '@/lib/stats'
+
+interface Props {
+ slug: string
+ configurationId?: string
+}
+
+// Parses a QR payload and returns a navigation path within the current app, or null.
+// Accepts:
+// https://app.myinfomate.be/{slug}
+// https://app.myinfomate.be/{slug}/{configId}
+// https://app.myinfomate.be/{slug}/{configId}/sections/{sectionId}
+// /{slug}/... (relative)
+function parseQrToPath(payload: string): string | null {
+ try {
+ const m = payload.match(/(?:https?:\/\/[^/]+)?(\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*(?:\/sections\/[A-Za-z0-9_-]+)?)/)
+ if (m && m[1]) return m[1]
+ } catch {
+ // ignore
+ }
+ return null
+}
+
+export default function QRScannerButton({ slug, configurationId }: Props) {
+ const router = useRouter()
+ const { instanceId, language } = useVisitor()
+ const [open, setOpen] = useState(false)
+ const [error, setError] = useState(null)
+ const videoRef = useRef(null)
+ const scannerRef = useRef<{ destroy: () => void } | null>(null)
+
+ useEffect(() => {
+ if (!open) return
+ let cancelled = false
+ setError(null)
+
+ ;(async () => {
+ try {
+ const { default: QrScanner } = await import('qr-scanner')
+ if (cancelled || !videoRef.current) return
+ const scanner = new QrScanner(
+ videoRef.current,
+ (result) => {
+ const payload = typeof result === 'string' ? result : result.data
+ const path = parseQrToPath(payload)
+ if (instanceId) {
+ trackEvent({
+ instanceId,
+ configurationId,
+ eventType: 'QrScan',
+ language,
+ metadata: JSON.stringify({ payload: payload.slice(0, 200), parsed: path }),
+ })
+ }
+ if (path) {
+ setOpen(false)
+ router.push(path)
+ } else {
+ setError('Code QR non reconnu')
+ }
+ },
+ { highlightScanRegion: true, highlightCodeOutline: true, returnDetailedScanResult: true }
+ )
+ await scanner.start()
+ scannerRef.current = scanner
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : 'Erreur d’accès à la caméra'
+ setError(msg.includes('Permission') ? 'Accès à la caméra refusé' : msg)
+ }
+ })()
+
+ return () => {
+ cancelled = true
+ scannerRef.current?.destroy()
+ scannerRef.current = null
+ }
+ }, [open, instanceId, configurationId, language, router])
+
+ return (
+ <>
+
+
+ {open && (
+
+
+
+
+
+ Scanner un QR code
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )}
+ {/* slug is unused in the parsing logic but kept in the API for future use cases */}
+ {slug}
+ >
+ )
+}
diff --git a/src/components/TrackedSection.tsx b/src/components/TrackedSection.tsx
new file mode 100644
index 0000000..f068746
--- /dev/null
+++ b/src/components/TrackedSection.tsx
@@ -0,0 +1,14 @@
+'use client'
+
+import { useSectionTracking } from '@/hooks/useSectionTracking'
+
+export default function TrackedSection({
+ sectionId, configurationId, children,
+}: {
+ sectionId: string
+ configurationId: string
+ children: React.ReactNode
+}) {
+ useSectionTracking(sectionId, configurationId)
+ return <>{children}>
+}
diff --git a/src/components/sections/AgendaSection.tsx b/src/components/sections/AgendaSection.tsx
index 2578f2f..90ff725 100644
--- a/src/components/sections/AgendaSection.tsx
+++ b/src/components/sections/AgendaSection.tsx
@@ -2,11 +2,22 @@
import { useEffect, useState } from 'react'
import Image from 'next/image'
+import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO, EventAgendaDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar'
+import { trackEvent } from '@/lib/stats'
+
+const EventMiniMap = dynamic(() => import('./agenda/EventMiniMap'), {
+ ssr: false,
+ loading: () => (
+
+ Chargement…
+
+ ),
+})
interface Props {
section: SectionDTO
@@ -17,8 +28,37 @@ interface Props {
const MONTHS = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc']
-export default function AgendaSection({ section, slug, configId, languages }: Props) {
- const { language, setAvailableLanguages } = useVisitor()
+function eventImage(e: EventAgendaDTO): string | undefined {
+ return e.resource?.url
+}
+
+function eventCoords(e: EventAgendaDTO): { lat: number; lng: number } | null {
+ const c = e.address?.geometry?.coordinates
+ if (!Array.isArray(c) || c.length < 2) return null
+ return { lat: c[1], lng: c[0] }
+}
+
+function fullAddress(e: EventAgendaDTO): string {
+ const a = e.address
+ if (!a) return ''
+ return [
+ [a.streetNumber, a.streetName].filter(Boolean).join(' '),
+ [a.postCode, a.city].filter(Boolean).join(' '),
+ a.country,
+ ].filter(Boolean).join(', ')
+}
+
+function youtubeEmbedUrl(idOrUrl: string): string {
+ if (idOrUrl.includes('youtube.com') || idOrUrl.includes('youtu.be')) {
+ const m = idOrUrl.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/)
+ if (m) return `https://www.youtube.com/embed/${m[1]}`
+ return idOrUrl
+ }
+ return `https://www.youtube.com/embed/${idOrUrl}`
+}
+
+export default function AgendaSection({ section, configId, languages }: Props) {
+ const { language, setAvailableLanguages, instanceId } = useVisitor()
const router = useRouter()
const events = section.agenda?.events ?? []
@@ -30,8 +70,8 @@ export default function AgendaSection({ section, slug, configId, languages }: Pr
const [selected, setSelected] = useState(null)
const filtered = events.filter((e) => {
- if (!e.startDate) return false
- const d = new Date(e.startDate)
+ if (!e.dateFrom) return false
+ const d = new Date(e.dateFrom)
return d.getMonth() === selectedMonth && d.getFullYear() === selectedYear
})
@@ -72,62 +112,206 @@ export default function AgendaSection({ section, slug, configId, languages }: Pr
Aucun événement ce mois-ci
)}
- {filtered.map((event) => (
-
- ))}
+
+
+ {event.dateFrom && (
+
+ {new Date(event.dateFrom).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}
+
+ )}
+
+
+ )
+ })}
{/* Event detail popup */}
{selected && (
-
-
setSelected(null)} />
-
- {selected.imageSource && (
-
-
-
- )}
-
- {selected.startDate && (
-
- {new Date(selected.startDate).toLocaleDateString(language.toLowerCase(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
- {selected.endDate && selected.endDate !== selected.startDate && (
- <> — {new Date(selected.endDate).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}>
- )}
-
- )}
- {t(selected.description, language) && (
-
- )}
-
-
-
+ setSelected(null)}
+ />
)}
)
}
+
+function EventDetail({
+ event, language, onClose,
+}: {
+ event: EventAgendaDTO
+ language: string
+ onClose: () => void
+}) {
+ const img = eventImage(event)
+ const coords = eventCoords(event)
+ const address = fullAddress(event)
+ const yt = event.idVideoYoutube
+ const videoLink = event.videoLink
+ const videoResource = event.videoResource?.url
+
+ return (
+
+
+
+ {img && (
+
+
+
+ )}
+
+ {event.dateFrom && (
+
+ {new Date(event.dateFrom).toLocaleDateString(language.toLowerCase(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
+ {event.dateTo && event.dateTo !== event.dateFrom && (
+ <> → {new Date(event.dateTo).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}>
+ )}
+
+ )}
+
+ {t(event.description, language) && (
+
+ )}
+
+ {/* Video */}
+ {(yt || videoLink || videoResource) && (
+
+ {yt ? (
+
+ ) : videoLink ? (
+ videoLink.includes('youtube') || videoLink.includes('youtu.be') ? (
+
+ ) : (
+ // eslint-disable-next-line jsx-a11y/media-has-caption
+
+ )
+ ) : videoResource ? (
+ // eslint-disable-next-line jsx-a11y/media-has-caption
+
+ ) : null}
+
+ )}
+
+ {/* Mini map */}
+ {coords && (
+
+
+
+ )}
+
+ {/* Address */}
+ {address && (
+
+
+ {address}
+
+ )}
+
+ {/* Contact */}
+ {(event.phone || event.email || event.website) && (
+
+ )}
+
+
+
+ )
+}
+
+function Icon({ name }: { name: 'phone' | 'email' | 'web' | 'place' }) {
+ const paths: Record = {
+ phone: 'M6.62 10.79a15.05 15.05 0 0 0 6.59 6.59l2.2-2.2a1 1 0 0 1 1.05-.24c1.12.37 2.33.57 3.57.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57a1 1 0 0 1-.25 1.05l-2.2 2.17z',
+ email: 'M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z',
+ web: 'M12 2A10 10 0 1 0 22 12 10 10 0 0 0 12 2zm6.93 6h-2.95a15.65 15.65 0 0 0-1.38-3.56A8 8 0 0 1 18.92 8zM12 4a14 14 0 0 1 1.81 4h-3.62A14 14 0 0 1 12 4zM4.26 14a8 8 0 0 1 0-4h3.38a16.5 16.5 0 0 0 0 4zm.82 2h2.95a15.65 15.65 0 0 0 1.38 3.56A8 8 0 0 1 5.08 16zm2.95-8H5.08a8 8 0 0 1 4.33-3.56A15.65 15.65 0 0 0 8.05 8zM12 20a14 14 0 0 1-1.81-4h3.62A14 14 0 0 1 12 20zm2.34-6h-4.68a14.6 14.6 0 0 1 0-4h4.68a14.6 14.6 0 0 1 0 4zm.25 5.56A15.65 15.65 0 0 0 15.97 16h2.95a8 8 0 0 1-4.33 3.56zM16.36 14a16.5 16.5 0 0 0 0-4h3.38a8 8 0 0 1 0 4z',
+ place: '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',
+ }
+ return (
+
+ )
+}
diff --git a/src/components/sections/EventSection.tsx b/src/components/sections/EventSection.tsx
new file mode 100644
index 0000000..0febf7c
--- /dev/null
+++ b/src/components/sections/EventSection.tsx
@@ -0,0 +1,436 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import dynamic from 'next/dynamic'
+import { useVisitor } from '@/context/VisitorContext'
+import { t, tPlain } from '@/lib/i18n'
+import type { SectionDTO, ProgrammeBlock, MapAnnotationDTO, GuidedPathDTO } from '@/lib/api/types'
+
+const EventMap = dynamic(() => import('./event/EventMap'), {
+ ssr: false,
+ loading: () => (
+
+
Chargement de la carte…
+
+ ),
+})
+
+interface Props {
+ section: SectionDTO
+ slug: string
+ configId: string
+ languages: string[]
+}
+
+function formatDateRange(start?: string, end?: string): string {
+ if (!start) return ''
+ const d1 = new Date(start)
+ const d2 = end ? new Date(end) : null
+ const fmt = (d: Date) => `${d.getDate().toString().padStart(2, '0')}/${(d.getMonth() + 1).toString().padStart(2, '0')}`
+ if (!d2 || d1.toDateString() === d2.toDateString()) return fmt(d1)
+ if (d1.getFullYear() === d2.getFullYear()) return `${fmt(d1)} → ${fmt(d2)}/${d2.getFullYear()}`
+ return `${fmt(d1)}/${d1.getFullYear()} → ${fmt(d2)}/${d2.getFullYear()}`
+}
+
+function formatTime(iso?: string): string {
+ if (!iso) return ''
+ const d = new Date(iso)
+ return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
+}
+
+export default function EventSection({ section, slug, configId, languages }: Props) {
+ const { language, setAvailableLanguages } = useVisitor()
+ const router = useRouter()
+
+ useEffect(() => { setAvailableLanguages(languages) }, [languages])
+
+ const event = section.event
+ const programme = useMemo(
+ () => [...(event?.programme ?? [])].sort((a, b) => {
+ const ta = a.startTime ? new Date(a.startTime).getTime() : 0
+ const tb = b.startTime ? new Date(b.startTime).getTime() : 0
+ return ta - tb
+ }),
+ [event]
+ )
+ const globalAnnotations = useMemo(() => event?.globalMapAnnotations ?? [], [event])
+
+ const activeBlock = useMemo(() => {
+ const now = Date.now()
+ return programme.find((b) => {
+ if (!b.startTime || !b.endTime) return false
+ const s = new Date(b.startTime).getTime()
+ const e = new Date(b.endTime).getTime()
+ return now >= s && now <= e
+ }) ?? null
+ }, [programme])
+
+ const [selectedBlock, setSelectedBlock] = useState(null)
+ const [mapFullscreen, setMapFullscreen] = useState(false)
+
+ const [primaryColor, setPrimaryColor] = useState('#3a6ea5')
+ useEffect(() => {
+ const c = getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim()
+ if (c) setPrimaryColor(c)
+ }, [])
+
+ const centerLat = section.latitude ?? 50.85
+ const centerLng = section.longitude ?? 4.35
+
+ const hasMap = globalAnnotations.length > 0 || !!event?.baseSectionMapId
+ const hasPaths = (section.event as { guidedPaths?: GuidedPathDTO[] } | undefined)?.guidedPaths?.length
+ const paths: GuidedPathDTO[] = (section.event as { guidedPaths?: GuidedPathDTO[] } | undefined)?.guidedPaths ?? []
+
+ const description = t(section.description, language)
+
+ return (
+
+ {/* Hero */}
+
+ {section.imageSource && (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ )}
+
+
+
+
router.back()}
+ className="w-10 h-10 rounded-full flex items-center justify-center"
+ style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(8px)' }}
+ aria-label="Retour"
+ >
+
+
+
+
+
+ {(event?.startDate || event?.endDate) && (
+
+ {formatDateRange(event?.startDate, event?.endDate)}
+
+ )}
+
+
+
+
+ {/* Programme */}
+ {programme.length > 0 && (
+
+
+ {programme.map((block, i) => {
+ const isActive = activeBlock?.id === block.id
+ return (
+
setSelectedBlock(block)}
+ className="flex gap-3 text-left"
+ >
+ {/* Time + dot column */}
+
+
+ {formatTime(block.startTime)}
+
+
+ {i < programme.length - 1 && (
+
+ )}
+
+
+ {/* Block content */}
+
+
+
+ {isActive && (
+
+ EN COURS
+
+ )}
+
+ {(block.startTime && block.endTime) && (
+
+ {formatTime(block.startTime)} – {formatTime(block.endTime)}
+
+ )}
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* Carte */}
+ {hasMap && (
+
+ setMapFullscreen(true)}
+ className="block w-full rounded-2xl overflow-hidden relative"
+ style={{ height: 220, border: '1px solid var(--color-border)' }}
+ >
+
+
+
+ {activeBlock && (activeBlock.mapAnnotations?.length ?? 0) > 0 && (
+
+
+ Annotations du bloc en cours en orange
+
+ )}
+
+ )}
+
+ {/* Parcours */}
+ {hasPaths && (
+
+
+ {paths.length} parcours disponible{paths.length > 1 ? 's' : ''}
+
+
+ {paths.map((p) => (
+
{
+ // Navigate to the SectionMap page if event has baseSectionMapId, otherwise stay
+ if (event?.baseSectionMapId) {
+ router.push(`/${slug}/${configId}/sections/${event.baseSectionMapId}`)
+ }
+ }}
+ className="flex-shrink-0 flex flex-col gap-2 p-3 rounded-2xl text-left"
+ style={{
+ width: 180,
+ background: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+
+
+
+ {p.steps?.length ?? 0} étape{(p.steps?.length ?? 0) > 1 ? 's' : ''}
+
+
+ ))}
+
+
+ )}
+
+ {/* À propos */}
+ {description && (
+
+ )}
+
+
+
+ {/* Block detail bottom sheet */}
+ {selectedBlock && (
+
setSelectedBlock(null)}
+ />
+ )}
+
+ {/* Fullscreen map overlay */}
+ {mapFullscreen && (
+
+
+
+
setMapFullscreen(false)}
+ className="w-10 h-10 rounded-full flex items-center justify-center"
+ style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)' }}
+ aria-label="Fermer"
+ >
+
+
+
+ {tPlain(section.title, language)}
+
+
+
+ )}
+
+ )
+}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ )
+}
+
+function BlockDetailSheet({
+ block, language, onClose,
+}: {
+ block: ProgrammeBlock
+ language: string
+ onClose: () => void
+}) {
+ return (
+ <>
+
+
+
+
+
+ {(block.startTime && block.endTime) && (
+
+ {formatTime(block.startTime)} – {formatTime(block.endTime)}
+
+ )}
+
+ {t(block.description, language) && (
+
+ )}
+ {(block.mapAnnotations?.length ?? 0) > 0 && (
+
+
+ Lieux concernés
+
+
+ {block.mapAnnotations!.map((a, i) => (
+
+ ))}
+
+
+ )}
+
+
+ >
+ )
+}
diff --git a/src/components/sections/GameSection.tsx b/src/components/sections/GameSection.tsx
new file mode 100644
index 0000000..2d83afd
--- /dev/null
+++ b/src/components/sections/GameSection.tsx
@@ -0,0 +1,243 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useVisitor } from '@/context/VisitorContext'
+import { t, tPlain } from '@/lib/i18n'
+import type { SectionDTO } 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 {
+ section: SectionDTO
+ slug: string
+ configId: string
+ languages: string[]
+}
+
+type GameKind = 'Puzzle' | 'SlidingPuzzle' | 'Escape' | 'Unknown'
+
+function detectKind(raw?: string): GameKind {
+ if (!raw) return 'Unknown'
+ const v = raw.toString().toLowerCase()
+ if (v.includes('slid')) return 'SlidingPuzzle'
+ if (v.includes('escape')) return 'Escape'
+ if (v.includes('puzzle') || v === '0') return 'Puzzle'
+ if (v === '1') return 'SlidingPuzzle'
+ if (v === '2') return 'Escape'
+ return 'Unknown'
+}
+
+export default function GameSection({ section, configId, languages }: Props) {
+ const { language, setAvailableLanguages, instanceId } = useVisitor()
+ const router = useRouter()
+
+ useEffect(() => { setAvailableLanguages(languages) }, [languages])
+
+ const game = section.game
+ const kind = detectKind(game?.gameType)
+ const rows = Math.max(2, game?.rows ?? 3)
+ const cols = Math.max(2, game?.cols ?? 3)
+ const puzzleImage = game?.puzzleImage
+
+ const startMsg = t(game?.messageDebut, language)
+ const endMsg = t(game?.messageFin, language)
+
+ const [showStart, setShowStart] = useState(!!startMsg)
+ const [showEnd, setShowEnd] = useState(false)
+ const [showHint, setShowHint] = useState(false)
+ const [resetKey, setResetKey] = useState(0)
+ const startedAtRef = useRef(Date.now())
+
+ function handleWin() {
+ setShowEnd(true)
+ if (instanceId) {
+ trackEvent({
+ instanceId, configurationId: configId, sectionId: section.id,
+ eventType: 'GameComplete', language,
+ durationSeconds: Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000)),
+ metadata: JSON.stringify({ gameType: kind }),
+ })
+ }
+ }
+
+ function restart() {
+ setShowEnd(false)
+ setResetKey((k) => k + 1)
+ startedAtRef.current = Date.now()
+ }
+
+ return (
+
+ {/* Header with gradient */}
+
+ {section.imageSource && (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ )}
+
+
+
+
router.back()}
+ className="w-10 h-10 rounded-full flex items-center justify-center"
+ style={{ background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(8px)' }}
+ aria-label="Retour"
+ >
+
+
+
+
+
+
+
+ {/* Game area */}
+
+ {kind === 'Puzzle' && puzzleImage && (
+
+ )}
+ {kind === 'SlidingPuzzle' && puzzleImage && (
+
+ )}
+ {kind === 'Escape' && (
+ (game?.guidedPaths?.length ?? 0) > 0 ? (
+
+ ) : (
+
+ )
+ )}
+ {kind === 'Unknown' && (
+
+ Type de jeu inconnu.
+
+ )}
+ {!puzzleImage && (kind === 'Puzzle' || kind === 'SlidingPuzzle') && (
+
+ Image du puzzle manquante.
+
+ )}
+
+ {/* Floating buttons */}
+ {(kind === 'Puzzle' || kind === 'SlidingPuzzle') && puzzleImage && (
+
+
setShowHint((v) => !v)}
+ className="w-12 h-12 rounded-full flex items-center justify-center"
+ style={{
+ background: showHint ? 'var(--color-primary)' : 'var(--color-surface)',
+ color: showHint ? 'var(--color-on-primary)' : 'var(--color-text)',
+ boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
+ border: '1px solid var(--color-border)',
+ }}
+ aria-label="Aide"
+ title="Afficher / masquer l'aperçu"
+ >
+
+
+
+
+
+
+ )}
+
+
+ {/* Start dialog */}
+
setShowStart(false)}
+ primaryAction={{ label: 'Commencer', onClick: () => setShowStart(false) }}
+ />
+
+ {/* End dialog */}
+ router.back() }}
+ />
+
+ )
+}
+
+function EscapeStub({ title, description }: { title: string; description: string }) {
+ return (
+
+
+
{title}
+ {description && (
+
+ )}
+
+ Parcours guidé bientôt disponible.
+
+
+ )
+}
diff --git a/src/components/sections/MapSection.tsx b/src/components/sections/MapSection.tsx
index d646e67..bc140f0 100644
--- a/src/components/sections/MapSection.tsx
+++ b/src/components/sections/MapSection.tsx
@@ -1,5 +1,1097 @@
'use client'
-import type { SectionDTO } from '@/lib/api/types'
-export default function MapSection({ section }: { section: SectionDTO; slug: string; configId: string; languages: string[] }) {
- return Carte — à venir
+
+import { useEffect, useMemo, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import dynamic from 'next/dynamic'
+import { useVisitor } from '@/context/VisitorContext'
+import { t, tPlain } from '@/lib/i18n'
+import type { SectionDTO, GeoPointDTO, GuidedPathDTO, GuidedStepDTO } from '@/lib/api/types'
+import { useGeolocation } from '@/hooks/useGeolocation'
+import { haversineMeters, formatDistance } from '@/lib/geo'
+import { trackEvent } from '@/lib/stats'
+import StepTimer from './map/StepTimer'
+import StepQuiz from './map/StepQuiz'
+import Toast from './map/Toast'
+import './map/map.css'
+
+const LeafletMap = dynamic(() => import('./map/LeafletMap'), {
+ ssr: false,
+ loading: () => (
+
+
Chargement de la carte…
+
+ ),
+})
+
+interface Props {
+ section: SectionDTO
+ slug: string
+ configId: string
+ languages: string[]
+}
+
+type Mode = 'map' | 'list'
+
+export default function MapSection({ section, configId, languages }: Props) {
+ const { language, setAvailableLanguages, instanceId } = useVisitor()
+ const router = useRouter()
+
+ useEffect(() => { setAvailableLanguages(languages) }, [languages])
+
+ const map = section.map
+ const hasData = !!map && (map.points?.length ?? 0) > 0
+ const points = useMemo(() => map?.points ?? [], [map])
+ const categories = useMemo(() => map?.categories ?? [], [map])
+ const guidedPaths = useMemo(() => map?.guidedPaths ?? [], [map])
+
+ const [mode, setMode] = useState('map')
+ const [selectedId, setSelectedId] = useState(null)
+ const [search, setSearch] = useState('')
+ const [activeCats, setActiveCats] = useState>(new Set())
+ const [filterOpen, setFilterOpen] = useState(false)
+ const [pathListOpen, setPathListOpen] = useState(false)
+ const [activePathId, setActivePathId] = useState(null)
+ const [activeStepId, setActiveStepId] = useState(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>(new Set())
+ const [quizPassedSteps, setQuizPassedSteps] = useState>(new Set())
+ const [timerExpiredSteps, setTimerExpiredSteps] = useState>(new Set())
+ const [zoneNotifiedSteps, setZoneNotifiedSteps] = useState>(new Set())
+ const [toastMessage, setToastMessage] = useState(null)
+ const [showEndModal, setShowEndModal] = useState(false)
+ const [geoEnabledKey, setGeoEnabledKey] = useState(0)
+
+ // Reset progression when path changes
+ useEffect(() => {
+ setCompletedSteps(new Set())
+ setQuizPassedSteps(new Set())
+ setTimerExpiredSteps(new Set())
+ setZoneNotifiedSteps(new Set())
+ setShowEndModal(false)
+ setToastMessage(null)
+ }, [activePathId])
+
+ // First non-completed step in order = current
+ const currentStepId = useMemo(() => {
+ if (!activePath) return null
+ const next = activeSteps.find((s) => !completedSteps.has(s.id))
+ return next?.id ?? null
+ }, [activePath, activeSteps, completedSteps])
+
+ // Hide future steps if path requires it; also hide isHiddenInitially steps not yet current/completed
+ const visibleSteps = useMemo(() => {
+ if (!activePath) return []
+ let list = activeSteps
+ if (activePath.hideNextStepsUntilComplete) {
+ const idx = currentStepId ? activeSteps.findIndex((s) => s.id === currentStepId) : activeSteps.length
+ list = activeSteps.slice(0, idx + 1)
+ }
+ return list.filter((s) => {
+ if (!s.isHiddenInitially) return true
+ return completedSteps.has(s.id) || s.id === currentStepId
+ })
+ }, [activePath, activeSteps, currentStepId, completedSteps])
+
+ function markStepComplete(stepId: string) {
+ setCompletedSteps((prev) => {
+ const next = new Set(prev)
+ next.add(stepId)
+ return next
+ })
+ const idx = activeSteps.findIndex((s) => s.id === stepId)
+ if (idx === activeSteps.length - 1) {
+ setShowEndModal(true)
+ setActiveStepId(null)
+ } else {
+ const nextStep = activeSteps[idx + 1]
+ if (nextStep) setActiveStepId(nextStep.id)
+ }
+ }
+
+ const geo = useGeolocation(!!activePath, geoEnabledKey)
+
+ // Toast on first entry into the current step's zone
+ useEffect(() => {
+ if (!activePath || !currentStepId) return
+ const step = activeSteps.find((s) => s.id === currentStepId)
+ if (!step) return
+ const radius = step.zoneRadiusMeters ?? 0
+ const coords = step.geometry?.coordinates
+ if (radius <= 0 || !coords || geo.lat == null || geo.lng == null) return
+ const dist = haversineMeters({ lat: geo.lat, lng: geo.lng }, { lat: coords[1], lng: coords[0] })
+ if (dist <= radius && !zoneNotifiedSteps.has(currentStepId)) {
+ const title = tPlain(step.title, language) || 'cette étape'
+ setToastMessage(`Vous êtes arrivé à « ${title} »`)
+ setZoneNotifiedSteps((prev) => {
+ const next = new Set(prev)
+ next.add(currentStepId)
+ return next
+ })
+ }
+ }, [activePath, currentStepId, activeSteps, geo.lat, geo.lng, zoneNotifiedSteps, language])
+
+ const norm = (s: string) =>
+ s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
+
+ const filteredPoints = useMemo(() => {
+ const q = norm(search.trim())
+ return points.filter((p) => {
+ if (activeCats.size > 0 && (p.categorieId == null || !activeCats.has(p.categorieId))) return false
+ if (!q) return true
+ const title = norm(tPlain(p.title, language))
+ return title.includes(q)
+ })
+ }, [points, activeCats, search, language])
+
+ const center: [number, number] = useMemo(() => {
+ const lat = parseFloat(map?.centerLatitude ?? '')
+ const lng = parseFloat(map?.centerLongitude ?? '')
+ if (!isNaN(lat) && !isNaN(lng)) return [lat, lng]
+ const first = points.find((p) => p.geometry?.coordinates)
+ if (first?.geometry?.coordinates) {
+ return [first.geometry.coordinates[1], first.geometry.coordinates[0]]
+ }
+ return [50.5, 4.5]
+ }, [map, points])
+
+ const zoom = map?.zoom ?? 14
+ const selected = points.find((p) => p.id === selectedId) ?? null
+
+ const [primaryColor, setPrimaryColor] = useState('#3a6ea5')
+ useEffect(() => {
+ const c = getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim()
+ if (c) setPrimaryColor(c)
+ }, [])
+
+ function toggleCat(id: number) {
+ setActiveCats((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ if (!hasData) {
+ return (
+
+
+
router.back()} className="w-10 h-10 rounded-full flex items-center justify-center" aria-label="Retour">
+
+
+
{tPlain(section.title, language)}
+
+
+ Aucun lieu à afficher sur la carte.
+
+
+ )
+ }
+
+ return (
+
+ {/* Map / List */}
+ {mode === 'map' ? (
+
{
+ setSelectedId(id); setActiveStepId(null)
+ const p = points.find((pp) => pp.id === id)
+ if (instanceId && p) {
+ trackEvent({
+ instanceId, configurationId: configId, sectionId: section.id,
+ eventType: 'MapPoiTap', language,
+ metadata: JSON.stringify({ geoPointId: p.id, title: tPlain(p.title, language) }),
+ })
+ }
+ }}
+ primaryColor={primaryColor}
+ pathSteps={visibleSteps}
+ selectedStepId={activeStepId}
+ onSelectStep={(id) => { setActiveStepId(id); setSelectedId(null) }}
+ currentStepId={currentStepId}
+ completedStepIds={completedSteps}
+ userPosition={geo.lat != null && geo.lng != null ? { lat: geo.lat, lng: geo.lng } : null}
+ />
+ ) : (
+ { setSelectedId(id); setMode('map') }}
+ />
+ )}
+
+ {/* Top dark AppBar */}
+
+
router.back()}
+ className="w-10 h-10 rounded-full flex items-center justify-center"
+ style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)' }}
+ aria-label="Retour"
+ >
+
+
+
+ {tPlain(section.title, language)}
+
+ {guidedPaths.length > 0 && (
+
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"
+ >
+
+ {activePath ? `Étape ${(activeSteps.findIndex((s) => s.id === activeStepId) + 1) || 1}/${activeSteps.length}` : `Parcours (${guidedPaths.length})`}
+
+ )}
+
setFilterOpen((v) => !v)}
+ className="w-10 h-10 rounded-full flex items-center justify-center relative"
+ style={{ background: filterOpen ? 'var(--color-primary)' : 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)' }}
+ aria-label="Filtres"
+ >
+
+ {activeCats.size > 0 && (
+
+ {activeCats.size}
+
+ )}
+
+
+
+ {/* Filter panel */}
+ {filterOpen && (
+
+
+
+
setSearch(e.target.value)}
+ placeholder="Rechercher un lieu…"
+ className="flex-1 bg-transparent text-sm outline-none"
+ style={{ color: '#1a1a1a' }}
+ />
+ {search && (
+
setSearch('')} aria-label="Effacer">
+
+
+ )}
+
+
+ {categories.length > 0 && (
+
+ {categories.map((cat) => {
+ const active = activeCats.has(cat.id)
+ const color = cat.color || primaryColor
+ return (
+ toggleCat(cat.id)}
+ className="px-3 py-1.5 rounded-full text-xs font-medium flex items-center gap-1.5 transition-all"
+ style={{
+ background: active ? color : 'white',
+ color: active ? 'white' : '#333',
+ border: `1.5px solid ${color}`,
+ }}
+ >
+
+ {tPlain(cat.name, language) || `Cat. ${cat.id}`}
+
+ )
+ })}
+
+ )}
+
+
+ {filteredPoints.length} lieu{filteredPoints.length > 1 ? 'x' : ''}
+ {(activeCats.size > 0 || search) && (
+ { setActiveCats(new Set()); setSearch('') }}
+ className="font-medium"
+ style={{ color: 'var(--color-primary)' }}
+ >
+ Réinitialiser
+
+ )}
+
+
+ )}
+
+ {/* Bottom-right toggle */}
+ {map?.isListViewEnabled && (
+ setMode((m) => (m === 'map' ? 'list' : 'map'))}
+ className="absolute bottom-6 right-4 rounded-full flex items-center gap-2 px-4 py-3 font-semibold text-sm z-[1000]"
+ style={{
+ background: 'var(--color-primary)',
+ color: 'var(--color-on-primary)',
+ boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
+ }}
+ >
+ {mode === 'map' ? (
+ <>
+
+ Liste
+ >
+ ) : (
+ <>
+
+ Carte
+ >
+ )}
+
+ )}
+
+ {/* Quitter parcours button (when active) */}
+ {activePath && !pathListOpen && !activeStep && !selected && (
+ { 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)',
+ }}
+ >
+
+ Quitter le parcours
+
+ )}
+
+ {/* Detail sheet (POI) */}
+ {selected && (
+ setSelectedId(null)} />
+ )}
+
+ {/* Step detail sheet (parcours) */}
+ {activeStep && activePath && (
+ 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 && (
+ setToastMessage(null)} />
+ )}
+
+ {/* End of path modal */}
+ {showEndModal && activePath && (
+ {
+ setCompletedSteps(new Set())
+ setShowEndModal(false)
+ if (activeSteps[0]) setActiveStepId(activeSteps[0].id)
+ }}
+ onLeave={() => {
+ setActivePathId(null)
+ setActiveStepId(null)
+ setShowEndModal(false)
+ }}
+ />
+ )}
+
+ {/* Path list bottom sheet */}
+ {pathListOpen && (
+ {
+ setActivePathId(id)
+ setActiveStepId(null)
+ setSelectedId(null)
+ setPathListOpen(false)
+ }}
+ onLeave={() => {
+ setActivePathId(null)
+ setActiveStepId(null)
+ setPathListOpen(false)
+ }}
+ onClose={() => setPathListOpen(false)}
+ />
+ )}
+
+ )
+}
+
+// ── 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 (
+ <>
+
+
+
+
+
+
Parcours
+ {activePathId && (
+
+ Quitter le parcours
+
+ )}
+
+
+ {paths.map((p) => {
+ const active = p.id === activePathId
+ const stepCount = p.steps?.length ?? 0
+ return (
+
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)'}`,
+ }}
+ >
+
+
+
+
+ {stepCount} étape{stepCount > 1 ? 's' : ''}
+ {p.isLinear && ' · linéaire'}
+
+
+
+
+ )
+ })}
+
+
+ >
+ )
+}
+
+// ── 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
+ 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 (
+ <>
+
+
+
+ {step.imageUrl && (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ )}
+
+
+
+
+
+ Étape {stepIndex + 1} / {totalSteps}
+
+
+
+
+
+ {t(step.description, language) && (
+
+ )}
+ {radius > 0 && (
+
+
+
+ {inZone
+ ? 'Vous êtes dans la zone'
+ : distance != null
+ ? `À ${formatDistance(distance)} de la zone (${radius} m)`
+ : `Zone à atteindre · ${radius} m`}
+
+ {geo.status === 'requesting' && (
+
Localisation en cours…
+ )}
+ {geo.status === 'denied' && (
+
+ Localisation refusée — réessayer
+
+ )}
+ {geo.status === 'unavailable' && (
+
GPS indisponible
+ )}
+
+ )}
+
+ {isLocked && !isCompleted && (
+
+ 🔒
+ {quizPassed || inZone ? 'Étape déverrouillée' : 'Étape verrouillée — validez la condition'}
+
+ )}
+
+ {hasTimer && isCurrent && !isCompleted && (
+
+
+ {timerExpired && t(step.timerExpiredMessage, language) && (
+
+ )}
+
+ )}
+
+ {hasQuiz && isCurrent && !isCompleted && (
+
+
+ Défi
+
+
+ {quizPassed && (
+
+ ✓ Défi réussi
+
+ )}
+
+ )}
+
+
+
+ {isCompleted && (
+
+ ✓ Étape terminée
+
+ )}
+ {isCurrent && !isCompleted && (
+
+ {stepIndex === totalSteps - 1 ? 'Terminer le parcours' : 'Marquer terminée'}
+
+ )}
+ {!isLinear && (
+
+
+
+ Précédente
+
+
+ Suivante
+
+
+
+ )}
+
+
+ >
+ )
+}
+
+// ── Point list view ─────────────────────────────────────────────────────────
+function PointList({
+ points, language, categories, onSelect,
+}: {
+ points: GeoPointDTO[]
+ language: string
+ categories: { id: number; color?: string }[]
+ onSelect: (id: number) => void
+}) {
+ const colorOf = (id?: number) => {
+ if (id == null) return 'var(--color-primary)'
+ return categories.find((c) => c.id === id)?.color || 'var(--color-primary)'
+ }
+ return (
+
+
+ {points.length === 0 && (
+
+ Aucun lieu à afficher.
+
+ )}
+ {points.map((p) => (
+
onSelect(p.id)}
+ className="flex items-center gap-3 p-3 rounded-2xl text-left"
+ style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
+ >
+ {p.imageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+ )
+}
+
+// ── Detail sheet ────────────────────────────────────────────────────────────
+function PointDetail({
+ point, language, onClose,
+}: {
+ point: GeoPointDTO
+ language: string
+ onClose: () => void
+}) {
+ const phone = tPlain(point.phone, language)
+ const email = tPlain(point.email, language)
+ const site = tPlain(point.site, language)
+ const prices = t(point.prices, language)
+ const schedules = t(point.schedules, language)
+
+ return (
+ <>
+
+
+
+
+ {/* Header image */}
+
+ {point.imageUrl && (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ )}
+
+
+
+
+
+
+
+ {/* Body */}
+
+ {t(point.description, language) && (
+
+ )}
+
+ {schedules && (
+
+ )}
+ {prices && (
+
+ )}
+
+ {(phone || email || site) && (
+
+ )}
+
+
+ >
+ )
+}
+
+function InfoBlock({ icon, label, html }: { icon: 'schedule' | 'euro'; label: string; html: string }) {
+ return (
+
+ )
+}
+
+function Icon({ name }: { name: 'phone' | 'email' | 'web' | 'schedule' | 'euro' }) {
+ const paths: Record = {
+ phone: 'M6.62 10.79a15.05 15.05 0 0 0 6.59 6.59l2.2-2.2a1 1 0 0 1 1.05-.24c1.12.37 2.33.57 3.57.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57a1 1 0 0 1-.25 1.05l-2.2 2.17z',
+ email: 'M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z',
+ web: 'M12 2A10 10 0 1 0 22 12 10 10 0 0 0 12 2zm6.93 6h-2.95a15.65 15.65 0 0 0-1.38-3.56A8 8 0 0 1 18.92 8zM12 4a14 14 0 0 1 1.81 4h-3.62A14 14 0 0 1 12 4zM4.26 14a8 8 0 0 1 0-4h3.38a16.5 16.5 0 0 0 0 4zm.82 2h2.95a15.65 15.65 0 0 0 1.38 3.56A8 8 0 0 1 5.08 16zm2.95-8H5.08a8 8 0 0 1 4.33-3.56A15.65 15.65 0 0 0 8.05 8zM12 20a14 14 0 0 1-1.81-4h3.62A14 14 0 0 1 12 20zm2.34-6h-4.68a14.6 14.6 0 0 1 0-4h4.68a14.6 14.6 0 0 1 0 4zm.25 5.56A15.65 15.65 0 0 0 15.97 16h2.95a8 8 0 0 1-4.33 3.56zM16.36 14a16.5 16.5 0 0 0 0-4h3.38a8 8 0 0 1 0 4z',
+ schedule: 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z',
+ euro: 'M15 18.5A6.48 6.48 0 0 1 9.24 15H15v-2H8.58a6.6 6.6 0 0 1 0-2H15V9H9.24A6.49 6.49 0 0 1 15 5.5c1.61 0 3.09.59 4.23 1.57L21 5.3A8.96 8.96 0 0 0 15 3a9 9 0 0 0-8.55 6H3v2h2.05c-.03.33-.05.66-.05 1s.02.67.05 1H3v2h3.45a9 9 0 0 0 8.55 6c2.3 0 4.41-.87 6-2.3l-1.78-1.77c-1.13.98-2.6 1.57-4.22 1.57z',
+ }
+ return (
+
+ )
+}
+
+// ── End of path modal ──────────────────────────────────────────────────────
+function EndOfPathModal({
+ pathTitle, stepCount, onRestart, onLeave,
+}: {
+ pathTitle: string
+ stepCount: number
+ onRestart: () => void
+ onLeave: () => void
+}) {
+ return (
+
+
+
+
+
+
Bravo !
+
+ Tu as terminé le parcours « {pathTitle} » ({stepCount} étape{stepCount > 1 ? 's' : ''}).
+
+
+
+
+ Retour à la carte
+
+
+ Recommencer
+
+
+
+
+ )
}
diff --git a/src/components/sections/QuizSection.tsx b/src/components/sections/QuizSection.tsx
index 110de34..3d326ac 100644
--- a/src/components/sections/QuizSection.tsx
+++ b/src/components/sections/QuizSection.tsx
@@ -1,9 +1,10 @@
'use client'
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n'
+import { trackEvent } from '@/lib/stats'
import type { SectionDTO, QuestionDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar'
@@ -16,8 +17,8 @@ interface Props {
type Phase = 'quiz' | 'result' | 'review'
-export default function QuizSection({ section, slug, configId, languages }: Props) {
- const { language, setAvailableLanguages } = useVisitor()
+export default function QuizSection({ section, configId, languages }: Props) {
+ const { language, setAvailableLanguages, instanceId } = useVisitor()
const router = useRouter()
useEffect(() => { setAvailableLanguages(languages) }, [languages])
@@ -28,6 +29,7 @@ export default function QuizSection({ section, slug, configId, languages }: Prop
const [phase, setPhase] = useState('quiz')
const [currentIndex, setCurrentIndex] = useState(0)
const [answers, setAnswers] = useState>({})
+ const trackedRef = useRef(false)
if (questions.length === 0) {
return (
@@ -71,10 +73,19 @@ export default function QuizSection({ section, slug, configId, languages }: Prop
setAnswers({})
setCurrentIndex(0)
setPhase('quiz')
+ trackedRef.current = false
}
// ── RESULT SCREEN ────────────────────────────────────────────────────────
if (phase === 'result') {
+ if (!trackedRef.current && instanceId) {
+ trackedRef.current = true
+ trackEvent({
+ instanceId, configurationId: configId, sectionId: section.id,
+ eventType: 'QuizComplete', language,
+ metadata: JSON.stringify({ score: correctCount, totalQuestions }),
+ })
+ }
const levelText = t(getLevel(), language)
return (
diff --git a/src/components/sections/agenda/EventMiniMap.tsx b/src/components/sections/agenda/EventMiniMap.tsx
new file mode 100644
index 0000000..ec1bc17
--- /dev/null
+++ b/src/components/sections/agenda/EventMiniMap.tsx
@@ -0,0 +1,43 @@
+'use client'
+
+import { MapContainer, TileLayer, Marker } from 'react-leaflet'
+import L from 'leaflet'
+import 'leaflet/dist/leaflet.css'
+import '../map/map.css'
+
+function pinIcon(color: string) {
+ return L.divIcon({
+ className: 'mim-pin-wrapper',
+ html: `
+
+ `,
+ iconSize: [32, 40],
+ iconAnchor: [16, 36],
+ })
+}
+
+export default function EventMiniMap({ lat, lng }: { lat: number; lng: number }) {
+ let primaryColor = '#3a6ea5'
+ if (typeof document !== 'undefined') {
+ const c = getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim()
+ if (c) primaryColor = c
+ }
+ return (
+
+
+
+
+ )
+}
diff --git a/src/components/sections/event/EventMap.tsx b/src/components/sections/event/EventMap.tsx
new file mode 100644
index 0000000..c2ac1c1
--- /dev/null
+++ b/src/components/sections/event/EventMap.tsx
@@ -0,0 +1,115 @@
+'use client'
+
+import { useEffect, useMemo } from 'react'
+import { MapContainer, TileLayer, Marker, Polyline, useMap } from 'react-leaflet'
+import L from 'leaflet'
+import 'leaflet/dist/leaflet.css'
+import type { MapAnnotationDTO } from '@/lib/api/types'
+import '../map/map.css'
+
+interface Props {
+ globalAnnotations: MapAnnotationDTO[]
+ blockAnnotations: MapAnnotationDTO[]
+ centerLat: number
+ centerLng: number
+ zoom: number
+ primaryColor: string
+}
+
+function pinIcon(color: string) {
+ return L.divIcon({
+ className: 'mim-pin-wrapper',
+ html: `
+
+ `,
+ iconSize: [32, 40],
+ iconAnchor: [16, 36],
+ })
+}
+
+function FitToContent({ positions }: { positions: [number, number][] }) {
+ const map = useMap()
+ useEffect(() => {
+ if (positions.length === 0) return
+ if (positions.length === 1) {
+ map.flyTo(positions[0], 15, { duration: 0.8 })
+ return
+ }
+ const bounds = L.latLngBounds(positions)
+ map.flyToBounds(bounds, { padding: [40, 40], duration: 0.8, maxZoom: 17 })
+ }, [positions, map])
+ return null
+}
+
+function extractPoints(annotations: MapAnnotationDTO[]): { positions: [number, number][]; polylines: [number, number][][] } {
+ const positions: [number, number][] = []
+ const polylines: [number, number][][] = []
+ for (const a of annotations) {
+ const coords = a.geometry?.coordinates
+ if (!coords) continue
+ if (a.geometryType === 1 && Array.isArray(coords)) {
+ // Polyline: [[lng, lat], …]
+ const line = (coords as unknown as [number, number][])
+ .filter((c) => Array.isArray(c) && c.length >= 2)
+ .map((c) => [c[1], c[0]] as [number, number])
+ if (line.length > 1) polylines.push(line)
+ } else if (a.geometryType === 0 || a.geometryType == null) {
+ // Point: [lng, lat]
+ const arr = coords as unknown as number[]
+ if (Array.isArray(arr) && arr.length >= 2) {
+ positions.push([arr[1], arr[0]])
+ }
+ }
+ }
+ return { positions, polylines }
+}
+
+export default function EventMap({
+ globalAnnotations, blockAnnotations, centerLat, centerLng, zoom, primaryColor,
+}: Props) {
+ const global = useMemo(() => extractPoints(globalAnnotations), [globalAnnotations])
+ const block = useMemo(() => extractPoints(blockAnnotations), [blockAnnotations])
+
+ const allPositions: [number, number][] = useMemo(
+ () => [...global.positions, ...block.positions, ...global.polylines.flat(), ...block.polylines.flat()],
+ [global, block]
+ )
+
+ return (
+
+
+
+ {/* Global annotations: primary color */}
+ {global.positions.map((pos, i) => (
+
+ ))}
+ {global.polylines.map((line, i) => {
+ const ann = globalAnnotations.find((a) => a.geometryType === 1)
+ const color = ann?.polyColor || primaryColor
+ return
+ })}
+
+ {/* Active block annotations: orange overlay */}
+ {block.positions.map((pos, i) => (
+
+ ))}
+ {block.polylines.map((line, i) => (
+
+ ))}
+
+ 0 ? allPositions : [[centerLat, centerLng]]} />
+
+ )
+}
diff --git a/src/components/sections/game/EscapeProgression.tsx b/src/components/sections/game/EscapeProgression.tsx
new file mode 100644
index 0000000..52e11ba
--- /dev/null
+++ b/src/components/sections/game/EscapeProgression.tsx
@@ -0,0 +1,369 @@
+'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
(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>(new Set())
+ const [quizPassedSteps, setQuizPassedSteps] = useState>(new Set())
+ const [timerExpiredSteps, setTimerExpiredSteps] = useState>(new Set())
+ const [zoneNotifiedSteps, setZoneNotifiedSteps] = useState>(new Set())
+ const [toastMessage, setToastMessage] = useState(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 (
+
+
+ Choisis un parcours pour démarrer.
+
+ {paths.map((p) => (
+
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)' }}
+ >
+
+
+
+
+ {p.steps?.length ?? 0} étape{(p.steps?.length ?? 0) > 1 ? 's' : ''}
+ {p.isLinear && ' · linéaire'}
+
+
+
+ ))}
+
+ )
+ }
+
+ return (
+
+
+
+ {completedSteps.size} / {activeSteps.length} étapes
+
+ {paths.length > 1 && (
+
setActivePathId(null)}
+ className="text-xs font-medium"
+ style={{ color: 'var(--color-primary)' }}
+ >
+ Changer de parcours
+
+ )}
+
+
+
+ {visibleSteps.map((step, i) => (
+ 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)}
+ />
+ ))}
+
+
+ {toastMessage &&
setToastMessage(null)} />}
+
+ { 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) } }}
+ />
+
+ )
+}
+
+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
+ 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 (
+
+ {step.imageUrl && (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+ Étape {index + 1} / {total}
+ {isCompleted && ' · ✓'}
+
+
+ )}
+
+ {!step.imageUrl && (
+
+
+ Étape {index + 1} / {total}
+ {isCompleted && ' · ✓'}
+
+
+ )}
+
+ {t(step.description, language) && (
+
+ )}
+
+ {radius > 0 && (
+
+
+
+ {inZone
+ ? 'Vous êtes dans la zone'
+ : distance != null
+ ? `À ${formatDistance(distance)} de la zone (${radius} m)`
+ : `Zone à atteindre · ${radius} m`}
+
+ {geo.status === 'denied' && (
+
+ Localisation refusée — réessayer
+
+ )}
+
+ )}
+
+ {isLocked && !isCompleted && (
+
+ 🔒
+ {quizPassed || inZone ? 'Étape déverrouillée' : 'Étape verrouillée'}
+
+ )}
+
+ {hasTimer && isCurrent && !isCompleted && (
+
+
+ {timerExpired && t(step.timerExpiredMessage, language) && (
+
+ )}
+
+ )}
+
+ {hasQuiz && isCurrent && !isCompleted && (
+
+
+ Défi
+
+
+ {quizPassed && (
+
+ ✓ Défi réussi
+
+ )}
+
+ )}
+
+ {isCurrent && !isCompleted && (
+
+ {index === total - 1 ? 'Terminer' : 'Marquer terminée'}
+
+ )}
+
+ {isCompleted && (
+
+ ✓ Étape terminée
+
+ )}
+
+
+ )
+}
diff --git a/src/components/sections/game/MessageDialog.tsx b/src/components/sections/game/MessageDialog.tsx
new file mode 100644
index 0000000..6806dd2
--- /dev/null
+++ b/src/components/sections/game/MessageDialog.tsx
@@ -0,0 +1,78 @@
+'use client'
+
+import { ReactNode } from 'react'
+
+interface Props {
+ open: boolean
+ title?: string
+ html?: string
+ onClose: () => void
+ primaryAction?: { label: string; onClick: () => void }
+ secondaryAction?: { label: string; onClick: () => void }
+ children?: ReactNode
+}
+
+export default function MessageDialog({
+ open, title, html, onClose, primaryAction, secondaryAction, children,
+}: Props) {
+ if (!open) return null
+ return (
+
+
+
+
+ {title}
+
+
+
+ {primaryAction && (
+
+ {primaryAction.label}
+
+ )}
+ {secondaryAction && (
+
+ {secondaryAction.label}
+
+ )}
+ {!primaryAction && !secondaryAction && (
+
+ OK
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/sections/game/PuzzleGame.tsx b/src/components/sections/game/PuzzleGame.tsx
new file mode 100644
index 0000000..f3ebb85
--- /dev/null
+++ b/src/components/sections/game/PuzzleGame.tsx
@@ -0,0 +1,202 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+
+interface Props {
+ imageUrl: string
+ rows: number
+ cols: number
+ showHint: boolean
+ onWin: () => void
+}
+
+interface Piece {
+ id: number
+ row: number
+ col: number
+ x: number // current pos (px from board origin, top-left of piece)
+ y: number
+ placed: boolean
+ z: number
+}
+
+export default function PuzzleGame({ imageUrl, rows, cols, showHint, onWin }: Props) {
+ const containerRef = useRef(null)
+ const [board, setBoard] = useState<{ w: number; h: number } | null>(null)
+ const [pieces, setPieces] = useState([])
+ const [imgRatio, setImgRatio] = useState(null)
+ const [topZ, setTopZ] = useState(rows * cols)
+ const dragRef = useRef<{ id: number; offsetX: number; offsetY: number } | null>(null)
+
+ // Load image to get aspect ratio
+ useEffect(() => {
+ if (!imageUrl) return
+ const img = new Image()
+ img.onload = () => setImgRatio(img.width / img.height)
+ img.src = imageUrl
+ }, [imageUrl])
+
+ // Compute board dimensions based on container + image aspect
+ useEffect(() => {
+ if (!imgRatio || !containerRef.current) return
+ const compute = () => {
+ const el = containerRef.current
+ if (!el) return
+ const cw = el.clientWidth
+ const ch = el.clientHeight
+ let w = cw, h = cw / imgRatio
+ if (h > ch) { h = ch; w = ch * imgRatio }
+ setBoard({ w, h })
+ }
+ compute()
+ const ro = new ResizeObserver(compute)
+ ro.observe(containerRef.current)
+ return () => ro.disconnect()
+ }, [imgRatio])
+
+ // Init pieces (scattered) once we know board dims
+ useEffect(() => {
+ if (!board) return
+ const pw = board.w / cols
+ const ph = board.h / rows
+ const list: Piece[] = []
+ let z = 1
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ list.push({
+ id: r * cols + c,
+ row: r,
+ col: c,
+ x: Math.random() * (board.w - pw),
+ y: Math.random() * (board.h - ph),
+ placed: false,
+ z: z++,
+ })
+ }
+ }
+ setPieces(list)
+ setTopZ(z)
+ }, [board, rows, cols])
+
+ function bringToTop(id: number) {
+ setPieces((prev) => prev.map((p) => p.id === id ? { ...p, z: topZ } : p))
+ setTopZ((z) => z + 1)
+ }
+
+ function onPointerDown(e: React.PointerEvent, piece: Piece) {
+ if (piece.placed || !containerRef.current || !board) return
+ const rect = containerRef.current.getBoundingClientRect()
+ const boardLeft = rect.left + (rect.width - board.w) / 2
+ const boardTop = rect.top + (rect.height - board.h) / 2
+ const px = e.clientX - boardLeft - piece.x
+ const py = e.clientY - boardTop - piece.y
+ dragRef.current = { id: piece.id, offsetX: px, offsetY: py }
+ bringToTop(piece.id)
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
+ }
+
+ function onPointerMove(e: React.PointerEvent) {
+ if (!dragRef.current || !containerRef.current || !board) return
+ const rect = containerRef.current.getBoundingClientRect()
+ const boardLeft = rect.left + (rect.width - board.w) / 2
+ const boardTop = rect.top + (rect.height - board.h) / 2
+ const x = e.clientX - boardLeft - dragRef.current.offsetX
+ const y = e.clientY - boardTop - dragRef.current.offsetY
+ const id = dragRef.current.id
+ setPieces((prev) => prev.map((p) => p.id === id ? { ...p, x, y } : p))
+ }
+
+ function onPointerUp() {
+ if (!dragRef.current || !board) return
+ const id = dragRef.current.id
+ dragRef.current = null
+ const pw = board.w / cols
+ const ph = board.h / rows
+ setPieces((prev) => {
+ const next = prev.map((p) => {
+ if (p.id !== id) return p
+ const targetX = p.col * pw
+ const targetY = p.row * ph
+ const dx = p.x - targetX
+ const dy = p.y - targetY
+ if (Math.abs(dx) < 18 && Math.abs(dy) < 18) {
+ return { ...p, x: targetX, y: targetY, placed: true, z: 0 }
+ }
+ return p
+ })
+ if (next.every((p) => p.placed)) {
+ setTimeout(onWin, 400)
+ }
+ return next
+ })
+ }
+
+ if (!imageUrl) {
+ return Image manquante.
+ }
+
+ return (
+
+ {board && (
+ <>
+ {/* Board frame (target outlines) */}
+
+ {/* Pieces */}
+ {pieces.map((p) => {
+ const pw = board.w / cols
+ const ph = board.h / rows
+ const isDragging = dragRef.current?.id === p.id
+ return (
+
onPointerDown(e, p)}
+ style={{
+ position: 'absolute',
+ left: `calc(50% - ${board.w / 2}px + ${p.x}px)`,
+ top: `calc(50% - ${board.h / 2}px + ${p.y}px)`,
+ width: pw,
+ height: ph,
+ backgroundImage: `url(${imageUrl})`,
+ backgroundSize: `${board.w}px ${board.h}px`,
+ backgroundPosition: `-${p.col * pw}px -${p.row * ph}px`,
+ borderRadius: 6,
+ border: '1px solid rgba(0,0,0,0.15)',
+ boxShadow: p.placed
+ ? 'none'
+ : isDragging
+ ? '0 8px 20px rgba(0,0,0,0.35)'
+ : '0 2px 6px rgba(0,0,0,0.2)',
+ transform: isDragging ? 'scale(1.06)' : 'scale(1)',
+ transition: isDragging ? 'none' : 'transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), left 0.25s, top 0.25s, box-shadow 0.2s',
+ cursor: p.placed ? 'default' : isDragging ? 'grabbing' : 'grab',
+ zIndex: p.z,
+ touchAction: 'none',
+ }}
+ />
+ )
+ })}
+ >
+ )}
+
+ )
+}
diff --git a/src/components/sections/game/SlidingPuzzle.tsx b/src/components/sections/game/SlidingPuzzle.tsx
new file mode 100644
index 0000000..1e3204d
--- /dev/null
+++ b/src/components/sections/game/SlidingPuzzle.tsx
@@ -0,0 +1,163 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+
+interface Props {
+ imageUrl: string
+ rows: number
+ cols: number
+ showHint: boolean
+ onWin: () => void
+}
+
+const GAP = 4
+
+export default function SlidingPuzzle({ imageUrl, rows, cols, showHint, onWin }: Props) {
+ const containerRef = useRef
(null)
+ const [imgRatio, setImgRatio] = useState(null)
+ const [board, setBoard] = useState<{ w: number; h: number } | null>(null)
+ // tiles[i] = original index at slot i; -1 = empty
+ const [tiles, setTiles] = useState([])
+ const wonRef = useRef(false)
+
+ useEffect(() => {
+ if (!imageUrl) return
+ const img = new Image()
+ img.onload = () => setImgRatio(img.width / img.height)
+ img.src = imageUrl
+ }, [imageUrl])
+
+ useEffect(() => {
+ if (!imgRatio || !containerRef.current) return
+ const compute = () => {
+ const el = containerRef.current
+ if (!el) return
+ const cw = el.clientWidth
+ const ch = el.clientHeight
+ let w = cw, h = cw / imgRatio
+ if (h > ch) { h = ch; w = ch * imgRatio }
+ setBoard({ w, h })
+ }
+ compute()
+ const ro = new ResizeObserver(compute)
+ ro.observe(containerRef.current)
+ return () => ro.disconnect()
+ }, [imgRatio])
+
+ // Initialize tiles + shuffle (100 valid moves)
+ useEffect(() => {
+ const total = rows * cols
+ const arr: number[] = []
+ for (let i = 0; i < total - 1; i++) arr.push(i)
+ arr.push(-1)
+ let emptyIdx = total - 1
+ for (let n = 0; n < 200; n++) {
+ const neighbours = neighboursOf(emptyIdx, rows, cols)
+ const pick = neighbours[Math.floor(Math.random() * neighbours.length)]
+ ;[arr[emptyIdx], arr[pick]] = [arr[pick], arr[emptyIdx]]
+ emptyIdx = pick
+ }
+ setTiles(arr)
+ wonRef.current = false
+ }, [rows, cols])
+
+ function neighboursOf(idx: number, r: number, c: number): number[] {
+ const row = Math.floor(idx / c)
+ const col = idx % c
+ const list: number[] = []
+ if (row > 0) list.push(idx - c)
+ if (row < r - 1) list.push(idx + c)
+ if (col > 0) list.push(idx - 1)
+ if (col < c - 1) list.push(idx + 1)
+ return list
+ }
+
+ function onTileTap(slotIdx: number) {
+ if (wonRef.current) return
+ const emptyIdx = tiles.indexOf(-1)
+ if (!neighboursOf(emptyIdx, rows, cols).includes(slotIdx)) return
+ const next = [...tiles]
+ ;[next[emptyIdx], next[slotIdx]] = [next[slotIdx], next[emptyIdx]]
+ setTiles(next)
+ const won = next.every((v, i) => i === next.length - 1 ? v === -1 : v === i)
+ if (won) {
+ wonRef.current = true
+ setTimeout(onWin, 400)
+ }
+ }
+
+ if (!imageUrl) {
+ return Image manquante.
+ }
+
+ return (
+
+ {board && (
+ <>
+ {showHint && (
+
+ )}
+
+ {(() => {
+ const tileW = (board.w - GAP * (cols - 1)) / cols
+ const tileH = (board.h - GAP * (rows - 1)) / rows
+ const imgW = tileW * cols
+ const imgH = tileH * rows
+ return tiles.map((origIdx, slotIdx) => {
+ if (origIdx === -1) return null
+ const slotRow = Math.floor(slotIdx / cols)
+ const slotCol = slotIdx % cols
+ const origRow = Math.floor(origIdx / cols)
+ const origCol = origIdx % cols
+ const x = slotCol * (tileW + GAP)
+ const y = slotRow * (tileH + GAP)
+ return (
+
onTileTap(slotIdx)}
+ style={{
+ position: 'absolute',
+ left: x,
+ top: y,
+ width: tileW,
+ height: tileH,
+ backgroundImage: `url(${imageUrl})`,
+ backgroundSize: `${imgW}px ${imgH}px`,
+ backgroundPosition: `-${origCol * tileW}px -${origRow * tileH}px`,
+ borderRadius: 8,
+ boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
+ cursor: 'pointer',
+ transition: 'left 0.25s ease, top 0.25s ease, transform 0.15s',
+ }}
+ onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.transform = 'scale(0.98)' }}
+ onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.transform = 'scale(1)' }}
+ />
+ )
+ })
+ })()}
+
+ >
+ )}
+
+ )
+}
diff --git a/src/components/sections/map/LeafletMap.tsx b/src/components/sections/map/LeafletMap.tsx
new file mode 100644
index 0000000..1bcf645
--- /dev/null
+++ b/src/components/sections/map/LeafletMap.tsx
@@ -0,0 +1,216 @@
+'use client'
+
+import { useEffect, useMemo } from 'react'
+import { MapContainer, TileLayer, Marker, Polyline, useMap } from 'react-leaflet'
+import L from 'leaflet'
+import 'leaflet/dist/leaflet.css'
+import type { GeoPointDTO, CategorieDTO, GuidedStepDTO } from '@/lib/api/types'
+
+interface PathStep {
+ id: string
+ order: number
+ lat: number
+ lng: number
+}
+
+type StepState = 'completed' | 'current' | 'future' | 'locked'
+
+interface Props {
+ points: GeoPointDTO[]
+ categories: CategorieDTO[]
+ center: [number, number]
+ zoom: number
+ selectedId: number | null
+ onSelect: (id: number) => void
+ primaryColor: string
+ pathSteps?: GuidedStepDTO[]
+ selectedStepId?: string | null
+ onSelectStep?: (id: string) => void
+ currentStepId?: string | null
+ completedStepIds?: Set
+ userPosition?: { lat: number; lng: number } | null
+}
+
+function pinIcon(color: string, selected: boolean) {
+ const size = selected ? 44 : 36
+ const headSize = selected ? 32 : 26
+ return L.divIcon({
+ className: 'mim-pin-wrapper',
+ html: `
+
+ `,
+ iconSize: [size, size + 8],
+ iconAnchor: [size / 2, size + 4],
+ })
+}
+
+function stepIcon(order: number, primaryColor: string, state: StepState, selected: boolean) {
+ const size = selected || state === 'current' ? 40 : 34
+ let bg = primaryColor
+ let symbol = String(order)
+ let extraClass = ''
+ if (state === 'completed') { bg = '#16a34a'; symbol = '✓' }
+ else if (state === 'current') { bg = primaryColor; extraClass = 'mim-step-current' }
+ else if (state === 'locked') { bg = '#9ca3af'; symbol = '🔒' }
+ else if (state === 'future') { bg = '#9ca3af' }
+ if (selected && state !== 'current') extraClass += ' mim-step-selected'
+ return L.divIcon({
+ className: 'mim-step-wrapper',
+ html: `
+
+ `,
+ iconSize: [size, size],
+ iconAnchor: [size / 2, size / 2],
+ })
+}
+
+function userIcon() {
+ return L.divIcon({
+ className: 'mim-user-wrapper',
+ html: ``,
+ iconSize: [24, 24],
+ iconAnchor: [12, 12],
+ })
+}
+
+function FitToTargets({ target, fitBounds }: { target: [number, number] | null; fitBounds: [number, number][] | null }) {
+ const map = useMap()
+ useEffect(() => {
+ if (fitBounds && fitBounds.length > 0) {
+ const bounds = L.latLngBounds(fitBounds)
+ map.flyToBounds(bounds, { padding: [80, 80], duration: 1.0, maxZoom: 17 })
+ return
+ }
+ if (target) {
+ const current = map.getZoom()
+ map.flyTo(target, current < 16 ? 17 : current, { duration: 1.2 })
+ }
+ }, [target, fitBounds, map])
+ return null
+}
+
+export default function LeafletMap({
+ points,
+ categories,
+ center,
+ zoom,
+ selectedId,
+ onSelect,
+ primaryColor,
+ pathSteps,
+ selectedStepId,
+ onSelectStep,
+ currentStepId,
+ completedStepIds,
+ userPosition,
+}: Props) {
+ const categoryColor = (id?: number): string => {
+ if (id == null) return primaryColor
+ return categories.find((c) => c.id === id)?.color || primaryColor
+ }
+
+ const selected = points.find((p) => p.id === selectedId)
+ const flyTarget: [number, number] | null = selected?.geometry?.coordinates
+ ? [selected.geometry.coordinates[1], selected.geometry.coordinates[0]]
+ : null
+
+ 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],
+ }))
+ .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
+
+ return (
+
+
+
+ {/* POI markers — dimmed when a path is active */}
+ {points.map((p) => {
+ const coords = p.geometry?.coordinates
+ if (!coords || coords.length < 2) return null
+ const lat = coords[1]
+ const lng = coords[0]
+ const isSelected = p.id === selectedId
+ return (
+ onSelect(p.id) }}
+ opacity={stepCoords.length > 0 ? 0.45 : 1}
+ />
+ )
+ })}
+
+ {/* Path polyline + numbered step markers */}
+ {polylinePositions.length > 1 && (
+
+ )}
+ {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'
+ }
+ return (
+ onSelectStep?.(s.id) }}
+ zIndexOffset={state === 'current' ? 2000 : 1000}
+ />
+ )
+ })}
+
+ {userPosition && (
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/sections/map/StepQuiz.tsx b/src/components/sections/map/StepQuiz.tsx
new file mode 100644
index 0000000..6cd417b
--- /dev/null
+++ b/src/components/sections/map/StepQuiz.tsx
@@ -0,0 +1,107 @@
+'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>({})
+ 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 (
+
+ {sorted.map((q, qi) => {
+ const responses = [...(q.responses ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
+ return (
+
+
+
+ {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 (
+ 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) }}
+ />
+ )
+ })}
+
+
+ )
+ })}
+ {!submitted ? (
+
+ Valider mes réponses
+
+ ) : (
+
+ Réessayer
+
+ )}
+
+ )
+}
diff --git a/src/components/sections/map/StepTimer.tsx b/src/components/sections/map/StepTimer.tsx
new file mode 100644
index 0000000..991fe75
--- /dev/null
+++ b/src/components/sections/map/StepTimer.tsx
@@ -0,0 +1,61 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+
+interface Props {
+ totalSeconds: number
+ active: boolean
+ onExpired?: () => void
+}
+
+function format(s: number): string {
+ const m = Math.floor(s / 60)
+ const r = s % 60
+ 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
+ const color = pct > 50 ? '#16a34a' : pct > 25 ? '#f59e0b' : '#dc2626'
+
+ return (
+
+
+ Temps restant
+ {format(remaining)}
+
+
+
+ )
+}
diff --git a/src/components/sections/map/Toast.tsx b/src/components/sections/map/Toast.tsx
new file mode 100644
index 0000000..300f54a
--- /dev/null
+++ b/src/components/sections/map/Toast.tsx
@@ -0,0 +1,37 @@
+'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 (
+
+ )
+}
diff --git a/src/components/sections/map/map.css b/src/components/sections/map/map.css
new file mode 100644
index 0000000..83e01f5
--- /dev/null
+++ b/src/components/sections/map/map.css
@@ -0,0 +1,115 @@
+.mim-pin-wrapper {
+ background: transparent !important;
+ border: none !important;
+}
+
+.mim-pin {
+ width: var(--pin-size);
+ height: calc(var(--pin-size) + 8px);
+ position: relative;
+ filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.35));
+ transition: transform 0.2s ease;
+}
+
+.mim-pin-selected {
+ animation: mim-pin-bounce 0.4s ease;
+}
+
+@keyframes mim-pin-bounce {
+ 0% { transform: translateY(0) scale(1); }
+ 50% { transform: translateY(-6px) scale(1.05); }
+ 100% { transform: translateY(0) scale(1); }
+}
+
+.mim-pin-head {
+ position: absolute;
+ width: var(--pin-head);
+ height: var(--pin-head);
+ left: calc((var(--pin-size) - var(--pin-head)) / 2);
+ top: 0;
+ background: var(--pin-color);
+ border-radius: 50% 50% 50% 0;
+ transform: rotate(-45deg);
+ border: 3px solid white;
+ box-sizing: border-box;
+}
+
+.mim-pin-dot {
+ position: absolute;
+ width: 8px;
+ height: 8px;
+ background: white;
+ border-radius: 50%;
+ left: calc(var(--pin-size) / 2 - 4px);
+ top: calc(var(--pin-head) / 2 - 4px);
+}
+
+/* Numbered step markers (parcours) */
+.mim-step-wrapper {
+ background: transparent !important;
+ border: none !important;
+}
+
+.mim-step {
+ width: var(--step-size);
+ height: var(--step-size);
+ border-radius: 50%;
+ background: var(--step-color);
+ color: white;
+ font-weight: 700;
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 3px solid white;
+ box-shadow: 0 3px 10px rgba(0, 0, 0, 0.4);
+ box-sizing: border-box;
+ transition: transform 0.2s ease;
+}
+
+.mim-step-selected,
+.mim-step-current {
+ animation: mim-step-pulse 1.6s ease-in-out infinite;
+}
+
+@keyframes mim-step-pulse {
+ 0%, 100% { transform: scale(1); box-shadow: 0 3px 10px rgba(0,0,0,0.4), 0 0 0 0 rgba(58, 110, 165, 0.7); }
+ 50% { transform: scale(1.1); box-shadow: 0 3px 10px rgba(0,0,0,0.4), 0 0 0 14px rgba(58, 110, 165, 0); }
+}
+
+.mim-user-wrapper {
+ background: transparent !important;
+ border: none !important;
+}
+.mim-user {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: rgba(37, 99, 235, 0.25);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ animation: mim-user-pulse 2s ease-in-out infinite;
+}
+.mim-user-dot {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: #2563eb;
+ border: 2.5px solid white;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.4);
+}
+@keyframes mim-user-pulse {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.5); }
+ 50% { box-shadow: 0 0 0 12px rgba(37, 99, 235, 0); }
+}
+
+.leaflet-container {
+ font-family: inherit;
+ background: #e8eef3;
+}
+
+.leaflet-control-attribution {
+ font-size: 10px !important;
+ background: rgba(255, 255, 255, 0.7) !important;
+}
diff --git a/src/context/VisitorContext.tsx b/src/context/VisitorContext.tsx
index 98129b8..2a6da4b 100644
--- a/src/context/VisitorContext.tsx
+++ b/src/context/VisitorContext.tsx
@@ -7,6 +7,7 @@ interface VisitorContextValue {
setLanguage: (lang: string) => void
availableLanguages: string[]
setAvailableLanguages: (langs: string[]) => void
+ instanceId: string | null
}
const VisitorContext = createContext({
@@ -14,9 +15,16 @@ const VisitorContext = createContext({
setLanguage: () => {},
availableLanguages: ['FR'],
setAvailableLanguages: () => {},
+ instanceId: null,
})
-export function VisitorProvider({ children }: { children: React.ReactNode }) {
+export function VisitorProvider({
+ children,
+ instanceId,
+}: {
+ children: React.ReactNode
+ instanceId?: string | null
+}) {
const [language, setLanguageState] = useState('FR')
const [availableLanguages, setAvailableLanguages] = useState(['FR'])
@@ -31,7 +39,11 @@ export function VisitorProvider({ children }: { children: React.ReactNode }) {
}
return (
-
+
{children}
)
diff --git a/src/hooks/useGeolocation.ts b/src/hooks/useGeolocation.ts
new file mode 100644
index 0000000..a7b273e
--- /dev/null
+++ b/src/hooks/useGeolocation.ts
@@ -0,0 +1,57 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+
+export type GeoStatus = 'idle' | 'requesting' | 'granted' | 'denied' | 'unavailable'
+
+export interface GeoState {
+ status: GeoStatus
+ lat: number | null
+ lng: number | null
+ accuracy: number | null
+ error: string | null
+}
+
+const initial: GeoState = {
+ status: 'idle',
+ lat: null,
+ lng: null,
+ accuracy: null,
+ error: null,
+}
+
+export function useGeolocation(enabled: boolean, refreshKey: number = 0): GeoState {
+ const [state, setState] = useState(initial)
+
+ useEffect(() => {
+ if (!enabled) return
+ void refreshKey
+ if (typeof navigator === 'undefined' || !navigator.geolocation) {
+ setState({ ...initial, status: 'unavailable', error: 'Geolocation non supportée' })
+ return
+ }
+ setState((s) => ({ ...s, status: 'requesting' }))
+ const watchId = navigator.geolocation.watchPosition(
+ (pos) => {
+ setState({
+ status: 'granted',
+ lat: pos.coords.latitude,
+ lng: pos.coords.longitude,
+ accuracy: pos.coords.accuracy,
+ error: null,
+ })
+ },
+ (err) => {
+ setState({
+ ...initial,
+ status: err.code === err.PERMISSION_DENIED ? 'denied' : 'unavailable',
+ error: err.message,
+ })
+ },
+ { enableHighAccuracy: true, maximumAge: 5000, timeout: 15000 }
+ )
+ return () => navigator.geolocation.clearWatch(watchId)
+ }, [enabled, refreshKey])
+
+ return state
+}
diff --git a/src/hooks/useSectionTracking.ts b/src/hooks/useSectionTracking.ts
new file mode 100644
index 0000000..06abbb6
--- /dev/null
+++ b/src/hooks/useSectionTracking.ts
@@ -0,0 +1,36 @@
+'use client'
+
+import { useEffect, useRef } from 'react'
+import { useVisitor } from '@/context/VisitorContext'
+import { trackEvent } from '@/lib/stats'
+
+// Tracks SectionView on mount and SectionLeave on unmount with the time spent in seconds.
+// No-op until the VisitorContext has an instanceId.
+export function useSectionTracking(sectionId: string, configurationId: string) {
+ const { instanceId, language } = useVisitor()
+ const startedAt = useRef(Date.now())
+
+ useEffect(() => {
+ if (!instanceId) return
+ startedAt.current = Date.now()
+ trackEvent({
+ instanceId,
+ configurationId,
+ sectionId,
+ eventType: 'SectionView',
+ language,
+ })
+ return () => {
+ const durationSeconds = Math.max(1, Math.round((Date.now() - startedAt.current) / 1000))
+ trackEvent({
+ instanceId,
+ configurationId,
+ sectionId,
+ eventType: 'SectionLeave',
+ durationSeconds,
+ language,
+ })
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [instanceId, sectionId, configurationId])
+}
diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts
index 94da116..4973089 100644
--- a/src/lib/api/client.ts
+++ b/src/lib/api/client.ts
@@ -1,4 +1,4 @@
-import type { ApplicationInstanceDTO, ConfigurationDTO, SectionDTO } from './types'
+import type { ApplicationInstanceDTO, ConfigurationDTO, SectionDTO, GuidedPathDTO } from './types'
const BASE_URL = process.env.NEXT_PUBLIC_API_URL
@@ -29,3 +29,13 @@ export async function getConfiguration(configId: string, apiKey: string): Promis
export async function getSections(configId: string, apiKey: string): Promise {
return apiFetch(`/api/Section/configuration/${configId}/detail`, apiKey)
}
+
+export async function getGuidedPaths(sectionMapId: string, apiKey: string): Promise {
+ return apiFetch(`/api/SectionMap/${sectionMapId}/GuidedPath`, apiKey)
+}
+
+// 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 {
+ return apiFetch(`/api/SectionMap/${sectionGameId}/GuidedPath`, apiKey)
+}
diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts
index 786ebfb..28f5ec6 100644
--- a/src/lib/api/types.ts
+++ b/src/lib/api/types.ts
@@ -26,6 +26,8 @@ export interface ApplicationInstanceDTO {
loaderImageUrl?: string
webSlug?: string
publicApiKey?: string
+ sectionEventId?: string
+ sectionEventDTO?: SectionDTO
}
export interface ConfigurationDTO {
@@ -86,6 +88,35 @@ export interface SectionDTO {
source?: string
weather?: WeatherDTO
web?: WebDTO
+ event?: EventDTO
+}
+
+export interface MapAnnotationDTO {
+ id?: string
+ label?: TranslationDTO[]
+ geometryType?: number // 0=Point, 1=Polyline, 2=Circle, 3=Polygon
+ geometry?: { type?: string; coordinates?: unknown }
+ polyColor?: string
+ icon?: string
+ iconResourceUrl?: string
+}
+
+export interface ProgrammeBlock {
+ id?: string
+ title?: TranslationDTO[]
+ description?: TranslationDTO[]
+ startTime?: string // ISO datetime
+ endTime?: string // ISO datetime
+ mapAnnotations?: MapAnnotationDTO[]
+}
+
+export interface EventDTO {
+ startDate?: string
+ endDate?: string
+ baseSectionMapId?: string
+ parcoursIds?: string[]
+ globalMapAnnotations?: MapAnnotationDTO[]
+ programme?: ProgrammeBlock[]
}
export interface ArticleDTO {
@@ -164,17 +195,70 @@ export interface MapDTO {
centerLatitude?: string
centerLongitude?: string
isParcours?: boolean
+ guidedPaths?: GuidedPathDTO[]
}
-export interface EventAgendaDTO {
+export interface GuidedPathDTO {
id: string
title?: TranslationDTO[]
description?: TranslationDTO[]
- imageSource?: string
- startDate?: string
- endDate?: string
- latitude?: number
- longitude?: number
+ isLinear?: boolean
+ requireSuccessToAdvance?: boolean
+ hideNextStepsUntilComplete?: boolean
+ order?: number
+ steps?: GuidedStepDTO[]
+}
+
+export interface GuidedStepDTO {
+ id: string
+ guidedPathId?: string
+ order?: number
+ title?: TranslationDTO[]
+ description?: TranslationDTO[]
+ geometry?: GeometryDTO
+ zoneRadiusMeters?: number
+ imageUrl?: string
+ triggerGeoPointId?: number
+ isHiddenInitially?: boolean
+ isStepTimer?: boolean
+ isStepLocked?: boolean
+ timerSeconds?: number
+ timerExpiredMessage?: TranslationDTO[]
+ quizQuestions?: QuestionDTO[]
+}
+
+export interface EventAgendaAddressDTO {
+ address?: string
+ streetNumber?: string
+ streetName?: string
+ city?: string
+ state?: string
+ postCode?: string
+ country?: string
+ geometry?: GeometryDTO // coordinates: [lng, lat]
+ polyColor?: string
+ zoom?: number
+}
+
+export interface EventAgendaDTO {
+ id: number
+ label?: TranslationDTO[]
+ description?: TranslationDTO[]
+ type?: string
+ dateAdded?: string
+ dateFrom?: string
+ dateTo?: string
+ website?: string
+ resourceId?: string
+ resource?: ResourceDTO
+ videoResourceId?: string
+ videoResource?: ResourceDTO
+ address?: EventAgendaAddressDTO
+ phone?: string
+ email?: string
+ isSynced?: boolean
+ idVideoYoutube?: string
+ videoLink?: string
}
export interface AgendaDTO {
@@ -189,6 +273,7 @@ export interface GameDTO {
rows?: number
cols?: number
gameType?: string
+ guidedPaths?: GuidedPathDTO[]
}
export interface TranslationAndResourceDTO {
diff --git a/src/lib/geo.ts b/src/lib/geo.ts
new file mode 100644
index 0000000..e5e4237
--- /dev/null
+++ b/src/lib/geo.ts
@@ -0,0 +1,20 @@
+export function haversineMeters(
+ a: { lat: number; lng: number },
+ b: { lat: number; lng: number }
+): number {
+ const R = 6371000
+ const toRad = (d: number) => (d * Math.PI) / 180
+ const dLat = toRad(b.lat - a.lat)
+ const dLng = toRad(b.lng - a.lng)
+ const lat1 = toRad(a.lat)
+ const lat2 = toRad(b.lat)
+ const x =
+ Math.sin(dLat / 2) ** 2 +
+ Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2)
+ return 2 * R * Math.asin(Math.sqrt(x))
+}
+
+export function formatDistance(m: number): string {
+ if (m < 1000) return `${Math.round(m)} m`
+ return `${(m / 1000).toFixed(m < 10000 ? 1 : 0)} km`
+}
diff --git a/src/lib/stats.ts b/src/lib/stats.ts
new file mode 100644
index 0000000..ef50df2
--- /dev/null
+++ b/src/lib/stats.ts
@@ -0,0 +1,64 @@
+// Lightweight visitor stats tracking. Calls POST /api/Stats/event (anonymous endpoint).
+// Failures are silently swallowed — analytics must never break the UX.
+
+export type VisitEventType =
+ | 'SectionView'
+ | 'SectionLeave'
+ | 'MapPoiTap'
+ | 'QrScan'
+ | 'QuizComplete'
+ | 'GameComplete'
+ | 'AgendaEventTap'
+ | 'MenuItemTap'
+ | 'ArticleRead'
+
+interface TrackPayload {
+ instanceId: string
+ configurationId?: string
+ sectionId?: string
+ sessionId: string
+ eventType: VisitEventType
+ appType?: 'Web'
+ language?: string
+ durationSeconds?: number
+ metadata?: string
+ timestamp?: string
+}
+
+const SESSION_KEY = 'mim_session_id'
+
+export function getOrCreateSessionId(): string {
+ if (typeof window === 'undefined') return 'ssr'
+ try {
+ let id = sessionStorage.getItem(SESSION_KEY)
+ if (!id) {
+ id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
+ sessionStorage.setItem(SESSION_KEY, id)
+ }
+ return id
+ } catch {
+ return 'no-session'
+ }
+}
+
+export async function trackEvent(payload: Omit): Promise {
+ if (typeof window === 'undefined') return
+ const baseUrl = process.env.NEXT_PUBLIC_API_URL
+ if (!baseUrl) return
+ const body: TrackPayload = {
+ ...payload,
+ sessionId: getOrCreateSessionId(),
+ appType: 'Web',
+ timestamp: new Date().toISOString(),
+ }
+ try {
+ await fetch(`${baseUrl}/api/Stats/event`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ keepalive: true,
+ })
+ } catch {
+ // swallow
+ }
+}
diff --git a/todo-features.md b/todo-features.md
new file mode 100644
index 0000000..5d23c23
--- /dev/null
+++ b/todo-features.md
@@ -0,0 +1,141 @@
+# TODO — Parité visitapp-web vs mymuseum-visitapp
+
+> Suivi des features Flutter (`mymuseum-visitapp`) à porter sur la version web (`visitapp-web`).
+> Légende : ❌ Non implémenté · ⚠️ Partiel · ✅ Fait · 🚫 Hors scope web (natif uniquement)
+
+Référence Flutter : `c:\Users\ThomasFransolet\Documents\Documents\Perso\GITEA\mymuseum-visitapp\lib\`
+
+---
+
+## Sections
+
+| Section | Flutter | Web | Statut |
+|---|---|---|---|
+| Article | `Sections/Article/article_page.dart` | `ArticleSection.tsx` | ✅ |
+| Agenda | `Sections/Agenda/agenda_page.dart` | `AgendaSection.tsx` | ⚠️ Partiel — popup à enrichir |
+| Menu | `Sections/Menu/menu_page.dart` | `MenuSection.tsx` | ✅ |
+| Slider | `Sections/Slider/` | `SliderSection.tsx` | ✅ |
+| Video | `Sections/Video/` | `VideoSection.tsx` | ✅ |
+| Map (+ parcours) | `Sections/Map/` | `MapSection.tsx` | ✅ |
+| PDF | `Sections/PDF/` | `PdfSection.tsx` | ✅ |
+| Weather | `Sections/Weather/` | `WeatherSection.tsx` | ✅ |
+| Quiz | `Sections/Quiz/` | `QuizSection.tsx` | ✅ |
+| Web | `Sections/Web/` | `WebSection.tsx` | ✅ |
+| Game (Puzzle / Sliding / Escape) | `Sections/Game/` | `GameSection.tsx` + `EscapeProgression.tsx` | ✅ |
+| GuidedPath (intégré Map / Game Escape) | `Sections/GuidedPath/` | `MapSection.tsx` + `EscapeProgression.tsx` | ✅ |
+| **Event** | `Sections/Event/event_page.dart`, `event_map_full_page.dart` | `EventSection.tsx` + `event/EventMap.tsx` | ✅ |
+
+---
+
+## ✅ Section Event — fait
+
+Page dédiée pour les sections de type Event ([EventSection.tsx](visitapp-web/src/components/sections/EventSection.tsx) + [event/EventMap.tsx](visitapp-web/src/components/sections/event/EventMap.tsx)).
+
+**Implémenté** :
+- Layout vertical scrollable (pas de TabBar — aligné avec le Flutter)
+- Hero : image + gradient + titre + badge dates `startDate…endDate` + back button
+- Section **Programme** : timeline verticale des `ProgrammeBlock` (triés par `startTime`), bloc actif (heure courante dans `startTime…endTime`) mis en évidence (badge "EN COURS", bordure primaire, dot avec halo), tap → bottom sheet détail + liste des annotations spatiales du bloc
+- Section **Carte** : mini-carte Leaflet (220px) avec annotations globales en couleur primaire + annotations du bloc actif en orange superposées. Bouton "Plein écran" overlay
+- Section **Parcours** : chips horizontaux (parcours guidés liés à l'event), tap → navigation vers la section Map associée (`baseSectionMapId`) qui héberge la progression existante
+- Section **À propos** : description HTML
+- Bottom sheet block detail : titre + horaire + description + liste annotations (badge orange + label)
+- Pré-fetch server-side des `guidedPaths` via le même endpoint polymorphe `/api/SectionMap/{eventId}/GuidedPath`
+
+**Types ajoutés** : `EventDTO`, `ProgrammeBlock`, `MapAnnotationDTO` dans [types.ts](visitapp-web/src/lib/api/types.ts)
+
+**Reste à faire** (nice-to-have) :
+- [ ] Mise en avant home : si `applicationInstanceDTO.sectionEventId != null`, afficher un bloc "à la une" en haut de la grille de configurations. Nécessite d'étendre `ApplicationInstanceDTO` (champ `sectionEventDTO?`) et de lui dédier un widget sur `[slug]/page.tsx`.
+
+---
+
+## ✅ Agenda — popup événement enrichi
+
+**Bug fix bonus** : le type web `EventAgendaDTO` était désynchronisé du backend (`title`/`startDate` au lieu de `label`/`dateFrom`). Type aligné dans [types.ts](visitapp-web/src/lib/api/types.ts).
+
+**Implémenté dans [AgendaSection.tsx](visitapp-web/src/components/sections/AgendaSection.tsx) + [agenda/EventMiniMap.tsx](visitapp-web/src/components/sections/agenda/EventMiniMap.tsx)** :
+- Mini-carte Leaflet centrée sur `address.geometry.coordinates`
+- Vidéo embarquée : iframe YouTube (`idVideoYoutube` ou `videoLink` YouTube), lecteur HTML5 natif (`videoLink` direct ou `videoResource.url`)
+- Liens cliquables : email (`mailto:`), téléphone (`tel:`), site web (target="_blank")
+- Adresse postale formatée → lien Google Maps
+
+---
+
+## ✅ Statistiques — tracking visiteur
+
+**Endpoint backend** : `POST /api/Stats/event` (anonyme, accepte `appType: "Web"`).
+
+**Implémenté** :
+- [src/lib/stats.ts](visitapp-web/src/lib/stats.ts) : `trackEvent()` + sessionId persistant (`sessionStorage`)
+- [src/hooks/useSectionTracking.ts](visitapp-web/src/hooks/useSectionTracking.ts) : hook auto qui émet `SectionView` au mount + `SectionLeave` au unmount avec durée
+- [src/components/TrackedSection.tsx](visitapp-web/src/components/TrackedSection.tsx) : wrapper appliqué au dispatcher → toutes les sections trackées sans toucher leur code
+- `instanceId` propagé via `VisitorContext` (depuis le layout `[slug]`)
+- Events spécifiques branchés : `MapPoiTap`, `QuizComplete`, `GameComplete`, `AgendaEventTap`, `QrScan`
+
+**Reste à brancher (rapide)** :
+- [ ] `ArticleRead` quand l'article est scrollé jusqu'à un seuil (~80%)
+- [ ] `MenuItemTap` au tap sur un item de menu
+
+---
+
+---
+
+## ✅ Event "à la une" sur la home
+
+**Implémenté** :
+- `ApplicationInstanceDTO.sectionEventDTO` ajouté aux types
+- [src/components/FeaturedEvent.tsx](visitapp-web/src/components/FeaturedEvent.tsx) : carte hero (220px) avec image, badge "À LA UNE", badge dates, titre HTML
+- Branché dans [src/app/[slug]/page.tsx](visitapp-web/src/app/[slug]/page.tsx) : si `instance.sectionEventDTO` est rempli côté backend, le bloc s'affiche au-dessus de la grille de configurations
+
+---
+
+## ✅ Scanner QR code
+
+**Implémenté** :
+- Lib : `qr-scanner` (MIT, ~13kb gzip, cross-browser via `getUserMedia`)
+- [src/components/QRScannerButton.tsx](visitapp-web/src/components/QRScannerButton.tsx) :
+ - Bouton flottant en bas-droite sur la home `/[slug]`
+ - Modal fullscreen avec aperçu caméra + animation de scan
+ - Parser regex qui extrait un path `/{slug}/{configId}/sections/{sectionId}` depuis le payload (URL absolue ou relative)
+ - Sur match → `router.push()` vers le path
+ - Émet l'event `QrScan` (avec payload + path parsé)
+ - Gère les erreurs de permission caméra avec message visuel
+- Nécessite HTTPS en production (sauf localhost)
+
+**Reste à faire (nice-to-have)** :
+- [ ] Bouton scanner aussi accessible depuis `ConfigurationGrid` ou `SectionList` (actuellement seulement sur la home)
+
+---
+
+## 🚫 Hors scope (décision produit ou natif uniquement)
+
+Ces features ne sont **pas portées sur le web** :
+
+- **Assistant IA / Chat** — décision produit : feature premium réservée à l'app mobile native, pas portée côté web
+- **Mode offline / cache local** — décision produit : pas dans le scope web pour le moment, l'app web suppose une connexion
+- **Push notifications Firebase FCM** — réservées à l'app native (mobile installée)
+- **Beacons BLE** — Web Bluetooth pas supporté sur iOS Safari, et l'usage réel suppose un device mobile dédié
+- **Lecteur audio avancé** (just_audio Flutter) — l'audio HTML5 natif suffit, pas besoin d'aller plus loin
+- **Téléchargement caméra natif pour QR** — remplaçable par `getUserMedia` si on en a besoin (voir section QR ci-dessus)
+
+---
+
+## Suivi
+
+| Feature | Priorité | Statut |
+|---|---|---|
+| **Event Section** | Haute (gap fonctionnel) | ✅ Fait |
+| **Agenda popup enrichi** | Moyenne | ✅ Fait |
+| **Stats tracking** | Moyenne (besoin métier) | ✅ Fait (à compléter : ArticleRead, MenuItemTap) |
+| **Event "à la une" sur la home** | Basse (UX) | ✅ Fait |
+| **QR Scanner** | Basse | ✅ Fait |
+
+---
+
+## Référence — fichiers Flutter clés à consulter
+
+- Home + scanner : `lib/Screens/Home/home.dart`, `lib/Components/ScannerDialog.dart`
+- Event : `lib/Screens/Sections/Event/event_page.dart`, `event_map_full_page.dart`
+- Agenda popup : `lib/Screens/Sections/Agenda/event_popup.dart`
+- Services : `lib/Services/statisticsService.dart`, `assistantService.dart`
+- Cache local : `lib/Helpers/DatabaseHelper.dart`, `lib/Services/downloadConfiguration.dart`
+- Composants chat : `lib/Components/AssistantChatSheet.dart`