Wip (sections event, game, map +qr code) to be tested !
This commit is contained in:
parent
474cd61244
commit
931f35c818
@ -87,6 +87,13 @@ Deux patterns, calqués sur `mymuseum-visitapp` :
|
||||
**Full-screen** (`position: fixed; inset: 0` + dark AppBar) — contenu immersif :
|
||||
- Video, Web, PDF, Map
|
||||
|
||||
### Carte (MapSection)
|
||||
|
||||
- Librairie : **Leaflet + react-leaflet**, dynamic-importé (`ssr: false`) car Leaflet a besoin de `window`
|
||||
- Tiles : **CartoDB Voyager** (gratuit, sans clé API, plus joli qu'OSM brut)
|
||||
- Markers : pins CSS custom colorés par `categorie.color` (pas d'icônes PNG comme en Flutter — on n'a pas `iconSource` dans le DTO web)
|
||||
- Équivalent Flutter : `flutter_map` (même rendu Leaflet sous le capot)
|
||||
|
||||
Pour les titres : toujours `tPlain()` dans les AppBar et attributs `alt`.
|
||||
Pour les contenus longs : toujours `dangerouslySetInnerHTML` (Quill → HTML).
|
||||
|
||||
|
||||
255
guided-path-implementation.md
Normal file
255
guided-path-implementation.md
Normal 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
67
package-lock.json
generated
@ -8,10 +8,14 @@
|
||||
"name": "visitapp-web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"color2k": "^2.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "16.2.4",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"react-leaflet": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "^0.95.0",
|
||||
@ -861,6 +865,17 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@ -1141,6 +1156,12 @@
|
||||
"tailwindcss": "4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@ -1148,6 +1169,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
@ -1158,6 +1188,12 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/offscreencanvas": {
|
||||
"version": "2019.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
|
||||
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
@ -1612,6 +1648,12 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@ -2126,6 +2168,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qr-scanner": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz",
|
||||
"integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/offscreencanvas": "^2019.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||
@ -2158,6 +2209,20 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
|
||||
@ -8,10 +8,14 @@
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"color2k": "^2.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "16.2.4",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"react-leaflet": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "^0.95.0",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getInstanceBySlug, getConfiguration, getSections } from '@/lib/api/client'
|
||||
import { getInstanceBySlug, getConfiguration, getSections, getGuidedPaths, getGuidedPathsForGame } from '@/lib/api/client'
|
||||
import type { SectionDTO } from '@/lib/api/types'
|
||||
|
||||
// Section components
|
||||
@ -13,6 +13,9 @@ import PdfSection from '@/components/sections/PdfSection'
|
||||
import WeatherSection from '@/components/sections/WeatherSection'
|
||||
import WebSection from '@/components/sections/WebSection'
|
||||
import QuizSection from '@/components/sections/QuizSection'
|
||||
import GameSection from '@/components/sections/GameSection'
|
||||
import EventSection from '@/components/sections/EventSection'
|
||||
import TrackedSection from '@/components/TrackedSection'
|
||||
|
||||
export default async function SectionPage({
|
||||
params,
|
||||
@ -35,24 +38,63 @@ export default async function SectionPage({
|
||||
const section: SectionDTO | undefined = sections.find((s) => s.id === sectionId)
|
||||
if (!section || section.isActive === false) notFound()
|
||||
|
||||
// For Map sections, eagerly load the guided paths server-side and merge into the DTO
|
||||
if (section.type === 'Map' && section.map) {
|
||||
try {
|
||||
const paths = await getGuidedPaths(section.id, instance.publicApiKey!)
|
||||
section.map = { ...section.map, guidedPaths: paths }
|
||||
} catch {
|
||||
// Silently ignore — Map still works without paths
|
||||
}
|
||||
}
|
||||
|
||||
// Escape Games also expose guided paths
|
||||
if (section.type === 'Game' && section.game?.gameType?.toLowerCase().includes('escape')) {
|
||||
try {
|
||||
const paths = await getGuidedPathsForGame(section.id, instance.publicApiKey!)
|
||||
section.game = { ...section.game, guidedPaths: paths }
|
||||
} catch {
|
||||
// Silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Events also expose guided paths (same polymorphic endpoint)
|
||||
if (section.type === 'Event' && section.event) {
|
||||
try {
|
||||
const paths = await getGuidedPaths(section.id, instance.publicApiKey!)
|
||||
;(section.event as { guidedPaths?: typeof paths }).guidedPaths = paths
|
||||
} catch {
|
||||
// Silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
const props = { section, slug, configId, languages: config.languages ?? ['FR'] }
|
||||
|
||||
let content: React.ReactNode
|
||||
switch (section.type) {
|
||||
case 'Article': return <ArticleSection {...props} />
|
||||
case 'Agenda': return <AgendaSection {...props} />
|
||||
case 'Menu': return <MenuSection {...props} />
|
||||
case 'Slider': return <SliderSection {...props} />
|
||||
case 'Video': return <VideoSection {...props} />
|
||||
case 'Map': return <MapSection {...props} />
|
||||
case 'Pdf': return <PdfSection {...props} />
|
||||
case 'Weather': return <WeatherSection {...props} />
|
||||
case 'Quiz': return <QuizSection {...props} />
|
||||
case 'Web': return <WebSection {...props} />
|
||||
case 'Article': content = <ArticleSection {...props} />; break
|
||||
case 'Agenda': content = <AgendaSection {...props} />; break
|
||||
case 'Menu': content = <MenuSection {...props} />; break
|
||||
case 'Slider': content = <SliderSection {...props} />; break
|
||||
case 'Video': content = <VideoSection {...props} />; break
|
||||
case 'Map': content = <MapSection {...props} />; break
|
||||
case 'Pdf': content = <PdfSection {...props} />; break
|
||||
case 'Weather': content = <WeatherSection {...props} />; break
|
||||
case 'Quiz': content = <QuizSection {...props} />; break
|
||||
case 'Game': content = <GameSection {...props} />; break
|
||||
case 'Event': content = <EventSection {...props} />; break
|
||||
case 'Web': content = <WebSection {...props} />; break
|
||||
default:
|
||||
return (
|
||||
content = (
|
||||
<div className="p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Section de type « {section.type} » à venir.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TrackedSection sectionId={section.id} configurationId={configId}>
|
||||
{content}
|
||||
</TrackedSection>
|
||||
)
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ export default async function SlugLayout({
|
||||
const theme = resolveColors(instance)
|
||||
|
||||
return (
|
||||
<VisitorProvider>
|
||||
<VisitorProvider instanceId={instance.id}>
|
||||
<div style={theme as React.CSSProperties}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { getInstanceBySlug, getConfigurations } from '@/lib/api/client'
|
||||
import { notFound } from 'next/navigation'
|
||||
import ConfigurationGrid from '@/components/ConfigurationGrid'
|
||||
import FeaturedEvent from '@/components/FeaturedEvent'
|
||||
import QRScannerButton from '@/components/QRScannerButton'
|
||||
|
||||
export default async function HomePage({
|
||||
params,
|
||||
@ -17,5 +19,17 @@ export default async function HomePage({
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
79
src/components/FeaturedEvent.tsx
Normal file
79
src/components/FeaturedEvent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
src/components/QRScannerButton.tsx
Normal file
136
src/components/QRScannerButton.tsx
Normal 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 d’accès à la caméra'
|
||||
setError(msg.includes('Permission') ? 'Accès à la caméra refusé' : msg)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
scannerRef.current?.destroy()
|
||||
scannerRef.current = null
|
||||
}
|
||||
}, [open, instanceId, configurationId, language, router])
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
14
src/components/TrackedSection.tsx
Normal file
14
src/components/TrackedSection.tsx
Normal 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}</>
|
||||
}
|
||||
@ -2,11 +2,22 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
import type { SectionDTO, EventAgendaDTO } from '@/lib/api/types'
|
||||
import AppBar from '@/components/ui/AppBar'
|
||||
import { trackEvent } from '@/lib/stats'
|
||||
|
||||
const EventMiniMap = dynamic(() => import('./agenda/EventMiniMap'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<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 {
|
||||
section: SectionDTO
|
||||
@ -17,8 +28,37 @@ interface Props {
|
||||
|
||||
const MONTHS = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc']
|
||||
|
||||
export default function AgendaSection({ section, slug, configId, languages }: Props) {
|
||||
const { language, setAvailableLanguages } = useVisitor()
|
||||
function eventImage(e: EventAgendaDTO): string | undefined {
|
||||
return e.resource?.url
|
||||
}
|
||||
|
||||
function eventCoords(e: EventAgendaDTO): { lat: number; lng: number } | null {
|
||||
const c = e.address?.geometry?.coordinates
|
||||
if (!Array.isArray(c) || c.length < 2) return null
|
||||
return { lat: c[1], lng: c[0] }
|
||||
}
|
||||
|
||||
function fullAddress(e: EventAgendaDTO): string {
|
||||
const a = e.address
|
||||
if (!a) return ''
|
||||
return [
|
||||
[a.streetNumber, a.streetName].filter(Boolean).join(' '),
|
||||
[a.postCode, a.city].filter(Boolean).join(' '),
|
||||
a.country,
|
||||
].filter(Boolean).join(', ')
|
||||
}
|
||||
|
||||
function youtubeEmbedUrl(idOrUrl: string): string {
|
||||
if (idOrUrl.includes('youtube.com') || idOrUrl.includes('youtu.be')) {
|
||||
const m = idOrUrl.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/)
|
||||
if (m) return `https://www.youtube.com/embed/${m[1]}`
|
||||
return idOrUrl
|
||||
}
|
||||
return `https://www.youtube.com/embed/${idOrUrl}`
|
||||
}
|
||||
|
||||
export default function AgendaSection({ section, configId, languages }: Props) {
|
||||
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||
const router = useRouter()
|
||||
const events = section.agenda?.events ?? []
|
||||
|
||||
@ -30,8 +70,8 @@ export default function AgendaSection({ section, slug, configId, languages }: Pr
|
||||
const [selected, setSelected] = useState<EventAgendaDTO | null>(null)
|
||||
|
||||
const filtered = events.filter((e) => {
|
||||
if (!e.startDate) return false
|
||||
const d = new Date(e.startDate)
|
||||
if (!e.dateFrom) return false
|
||||
const d = new Date(e.dateFrom)
|
||||
return d.getMonth() === selectedMonth && d.getFullYear() === selectedYear
|
||||
})
|
||||
|
||||
@ -72,62 +112,206 @@ export default function AgendaSection({ section, slug, configId, languages }: Pr
|
||||
Aucun événement ce mois-ci
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((event) => (
|
||||
{filtered.map((event) => {
|
||||
const img = eventImage(event)
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => setSelected(event)}
|
||||
onClick={() => {
|
||||
setSelected(event)
|
||||
if (instanceId) {
|
||||
trackEvent({
|
||||
instanceId, configurationId: configId, sectionId: section.id,
|
||||
eventType: 'AgendaEventTap', language,
|
||||
metadata: JSON.stringify({ eventAgendaId: event.id, title: tPlain(event.label, language) }),
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-2xl overflow-hidden text-left flex gap-0"
|
||||
style={{ background: 'var(--color-surface)' }}
|
||||
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{event.imageSource && (
|
||||
{img && (
|
||||
<div className="relative w-20 shrink-0">
|
||||
<Image src={event.imageSource} alt="" fill className="object-cover" sizes="80px" />
|
||||
<Image src={img} alt="" fill className="object-cover" sizes="80px" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 flex-1">
|
||||
<p className="font-semibold text-sm" style={{ color: 'var(--color-text)' }}>
|
||||
{t(event.title, language)}
|
||||
</p>
|
||||
{event.startDate && (
|
||||
<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.startDate).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}
|
||||
{new Date(event.dateFrom).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</main>
|
||||
|
||||
{/* Event detail popup */}
|
||||
{selected && (
|
||||
<div className="fixed inset-0 z-50 flex flex-col" style={{ background: 'var(--color-background)' }}>
|
||||
<AppBar title={tPlain(selected.title, language)} onBack={() => setSelected(null)} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selected.imageSource && (
|
||||
<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) }}
|
||||
<EventDetail
|
||||
event={selected}
|
||||
language={language}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
436
src/components/sections/EventSection.tsx
Normal file
436
src/components/sections/EventSection.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
243
src/components/sections/GameSection.tsx
Normal file
243
src/components/sections/GameSection.tsx
Normal 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
@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
import { trackEvent } from '@/lib/stats'
|
||||
import type { SectionDTO, QuestionDTO } from '@/lib/api/types'
|
||||
import AppBar from '@/components/ui/AppBar'
|
||||
|
||||
@ -16,8 +17,8 @@ interface Props {
|
||||
|
||||
type Phase = 'quiz' | 'result' | 'review'
|
||||
|
||||
export default function QuizSection({ section, slug, configId, languages }: Props) {
|
||||
const { language, setAvailableLanguages } = useVisitor()
|
||||
export default function QuizSection({ section, configId, languages }: Props) {
|
||||
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => { setAvailableLanguages(languages) }, [languages])
|
||||
@ -28,6 +29,7 @@ export default function QuizSection({ section, slug, configId, languages }: Prop
|
||||
const [phase, setPhase] = useState<Phase>('quiz')
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [answers, setAnswers] = useState<Record<number, number>>({})
|
||||
const trackedRef = useRef(false)
|
||||
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
@ -71,10 +73,19 @@ export default function QuizSection({ section, slug, configId, languages }: Prop
|
||||
setAnswers({})
|
||||
setCurrentIndex(0)
|
||||
setPhase('quiz')
|
||||
trackedRef.current = false
|
||||
}
|
||||
|
||||
// ── RESULT SCREEN ────────────────────────────────────────────────────────
|
||||
if (phase === 'result') {
|
||||
if (!trackedRef.current && instanceId) {
|
||||
trackedRef.current = true
|
||||
trackEvent({
|
||||
instanceId, configurationId: configId, sectionId: section.id,
|
||||
eventType: 'QuizComplete', language,
|
||||
metadata: JSON.stringify({ score: correctCount, totalQuestions }),
|
||||
})
|
||||
}
|
||||
const levelText = t(getLevel(), language)
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
|
||||
|
||||
43
src/components/sections/agenda/EventMiniMap.tsx
Normal file
43
src/components/sections/agenda/EventMiniMap.tsx
Normal 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='© OSM © CARTO'
|
||||
url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
|
||||
/>
|
||||
<Marker position={[lat, lng]} icon={pinIcon(primaryColor)} />
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
115
src/components/sections/event/EventMap.tsx
Normal file
115
src/components/sections/event/EventMap.tsx
Normal 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='© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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>
|
||||
)
|
||||
}
|
||||
369
src/components/sections/game/EscapeProgression.tsx
Normal file
369
src/components/sections/game/EscapeProgression.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
src/components/sections/game/MessageDialog.tsx
Normal file
78
src/components/sections/game/MessageDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
202
src/components/sections/game/PuzzleGame.tsx
Normal file
202
src/components/sections/game/PuzzleGame.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
163
src/components/sections/game/SlidingPuzzle.tsx
Normal file
163
src/components/sections/game/SlidingPuzzle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
216
src/components/sections/map/LeafletMap.tsx
Normal file
216
src/components/sections/map/LeafletMap.tsx
Normal 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='© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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>
|
||||
)
|
||||
}
|
||||
107
src/components/sections/map/StepQuiz.tsx
Normal file
107
src/components/sections/map/StepQuiz.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
src/components/sections/map/StepTimer.tsx
Normal file
61
src/components/sections/map/StepTimer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
src/components/sections/map/Toast.tsx
Normal file
37
src/components/sections/map/Toast.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
src/components/sections/map/map.css
Normal file
115
src/components/sections/map/map.css
Normal 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;
|
||||
}
|
||||
@ -7,6 +7,7 @@ interface VisitorContextValue {
|
||||
setLanguage: (lang: string) => void
|
||||
availableLanguages: string[]
|
||||
setAvailableLanguages: (langs: string[]) => void
|
||||
instanceId: string | null
|
||||
}
|
||||
|
||||
const VisitorContext = createContext<VisitorContextValue>({
|
||||
@ -14,9 +15,16 @@ const VisitorContext = createContext<VisitorContextValue>({
|
||||
setLanguage: () => {},
|
||||
availableLanguages: ['FR'],
|
||||
setAvailableLanguages: () => {},
|
||||
instanceId: null,
|
||||
})
|
||||
|
||||
export function VisitorProvider({ children }: { children: React.ReactNode }) {
|
||||
export function VisitorProvider({
|
||||
children,
|
||||
instanceId,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
instanceId?: string | null
|
||||
}) {
|
||||
const [language, setLanguageState] = useState('FR')
|
||||
const [availableLanguages, setAvailableLanguages] = useState<string[]>(['FR'])
|
||||
|
||||
@ -31,7 +39,11 @@ export function VisitorProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<VisitorContext.Provider value={{ language, setLanguage, availableLanguages, setAvailableLanguages }}>
|
||||
<VisitorContext.Provider value={{
|
||||
language, setLanguage,
|
||||
availableLanguages, setAvailableLanguages,
|
||||
instanceId: instanceId ?? null,
|
||||
}}>
|
||||
{children}
|
||||
</VisitorContext.Provider>
|
||||
)
|
||||
|
||||
57
src/hooks/useGeolocation.ts
Normal file
57
src/hooks/useGeolocation.ts
Normal 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
|
||||
}
|
||||
36
src/hooks/useSectionTracking.ts
Normal file
36
src/hooks/useSectionTracking.ts
Normal 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])
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import type { ApplicationInstanceDTO, ConfigurationDTO, SectionDTO } from './types'
|
||||
import type { ApplicationInstanceDTO, ConfigurationDTO, SectionDTO, GuidedPathDTO } from './types'
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL
|
||||
|
||||
@ -29,3 +29,13 @@ export async function getConfiguration(configId: string, apiKey: string): Promis
|
||||
export async function getSections(configId: string, apiKey: string): Promise<SectionDTO[]> {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -26,6 +26,8 @@ export interface ApplicationInstanceDTO {
|
||||
loaderImageUrl?: string
|
||||
webSlug?: string
|
||||
publicApiKey?: string
|
||||
sectionEventId?: string
|
||||
sectionEventDTO?: SectionDTO
|
||||
}
|
||||
|
||||
export interface ConfigurationDTO {
|
||||
@ -86,6 +88,35 @@ export interface SectionDTO {
|
||||
source?: string
|
||||
weather?: WeatherDTO
|
||||
web?: WebDTO
|
||||
event?: EventDTO
|
||||
}
|
||||
|
||||
export interface MapAnnotationDTO {
|
||||
id?: string
|
||||
label?: TranslationDTO[]
|
||||
geometryType?: number // 0=Point, 1=Polyline, 2=Circle, 3=Polygon
|
||||
geometry?: { type?: string; coordinates?: unknown }
|
||||
polyColor?: string
|
||||
icon?: string
|
||||
iconResourceUrl?: string
|
||||
}
|
||||
|
||||
export interface ProgrammeBlock {
|
||||
id?: string
|
||||
title?: TranslationDTO[]
|
||||
description?: TranslationDTO[]
|
||||
startTime?: string // ISO datetime
|
||||
endTime?: string // ISO datetime
|
||||
mapAnnotations?: MapAnnotationDTO[]
|
||||
}
|
||||
|
||||
export interface EventDTO {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
baseSectionMapId?: string
|
||||
parcoursIds?: string[]
|
||||
globalMapAnnotations?: MapAnnotationDTO[]
|
||||
programme?: ProgrammeBlock[]
|
||||
}
|
||||
|
||||
export interface ArticleDTO {
|
||||
@ -164,17 +195,70 @@ export interface MapDTO {
|
||||
centerLatitude?: string
|
||||
centerLongitude?: string
|
||||
isParcours?: boolean
|
||||
guidedPaths?: GuidedPathDTO[]
|
||||
}
|
||||
|
||||
export interface EventAgendaDTO {
|
||||
export interface GuidedPathDTO {
|
||||
id: string
|
||||
title?: TranslationDTO[]
|
||||
description?: TranslationDTO[]
|
||||
imageSource?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
isLinear?: boolean
|
||||
requireSuccessToAdvance?: boolean
|
||||
hideNextStepsUntilComplete?: boolean
|
||||
order?: number
|
||||
steps?: GuidedStepDTO[]
|
||||
}
|
||||
|
||||
export interface GuidedStepDTO {
|
||||
id: string
|
||||
guidedPathId?: string
|
||||
order?: number
|
||||
title?: TranslationDTO[]
|
||||
description?: TranslationDTO[]
|
||||
geometry?: GeometryDTO
|
||||
zoneRadiusMeters?: number
|
||||
imageUrl?: string
|
||||
triggerGeoPointId?: number
|
||||
isHiddenInitially?: boolean
|
||||
isStepTimer?: boolean
|
||||
isStepLocked?: boolean
|
||||
timerSeconds?: number
|
||||
timerExpiredMessage?: TranslationDTO[]
|
||||
quizQuestions?: QuestionDTO[]
|
||||
}
|
||||
|
||||
export interface EventAgendaAddressDTO {
|
||||
address?: string
|
||||
streetNumber?: string
|
||||
streetName?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postCode?: string
|
||||
country?: string
|
||||
geometry?: GeometryDTO // coordinates: [lng, lat]
|
||||
polyColor?: string
|
||||
zoom?: number
|
||||
}
|
||||
|
||||
export interface EventAgendaDTO {
|
||||
id: number
|
||||
label?: TranslationDTO[]
|
||||
description?: TranslationDTO[]
|
||||
type?: string
|
||||
dateAdded?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
website?: string
|
||||
resourceId?: string
|
||||
resource?: ResourceDTO
|
||||
videoResourceId?: string
|
||||
videoResource?: ResourceDTO
|
||||
address?: EventAgendaAddressDTO
|
||||
phone?: string
|
||||
email?: string
|
||||
isSynced?: boolean
|
||||
idVideoYoutube?: string
|
||||
videoLink?: string
|
||||
}
|
||||
|
||||
export interface AgendaDTO {
|
||||
@ -189,6 +273,7 @@ export interface GameDTO {
|
||||
rows?: number
|
||||
cols?: number
|
||||
gameType?: string
|
||||
guidedPaths?: GuidedPathDTO[]
|
||||
}
|
||||
|
||||
export interface TranslationAndResourceDTO {
|
||||
|
||||
20
src/lib/geo.ts
Normal file
20
src/lib/geo.ts
Normal 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
64
src/lib/stats.ts
Normal 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
141
todo-features.md
Normal 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`
|
||||
Loading…
x
Reference in New Issue
Block a user