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 :
|
**Full-screen** (`position: fixed; inset: 0` + dark AppBar) — contenu immersif :
|
||||||
- Video, Web, PDF, Map
|
- Video, Web, PDF, Map
|
||||||
|
|
||||||
|
### Carte (MapSection)
|
||||||
|
|
||||||
|
- Librairie : **Leaflet + react-leaflet**, dynamic-importé (`ssr: false`) car Leaflet a besoin de `window`
|
||||||
|
- Tiles : **CartoDB Voyager** (gratuit, sans clé API, plus joli qu'OSM brut)
|
||||||
|
- Markers : pins CSS custom colorés par `categorie.color` (pas d'icônes PNG comme en Flutter — on n'a pas `iconSource` dans le DTO web)
|
||||||
|
- Équivalent Flutter : `flutter_map` (même rendu Leaflet sous le capot)
|
||||||
|
|
||||||
Pour les titres : toujours `tPlain()` dans les AppBar et attributs `alt`.
|
Pour les titres : toujours `tPlain()` dans les AppBar et attributs `alt`.
|
||||||
Pour les contenus longs : toujours `dangerouslySetInnerHTML` (Quill → HTML).
|
Pour les contenus longs : toujours `dangerouslySetInnerHTML` (Quill → HTML).
|
||||||
|
|
||||||
|
|||||||
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",
|
"name": "visitapp-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"color2k": "^2.0.3",
|
"color2k": "^2.0.3",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
|
"qr-scanner": "^1.4.2",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"react-leaflet": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "^0.95.0",
|
"@hey-api/openapi-ts": "^0.95.0",
|
||||||
@ -861,6 +865,17 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-leaflet/core": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@ -1141,6 +1156,12 @@
|
|||||||
"tailwindcss": "4.2.4"
|
"tailwindcss": "4.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@ -1148,6 +1169,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.39",
|
"version": "20.19.39",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||||
@ -1158,6 +1188,12 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/offscreencanvas": {
|
||||||
|
"version": "2019.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
|
||||||
|
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@ -1612,6 +1648,12 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@ -2126,6 +2168,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qr-scanner": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/offscreencanvas": "^2019.6.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rc9": {
|
"node_modules/rc9": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||||
@ -2158,6 +2209,20 @@
|
|||||||
"react": "^19.2.4"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-leaflet": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
|
|||||||
@ -8,10 +8,14 @@
|
|||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"color2k": "^2.0.3",
|
"color2k": "^2.0.3",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
|
"qr-scanner": "^1.4.2",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"react-leaflet": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "^0.95.0",
|
"@hey-api/openapi-ts": "^0.95.0",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { getInstanceBySlug, getConfiguration, getSections } from '@/lib/api/client'
|
import { getInstanceBySlug, getConfiguration, getSections, getGuidedPaths, getGuidedPathsForGame } from '@/lib/api/client'
|
||||||
import type { SectionDTO } from '@/lib/api/types'
|
import type { SectionDTO } from '@/lib/api/types'
|
||||||
|
|
||||||
// Section components
|
// Section components
|
||||||
@ -13,6 +13,9 @@ import PdfSection from '@/components/sections/PdfSection'
|
|||||||
import WeatherSection from '@/components/sections/WeatherSection'
|
import WeatherSection from '@/components/sections/WeatherSection'
|
||||||
import WebSection from '@/components/sections/WebSection'
|
import WebSection from '@/components/sections/WebSection'
|
||||||
import QuizSection from '@/components/sections/QuizSection'
|
import QuizSection from '@/components/sections/QuizSection'
|
||||||
|
import GameSection from '@/components/sections/GameSection'
|
||||||
|
import EventSection from '@/components/sections/EventSection'
|
||||||
|
import TrackedSection from '@/components/TrackedSection'
|
||||||
|
|
||||||
export default async function SectionPage({
|
export default async function SectionPage({
|
||||||
params,
|
params,
|
||||||
@ -35,24 +38,63 @@ export default async function SectionPage({
|
|||||||
const section: SectionDTO | undefined = sections.find((s) => s.id === sectionId)
|
const section: SectionDTO | undefined = sections.find((s) => s.id === sectionId)
|
||||||
if (!section || section.isActive === false) notFound()
|
if (!section || section.isActive === false) notFound()
|
||||||
|
|
||||||
|
// For Map sections, eagerly load the guided paths server-side and merge into the DTO
|
||||||
|
if (section.type === 'Map' && section.map) {
|
||||||
|
try {
|
||||||
|
const paths = await getGuidedPaths(section.id, instance.publicApiKey!)
|
||||||
|
section.map = { ...section.map, guidedPaths: paths }
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — Map still works without paths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape Games also expose guided paths
|
||||||
|
if (section.type === 'Game' && section.game?.gameType?.toLowerCase().includes('escape')) {
|
||||||
|
try {
|
||||||
|
const paths = await getGuidedPathsForGame(section.id, instance.publicApiKey!)
|
||||||
|
section.game = { ...section.game, guidedPaths: paths }
|
||||||
|
} catch {
|
||||||
|
// Silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events also expose guided paths (same polymorphic endpoint)
|
||||||
|
if (section.type === 'Event' && section.event) {
|
||||||
|
try {
|
||||||
|
const paths = await getGuidedPaths(section.id, instance.publicApiKey!)
|
||||||
|
;(section.event as { guidedPaths?: typeof paths }).guidedPaths = paths
|
||||||
|
} catch {
|
||||||
|
// Silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const props = { section, slug, configId, languages: config.languages ?? ['FR'] }
|
const props = { section, slug, configId, languages: config.languages ?? ['FR'] }
|
||||||
|
|
||||||
|
let content: React.ReactNode
|
||||||
switch (section.type) {
|
switch (section.type) {
|
||||||
case 'Article': return <ArticleSection {...props} />
|
case 'Article': content = <ArticleSection {...props} />; break
|
||||||
case 'Agenda': return <AgendaSection {...props} />
|
case 'Agenda': content = <AgendaSection {...props} />; break
|
||||||
case 'Menu': return <MenuSection {...props} />
|
case 'Menu': content = <MenuSection {...props} />; break
|
||||||
case 'Slider': return <SliderSection {...props} />
|
case 'Slider': content = <SliderSection {...props} />; break
|
||||||
case 'Video': return <VideoSection {...props} />
|
case 'Video': content = <VideoSection {...props} />; break
|
||||||
case 'Map': return <MapSection {...props} />
|
case 'Map': content = <MapSection {...props} />; break
|
||||||
case 'Pdf': return <PdfSection {...props} />
|
case 'Pdf': content = <PdfSection {...props} />; break
|
||||||
case 'Weather': return <WeatherSection {...props} />
|
case 'Weather': content = <WeatherSection {...props} />; break
|
||||||
case 'Quiz': return <QuizSection {...props} />
|
case 'Quiz': content = <QuizSection {...props} />; break
|
||||||
case 'Web': return <WebSection {...props} />
|
case 'Game': content = <GameSection {...props} />; break
|
||||||
|
case 'Event': content = <EventSection {...props} />; break
|
||||||
|
case 'Web': content = <WebSection {...props} />; break
|
||||||
default:
|
default:
|
||||||
return (
|
content = (
|
||||||
<div className="p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>
|
<div className="p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
Section de type « {section.type} » à venir.
|
Section de type « {section.type} » à venir.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TrackedSection sectionId={section.id} configurationId={configId}>
|
||||||
|
{content}
|
||||||
|
</TrackedSection>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export default async function SlugLayout({
|
|||||||
const theme = resolveColors(instance)
|
const theme = resolveColors(instance)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VisitorProvider>
|
<VisitorProvider instanceId={instance.id}>
|
||||||
<div style={theme as React.CSSProperties}>
|
<div style={theme as React.CSSProperties}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { getInstanceBySlug, getConfigurations } from '@/lib/api/client'
|
import { getInstanceBySlug, getConfigurations } from '@/lib/api/client'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import ConfigurationGrid from '@/components/ConfigurationGrid'
|
import ConfigurationGrid from '@/components/ConfigurationGrid'
|
||||||
|
import FeaturedEvent from '@/components/FeaturedEvent'
|
||||||
|
import QRScannerButton from '@/components/QRScannerButton'
|
||||||
|
|
||||||
export default async function HomePage({
|
export default async function HomePage({
|
||||||
params,
|
params,
|
||||||
@ -17,5 +19,17 @@ export default async function HomePage({
|
|||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ConfigurationGrid configurations={configurations} slug={slug} />
|
const featuredEvent = instance.sectionEventDTO
|
||||||
|
// The featured event is rendered inside its parent configuration's context
|
||||||
|
const featuredConfigId = featuredEvent?.configurationId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{featuredEvent && featuredConfigId && (
|
||||||
|
<FeaturedEvent event={featuredEvent} slug={slug} configurationId={featuredConfigId} />
|
||||||
|
)}
|
||||||
|
<ConfigurationGrid configurations={configurations} slug={slug} />
|
||||||
|
<QRScannerButton slug={slug} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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 { useEffect, useState } from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useVisitor } from '@/context/VisitorContext'
|
import { useVisitor } from '@/context/VisitorContext'
|
||||||
import { t, tPlain } from '@/lib/i18n'
|
import { t, tPlain } from '@/lib/i18n'
|
||||||
import type { SectionDTO, EventAgendaDTO } from '@/lib/api/types'
|
import type { SectionDTO, EventAgendaDTO } from '@/lib/api/types'
|
||||||
import AppBar from '@/components/ui/AppBar'
|
import AppBar from '@/components/ui/AppBar'
|
||||||
|
import { trackEvent } from '@/lib/stats'
|
||||||
|
|
||||||
|
const EventMiniMap = dynamic(() => import('./agenda/EventMiniMap'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-xs" style={{ background: '#e8eef3', color: 'var(--color-text-muted)' }}>
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
section: SectionDTO
|
section: SectionDTO
|
||||||
@ -17,8 +28,37 @@ interface Props {
|
|||||||
|
|
||||||
const MONTHS = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc']
|
const MONTHS = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc']
|
||||||
|
|
||||||
export default function AgendaSection({ section, slug, configId, languages }: Props) {
|
function eventImage(e: EventAgendaDTO): string | undefined {
|
||||||
const { language, setAvailableLanguages } = useVisitor()
|
return e.resource?.url
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventCoords(e: EventAgendaDTO): { lat: number; lng: number } | null {
|
||||||
|
const c = e.address?.geometry?.coordinates
|
||||||
|
if (!Array.isArray(c) || c.length < 2) return null
|
||||||
|
return { lat: c[1], lng: c[0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullAddress(e: EventAgendaDTO): string {
|
||||||
|
const a = e.address
|
||||||
|
if (!a) return ''
|
||||||
|
return [
|
||||||
|
[a.streetNumber, a.streetName].filter(Boolean).join(' '),
|
||||||
|
[a.postCode, a.city].filter(Boolean).join(' '),
|
||||||
|
a.country,
|
||||||
|
].filter(Boolean).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function youtubeEmbedUrl(idOrUrl: string): string {
|
||||||
|
if (idOrUrl.includes('youtube.com') || idOrUrl.includes('youtu.be')) {
|
||||||
|
const m = idOrUrl.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/)
|
||||||
|
if (m) return `https://www.youtube.com/embed/${m[1]}`
|
||||||
|
return idOrUrl
|
||||||
|
}
|
||||||
|
return `https://www.youtube.com/embed/${idOrUrl}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgendaSection({ section, configId, languages }: Props) {
|
||||||
|
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const events = section.agenda?.events ?? []
|
const events = section.agenda?.events ?? []
|
||||||
|
|
||||||
@ -30,8 +70,8 @@ export default function AgendaSection({ section, slug, configId, languages }: Pr
|
|||||||
const [selected, setSelected] = useState<EventAgendaDTO | null>(null)
|
const [selected, setSelected] = useState<EventAgendaDTO | null>(null)
|
||||||
|
|
||||||
const filtered = events.filter((e) => {
|
const filtered = events.filter((e) => {
|
||||||
if (!e.startDate) return false
|
if (!e.dateFrom) return false
|
||||||
const d = new Date(e.startDate)
|
const d = new Date(e.dateFrom)
|
||||||
return d.getMonth() === selectedMonth && d.getFullYear() === selectedYear
|
return d.getMonth() === selectedMonth && d.getFullYear() === selectedYear
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -72,62 +112,206 @@ export default function AgendaSection({ section, slug, configId, languages }: Pr
|
|||||||
Aucun événement ce mois-ci
|
Aucun événement ce mois-ci
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{filtered.map((event) => (
|
{filtered.map((event) => {
|
||||||
<button
|
const img = eventImage(event)
|
||||||
key={event.id}
|
return (
|
||||||
onClick={() => setSelected(event)}
|
<button
|
||||||
className="w-full rounded-2xl overflow-hidden text-left flex gap-0"
|
key={event.id}
|
||||||
style={{ background: 'var(--color-surface)' }}
|
onClick={() => {
|
||||||
>
|
setSelected(event)
|
||||||
{event.imageSource && (
|
if (instanceId) {
|
||||||
<div className="relative w-20 shrink-0">
|
trackEvent({
|
||||||
<Image src={event.imageSource} alt="" fill className="object-cover" sizes="80px" />
|
instanceId, configurationId: configId, sectionId: section.id,
|
||||||
</div>
|
eventType: 'AgendaEventTap', language,
|
||||||
)}
|
metadata: JSON.stringify({ eventAgendaId: event.id, title: tPlain(event.label, language) }),
|
||||||
<div className="p-3 flex-1">
|
})
|
||||||
<p className="font-semibold text-sm" style={{ color: 'var(--color-text)' }}>
|
}
|
||||||
{t(event.title, language)}
|
}}
|
||||||
</p>
|
className="w-full rounded-2xl overflow-hidden text-left flex gap-0"
|
||||||
{event.startDate && (
|
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
|
>
|
||||||
{new Date(event.startDate).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}
|
{img && (
|
||||||
</p>
|
<div className="relative w-20 shrink-0">
|
||||||
|
<Image src={img} alt="" fill className="object-cover" sizes="80px" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="p-3 flex-1">
|
||||||
</button>
|
<p
|
||||||
))}
|
className="font-semibold text-sm [&_p]:m-0"
|
||||||
|
style={{ color: 'var(--color-text)' }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: t(event.label, language) }}
|
||||||
|
/>
|
||||||
|
{event.dateFrom && (
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
{new Date(event.dateFrom).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Event detail popup */}
|
{/* Event detail popup */}
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col" style={{ background: 'var(--color-background)' }}>
|
<EventDetail
|
||||||
<AppBar title={tPlain(selected.title, language)} onBack={() => setSelected(null)} />
|
event={selected}
|
||||||
<div className="flex-1 overflow-y-auto">
|
language={language}
|
||||||
{selected.imageSource && (
|
onClose={() => setSelected(null)}
|
||||||
<div className="relative w-full h-48">
|
/>
|
||||||
<Image src={selected.imageSource} alt="" fill className="object-cover" sizes="100vw" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="p-4">
|
|
||||||
{selected.startDate && (
|
|
||||||
<p className="text-sm mb-3" style={{ color: 'var(--color-primary)' }}>
|
|
||||||
{new Date(selected.startDate).toLocaleDateString(language.toLowerCase(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
|
||||||
{selected.endDate && selected.endDate !== selected.startDate && (
|
|
||||||
<> — {new Date(selected.endDate).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{t(selected.description, language) && (
|
|
||||||
<div
|
|
||||||
className="prose prose-sm"
|
|
||||||
style={{ color: 'var(--color-text)' }}
|
|
||||||
dangerouslySetInnerHTML={{ __html: t(selected.description, language) }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EventDetail({
|
||||||
|
event, language, onClose,
|
||||||
|
}: {
|
||||||
|
event: EventAgendaDTO
|
||||||
|
language: string
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const img = eventImage(event)
|
||||||
|
const coords = eventCoords(event)
|
||||||
|
const address = fullAddress(event)
|
||||||
|
const yt = event.idVideoYoutube
|
||||||
|
const videoLink = event.videoLink
|
||||||
|
const videoResource = event.videoResource?.url
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col" style={{ background: 'var(--color-background)' }}>
|
||||||
|
<AppBar title={tPlain(event.label, language)} onBack={onClose} />
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{img && (
|
||||||
|
<div className="relative w-full" style={{ height: 220 }}>
|
||||||
|
<Image src={img} alt="" fill className="object-cover" sizes="100vw" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-4 flex flex-col gap-4">
|
||||||
|
{event.dateFrom && (
|
||||||
|
<div
|
||||||
|
className="self-start px-3 py-1.5 rounded-full text-xs font-bold"
|
||||||
|
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
{new Date(event.dateFrom).toLocaleDateString(language.toLowerCase(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
{event.dateTo && event.dateTo !== event.dateFrom && (
|
||||||
|
<> → {new Date(event.dateTo).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{t(event.description, language) && (
|
||||||
|
<div
|
||||||
|
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"
|
||||||
|
style={{ color: 'var(--color-text)' }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: t(event.description, language) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Video */}
|
||||||
|
{(yt || videoLink || videoResource) && (
|
||||||
|
<div className="rounded-xl overflow-hidden" style={{ background: '#000', aspectRatio: '16/9' }}>
|
||||||
|
{yt ? (
|
||||||
|
<iframe
|
||||||
|
src={youtubeEmbedUrl(yt)}
|
||||||
|
title="Vidéo"
|
||||||
|
className="w-full h-full"
|
||||||
|
allowFullScreen
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
/>
|
||||||
|
) : videoLink ? (
|
||||||
|
videoLink.includes('youtube') || videoLink.includes('youtu.be') ? (
|
||||||
|
<iframe
|
||||||
|
src={youtubeEmbedUrl(videoLink)}
|
||||||
|
title="Vidéo"
|
||||||
|
className="w-full h-full"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
|
<video src={videoLink} controls className="w-full h-full" />
|
||||||
|
)
|
||||||
|
) : videoResource ? (
|
||||||
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
|
<video src={videoResource} controls className="w-full h-full" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mini map */}
|
||||||
|
{coords && (
|
||||||
|
<div className="rounded-xl overflow-hidden" style={{ height: 180, border: '1px solid var(--color-border)' }}>
|
||||||
|
<EventMiniMap lat={coords.lat} lng={coords.lng} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
{address && (
|
||||||
|
<a
|
||||||
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-3 p-3 rounded-xl"
|
||||||
|
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
<Icon name="place" />
|
||||||
|
<span className="text-sm flex-1" style={{ color: 'var(--color-text)' }}>{address}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
{(event.phone || event.email || event.website) && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{event.phone && (
|
||||||
|
<a
|
||||||
|
href={`tel:${event.phone}`}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-xl"
|
||||||
|
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
<Icon name="phone" />
|
||||||
|
<span className="text-sm">{event.phone}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{event.email && (
|
||||||
|
<a
|
||||||
|
href={`mailto:${event.email}`}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-xl"
|
||||||
|
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
<Icon name="email" />
|
||||||
|
<span className="text-sm truncate">{event.email}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{event.website && (
|
||||||
|
<a
|
||||||
|
href={event.website.startsWith('http') ? event.website : `https://${event.website}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-3 p-3 rounded-xl"
|
||||||
|
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
<Icon name="web" />
|
||||||
|
<span className="text-sm truncate">{event.website}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Icon({ name }: { name: 'phone' | 'email' | 'web' | 'place' }) {
|
||||||
|
const paths: Record<string, string> = {
|
||||||
|
phone: 'M6.62 10.79a15.05 15.05 0 0 0 6.59 6.59l2.2-2.2a1 1 0 0 1 1.05-.24c1.12.37 2.33.57 3.57.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57a1 1 0 0 1-.25 1.05l-2.2 2.17z',
|
||||||
|
email: 'M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z',
|
||||||
|
web: 'M12 2A10 10 0 1 0 22 12 10 10 0 0 0 12 2zm6.93 6h-2.95a15.65 15.65 0 0 0-1.38-3.56A8 8 0 0 1 18.92 8zM12 4a14 14 0 0 1 1.81 4h-3.62A14 14 0 0 1 12 4zM4.26 14a8 8 0 0 1 0-4h3.38a16.5 16.5 0 0 0 0 4zm.82 2h2.95a15.65 15.65 0 0 0 1.38 3.56A8 8 0 0 1 5.08 16zm2.95-8H5.08a8 8 0 0 1 4.33-3.56A15.65 15.65 0 0 0 8.05 8zM12 20a14 14 0 0 1-1.81-4h3.62A14 14 0 0 1 12 20zm2.34-6h-4.68a14.6 14.6 0 0 1 0-4h4.68a14.6 14.6 0 0 1 0 4zm.25 5.56A15.65 15.65 0 0 0 15.97 16h2.95a8 8 0 0 1-4.33 3.56zM16.36 14a16.5 16.5 0 0 0 0-4h3.38a8 8 0 0 1 0 4z',
|
||||||
|
place: 'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d={paths[name]} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
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'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useVisitor } from '@/context/VisitorContext'
|
import { useVisitor } from '@/context/VisitorContext'
|
||||||
import { t, tPlain } from '@/lib/i18n'
|
import { t, tPlain } from '@/lib/i18n'
|
||||||
|
import { trackEvent } from '@/lib/stats'
|
||||||
import type { SectionDTO, QuestionDTO } from '@/lib/api/types'
|
import type { SectionDTO, QuestionDTO } from '@/lib/api/types'
|
||||||
import AppBar from '@/components/ui/AppBar'
|
import AppBar from '@/components/ui/AppBar'
|
||||||
|
|
||||||
@ -16,8 +17,8 @@ interface Props {
|
|||||||
|
|
||||||
type Phase = 'quiz' | 'result' | 'review'
|
type Phase = 'quiz' | 'result' | 'review'
|
||||||
|
|
||||||
export default function QuizSection({ section, slug, configId, languages }: Props) {
|
export default function QuizSection({ section, configId, languages }: Props) {
|
||||||
const { language, setAvailableLanguages } = useVisitor()
|
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => { setAvailableLanguages(languages) }, [languages])
|
useEffect(() => { setAvailableLanguages(languages) }, [languages])
|
||||||
@ -28,6 +29,7 @@ export default function QuizSection({ section, slug, configId, languages }: Prop
|
|||||||
const [phase, setPhase] = useState<Phase>('quiz')
|
const [phase, setPhase] = useState<Phase>('quiz')
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
const [answers, setAnswers] = useState<Record<number, number>>({})
|
const [answers, setAnswers] = useState<Record<number, number>>({})
|
||||||
|
const trackedRef = useRef(false)
|
||||||
|
|
||||||
if (questions.length === 0) {
|
if (questions.length === 0) {
|
||||||
return (
|
return (
|
||||||
@ -71,10 +73,19 @@ export default function QuizSection({ section, slug, configId, languages }: Prop
|
|||||||
setAnswers({})
|
setAnswers({})
|
||||||
setCurrentIndex(0)
|
setCurrentIndex(0)
|
||||||
setPhase('quiz')
|
setPhase('quiz')
|
||||||
|
trackedRef.current = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── RESULT SCREEN ────────────────────────────────────────────────────────
|
// ── RESULT SCREEN ────────────────────────────────────────────────────────
|
||||||
if (phase === 'result') {
|
if (phase === 'result') {
|
||||||
|
if (!trackedRef.current && instanceId) {
|
||||||
|
trackedRef.current = true
|
||||||
|
trackEvent({
|
||||||
|
instanceId, configurationId: configId, sectionId: section.id,
|
||||||
|
eventType: 'QuizComplete', language,
|
||||||
|
metadata: JSON.stringify({ score: correctCount, totalQuestions }),
|
||||||
|
})
|
||||||
|
}
|
||||||
const levelText = t(getLevel(), language)
|
const levelText = t(getLevel(), language)
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
|
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
|
||||||
|
|||||||
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
|
setLanguage: (lang: string) => void
|
||||||
availableLanguages: string[]
|
availableLanguages: string[]
|
||||||
setAvailableLanguages: (langs: string[]) => void
|
setAvailableLanguages: (langs: string[]) => void
|
||||||
|
instanceId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const VisitorContext = createContext<VisitorContextValue>({
|
const VisitorContext = createContext<VisitorContextValue>({
|
||||||
@ -14,9 +15,16 @@ const VisitorContext = createContext<VisitorContextValue>({
|
|||||||
setLanguage: () => {},
|
setLanguage: () => {},
|
||||||
availableLanguages: ['FR'],
|
availableLanguages: ['FR'],
|
||||||
setAvailableLanguages: () => {},
|
setAvailableLanguages: () => {},
|
||||||
|
instanceId: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function VisitorProvider({ children }: { children: React.ReactNode }) {
|
export function VisitorProvider({
|
||||||
|
children,
|
||||||
|
instanceId,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
instanceId?: string | null
|
||||||
|
}) {
|
||||||
const [language, setLanguageState] = useState('FR')
|
const [language, setLanguageState] = useState('FR')
|
||||||
const [availableLanguages, setAvailableLanguages] = useState<string[]>(['FR'])
|
const [availableLanguages, setAvailableLanguages] = useState<string[]>(['FR'])
|
||||||
|
|
||||||
@ -31,7 +39,11 @@ export function VisitorProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VisitorContext.Provider value={{ language, setLanguage, availableLanguages, setAvailableLanguages }}>
|
<VisitorContext.Provider value={{
|
||||||
|
language, setLanguage,
|
||||||
|
availableLanguages, setAvailableLanguages,
|
||||||
|
instanceId: instanceId ?? null,
|
||||||
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</VisitorContext.Provider>
|
</VisitorContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
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
|
const BASE_URL = process.env.NEXT_PUBLIC_API_URL
|
||||||
|
|
||||||
@ -29,3 +29,13 @@ export async function getConfiguration(configId: string, apiKey: string): Promis
|
|||||||
export async function getSections(configId: string, apiKey: string): Promise<SectionDTO[]> {
|
export async function getSections(configId: string, apiKey: string): Promise<SectionDTO[]> {
|
||||||
return apiFetch(`/api/Section/configuration/${configId}/detail`, apiKey)
|
return apiFetch(`/api/Section/configuration/${configId}/detail`, apiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getGuidedPaths(sectionMapId: string, apiKey: string): Promise<GuidedPathDTO[]> {
|
||||||
|
return apiFetch(`/api/SectionMap/${sectionMapId}/GuidedPath`, apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape games also expose guided paths. The backend uses the same SectionMap endpoint
|
||||||
|
// since GuidedPath is a polymorphic entity attached via parent ID.
|
||||||
|
export async function getGuidedPathsForGame(sectionGameId: string, apiKey: string): Promise<GuidedPathDTO[]> {
|
||||||
|
return apiFetch(`/api/SectionMap/${sectionGameId}/GuidedPath`, apiKey)
|
||||||
|
}
|
||||||
|
|||||||
@ -26,6 +26,8 @@ export interface ApplicationInstanceDTO {
|
|||||||
loaderImageUrl?: string
|
loaderImageUrl?: string
|
||||||
webSlug?: string
|
webSlug?: string
|
||||||
publicApiKey?: string
|
publicApiKey?: string
|
||||||
|
sectionEventId?: string
|
||||||
|
sectionEventDTO?: SectionDTO
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigurationDTO {
|
export interface ConfigurationDTO {
|
||||||
@ -86,6 +88,35 @@ export interface SectionDTO {
|
|||||||
source?: string
|
source?: string
|
||||||
weather?: WeatherDTO
|
weather?: WeatherDTO
|
||||||
web?: WebDTO
|
web?: WebDTO
|
||||||
|
event?: EventDTO
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapAnnotationDTO {
|
||||||
|
id?: string
|
||||||
|
label?: TranslationDTO[]
|
||||||
|
geometryType?: number // 0=Point, 1=Polyline, 2=Circle, 3=Polygon
|
||||||
|
geometry?: { type?: string; coordinates?: unknown }
|
||||||
|
polyColor?: string
|
||||||
|
icon?: string
|
||||||
|
iconResourceUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgrammeBlock {
|
||||||
|
id?: string
|
||||||
|
title?: TranslationDTO[]
|
||||||
|
description?: TranslationDTO[]
|
||||||
|
startTime?: string // ISO datetime
|
||||||
|
endTime?: string // ISO datetime
|
||||||
|
mapAnnotations?: MapAnnotationDTO[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventDTO {
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
baseSectionMapId?: string
|
||||||
|
parcoursIds?: string[]
|
||||||
|
globalMapAnnotations?: MapAnnotationDTO[]
|
||||||
|
programme?: ProgrammeBlock[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArticleDTO {
|
export interface ArticleDTO {
|
||||||
@ -164,17 +195,70 @@ export interface MapDTO {
|
|||||||
centerLatitude?: string
|
centerLatitude?: string
|
||||||
centerLongitude?: string
|
centerLongitude?: string
|
||||||
isParcours?: boolean
|
isParcours?: boolean
|
||||||
|
guidedPaths?: GuidedPathDTO[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventAgendaDTO {
|
export interface GuidedPathDTO {
|
||||||
id: string
|
id: string
|
||||||
title?: TranslationDTO[]
|
title?: TranslationDTO[]
|
||||||
description?: TranslationDTO[]
|
description?: TranslationDTO[]
|
||||||
imageSource?: string
|
isLinear?: boolean
|
||||||
startDate?: string
|
requireSuccessToAdvance?: boolean
|
||||||
endDate?: string
|
hideNextStepsUntilComplete?: boolean
|
||||||
latitude?: number
|
order?: number
|
||||||
longitude?: number
|
steps?: GuidedStepDTO[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuidedStepDTO {
|
||||||
|
id: string
|
||||||
|
guidedPathId?: string
|
||||||
|
order?: number
|
||||||
|
title?: TranslationDTO[]
|
||||||
|
description?: TranslationDTO[]
|
||||||
|
geometry?: GeometryDTO
|
||||||
|
zoneRadiusMeters?: number
|
||||||
|
imageUrl?: string
|
||||||
|
triggerGeoPointId?: number
|
||||||
|
isHiddenInitially?: boolean
|
||||||
|
isStepTimer?: boolean
|
||||||
|
isStepLocked?: boolean
|
||||||
|
timerSeconds?: number
|
||||||
|
timerExpiredMessage?: TranslationDTO[]
|
||||||
|
quizQuestions?: QuestionDTO[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventAgendaAddressDTO {
|
||||||
|
address?: string
|
||||||
|
streetNumber?: string
|
||||||
|
streetName?: string
|
||||||
|
city?: string
|
||||||
|
state?: string
|
||||||
|
postCode?: string
|
||||||
|
country?: string
|
||||||
|
geometry?: GeometryDTO // coordinates: [lng, lat]
|
||||||
|
polyColor?: string
|
||||||
|
zoom?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventAgendaDTO {
|
||||||
|
id: number
|
||||||
|
label?: TranslationDTO[]
|
||||||
|
description?: TranslationDTO[]
|
||||||
|
type?: string
|
||||||
|
dateAdded?: string
|
||||||
|
dateFrom?: string
|
||||||
|
dateTo?: string
|
||||||
|
website?: string
|
||||||
|
resourceId?: string
|
||||||
|
resource?: ResourceDTO
|
||||||
|
videoResourceId?: string
|
||||||
|
videoResource?: ResourceDTO
|
||||||
|
address?: EventAgendaAddressDTO
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
isSynced?: boolean
|
||||||
|
idVideoYoutube?: string
|
||||||
|
videoLink?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgendaDTO {
|
export interface AgendaDTO {
|
||||||
@ -189,6 +273,7 @@ export interface GameDTO {
|
|||||||
rows?: number
|
rows?: number
|
||||||
cols?: number
|
cols?: number
|
||||||
gameType?: string
|
gameType?: string
|
||||||
|
guidedPaths?: GuidedPathDTO[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranslationAndResourceDTO {
|
export interface TranslationAndResourceDTO {
|
||||||
|
|||||||
20
src/lib/geo.ts
Normal file
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