Wip (sections event, game, map +qr code) to be tested !

This commit is contained in:
Thomas Fransolet 2026-05-04 14:23:50 +02:00
parent 474cd61244
commit 931f35c818
34 changed files with 4598 additions and 85 deletions

View File

@ -87,6 +87,13 @@ Deux patterns, calqués sur `mymuseum-visitapp` :
**Full-screen** (`position: fixed; inset: 0` + dark AppBar) — contenu immersif : **Full-screen** (`position: fixed; inset: 0` + dark AppBar) — contenu immersif :
- Video, Web, PDF, Map - 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 titres : toujours `tPlain()` dans les AppBar et attributs `alt`.
Pour les contenus longs : toujours `dangerouslySetInnerHTML` (Quill → HTML). Pour les contenus longs : toujours `dangerouslySetInnerHTML` (Quill → HTML).

View File

@ -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<string>` 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`

67
package-lock.json generated
View File

@ -8,10 +8,14 @@
"name": "visitapp-web", "name": "visitapp-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@types/leaflet": "^1.9.21",
"color2k": "^2.0.3", "color2k": "^2.0.3",
"leaflet": "^1.9.4",
"next": "16.2.4", "next": "16.2.4",
"qr-scanner": "^1.4.2",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"react-leaflet": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "^0.95.0", "@hey-api/openapi-ts": "^0.95.0",
@ -861,6 +865,17 @@
"node": ">= 10" "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": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -1141,6 +1156,12 @@
"tailwindcss": "4.2.4" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -1148,6 +1169,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "20.19.39", "version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
@ -1158,6 +1188,12 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@ -1612,6 +1648,12 @@
"js-yaml": "bin/js-yaml.js" "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": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@ -2126,6 +2168,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/rc9": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
@ -2158,6 +2209,20 @@
"react": "^19.2.4" "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": { "node_modules/readdirp": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",

View File

@ -8,10 +8,14 @@
"start": "next start" "start": "next start"
}, },
"dependencies": { "dependencies": {
"@types/leaflet": "^1.9.21",
"color2k": "^2.0.3", "color2k": "^2.0.3",
"leaflet": "^1.9.4",
"next": "16.2.4", "next": "16.2.4",
"qr-scanner": "^1.4.2",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"react-leaflet": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "^0.95.0", "@hey-api/openapi-ts": "^0.95.0",

View File

@ -1,5 +1,5 @@
import { notFound } from 'next/navigation' 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' import type { SectionDTO } from '@/lib/api/types'
// Section components // Section components
@ -13,6 +13,9 @@ import PdfSection from '@/components/sections/PdfSection'
import WeatherSection from '@/components/sections/WeatherSection' import WeatherSection from '@/components/sections/WeatherSection'
import WebSection from '@/components/sections/WebSection' import WebSection from '@/components/sections/WebSection'
import QuizSection from '@/components/sections/QuizSection' import QuizSection from '@/components/sections/QuizSection'
import GameSection from '@/components/sections/GameSection'
import EventSection from '@/components/sections/EventSection'
import TrackedSection from '@/components/TrackedSection'
export default async function SectionPage({ export default async function SectionPage({
params, params,
@ -35,24 +38,63 @@ export default async function SectionPage({
const section: SectionDTO | undefined = sections.find((s) => s.id === sectionId) const section: SectionDTO | undefined = sections.find((s) => s.id === sectionId)
if (!section || section.isActive === false) notFound() if (!section || section.isActive === false) notFound()
// For Map sections, eagerly load the guided paths server-side and merge into the DTO
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'] } const props = { section, slug, configId, languages: config.languages ?? ['FR'] }
let content: React.ReactNode
switch (section.type) { switch (section.type) {
case 'Article': return <ArticleSection {...props} /> case 'Article': content = <ArticleSection {...props} />; break
case 'Agenda': return <AgendaSection {...props} /> case 'Agenda': content = <AgendaSection {...props} />; break
case 'Menu': return <MenuSection {...props} /> case 'Menu': content = <MenuSection {...props} />; break
case 'Slider': return <SliderSection {...props} /> case 'Slider': content = <SliderSection {...props} />; break
case 'Video': return <VideoSection {...props} /> case 'Video': content = <VideoSection {...props} />; break
case 'Map': return <MapSection {...props} /> case 'Map': content = <MapSection {...props} />; break
case 'Pdf': return <PdfSection {...props} /> case 'Pdf': content = <PdfSection {...props} />; break
case 'Weather': return <WeatherSection {...props} /> case 'Weather': content = <WeatherSection {...props} />; break
case 'Quiz': return <QuizSection {...props} /> case 'Quiz': content = <QuizSection {...props} />; break
case 'Web': return <WebSection {...props} /> case 'Game': content = <GameSection {...props} />; break
case 'Event': content = <EventSection {...props} />; break
case 'Web': content = <WebSection {...props} />; break
default: default:
return ( content = (
<div className="p-8 text-center" style={{ color: 'var(--color-text-muted)' }}> <div className="p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>
Section de type « {section.type} » à venir. Section de type « {section.type} » à venir.
</div> </div>
) )
} }
return (
<TrackedSection sectionId={section.id} configurationId={configId}>
{content}
</TrackedSection>
)
} }

View File

@ -22,7 +22,7 @@ export default async function SlugLayout({
const theme = resolveColors(instance) const theme = resolveColors(instance)
return ( return (
<VisitorProvider> <VisitorProvider instanceId={instance.id}>
<div style={theme as React.CSSProperties}> <div style={theme as React.CSSProperties}>
{children} {children}
</div> </div>

View File

@ -1,6 +1,8 @@
import { getInstanceBySlug, getConfigurations } from '@/lib/api/client' import { getInstanceBySlug, getConfigurations } from '@/lib/api/client'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import ConfigurationGrid from '@/components/ConfigurationGrid' import ConfigurationGrid from '@/components/ConfigurationGrid'
import FeaturedEvent from '@/components/FeaturedEvent'
import QRScannerButton from '@/components/QRScannerButton'
export default async function HomePage({ export default async function HomePage({
params, params,
@ -17,5 +19,17 @@ export default async function HomePage({
notFound() notFound()
} }
return <ConfigurationGrid configurations={configurations} slug={slug} /> const featuredEvent = instance.sectionEventDTO
// The featured event is rendered inside its parent configuration's context
const featuredConfigId = featuredEvent?.configurationId
return (
<>
{featuredEvent && featuredConfigId && (
<FeaturedEvent event={featuredEvent} slug={slug} configurationId={featuredConfigId} />
)}
<ConfigurationGrid configurations={configurations} slug={slug} />
<QRScannerButton slug={slug} />
</>
)
} }

View File

@ -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 (
<Link
href={`/${slug}/${configurationId}/sections/${event.id}`}
className="block relative rounded-3xl overflow-hidden m-3"
style={{
height: 220,
boxShadow: '0 6px 18px rgba(0,0,0,0.25)',
}}
>
{event.imageSource ? (
<Image
src={event.imageSource}
alt={tPlain(event.title, language)}
fill
className="object-cover"
sizes="100vw"
priority
/>
) : (
<div className="absolute inset-0" style={{ background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))' }} />
)}
<div className="absolute inset-0" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.1) 60%)' }} />
<div
className="absolute top-3 left-3 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider"
style={{ background: 'rgba(255,255,255,0.95)', color: 'var(--color-primary)' }}
>
À la une
</div>
<div className="absolute bottom-3 left-4 right-4 flex flex-col gap-2">
{dateLabel && (
<span
className="self-start px-2.5 py-1 rounded-full text-xs font-bold"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
{dateLabel}
</span>
)}
<h2
className="text-white text-lg font-bold leading-tight [&_p]:m-0 line-clamp-2"
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
dangerouslySetInnerHTML={{ __html: t(event.title, language) || 'Événement' }}
/>
</div>
</Link>
)
}

View File

@ -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<string | null>(null)
const videoRef = useRef<HTMLVideoElement | null>(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 daccè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 (
<>
<button
onClick={() => setOpen(true)}
className="fixed bottom-5 right-5 w-14 h-14 rounded-full flex items-center justify-center z-40"
style={{
background: 'var(--color-primary)',
color: 'var(--color-on-primary)',
boxShadow: '0 6px 16px rgba(0,0,0,0.3)',
}}
aria-label="Scanner un QR code"
>
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.5 6.5v3h-3v-3h3M11 5H5v6h6V5zm-1.5 9.5v3h-3v-3h3M11 13H5v6h6v-6zm6.5-6.5v3h-3v-3h3M19 5h-6v6h6V5zm-6 8h1.5v1.5H13V13zm1.5 1.5H16V16h-1.5v-1.5zM16 13h1.5v1.5H16V13zm-3 3h1.5v1.5H13V16zm1.5 1.5H16V19h-1.5v-1.5zM16 16h1.5v1.5H16V16zm1.5-1.5H19V16h-1.5v-1.5zm0 3H19V19h-1.5v-1.5zM22 7h-2V4h-3V2h5v5zm0 15v-5h-2v3h-3v2h5zM2 22h5v-2H4v-3H2v5zM2 2v5h2V4h3V2H2z" />
</svg>
</button>
{open && (
<div className="fixed inset-0 z-[3000]" style={{ background: '#000' }}>
<video ref={videoRef} className="absolute inset-0 w-full h-full object-cover" playsInline muted />
<div
className="absolute top-0 left-0 right-0 flex items-center px-3 py-3"
style={{
background: 'linear-gradient(to bottom, rgba(0,0,0,0.6), rgba(0,0,0,0))',
paddingTop: 'max(env(safe-area-inset-top), 12px)',
}}
>
<button
onClick={() => setOpen(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"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button>
<span className="ml-3 text-white text-base font-semibold flex-1" style={{ textShadow: '0 1px 4px rgba(0,0,0,0.6)' }}>
Scanner un QR code
</span>
</div>
{error && (
<div className="absolute bottom-8 left-4 right-4 p-3 rounded-xl text-sm text-center" style={{ background: '#dc2626', color: 'white' }}>
{error}
</div>
)}
</div>
)}
{/* slug is unused in the parsing logic but kept in the API for future use cases */}
<span style={{ display: 'none' }} aria-hidden>{slug}</span>
</>
)
}

View File

@ -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}</>
}

View File

@ -2,11 +2,22 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Image from 'next/image' import Image from 'next/image'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO, EventAgendaDTO } from '@/lib/api/types' import type { SectionDTO, EventAgendaDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar' import AppBar from '@/components/ui/AppBar'
import { trackEvent } from '@/lib/stats'
const EventMiniMap = dynamic(() => import('./agenda/EventMiniMap'), {
ssr: false,
loading: () => (
<div className="w-full h-full flex items-center justify-center text-xs" style={{ background: '#e8eef3', color: 'var(--color-text-muted)' }}>
Chargement
</div>
),
})
interface Props { interface Props {
section: SectionDTO section: SectionDTO
@ -17,8 +28,37 @@ interface Props {
const MONTHS = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc'] 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) { function eventImage(e: EventAgendaDTO): string | undefined {
const { language, setAvailableLanguages } = useVisitor() 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 router = useRouter()
const events = section.agenda?.events ?? [] const events = section.agenda?.events ?? []
@ -30,8 +70,8 @@ export default function AgendaSection({ section, slug, configId, languages }: Pr
const [selected, setSelected] = useState<EventAgendaDTO | null>(null) const [selected, setSelected] = useState<EventAgendaDTO | null>(null)
const filtered = events.filter((e) => { const filtered = events.filter((e) => {
if (!e.startDate) return false if (!e.dateFrom) return false
const d = new Date(e.startDate) const d = new Date(e.dateFrom)
return d.getMonth() === selectedMonth && d.getFullYear() === selectedYear 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 Aucun événement ce mois-ci
</p> </p>
)} )}
{filtered.map((event) => ( {filtered.map((event) => {
<button const img = eventImage(event)
key={event.id} return (
onClick={() => setSelected(event)} <button
className="w-full rounded-2xl overflow-hidden text-left flex gap-0" key={event.id}
style={{ background: 'var(--color-surface)' }} onClick={() => {
> setSelected(event)
{event.imageSource && ( if (instanceId) {
<div className="relative w-20 shrink-0"> trackEvent({
<Image src={event.imageSource} alt="" fill className="object-cover" sizes="80px" /> instanceId, configurationId: configId, sectionId: section.id,
</div> eventType: 'AgendaEventTap', language,
)} metadata: JSON.stringify({ eventAgendaId: event.id, title: tPlain(event.label, language) }),
<div className="p-3 flex-1"> })
<p className="font-semibold text-sm" style={{ color: 'var(--color-text)' }}> }
{t(event.title, language)} }}
</p> className="w-full rounded-2xl overflow-hidden text-left flex gap-0"
{event.startDate && ( style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
<p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}> >
{new Date(event.startDate).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })} {img && (
</p> <div className="relative w-20 shrink-0">
<Image src={img} alt="" fill className="object-cover" sizes="80px" />
</div>
)} )}
</div> <div className="p-3 flex-1">
</button> <p
))} className="font-semibold text-sm [&_p]:m-0"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(event.label, language) }}
/>
{event.dateFrom && (
<p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
{new Date(event.dateFrom).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}
</p>
)}
</div>
</button>
)
})}
</main> </main>
{/* Event detail popup */} {/* Event detail popup */}
{selected && ( {selected && (
<div className="fixed inset-0 z-50 flex flex-col" style={{ background: 'var(--color-background)' }}> <EventDetail
<AppBar title={tPlain(selected.title, language)} onBack={() => setSelected(null)} /> event={selected}
<div className="flex-1 overflow-y-auto"> language={language}
{selected.imageSource && ( onClose={() => setSelected(null)}
<div className="relative w-full h-48"> />
<Image src={selected.imageSource} alt="" fill className="object-cover" sizes="100vw" />
</div>
)}
<div className="p-4">
{selected.startDate && (
<p className="text-sm mb-3" style={{ color: 'var(--color-primary)' }}>
{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' })}</>
)}
</p>
)}
{t(selected.description, language) && (
<div
className="prose prose-sm"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(selected.description, language) }}
/>
)}
</div>
</div>
</div>
)} )}
</div> </div>
) )
} }
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 (
<div className="fixed inset-0 z-50 flex flex-col" style={{ background: 'var(--color-background)' }}>
<AppBar title={tPlain(event.label, language)} onBack={onClose} />
<div className="flex-1 overflow-y-auto">
{img && (
<div className="relative w-full" style={{ height: 220 }}>
<Image src={img} alt="" fill className="object-cover" sizes="100vw" />
</div>
)}
<div className="p-4 flex flex-col gap-4">
{event.dateFrom && (
<div
className="self-start px-3 py-1.5 rounded-full text-xs font-bold"
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
>
{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' })}</>
)}
</div>
)}
{t(event.description, language) && (
<div
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(event.description, language) }}
/>
)}
{/* Video */}
{(yt || videoLink || videoResource) && (
<div className="rounded-xl overflow-hidden" style={{ background: '#000', aspectRatio: '16/9' }}>
{yt ? (
<iframe
src={youtubeEmbedUrl(yt)}
title="Vidéo"
className="w-full h-full"
allowFullScreen
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
) : videoLink ? (
videoLink.includes('youtube') || videoLink.includes('youtu.be') ? (
<iframe
src={youtubeEmbedUrl(videoLink)}
title="Vidéo"
className="w-full h-full"
allowFullScreen
/>
) : (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video src={videoLink} controls className="w-full h-full" />
)
) : videoResource ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video src={videoResource} controls className="w-full h-full" />
) : null}
</div>
)}
{/* Mini map */}
{coords && (
<div className="rounded-xl overflow-hidden" style={{ height: 180, border: '1px solid var(--color-border)' }}>
<EventMiniMap lat={coords.lat} lng={coords.lng} />
</div>
)}
{/* Address */}
{address && (
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-3 p-3 rounded-xl"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<Icon name="place" />
<span className="text-sm flex-1" style={{ color: 'var(--color-text)' }}>{address}</span>
</a>
)}
{/* Contact */}
{(event.phone || event.email || event.website) && (
<div className="flex flex-col gap-2">
{event.phone && (
<a
href={`tel:${event.phone}`}
className="flex items-center gap-3 p-3 rounded-xl"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }}
>
<Icon name="phone" />
<span className="text-sm">{event.phone}</span>
</a>
)}
{event.email && (
<a
href={`mailto:${event.email}`}
className="flex items-center gap-3 p-3 rounded-xl"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }}
>
<Icon name="email" />
<span className="text-sm truncate">{event.email}</span>
</a>
)}
{event.website && (
<a
href={event.website.startsWith('http') ? event.website : `https://${event.website}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-3 p-3 rounded-xl"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }}
>
<Icon name="web" />
<span className="text-sm truncate">{event.website}</span>
</a>
)}
</div>
)}
</div>
</div>
</div>
)
}
function Icon({ name }: { name: 'phone' | 'email' | 'web' | 'place' }) {
const paths: Record<string, string> = {
phone: 'M6.62 10.79a15.05 15.05 0 0 0 6.59 6.59l2.2-2.2a1 1 0 0 1 1.05-.24c1.12.37 2.33.57 3.57.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57a1 1 0 0 1-.25 1.05l-2.2 2.17z',
email: 'M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z',
web: 'M12 2A10 10 0 1 0 22 12 10 10 0 0 0 12 2zm6.93 6h-2.95a15.65 15.65 0 0 0-1.38-3.56A8 8 0 0 1 18.92 8zM12 4a14 14 0 0 1 1.81 4h-3.62A14 14 0 0 1 12 4zM4.26 14a8 8 0 0 1 0-4h3.38a16.5 16.5 0 0 0 0 4zm.82 2h2.95a15.65 15.65 0 0 0 1.38 3.56A8 8 0 0 1 5.08 16zm2.95-8H5.08a8 8 0 0 1 4.33-3.56A15.65 15.65 0 0 0 8.05 8zM12 20a14 14 0 0 1-1.81-4h3.62A14 14 0 0 1 12 20zm2.34-6h-4.68a14.6 14.6 0 0 1 0-4h4.68a14.6 14.6 0 0 1 0 4zm.25 5.56A15.65 15.65 0 0 0 15.97 16h2.95a8 8 0 0 1-4.33 3.56zM16.36 14a16.5 16.5 0 0 0 0-4h3.38a8 8 0 0 1 0 4z',
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 (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d={paths[name]} />
</svg>
)
}

View File

@ -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: () => (
<div className="w-full h-full flex items-center justify-center" style={{ background: '#e8eef3' }}>
<div className="text-sm" style={{ color: 'var(--color-text-muted)' }}>Chargement de la carte</div>
</div>
),
})
interface Props {
section: SectionDTO
slug: string
configId: string
languages: string[]
}
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<ProgrammeBlock | null>(() => {
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<ProgrammeBlock | null>(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 (
<div style={{ position: 'fixed', inset: 0, background: 'var(--color-background)', overflowY: 'auto' }}>
{/* Hero */}
<div
className="relative"
style={{
height: '52vh',
minHeight: 280,
background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))',
overflow: 'hidden',
}}
>
{section.imageSource && (
// eslint-disable-next-line @next/next/no-img-element
<img src={section.imageSource} alt="" className="absolute inset-0 w-full h-full object-cover" />
)}
<div className="absolute inset-0" style={{ background: 'linear-gradient(to bottom, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.05) 40%, rgba(0,0,0,0.7) 100%)' }} />
<div className="relative z-10 flex items-center px-3 py-3" style={{ paddingTop: 'max(env(safe-area-inset-top), 12px)' }}>
<button
onClick={() => 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"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
</div>
<div className="absolute bottom-5 left-5 right-5 flex flex-col gap-2">
{(event?.startDate || event?.endDate) && (
<span
className="self-start px-3 py-1 rounded-full text-xs font-bold"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)', boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }}
>
{formatDateRange(event?.startDate, event?.endDate)}
</span>
)}
<h1
className="text-white text-2xl font-bold leading-tight [&_p]:m-0"
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
dangerouslySetInnerHTML={{ __html: t(section.title, language) || 'Événement' }}
/>
</div>
</div>
{/* Programme */}
{programme.length > 0 && (
<Section title="Programme">
<div className="flex flex-col">
{programme.map((block, i) => {
const isActive = activeBlock?.id === block.id
return (
<button
key={block.id ?? i}
onClick={() => setSelectedBlock(block)}
className="flex gap-3 text-left"
>
{/* Time + dot column */}
<div className="flex flex-col items-center" style={{ width: 56 }}>
<div className="text-xs font-bold" style={{ color: isActive ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>
{formatTime(block.startTime)}
</div>
<div
className="w-3 h-3 rounded-full mt-1"
style={{
background: isActive ? 'var(--color-primary)' : 'var(--color-border)',
boxShadow: isActive ? `0 0 0 4px ${primaryColor}33` : undefined,
}}
/>
{i < programme.length - 1 && (
<div className="flex-1 w-px mt-1" style={{ background: 'var(--color-border)', minHeight: 30 }} />
)}
</div>
{/* Block content */}
<div
className="flex-1 mb-3 rounded-xl p-3"
style={{
background: isActive ? 'var(--color-primary-light)' : 'var(--color-surface)',
border: `1px solid ${isActive ? 'var(--color-primary)' : 'var(--color-border)'}`,
}}
>
<div className="flex items-start justify-between gap-2 mb-1">
<div
className="text-sm font-semibold flex-1 [&_p]:m-0"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(block.title, language) || 'Bloc' }}
/>
{isActive && (
<span
className="text-[10px] font-bold px-2 py-0.5 rounded-full flex-shrink-0"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
EN COURS
</span>
)}
</div>
{(block.startTime && block.endTime) && (
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{formatTime(block.startTime)} {formatTime(block.endTime)}
</div>
)}
</div>
</button>
)
})}
</div>
</Section>
)}
{/* Carte */}
{hasMap && (
<Section title="Carte">
<button
onClick={() => setMapFullscreen(true)}
className="block w-full rounded-2xl overflow-hidden relative"
style={{ height: 220, border: '1px solid var(--color-border)' }}
>
<EventMap
globalAnnotations={globalAnnotations}
blockAnnotations={activeBlock?.mapAnnotations ?? []}
centerLat={centerLat}
centerLng={centerLng}
zoom={14}
primaryColor={primaryColor}
/>
<div
className="absolute top-2 right-2 px-2.5 py-1 rounded-full text-xs font-semibold flex items-center gap-1"
style={{ background: 'rgba(255,255,255,0.95)', color: 'var(--color-text)' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 5v4h2V5h4V3H5c-1.1 0-2 .9-2 2zm2 10H3v4c0 1.1.9 2 2 2h4v-2H5v-4zm14 4h-4v2h4c1.1 0 2-.9 2-2v-4h-2v4zm0-16h-4v2h4v4h2V5c0-1.1-.9-2-2-2z" />
</svg>
Plein écran
</div>
</button>
{activeBlock && (activeBlock.mapAnnotations?.length ?? 0) > 0 && (
<div className="mt-2 text-xs flex items-center gap-1.5" style={{ color: 'var(--color-text-muted)' }}>
<span className="w-2 h-2 rounded-full" style={{ background: '#f97316' }} />
Annotations du bloc en cours en orange
</div>
)}
</Section>
)}
{/* Parcours */}
{hasPaths && (
<Section title="Parcours">
<div className="text-xs mb-2" style={{ color: 'var(--color-text-muted)' }}>
{paths.length} parcours disponible{paths.length > 1 ? 's' : ''}
</div>
<div className="flex gap-2 overflow-x-auto pb-2 -mx-4 px-4">
{paths.map((p) => (
<button
key={p.id}
onClick={() => {
// 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)',
}}
>
<div
className="w-10 h-10 rounded-xl flex items-center justify-center"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3z" />
</svg>
</div>
<div
className="text-sm font-semibold line-clamp-2 [&_p]:m-0"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(p.title, language) || 'Parcours' }}
/>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{p.steps?.length ?? 0} étape{(p.steps?.length ?? 0) > 1 ? 's' : ''}
</div>
</button>
))}
</div>
</Section>
)}
{/* À propos */}
{description && (
<Section title="À propos">
<div
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: description }}
/>
</Section>
)}
<div className="h-12" />
{/* Block detail bottom sheet */}
{selectedBlock && (
<BlockDetailSheet
block={selectedBlock}
language={language}
onClose={() => setSelectedBlock(null)}
/>
)}
{/* Fullscreen map overlay */}
{mapFullscreen && (
<div className="fixed inset-0 z-[2000]" style={{ background: 'var(--color-background)' }}>
<EventMap
globalAnnotations={globalAnnotations}
blockAnnotations={activeBlock?.mapAnnotations ?? []}
centerLat={centerLat}
centerLng={centerLng}
zoom={14}
primaryColor={primaryColor}
/>
<div
className="absolute top-0 left-0 right-0 flex items-center px-3 py-3 z-[10]"
style={{
background: 'linear-gradient(to bottom, rgba(0,0,0,0.55), rgba(0,0,0,0))',
paddingTop: 'max(env(safe-area-inset-top), 12px)',
}}
>
<button
onClick={() => 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"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button>
<span
className="ml-3 text-base font-semibold flex-1 truncate"
style={{ color: 'white', textShadow: '0 1px 4px rgba(0,0,0,0.6)' }}
>
{tPlain(section.title, language)}
</span>
</div>
</div>
)}
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="px-4 pt-5">
<h2 className="text-sm font-bold uppercase tracking-wider mb-3" style={{ color: 'var(--color-text-muted)' }}>
{title}
</h2>
{children}
</section>
)
}
function BlockDetailSheet({
block, language, onClose,
}: {
block: ProgrammeBlock
language: string
onClose: () => void
}) {
return (
<>
<div className="fixed inset-0 z-[1500]" style={{ background: 'rgba(0,0,0,0.4)' }} onClick={onClose} />
<div
className="fixed left-0 right-0 bottom-0 z-[1600] rounded-t-3xl overflow-hidden flex flex-col"
style={{
background: 'var(--color-surface)',
maxHeight: '78%',
boxShadow: '0 -8px 24px rgba(0,0,0,0.2)',
animation: 'mim-slide-up 0.25s ease',
}}
>
<style>{`@keyframes mim-slide-up { from { transform: translateY(100%); } to { transform: translateY(0); } }`}</style>
<div className="flex justify-center pt-2 pb-1">
<div style={{ width: 40, height: 4, borderRadius: 2, background: 'var(--color-border)' }} />
</div>
<div className="px-5 pb-3 flex items-start justify-between gap-3">
<div
className="text-lg font-bold flex-1 [&_p]:m-0"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(block.title, language) || 'Bloc' }}
/>
<button onClick={onClose} className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0" style={{ background: 'var(--color-background)' }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="var(--color-text)">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button>
</div>
{(block.startTime && block.endTime) && (
<div className="px-5 pb-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
{formatTime(block.startTime)} {formatTime(block.endTime)}
</div>
)}
<div className="overflow-y-auto px-5 pb-5 flex flex-col gap-4">
{t(block.description, language) && (
<div
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(block.description, language) }}
/>
)}
{(block.mapAnnotations?.length ?? 0) > 0 && (
<div>
<div className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--color-text-muted)' }}>
Lieux concernés
</div>
<div className="flex flex-col gap-2">
{block.mapAnnotations!.map((a, i) => (
<div
key={a.id ?? i}
className="flex items-center gap-2 p-2 rounded-lg"
style={{ background: 'var(--color-background)' }}
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
style={{ background: '#f97316' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z" />
</svg>
</div>
<div
className="text-sm flex-1 [&_p]:m-0"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(a.label, language) || 'Lieu' }}
/>
</div>
))}
</div>
</div>
)}
</div>
</div>
</>
)
}

View File

@ -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<boolean>(!!startMsg)
const [showEnd, setShowEnd] = useState(false)
const [showHint, setShowHint] = useState(false)
const [resetKey, setResetKey] = useState(0)
const startedAtRef = useRef<number>(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 (
<div style={{ position: 'fixed', inset: 0, background: 'var(--color-background)' }} className="flex flex-col">
{/* Header with gradient */}
<div
className="relative flex-shrink-0"
style={{
height: '24vh',
minHeight: 140,
background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))',
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
overflow: 'hidden',
}}
>
{section.imageSource && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={section.imageSource}
alt=""
className="absolute inset-0 w-full h-full object-cover"
style={{ opacity: 0.45 }}
/>
)}
<div className="absolute inset-0" style={{ background: 'linear-gradient(to bottom, rgba(0,0,0,0.25), rgba(0,0,0,0.45))' }} />
<div className="relative z-10 flex items-center gap-2 px-3 py-3" style={{ paddingTop: 'max(env(safe-area-inset-top), 12px)' }}>
<button
onClick={() => 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"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
</div>
<div
className="absolute bottom-3 left-5 right-5 text-white text-xl font-bold [&_p]:m-0"
style={{ textShadow: '0 2px 6px rgba(0,0,0,0.6)' }}
dangerouslySetInnerHTML={{ __html: t(section.title, language) }}
/>
</div>
{/* Game area */}
<div className="flex-1 relative flex flex-col min-h-0 p-3">
{kind === 'Puzzle' && puzzleImage && (
<PuzzleGame
key={resetKey}
imageUrl={puzzleImage}
rows={rows}
cols={cols}
showHint={showHint}
onWin={handleWin}
/>
)}
{kind === 'SlidingPuzzle' && puzzleImage && (
<SlidingPuzzle
key={resetKey}
imageUrl={puzzleImage}
rows={rows}
cols={cols}
showHint={showHint}
onWin={handleWin}
/>
)}
{kind === 'Escape' && (
(game?.guidedPaths?.length ?? 0) > 0 ? (
<EscapeProgression paths={game!.guidedPaths!} language={language} />
) : (
<EscapeStub title={tPlain(section.title, language)} description={t(section.description, language)} />
)
)}
{kind === 'Unknown' && (
<div className="flex-1 flex items-center justify-center text-sm text-center p-6" style={{ color: 'var(--color-text-muted)' }}>
Type de jeu inconnu.
</div>
)}
{!puzzleImage && (kind === 'Puzzle' || kind === 'SlidingPuzzle') && (
<div className="flex-1 flex items-center justify-center text-sm text-center p-6" style={{ color: 'var(--color-text-muted)' }}>
Image du puzzle manquante.
</div>
)}
{/* Floating buttons */}
{(kind === 'Puzzle' || kind === 'SlidingPuzzle') && puzzleImage && (
<div className="absolute bottom-4 right-4 flex flex-col gap-2 z-20">
<button
onClick={() => 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"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17a5 5 0 1 1 0-10 5 5 0 0 1 0 10zm0-8a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" />
</svg>
</button>
<button
onClick={restart}
className="w-12 h-12 rounded-full flex items-center justify-center"
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)',
}}
aria-label="Recommencer"
title="Recommencer"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.65 6.35A7.96 7.96 0 0 0 12 4a8 8 0 1 0 7.74 10h-2.08A6 6 0 1 1 12 6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" />
</svg>
</button>
</div>
)}
</div>
{/* Start dialog */}
<MessageDialog
open={showStart}
title="Règles du jeu"
html={startMsg}
onClose={() => setShowStart(false)}
primaryAction={{ label: 'Commencer', onClick: () => setShowStart(false) }}
/>
{/* End dialog */}
<MessageDialog
open={showEnd}
title="Bravo !"
html={endMsg || 'Tu as gagné !'}
onClose={restart}
primaryAction={{ label: 'Recommencer', onClick: restart }}
secondaryAction={{ label: 'Retour', onClick: () => router.back() }}
/>
</div>
)
}
function EscapeStub({ title, description }: { title: string; description: string }) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center gap-4">
<div
className="w-20 h-20 rounded-full flex items-center justify-center"
style={{ background: 'var(--color-primary-light)' }}
>
<svg width="42" height="42" viewBox="0 0 24 24" fill="var(--color-primary)">
<path d="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3zm-2 14l-4-4 1.41-1.41L10 13.17l6.59-6.59L18 8l-8 8z" />
</svg>
</div>
<div className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>{title}</div>
{description && (
<div
className="text-sm max-w-md [&_p]:m-0"
style={{ color: 'var(--color-text-muted)' }}
dangerouslySetInnerHTML={{ __html: description }}
/>
)}
<div className="text-sm mt-4 px-4 py-2 rounded-full" style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-text-muted)' }}>
Parcours guidé bientôt disponible.
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,10 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import { trackEvent } from '@/lib/stats'
import type { SectionDTO, QuestionDTO } from '@/lib/api/types' import type { SectionDTO, QuestionDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar' import AppBar from '@/components/ui/AppBar'
@ -16,8 +17,8 @@ interface Props {
type Phase = 'quiz' | 'result' | 'review' type Phase = 'quiz' | 'result' | 'review'
export default function QuizSection({ section, slug, configId, languages }: Props) { export default function QuizSection({ section, configId, languages }: Props) {
const { language, setAvailableLanguages } = useVisitor() const { language, setAvailableLanguages, instanceId } = useVisitor()
const router = useRouter() const router = useRouter()
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages(languages) }, [languages])
@ -28,6 +29,7 @@ export default function QuizSection({ section, slug, configId, languages }: Prop
const [phase, setPhase] = useState<Phase>('quiz') const [phase, setPhase] = useState<Phase>('quiz')
const [currentIndex, setCurrentIndex] = useState(0) const [currentIndex, setCurrentIndex] = useState(0)
const [answers, setAnswers] = useState<Record<number, number>>({}) const [answers, setAnswers] = useState<Record<number, number>>({})
const trackedRef = useRef(false)
if (questions.length === 0) { if (questions.length === 0) {
return ( return (
@ -71,10 +73,19 @@ export default function QuizSection({ section, slug, configId, languages }: Prop
setAnswers({}) setAnswers({})
setCurrentIndex(0) setCurrentIndex(0)
setPhase('quiz') setPhase('quiz')
trackedRef.current = false
} }
// ── RESULT SCREEN ──────────────────────────────────────────────────────── // ── RESULT SCREEN ────────────────────────────────────────────────────────
if (phase === 'result') { 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) const levelText = t(getLevel(), language)
return ( return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}> <div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>

View File

@ -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: `
<div class="mim-pin" style="--pin-color:${color};--pin-size:32px;--pin-head:24px">
<div class="mim-pin-head"></div>
<div class="mim-pin-dot"></div>
</div>
`,
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 (
<MapContainer
center={[lat, lng]}
zoom={15}
scrollWheelZoom={false}
zoomControl={false}
style={{ width: '100%', height: '100%' }}
>
<TileLayer
attribution='&copy; OSM &copy; CARTO'
url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
/>
<Marker position={[lat, lng]} icon={pinIcon(primaryColor)} />
</MapContainer>
)
}

View File

@ -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: `
<div class="mim-pin" style="--pin-color:${color};--pin-size:32px;--pin-head:24px">
<div class="mim-pin-head"></div>
<div class="mim-pin-dot"></div>
</div>
`,
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 (
<MapContainer
center={[centerLat, centerLng]}
zoom={zoom}
scrollWheelZoom
zoomControl={false}
style={{ width: '100%', height: '100%' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/attributions">CARTO</a>'
url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
/>
{/* Global annotations: primary color */}
{global.positions.map((pos, i) => (
<Marker key={`g-pt-${i}`} position={pos} icon={pinIcon(primaryColor)} />
))}
{global.polylines.map((line, i) => {
const ann = globalAnnotations.find((a) => a.geometryType === 1)
const color = ann?.polyColor || primaryColor
return <Polyline key={`g-line-${i}`} positions={line} pathOptions={{ color, weight: 4, opacity: 0.85 }} />
})}
{/* Active block annotations: orange overlay */}
{block.positions.map((pos, i) => (
<Marker key={`b-pt-${i}`} position={pos} icon={pinIcon('#f97316')} zIndexOffset={1000} />
))}
{block.polylines.map((line, i) => (
<Polyline key={`b-line-${i}`} positions={line} pathOptions={{ color: '#f97316', weight: 4.5, opacity: 0.95 }} />
))}
<FitToContent positions={allPositions.length > 0 ? allPositions : [[centerLat, centerLng]]} />
</MapContainer>
)
}

View File

@ -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<string | null>(paths.length === 1 ? paths[0].id : null)
const activePath = paths.find((p) => p.id === activePathId) ?? null
const activeSteps = useMemo(
() => [...(activePath?.steps ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
[activePath]
)
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set())
const [quizPassedSteps, setQuizPassedSteps] = useState<Set<string>>(new Set())
const [timerExpiredSteps, setTimerExpiredSteps] = useState<Set<string>>(new Set())
const [zoneNotifiedSteps, setZoneNotifiedSteps] = useState<Set<string>>(new Set())
const [toastMessage, setToastMessage] = useState<string | null>(null)
const [showEnd, setShowEnd] = useState(false)
const [geoKey, setGeoKey] = useState(0)
useEffect(() => {
setCompletedSteps(new Set())
setQuizPassedSteps(new Set())
setTimerExpiredSteps(new Set())
setZoneNotifiedSteps(new Set())
setShowEnd(false)
setToastMessage(null)
}, [activePathId])
const currentStepId = useMemo(() => {
if (!activePath) return null
return activeSteps.find((s) => !completedSteps.has(s.id))?.id ?? null
}, [activePath, activeSteps, completedSteps])
const visibleSteps = useMemo(() => {
if (!activePath) return []
let list = activeSteps
if (activePath.hideNextStepsUntilComplete) {
const idx = currentStepId ? activeSteps.findIndex((s) => s.id === currentStepId) : activeSteps.length
list = activeSteps.slice(0, idx + 1)
}
return list.filter((s) => {
if (!s.isHiddenInitially) return true
return completedSteps.has(s.id) || s.id === currentStepId
})
}, [activePath, activeSteps, currentStepId, completedSteps])
const geo = useGeolocation(!!activePath, geoKey)
// Toast on first entry into the current step's zone
useEffect(() => {
if (!activePath || !currentStepId) return
const step = activeSteps.find((s) => s.id === currentStepId)
if (!step) return
const radius = step.zoneRadiusMeters ?? 0
const coords = step.geometry?.coordinates
if (radius <= 0 || !coords || geo.lat == null || geo.lng == null) return
const dist = haversineMeters({ lat: geo.lat, lng: geo.lng }, { lat: coords[1], lng: coords[0] })
if (dist <= radius && !zoneNotifiedSteps.has(currentStepId)) {
const title = tPlain(step.title, language) || 'cette étape'
setToastMessage(`Zone atteinte : « ${title} »`)
setZoneNotifiedSteps((prev) => {
const next = new Set(prev)
next.add(currentStepId)
return next
})
}
}, [activePath, currentStepId, activeSteps, geo.lat, geo.lng, zoneNotifiedSteps, language])
function markComplete(stepId: string) {
setCompletedSteps((prev) => {
const next = new Set(prev)
next.add(stepId)
return next
})
const idx = activeSteps.findIndex((s) => s.id === stepId)
if (idx === activeSteps.length - 1) setShowEnd(true)
}
// Path picker
if (!activePath) {
return (
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-3">
<div className="text-sm text-center mb-2" style={{ color: 'var(--color-text-muted)' }}>
Choisis un parcours pour démarrer.
</div>
{paths.map((p) => (
<button
key={p.id}
onClick={() => setActivePathId(p.id)}
className="flex items-center gap-3 p-4 rounded-2xl text-left"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<div
className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div
className="text-sm font-semibold [&_p]:m-0"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(p.title, language) || 'Parcours' }}
/>
<div className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
{p.steps?.length ?? 0} étape{(p.steps?.length ?? 0) > 1 ? 's' : ''}
{p.isLinear && ' · linéaire'}
</div>
</div>
</button>
))}
</div>
)
}
return (
<div className="flex-1 overflow-y-auto p-3 pb-20">
<div className="flex items-center justify-between mb-3 px-1">
<div className="text-xs font-semibold" style={{ color: 'var(--color-text-muted)' }}>
{completedSteps.size} / {activeSteps.length} étapes
</div>
{paths.length > 1 && (
<button
onClick={() => setActivePathId(null)}
className="text-xs font-medium"
style={{ color: 'var(--color-primary)' }}
>
Changer de parcours
</button>
)}
</div>
<div className="flex flex-col gap-3">
{visibleSteps.map((step, i) => (
<EscapeStepCard
key={step.id}
step={step}
index={activeSteps.findIndex((s) => s.id === step.id)}
total={activeSteps.length}
language={language}
geo={geo}
isCurrent={step.id === currentStepId}
isCompleted={completedSteps.has(step.id)}
quizPassed={quizPassedSteps.has(step.id)}
timerExpired={timerExpiredSteps.has(step.id)}
requireSuccess={!!activePath.requireSuccessToAdvance}
onQuizResult={(passed) => {
if (passed) {
setQuizPassedSteps((prev) => {
const next = new Set(prev)
next.add(step.id)
return next
})
}
}}
onTimerExpired={() => {
setTimerExpiredSteps((prev) => {
const next = new Set(prev)
next.add(step.id)
return next
})
}}
onRetryGeo={() => setGeoKey((k) => k + 1)}
onMarkComplete={() => markComplete(step.id)}
/>
))}
</div>
{toastMessage && <Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />}
<MessageDialog
open={showEnd}
title="Bravo !"
html={`Tu as terminé le parcours « ${tPlain(activePath.title, language) || 'parcours'} ».`}
onClose={() => { setShowEnd(false); setActivePathId(null) }}
primaryAction={{ label: 'Recommencer', onClick: () => { setCompletedSteps(new Set()); setQuizPassedSteps(new Set()); setTimerExpiredSteps(new Set()); setZoneNotifiedSteps(new Set()); setShowEnd(false) } }}
secondaryAction={{ label: 'Retour', onClick: () => { setShowEnd(false); setActivePathId(null) } }}
/>
</div>
)
}
function EscapeStepCard({
step, index, total, language, geo,
isCurrent, isCompleted, quizPassed, timerExpired, requireSuccess,
onQuizResult, onTimerExpired, onRetryGeo, onMarkComplete,
}: {
step: GuidedStepDTO
index: number
total: number
language: string
geo: ReturnType<typeof useGeolocation>
isCurrent: boolean
isCompleted: boolean
quizPassed: boolean
timerExpired: boolean
requireSuccess: boolean
onQuizResult: (passed: boolean) => void
onTimerExpired: () => void
onRetryGeo: () => void
onMarkComplete: () => void
}) {
const radius = step.zoneRadiusMeters ?? 0
const coords = step.geometry?.coordinates
const distance = geo.lat != null && geo.lng != null && coords
? haversineMeters({ lat: geo.lat, lng: geo.lng }, { lat: coords[1], lng: coords[0] })
: null
const inZone = distance != null && radius > 0 && distance <= radius
const hasQuiz = (step.quizQuestions?.length ?? 0) > 0
const hasTimer = !!step.isStepTimer && (step.timerSeconds ?? 0) > 0
const isLocked = !!step.isStepLocked
const geoUnavailable = geo.status === 'denied' || geo.status === 'unavailable'
const quizOk = !hasQuiz || quizPassed || !requireSuccess
const zoneOk = radius === 0 || inZone || !requireSuccess || geoUnavailable
const lockOk = !isLocked || !requireSuccess || quizPassed || inZone || geoUnavailable
const canAdvance = quizOk && zoneOk && lockOk
const stateColor = isCompleted ? '#16a34a' : isCurrent ? 'var(--color-primary)' : 'var(--color-border)'
return (
<div
className="rounded-2xl overflow-hidden"
style={{
background: 'var(--color-surface)',
border: `2px solid ${stateColor}`,
opacity: !isCurrent && !isCompleted ? 0.6 : 1,
}}
>
{step.imageUrl && (
<div className="relative" style={{ height: 140 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={step.imageUrl} alt="" className="absolute inset-0 w-full h-full object-cover" />
<div className="absolute inset-0" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.5), transparent)' }} />
<div
className="absolute top-2 left-2 px-2 py-0.5 rounded-full text-[11px] font-bold"
style={{ background: stateColor, color: 'white' }}
>
Étape {index + 1} / {total}
{isCompleted && ' · ✓'}
</div>
</div>
)}
<div className="p-4 flex flex-col gap-3">
{!step.imageUrl && (
<div className="flex items-center gap-2">
<span
className="px-2 py-0.5 rounded-full text-[11px] font-bold"
style={{ background: stateColor, color: 'white' }}
>
Étape {index + 1} / {total}
{isCompleted && ' · ✓'}
</span>
</div>
)}
<div
className="text-base font-bold [&_p]:m-0"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(step.title, language) || `Étape ${index + 1}` }}
/>
{t(step.description, language) && (
<div
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: t(step.description, language) }}
/>
)}
{radius > 0 && (
<div className="flex flex-wrap gap-2 items-center">
<div
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full"
style={{
background: inZone ? '#d1fae5' : 'var(--color-primary-light)',
color: inZone ? '#065f46' : 'var(--color-primary)',
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z" />
</svg>
{inZone
? 'Vous êtes dans la zone'
: distance != null
? `À ${formatDistance(distance)} de la zone (${radius} m)`
: `Zone à atteindre · ${radius} m`}
</div>
{geo.status === 'denied' && (
<button onClick={onRetryGeo} className="text-xs underline" style={{ color: '#b91c1c' }}>
Localisation refusée réessayer
</button>
)}
</div>
)}
{isLocked && !isCompleted && (
<div className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full self-start" style={{ background: '#fef3c7', color: '#92400e' }}>
<span>🔒</span>
{quizPassed || inZone ? 'Étape déverrouillée' : 'Étape verrouillée'}
</div>
)}
{hasTimer && isCurrent && !isCompleted && (
<div>
<StepTimer totalSeconds={step.timerSeconds!} active={!timerExpired} onExpired={onTimerExpired} />
{timerExpired && t(step.timerExpiredMessage, language) && (
<div
className="mt-2 text-xs p-2 rounded-lg [&_p]:m-0"
style={{ background: '#fee2e2', color: '#991b1b' }}
dangerouslySetInnerHTML={{ __html: t(step.timerExpiredMessage, language) }}
/>
)}
</div>
)}
{hasQuiz && isCurrent && !isCompleted && (
<div>
<div className="text-xs font-semibold mb-2 uppercase tracking-wide" style={{ color: 'var(--color-text-muted)' }}>
Défi
</div>
<StepQuiz key={step.id} questions={step.quizQuestions!} language={language} onResult={onQuizResult} />
{quizPassed && (
<div className="mt-2 text-xs text-center py-1.5 rounded-full" style={{ background: '#d1fae5', color: '#065f46' }}>
Défi réussi
</div>
)}
</div>
)}
{isCurrent && !isCompleted && (
<button
onClick={onMarkComplete}
disabled={!canAdvance}
className="w-full py-3 rounded-2xl font-semibold text-sm mt-1"
style={{
background: canAdvance ? 'var(--color-primary)' : 'var(--color-surface)',
color: canAdvance ? 'var(--color-on-primary)' : 'var(--color-text-muted)',
border: canAdvance ? 'none' : '1px solid var(--color-border)',
cursor: canAdvance ? 'pointer' : 'not-allowed',
}}
>
{index === total - 1 ? 'Terminer' : 'Marquer terminée'}
</button>
)}
{isCompleted && (
<div className="text-xs text-center py-1.5 rounded-full" style={{ background: '#d1fae5', color: '#065f46' }}>
Étape terminée
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,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 (
<div className="absolute inset-0 z-[2000] flex items-center justify-center p-6" style={{ background: 'rgba(0,0,0,0.55)' }}>
<div
className="w-full max-w-md rounded-3xl overflow-hidden"
style={{
background: 'var(--color-surface)',
boxShadow: '0 20px 50px rgba(0,0,0,0.4)',
animation: 'mim-pop-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
>
<style>{`@keyframes mim-pop-in { from { transform: scale(0.85); opacity: 0; } to { transform: scale(1); opacity: 1; } }`}</style>
<div
className="px-5 pt-5 pb-3 text-center text-lg font-bold"
style={{ color: 'var(--color-text)' }}
>
{title}
</div>
<div
className="px-6 pb-4 text-sm text-center [&_p]:m-0 [&_p+p]:mt-2"
style={{ color: 'var(--color-text)' }}
>
{html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : children}
</div>
<div className="flex flex-col gap-2 px-5 pb-5 pt-2">
{primaryAction && (
<button
onClick={primaryAction.onClick}
className="w-full py-3 rounded-2xl font-semibold text-sm"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
{primaryAction.label}
</button>
)}
{secondaryAction && (
<button
onClick={secondaryAction.onClick}
className="w-full py-3 rounded-2xl font-semibold text-sm"
style={{
background: 'var(--color-surface)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
}}
>
{secondaryAction.label}
</button>
)}
{!primaryAction && !secondaryAction && (
<button
onClick={onClose}
className="w-full py-3 rounded-2xl font-semibold text-sm"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
OK
</button>
)}
</div>
</div>
</div>
)
}

View File

@ -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<HTMLDivElement>(null)
const [board, setBoard] = useState<{ w: number; h: number } | null>(null)
const [pieces, setPieces] = useState<Piece[]>([])
const [imgRatio, setImgRatio] = useState<number | null>(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 <div className="flex-1 flex items-center justify-center text-sm" style={{ color: 'var(--color-text-muted)' }}>Image manquante.</div>
}
return (
<div
ref={containerRef}
className="flex-1 relative w-full h-full select-none touch-none"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
{board && (
<>
{/* Board frame (target outlines) */}
<div
className="absolute"
style={{
left: `calc(50% - ${board.w / 2}px)`,
top: `calc(50% - ${board.h / 2}px)`,
width: board.w,
height: board.h,
backgroundImage: showHint ? `url(${imageUrl})` : undefined,
backgroundSize: 'cover',
opacity: showHint ? 0.25 : 1,
border: '1.5px dashed rgba(0,0,0,0.18)',
borderRadius: 8,
background: showHint ? undefined : 'rgba(255,255,255,0.4)',
}}
/>
{/* Pieces */}
{pieces.map((p) => {
const pw = board.w / cols
const ph = board.h / rows
const isDragging = dragRef.current?.id === p.id
return (
<div
key={p.id}
onPointerDown={(e) => 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',
}}
/>
)
})}
</>
)}
</div>
)
}

View File

@ -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<HTMLDivElement>(null)
const [imgRatio, setImgRatio] = useState<number | null>(null)
const [board, setBoard] = useState<{ w: number; h: number } | null>(null)
// tiles[i] = original index at slot i; -1 = empty
const [tiles, setTiles] = useState<number[]>([])
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 <div className="flex-1 flex items-center justify-center text-sm" style={{ color: 'var(--color-text-muted)' }}>Image manquante.</div>
}
return (
<div ref={containerRef} className="flex-1 relative w-full h-full select-none touch-none">
{board && (
<>
{showHint && (
<div
className="absolute"
style={{
left: `calc(50% - ${board.w / 2}px)`,
top: `calc(50% - ${board.h / 2}px)`,
width: board.w,
height: board.h,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
opacity: 0.25,
borderRadius: 8,
}}
/>
)}
<div
className="absolute"
style={{
left: `calc(50% - ${board.w / 2}px)`,
top: `calc(50% - ${board.h / 2}px)`,
width: board.w,
height: board.h,
}}
>
{(() => {
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 (
<div
key={origIdx}
onClick={() => 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)' }}
/>
)
})
})()}
</div>
</>
)}
</div>
)
}

View File

@ -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<string>
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: `
<div class="mim-pin ${selected ? 'mim-pin-selected' : ''}" style="--pin-color:${color};--pin-size:${size}px;--pin-head:${headSize}px">
<div class="mim-pin-head"></div>
<div class="mim-pin-dot"></div>
</div>
`,
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: `
<div class="mim-step ${extraClass}" style="--step-color:${bg};--step-size:${size}px">
<span>${symbol}</span>
</div>
`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
})
}
function userIcon() {
return L.divIcon({
className: 'mim-user-wrapper',
html: `<div class="mim-user"><div class="mim-user-dot"></div></div>`,
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 (
<MapContainer
center={center}
zoom={zoom}
scrollWheelZoom
zoomControl={false}
style={{ width: '100%', height: '100%' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/attributions">CARTO</a>'
url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
/>
{/* 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 (
<Marker
key={`p-${p.id}`}
position={[lat, lng]}
icon={pinIcon(categoryColor(p.categorieId), isSelected)}
eventHandlers={{ click: () => onSelect(p.id) }}
opacity={stepCoords.length > 0 ? 0.45 : 1}
/>
)
})}
{/* Path polyline + numbered step markers */}
{polylinePositions.length > 1 && (
<Polyline
positions={polylinePositions}
pathOptions={{
color: primaryColor,
weight: 4,
opacity: 0.85,
dashArray: '8 6',
lineCap: 'round',
lineJoin: 'round',
}}
/>
)}
{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 (
<Marker
key={`s-${s.id}`}
position={[s.lat, s.lng]}
icon={stepIcon(s.order, primaryColor, state, s.id === selectedStepId)}
eventHandlers={{ click: () => onSelectStep?.(s.id) }}
zIndexOffset={state === 'current' ? 2000 : 1000}
/>
)
})}
{userPosition && (
<Marker
position={[userPosition.lat, userPosition.lng]}
icon={userIcon()}
zIndexOffset={3000}
interactive={false}
/>
)}
<FitToTargets target={flyTarget} fitBounds={fitBounds} />
</MapContainer>
)
}

View File

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

View File

@ -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 (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span style={{ color: 'var(--color-text-muted)' }}>Temps restant</span>
<span className="font-mono font-bold" style={{ color }}>{format(remaining)}</span>
</div>
<div className="h-2 rounded-full overflow-hidden" style={{ background: 'var(--color-border)' }}>
<div
style={{
width: `${pct}%`,
height: '100%',
background: color,
transition: 'width 1s linear, background 0.3s',
}}
/>
</div>
</div>
)
}

View File

@ -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 (
<div
className="absolute left-1/2 z-[2500] flex items-center gap-2 px-4 py-3 rounded-2xl text-sm font-semibold"
style={{
top: 'calc(env(safe-area-inset-top, 0px) + 70px)',
transform: 'translateX(-50%)',
background: '#16a34a',
color: 'white',
boxShadow: '0 8px 20px rgba(0,0,0,0.3)',
animation: 'mim-toast-in 0.3s ease',
maxWidth: 'calc(100vw - 32px)',
}}
>
<style>{`@keyframes mim-toast-in { from { transform: translate(-50%, -20px); opacity: 0; } to { transform: translate(-50%, 0); opacity: 1; } }`}</style>
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{message}
</div>
)
}

View File

@ -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;
}

View File

@ -7,6 +7,7 @@ interface VisitorContextValue {
setLanguage: (lang: string) => void setLanguage: (lang: string) => void
availableLanguages: string[] availableLanguages: string[]
setAvailableLanguages: (langs: string[]) => void setAvailableLanguages: (langs: string[]) => void
instanceId: string | null
} }
const VisitorContext = createContext<VisitorContextValue>({ const VisitorContext = createContext<VisitorContextValue>({
@ -14,9 +15,16 @@ const VisitorContext = createContext<VisitorContextValue>({
setLanguage: () => {}, setLanguage: () => {},
availableLanguages: ['FR'], availableLanguages: ['FR'],
setAvailableLanguages: () => {}, 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 [language, setLanguageState] = useState('FR')
const [availableLanguages, setAvailableLanguages] = useState<string[]>(['FR']) const [availableLanguages, setAvailableLanguages] = useState<string[]>(['FR'])
@ -31,7 +39,11 @@ export function VisitorProvider({ children }: { children: React.ReactNode }) {
} }
return ( return (
<VisitorContext.Provider value={{ language, setLanguage, availableLanguages, setAvailableLanguages }}> <VisitorContext.Provider value={{
language, setLanguage,
availableLanguages, setAvailableLanguages,
instanceId: instanceId ?? null,
}}>
{children} {children}
</VisitorContext.Provider> </VisitorContext.Provider>
) )

View File

@ -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<GeoState>(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
}

View File

@ -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<number>(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])
}

View File

@ -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 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<SectionDTO[]> { export async function getSections(configId: string, apiKey: string): Promise<SectionDTO[]> {
return apiFetch(`/api/Section/configuration/${configId}/detail`, apiKey) return apiFetch(`/api/Section/configuration/${configId}/detail`, apiKey)
} }
export async function getGuidedPaths(sectionMapId: string, apiKey: string): Promise<GuidedPathDTO[]> {
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<GuidedPathDTO[]> {
return apiFetch(`/api/SectionMap/${sectionGameId}/GuidedPath`, apiKey)
}

View File

@ -26,6 +26,8 @@ export interface ApplicationInstanceDTO {
loaderImageUrl?: string loaderImageUrl?: string
webSlug?: string webSlug?: string
publicApiKey?: string publicApiKey?: string
sectionEventId?: string
sectionEventDTO?: SectionDTO
} }
export interface ConfigurationDTO { export interface ConfigurationDTO {
@ -86,6 +88,35 @@ export interface SectionDTO {
source?: string source?: string
weather?: WeatherDTO weather?: WeatherDTO
web?: WebDTO 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 { export interface ArticleDTO {
@ -164,17 +195,70 @@ export interface MapDTO {
centerLatitude?: string centerLatitude?: string
centerLongitude?: string centerLongitude?: string
isParcours?: boolean isParcours?: boolean
guidedPaths?: GuidedPathDTO[]
} }
export interface EventAgendaDTO { export interface GuidedPathDTO {
id: string id: string
title?: TranslationDTO[] title?: TranslationDTO[]
description?: TranslationDTO[] description?: TranslationDTO[]
imageSource?: string isLinear?: boolean
startDate?: string requireSuccessToAdvance?: boolean
endDate?: string hideNextStepsUntilComplete?: boolean
latitude?: number order?: number
longitude?: 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 { export interface AgendaDTO {
@ -189,6 +273,7 @@ export interface GameDTO {
rows?: number rows?: number
cols?: number cols?: number
gameType?: string gameType?: string
guidedPaths?: GuidedPathDTO[]
} }
export interface TranslationAndResourceDTO { export interface TranslationAndResourceDTO {

20
src/lib/geo.ts Normal file
View File

@ -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`
}

64
src/lib/stats.ts Normal file
View File

@ -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<TrackPayload, 'sessionId' | 'appType' | 'timestamp'>): Promise<void> {
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
}
}

141
todo-features.md Normal file
View File

@ -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`