From 931f35c818cc6eebd82f4feebc6e93d5a020a665 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Mon, 4 May 2026 14:23:50 +0200 Subject: [PATCH] Wip (sections event, game, map +qr code) to be tested ! --- CLAUDE.md | 7 + guided-path-implementation.md | 255 ++++ package-lock.json | 67 +- package.json | 6 +- .../[configId]/sections/[sectionId]/page.tsx | 66 +- src/app/[slug]/layout.tsx | 2 +- src/app/[slug]/page.tsx | 16 +- src/components/FeaturedEvent.tsx | 79 ++ src/components/QRScannerButton.tsx | 136 ++ src/components/TrackedSection.tsx | 14 + src/components/sections/AgendaSection.tsx | 292 ++++- src/components/sections/EventSection.tsx | 436 +++++++ src/components/sections/GameSection.tsx | 243 ++++ src/components/sections/MapSection.tsx | 1098 ++++++++++++++++- src/components/sections/QuizSection.tsx | 17 +- .../sections/agenda/EventMiniMap.tsx | 43 + src/components/sections/event/EventMap.tsx | 115 ++ .../sections/game/EscapeProgression.tsx | 369 ++++++ .../sections/game/MessageDialog.tsx | 78 ++ src/components/sections/game/PuzzleGame.tsx | 202 +++ .../sections/game/SlidingPuzzle.tsx | 163 +++ src/components/sections/map/LeafletMap.tsx | 216 ++++ src/components/sections/map/StepQuiz.tsx | 107 ++ src/components/sections/map/StepTimer.tsx | 61 + src/components/sections/map/Toast.tsx | 37 + src/components/sections/map/map.css | 115 ++ src/context/VisitorContext.tsx | 16 +- src/hooks/useGeolocation.ts | 57 + src/hooks/useSectionTracking.ts | 36 + src/lib/api/client.ts | 12 +- src/lib/api/types.ts | 97 +- src/lib/geo.ts | 20 + src/lib/stats.ts | 64 + todo-features.md | 141 +++ 34 files changed, 4598 insertions(+), 85 deletions(-) create mode 100644 guided-path-implementation.md create mode 100644 src/components/FeaturedEvent.tsx create mode 100644 src/components/QRScannerButton.tsx create mode 100644 src/components/TrackedSection.tsx create mode 100644 src/components/sections/EventSection.tsx create mode 100644 src/components/sections/GameSection.tsx create mode 100644 src/components/sections/agenda/EventMiniMap.tsx create mode 100644 src/components/sections/event/EventMap.tsx create mode 100644 src/components/sections/game/EscapeProgression.tsx create mode 100644 src/components/sections/game/MessageDialog.tsx create mode 100644 src/components/sections/game/PuzzleGame.tsx create mode 100644 src/components/sections/game/SlidingPuzzle.tsx create mode 100644 src/components/sections/map/LeafletMap.tsx create mode 100644 src/components/sections/map/StepQuiz.tsx create mode 100644 src/components/sections/map/StepTimer.tsx create mode 100644 src/components/sections/map/Toast.tsx create mode 100644 src/components/sections/map/map.css create mode 100644 src/hooks/useGeolocation.ts create mode 100644 src/hooks/useSectionTracking.ts create mode 100644 src/lib/geo.ts create mode 100644 src/lib/stats.ts create mode 100644 todo-features.md diff --git a/CLAUDE.md b/CLAUDE.md index ddb5efb..c8f40bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,13 @@ Deux patterns, calqués sur `mymuseum-visitapp` : **Full-screen** (`position: fixed; inset: 0` + dark AppBar) — contenu immersif : - Video, Web, PDF, Map +### Carte (MapSection) + +- Librairie : **Leaflet + react-leaflet**, dynamic-importé (`ssr: false`) car Leaflet a besoin de `window` +- Tiles : **CartoDB Voyager** (gratuit, sans clé API, plus joli qu'OSM brut) +- Markers : pins CSS custom colorés par `categorie.color` (pas d'icônes PNG comme en Flutter — on n'a pas `iconSource` dans le DTO web) +- Équivalent Flutter : `flutter_map` (même rendu Leaflet sous le capot) + Pour les titres : toujours `tPlain()` dans les AppBar et attributs `alt`. Pour les contenus longs : toujours `dangerouslySetInnerHTML` (Quill → HTML). diff --git a/guided-path-implementation.md b/guided-path-implementation.md new file mode 100644 index 0000000..9c1d703 --- /dev/null +++ b/guided-path-implementation.md @@ -0,0 +1,255 @@ +# GuidedPath — Implémentation visitapp-web + +Suivi d'implémentation du parcours guidé pour `visitapp-web`. Chaque phase peut être interrompue/reprise indépendamment. + +--- + +## ✅ À tester (checklist complète) + +### Pré-requis +- [ ] Avoir au moins une `SectionMap` avec quelques GeoPoints + au moins un parcours (idéalement plusieurs étapes) +- [ ] Avoir au moins un `SectionGame` de type `Escape` avec un parcours +- [ ] Tester depuis un device avec GPS (mobile ou navigateur desktop avec localisation activée) +- [ ] Tester en HTTPS (la Geolocation API est bloquée en HTTP sauf sur `localhost`) + +### Mode Carte (SectionMap) + +**Affichage de base** +- [ ] Sur une SectionMap **sans** parcours : aucun bouton "Parcours" visible +- [ ] Sur une SectionMap **avec** parcours : bouton **"Parcours (N)"** dans la top bar +- [ ] Tap sur le bouton → bottom sheet liste les parcours avec titre + nb étapes + badge "linéaire" si applicable + +**Sélection d'un parcours** +- [ ] Tap sur un parcours dans la liste → la carte se recadre (flyToBounds) sur les étapes +- [ ] Polyline pointillée colorée relie les étapes dans l'ordre +- [ ] Markers étapes numérotés (1, 2, 3…) visibles +- [ ] POI passent en opacity réduite (0.45) +- [ ] Bouton change en **"Étape 1/N"** +- [ ] Bouton **"Quitter le parcours"** apparaît en bottom-left + +**États visuels des markers étapes** +- [ ] Étape **courante** : couleur primaire avec animation pulse +- [ ] Étape **complétée** : vert avec ✓ +- [ ] Étape **future** : gris clair +- [ ] Étape avec `isStepLocked` : gris avec 🔒 +- [ ] Tap sur un marker step → bottom sheet de détail s'ouvre + +**Marqueur utilisateur GPS** +- [ ] Premier accès au parcours → demande de permission GPS du navigateur +- [ ] Permission accordée → point bleu pulsant apparaît à votre position +- [ ] Le point se déplace en live si vous bougez + +**StepDetail (bottom sheet d'une étape)** +- [ ] Header avec image de l'étape + badge "Étape N/M" +- [ ] Titre + description (HTML rendu) +- [ ] Si `zoneRadiusMeters > 0` : badge "À X m de la zone" qui passe vert "Vous êtes dans la zone" quand vous approchez +- [ ] Si GPS refusé : bouton "Localisation refusée — réessayer" (clic → re-demande la permission) +- [ ] Boutons Précédent/Suivant **uniquement** en mode non-linéaire + +**Validation et progression** +- [ ] Bouton **"Marquer terminée"** sur l'étape courante +- [ ] Si `requireSuccessToAdvance` + zone non atteinte → bouton désactivé +- [ ] Validation → étape passe en complétée + ouverture auto de la suivante +- [ ] Dernière étape validée → modal **"Bravo !"** avec "Recommencer" / "Retour à la carte" +- [ ] Étape déjà complétée → badge "✓ Étape terminée" affiché à la place du bouton + +**Quiz par étape** +- [ ] Si `step.quizQuestions` non vide → bloc "Défi" avec QCM +- [ ] Sélection des réponses → bouton "Valider mes réponses" s'active quand toutes répondues +- [ ] Validation → feedback vert (correct) / rouge (mauvaise sélection) sur les choix +- [ ] Si tout correct (100%) → badge "✓ Défi réussi" +- [ ] Si pas 100% → bouton "Réessayer" (réinitialise le quiz) +- [ ] Si `requireSuccessToAdvance` et quiz non passé → bouton "Marquer terminée" désactivé + +**Timer par étape** +- [ ] Si `step.isStepTimer && step.timerSeconds > 0` → barre décompte affichée +- [ ] Couleur barre : vert > 50%, orange 25-50%, rouge < 25% +- [ ] Format temps : MM:SS +- [ ] À expiration → message `timerExpiredMessage` affiché en rouge (si défini) + +**Toast d'entrée en zone** +- [ ] Première fois que vous entrez dans la zone de l'étape courante → toast vert "Zone atteinte : « titre »" +- [ ] Le toast disparaît après ~3.5s +- [ ] Si vous quittez puis revenez dans la zone : pas de re-déclenchement (1× par étape) + +**Modes de parcours** +- [ ] `isLinear = true` : pas de boutons Précédent/Suivant, progression strictement par validation +- [ ] `isLinear = false` : navigation libre Précédent/Suivant entre étapes +- [ ] `hideNextStepsUntilComplete = true` : seules les étapes complétées + courante visibles sur la carte +- [ ] `isHiddenInitially` sur une étape : invisible tant qu'elle n'est pas devenue courante ou complétée + +**Lock** +- [ ] `isStepLocked` + `requireSuccessToAdvance` → message "🔒 Étape verrouillée — validez la condition" +- [ ] Validation (quiz passé OU dans la zone) → message "🔒 Étape déverrouillée" + +### Mode Game Escape (SectionGame type Escape) + +- [ ] Sur un Game type Escape **sans** parcours : stub "Parcours guidé bientôt disponible" +- [ ] Avec parcours : `EscapeProgression` rendu (vue verticale, pas de carte) +- [ ] Si plusieurs parcours : sélecteur initial avec liste de cartes +- [ ] Si un seul parcours : auto-sélectionné +- [ ] Compteur "N/M étapes" affiché en haut + bouton "Changer de parcours" (si plusieurs) +- [ ] Étapes empilées verticalement (carte par étape) +- [ ] Étape courante : bordure couleur primaire, étapes complétées vertes, futures grisées (opacity 0.6) +- [ ] Quiz / Timer / Toast / GPS retry / lock fonctionnent identiquement au mode carte +- [ ] Modal de fin avec "Recommencer" / "Retour" + +### Bugs connus à valider +- [ ] **Endpoint Game Escape** : on utilise `/api/SectionMap/{id}/GuidedPath` pour les SectionGame aussi (hypothèse polymorphe). Si ça renvoie 404, le backend doit exposer `/api/SectionGame/{id}/GuidedPath` ou inclure `guidedPaths` dans le DTO `/api/Section/configuration/{id}/detail` + +--- + +## Contexte + +- **Pas un type de Section standalone** : GuidedPath est une entité attachée à `SectionMap`, `SectionGame` (type Escape), ou `SectionEvent`. +- **Endpoint backend** : `GET /api/SectionMap/{sectionMapId}/GuidedPath` retourne les parcours avec `steps + quizQuestions + triggerGeoPoint` eager-loadés. +- **Référence Flutter** : + - `mymuseum-visitapp/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart` (mode carte, ~770 lignes) + - `mymuseum-visitapp/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart` (mode contenu, ~450 lignes) + - `mymuseum-visitapp/lib/Screens/Sections/GuidedPath/guided_path_list_sheet.dart` (sélecteur) + - `mymuseum-visitapp/lib/Screens/Sections/GuidedPath/guided_step_timer.dart` (widget timer) + +--- + +## Phase 1 — Découverte et affichage statique sur la carte + +**Objectif** : permettre au visiteur de voir qu'il y a des parcours, choisir un parcours, et voir ses étapes tracées sur la carte (sans GPS, sans quiz, sans validation). + +### Fichiers touchés + +- `src/lib/api/types.ts` : ajout `GuidedPathDTO`, `GuidedStepDTO`, champ `guidedPaths?` sur `MapDTO` +- `src/lib/api/client.ts` : nouvelle fonction `getGuidedPaths(sectionMapId, apiKey)` +- `src/components/sections/MapSection.tsx` : + - fetch des parcours au mount (si `map.points` existe) + - bouton flottant "Parcours" en haut-droite (si parcours dispo) + - bottom sheet listant les parcours (titre + nb étapes) + - état `selectedPathId` qui filtre l'affichage carte +- `src/components/sections/map/LeafletMap.tsx` : + - prop optionnelle `pathSteps?: { lat, lng, order, title }[]` + - rendu polyline reliant les étapes + - markers étapes numérotés (style différent des points POI) + +### États UX à couvrir + +- Aucun parcours dispo → bouton caché +- Parcours dispo, aucun sélectionné → bouton "Parcours (N)" + ouverture liste au tap +- Parcours sélectionné → polyline + steps numérotés sur la carte + bouton "Quitter le parcours" +- Tap sur step → bottom sheet de l'étape (titre, image, description, badge ordre) + +### Hors scope Phase 1 + +- GPS tracking +- Quiz par étape +- Timer +- Lock / progression réelle +- Page de progression dédiée (full-screen) + +### État + +- [x] Types (`GuidedPathDTO`, `GuidedStepDTO`, champ `guidedPaths` sur `MapDTO`) +- [x] API client `getGuidedPaths(sectionMapId)` +- [x] Pré-fetch server-side dans `app/[slug]/[configId]/sections/[sectionId]/page.tsx` (les paths sont injectés dans `section.map.guidedPaths` avant le render client) +- [x] Bouton "Parcours" dans la top bar de `MapSection` (visible si paths > 0, change en "Étape N/M" quand parcours actif) +- [x] Bottom sheet `PathListSheet` listant les parcours (titre + nb étapes + flag linéaire) +- [x] Polyline pointillée + step markers numérotés stylés (CSS pulse sur sélection) +- [x] Bottom sheet `StepDetail` (image header + titre + description + badge zone + nav précédent/suivant) +- [x] Bouton "Quitter le parcours" en bottom-left quand un parcours est actif +- [x] Auto-fit des bounds de la carte sur les étapes du parcours sélectionné +- [x] POI dim (opacity 0.45) quand un parcours est actif pour mettre les étapes en avant + +--- + +## Phase 2 — Progression complète (en cours) + +**Objectif** : reproduire `GuidedPathMapProgressionPage` du Flutter. + +### Fait + +- [x] `src/lib/geo.ts` : `haversineMeters()` + `formatDistance()` +- [x] `src/hooks/useGeolocation.ts` : hook `watchPosition` avec états `idle | requesting | granted | denied | unavailable` +- [x] Hook activé seulement quand un parcours est actif (économie de batterie) +- [x] StepDetail affiche en live : + - Distance jusqu'à la zone (formatée m/km) + - Badge vert "Vous êtes dans la zone" si `distance <= zoneRadiusMeters` + - États dégradés : "Localisation en cours…", "Localisation refusée", "GPS indisponible" + +### Fait (suite — 2e itération) + +- [x] **Marqueurs étapes différenciés** dans `LeafletMap` : + - Étape courante : couleur primaire pulsante (animation `mim-step-current`), z-index 2000 + - Étape complétée : vert (#16a34a) avec ✓ + - Étape locked : gris avec 🔒 + - Étape future : gris clair +- [x] **Marqueur utilisateur** : point bleu pulsant (`mim-user`), position = `geo.lat/lng`, z-index 3000, non-interactif +- [x] **Progression in-memory** dans `MapSection` : + - `completedSteps: Set` reset à chaque changement de parcours + - `currentStepId` = première étape non complétée dans l'ordre + - `markStepComplete()` : ajoute aux completed + avance auto vers la suivante (ou ouvre la modal de fin si dernière) +- [x] **`hideNextStepsUntilComplete`** géré : `visibleSteps` slice à `currentStep + 1`, polyline et markers respectent +- [x] **Conditions `canAdvance`** dans `StepDetail` : + - `!requireSuccess || radius === 0 || inZone || geo refusé/indispo` + - Bouton "Marquer terminée" / "Terminer le parcours" disabled sinon, avec tooltip explicatif +- [x] **Mode non-linéaire** : nav Précédent/Suivant n'apparaît que si `!path.isLinear`. En linéaire, l'avancement est strictement par validation +- [x] **Badge "✓ Étape terminée"** affiché si l'étape consultée est déjà complétée +- [x] **Modal de fin** `EndOfPathModal` : bravo + boutons "Retour à la carte" / "Recommencer" + +### Fait (suite — 3e itération, finale Phase 2) + +- [x] **`StepTimer`** ([map/StepTimer.tsx](visitapp-web/src/components/sections/map/StepTimer.tsx)) : barre décroissante + MM:SS + couleurs vert/orange/rouge (≥50/≥25/<25%), callback `onExpired` +- [x] **`StepQuiz`** ([map/StepQuiz.tsx](visitapp-web/src/components/sections/map/StepQuiz.tsx)) : QCM par question (réponses ordonnées), bouton Valider si toutes répondues, feedback vert/rouge sur les bonnes/mauvaises réponses après validation, bouton Réessayer, callback `(passed, score)`. Passe = 100% +- [x] **`Toast`** ([map/Toast.tsx](visitapp-web/src/components/sections/map/Toast.tsx)) : toast vert qui apparaît en haut, auto-dismiss 3.5s, animation slide-in +- [x] **Quiz / Timer / Toast intégrés dans `StepDetail`** : + - Quiz affiché si `step.quizQuestions` non vide ET étape courante non complétée + - Timer affiché si `step.isStepTimer && step.timerSeconds > 0` ET étape courante + - Toast déclenché à la première entrée dans la zone d'une étape (transition false→true) +- [x] **Conditions `canAdvance` composées** : + ``` + quizOk = !hasQuiz || quizPassed || !requireSuccess + zoneOk = radius === 0 || inZone || !requireSuccess || geoUnavailable + lockOk = !isLocked || !requireSuccess || quizPassed || inZone || geoUnavailable + canAdvance = quizOk && zoneOk && lockOk + ``` +- [x] **Lock dur** : `isStepLocked` impose au moins une preuve (quiz passé OU dans la zone) pour valider quand `requireSuccess` +- [x] **`isHiddenInitially`** : filtré dans `visibleSteps` — uniquement affichées si complétée ou devenue courante +- [x] **Retry permission GPS** : bouton "Localisation refusée — réessayer" dans le StepDetail, qui incrémente `geoEnabledKey` → re-mount du watcher +- [x] **Hook `useGeolocation`** étendu pour accepter un `refreshKey` qui force le re-watch + +### Phase 2.bis — Game Escape (FAIT) + +- [x] `GameDTO.guidedPaths` ajouté aux types +- [x] `getGuidedPathsForGame()` dans le client API (utilise le même endpoint `/api/SectionMap/{id}/GuidedPath` car la table `GuidedPath` est polymorphe via parent ID — ajustable si le backend expose un endpoint dédié) +- [x] Server component pré-fetch les paths pour les sections Game de type Escape +- [x] [`game/EscapeProgression.tsx`](visitapp-web/src/components/sections/game/EscapeProgression.tsx) — vue verticale sans carte, avec : + - Sélecteur de parcours si plusieurs (auto-sélection si un seul) + - Liste verticale des étapes (cartes empilées) + - Étape courante mise en valeur avec bordure primaire, étapes complétées en vert, étapes futures grisées + - Réutilise `StepTimer`, `StepQuiz`, `Toast`, `MessageDialog` + - Compteur "N/M étapes" en haut + bouton "Changer de parcours" + - Modal de fin avec Recommencer / Retour +- [x] `GameSection` branche `EscapeProgression` quand `gameType === 'Escape'` ET `guidedPaths` non vide, sinon fallback `EscapeStub` + +--- + +## État global + +**Tout le périmètre GuidedPath spécifié est implémenté** côté visitapp-web : +- Mode carte (Map section) : sélection parcours, polyline + steps numérotés, marqueurs différenciés (courant/complété/locked/future), marqueur user GPS, progression in-memory, conditions composées, quiz/timer/lock/toast/retry GPS, modal de fin +- Mode contenu (Game type Escape) : même logique sans la carte, vue empilée verticale + +**Points d'attention pour validation backend** : +- L'endpoint `/api/SectionMap/{id}/GuidedPath` est utilisé pour Map ET Game Escape (hypothèse : table polymorphe via parent ID). À tester avec une section Game Escape réelle ; sinon le backend doit exposer `/api/SectionGame/{id}/GuidedPath` ou inclure `guidedPaths` directement dans le DTO retourné par `/api/Section/configuration/{id}/detail` +- [ ] **Modal de fin de parcours** : à la dernière étape validée, afficher un dialog "Bravo !" + bouton retour +- [ ] **Permission GPS** : si `geo.status === 'denied'`, afficher un overlay explicatif avec bouton "Réessayer" qui re-mount le hook (nécessite un `key` ou un `setEnabled(false); setEnabled(true)`) + +### Phase 2.bis — Mode Game Escape + +- [ ] Étendre `GameDTO` web avec `guidedPaths?: GuidedPathDTO[]` +- [ ] Endpoint backend équivalent : à vérifier (probablement `GET /api/SectionGame/{id}/GuidedPath` ou inclus dans le DTO) +- [ ] Page `GuidedPathContentProgressionPage` (sans carte, vue verticale) +- [ ] Brancher depuis `GameSection` quand `gameType === 'Escape'` (remplacer le stub actuel `EscapeStub`) + +### Phase 2.bis — Mode Game Escape + +- Page `GuidedPathContentProgressionPage` (sans carte, vue verticale) +- Brancher depuis `GameSection` quand `gameType === 'Escape'` +- Nécessite ajout `guidedPaths?` sur `GameDTO` diff --git a/package-lock.json b/package-lock.json index da9bac1..8165a06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,14 @@ "name": "visitapp-web", "version": "0.1.0", "dependencies": { + "@types/leaflet": "^1.9.21", "color2k": "^2.0.3", + "leaflet": "^1.9.4", "next": "16.2.4", + "qr-scanner": "^1.4.2", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "react-leaflet": "^5.0.0" }, "devDependencies": { "@hey-api/openapi-ts": "^0.95.0", @@ -861,6 +865,17 @@ "node": ">= 10" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1141,6 +1156,12 @@ "tailwindcss": "4.2.4" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1148,6 +1169,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", @@ -1158,6 +1188,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1612,6 +1648,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2126,6 +2168,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/qr-scanner": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz", + "integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==", + "license": "MIT", + "dependencies": { + "@types/offscreencanvas": "^2019.6.4" + } + }, "node_modules/rc9": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", @@ -2158,6 +2209,20 @@ "react": "^19.2.4" } }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", diff --git a/package.json b/package.json index 92d1cd4..de86d76 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,14 @@ "start": "next start" }, "dependencies": { + "@types/leaflet": "^1.9.21", "color2k": "^2.0.3", + "leaflet": "^1.9.4", "next": "16.2.4", + "qr-scanner": "^1.4.2", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "react-leaflet": "^5.0.0" }, "devDependencies": { "@hey-api/openapi-ts": "^0.95.0", diff --git a/src/app/[slug]/[configId]/sections/[sectionId]/page.tsx b/src/app/[slug]/[configId]/sections/[sectionId]/page.tsx index 0434bf2..6b26323 100644 --- a/src/app/[slug]/[configId]/sections/[sectionId]/page.tsx +++ b/src/app/[slug]/[configId]/sections/[sectionId]/page.tsx @@ -1,5 +1,5 @@ import { notFound } from 'next/navigation' -import { getInstanceBySlug, getConfiguration, getSections } from '@/lib/api/client' +import { getInstanceBySlug, getConfiguration, getSections, getGuidedPaths, getGuidedPathsForGame } from '@/lib/api/client' import type { SectionDTO } from '@/lib/api/types' // Section components @@ -13,6 +13,9 @@ import PdfSection from '@/components/sections/PdfSection' import WeatherSection from '@/components/sections/WeatherSection' import WebSection from '@/components/sections/WebSection' import QuizSection from '@/components/sections/QuizSection' +import GameSection from '@/components/sections/GameSection' +import EventSection from '@/components/sections/EventSection' +import TrackedSection from '@/components/TrackedSection' export default async function SectionPage({ params, @@ -35,24 +38,63 @@ export default async function SectionPage({ const section: SectionDTO | undefined = sections.find((s) => s.id === sectionId) if (!section || section.isActive === false) notFound() + // For Map sections, eagerly load the guided paths server-side and merge into the DTO + if (section.type === 'Map' && section.map) { + try { + const paths = await getGuidedPaths(section.id, instance.publicApiKey!) + section.map = { ...section.map, guidedPaths: paths } + } catch { + // Silently ignore — Map still works without paths + } + } + + // Escape Games also expose guided paths + if (section.type === 'Game' && section.game?.gameType?.toLowerCase().includes('escape')) { + try { + const paths = await getGuidedPathsForGame(section.id, instance.publicApiKey!) + section.game = { ...section.game, guidedPaths: paths } + } catch { + // Silently ignore + } + } + + // Events also expose guided paths (same polymorphic endpoint) + if (section.type === 'Event' && section.event) { + try { + const paths = await getGuidedPaths(section.id, instance.publicApiKey!) + ;(section.event as { guidedPaths?: typeof paths }).guidedPaths = paths + } catch { + // Silently ignore + } + } + const props = { section, slug, configId, languages: config.languages ?? ['FR'] } + let content: React.ReactNode switch (section.type) { - case 'Article': return - case 'Agenda': return - case 'Menu': return - case 'Slider': return - case 'Video': return - case 'Map': return - case 'Pdf': return - case 'Weather': return - case 'Quiz': return - case 'Web': return + case 'Article': content = ; break + case 'Agenda': content = ; break + case 'Menu': content = ; break + case 'Slider': content = ; break + case 'Video': content = ; break + case 'Map': content = ; break + case 'Pdf': content = ; break + case 'Weather': content = ; break + case 'Quiz': content = ; break + case 'Game': content = ; break + case 'Event': content = ; break + case 'Web': content = ; break default: - return ( + content = (
Section de type « {section.type} » à venir.
) } + + return ( + + {content} + + ) } diff --git a/src/app/[slug]/layout.tsx b/src/app/[slug]/layout.tsx index f08dc80..57dee21 100644 --- a/src/app/[slug]/layout.tsx +++ b/src/app/[slug]/layout.tsx @@ -22,7 +22,7 @@ export default async function SlugLayout({ const theme = resolveColors(instance) return ( - +
{children}
diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index 04f003d..21aa6c4 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -1,6 +1,8 @@ import { getInstanceBySlug, getConfigurations } from '@/lib/api/client' import { notFound } from 'next/navigation' import ConfigurationGrid from '@/components/ConfigurationGrid' +import FeaturedEvent from '@/components/FeaturedEvent' +import QRScannerButton from '@/components/QRScannerButton' export default async function HomePage({ params, @@ -17,5 +19,17 @@ export default async function HomePage({ notFound() } - return + const featuredEvent = instance.sectionEventDTO + // The featured event is rendered inside its parent configuration's context + const featuredConfigId = featuredEvent?.configurationId + + return ( + <> + {featuredEvent && featuredConfigId && ( + + )} + + + + ) } diff --git a/src/components/FeaturedEvent.tsx b/src/components/FeaturedEvent.tsx new file mode 100644 index 0000000..cfa32b0 --- /dev/null +++ b/src/components/FeaturedEvent.tsx @@ -0,0 +1,79 @@ +'use client' + +import Link from 'next/link' +import Image from 'next/image' +import { useVisitor } from '@/context/VisitorContext' +import { t, tPlain } from '@/lib/i18n' +import type { SectionDTO } from '@/lib/api/types' + +interface Props { + event: SectionDTO + slug: string + configurationId: string +} + +function formatDateRange(start?: string, end?: string, locale: string = 'fr'): string { + if (!start) return '' + const d1 = new Date(start) + const d2 = end ? new Date(end) : null + const opt: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' } + if (!d2 || d1.toDateString() === d2.toDateString()) { + return d1.toLocaleDateString(locale, opt) + } + return `${d1.toLocaleDateString(locale, opt)} → ${d2.toLocaleDateString(locale, opt)}` +} + +export default function FeaturedEvent({ event, slug, configurationId }: Props) { + const { language } = useVisitor() + const start = event.event?.startDate + const end = event.event?.endDate + const dateLabel = formatDateRange(start, end, language.toLowerCase()) + + return ( + + {event.imageSource ? ( + {tPlain(event.title, + ) : ( +
+ )} +
+ +
+ À la une +
+ +
+ {dateLabel && ( + + {dateLabel} + + )} +

+

+ + ) +} diff --git a/src/components/QRScannerButton.tsx b/src/components/QRScannerButton.tsx new file mode 100644 index 0000000..38afd2f --- /dev/null +++ b/src/components/QRScannerButton.tsx @@ -0,0 +1,136 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' +import { useVisitor } from '@/context/VisitorContext' +import { trackEvent } from '@/lib/stats' + +interface Props { + slug: string + configurationId?: string +} + +// Parses a QR payload and returns a navigation path within the current app, or null. +// Accepts: +// https://app.myinfomate.be/{slug} +// https://app.myinfomate.be/{slug}/{configId} +// https://app.myinfomate.be/{slug}/{configId}/sections/{sectionId} +// /{slug}/... (relative) +function parseQrToPath(payload: string): string | null { + try { + const m = payload.match(/(?:https?:\/\/[^/]+)?(\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*(?:\/sections\/[A-Za-z0-9_-]+)?)/) + if (m && m[1]) return m[1] + } catch { + // ignore + } + return null +} + +export default function QRScannerButton({ slug, configurationId }: Props) { + const router = useRouter() + const { instanceId, language } = useVisitor() + const [open, setOpen] = useState(false) + const [error, setError] = useState(null) + const videoRef = useRef(null) + const scannerRef = useRef<{ destroy: () => void } | null>(null) + + useEffect(() => { + if (!open) return + let cancelled = false + setError(null) + + ;(async () => { + try { + const { default: QrScanner } = await import('qr-scanner') + if (cancelled || !videoRef.current) return + const scanner = new QrScanner( + videoRef.current, + (result) => { + const payload = typeof result === 'string' ? result : result.data + const path = parseQrToPath(payload) + if (instanceId) { + trackEvent({ + instanceId, + configurationId, + eventType: 'QrScan', + language, + metadata: JSON.stringify({ payload: payload.slice(0, 200), parsed: path }), + }) + } + if (path) { + setOpen(false) + router.push(path) + } else { + setError('Code QR non reconnu') + } + }, + { highlightScanRegion: true, highlightCodeOutline: true, returnDetailedScanResult: true } + ) + await scanner.start() + scannerRef.current = scanner + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Erreur d’accès à la caméra' + setError(msg.includes('Permission') ? 'Accès à la caméra refusé' : msg) + } + })() + + return () => { + cancelled = true + scannerRef.current?.destroy() + scannerRef.current = null + } + }, [open, instanceId, configurationId, language, router]) + + return ( + <> + + + {open && ( +
+
+ )} + {/* slug is unused in the parsing logic but kept in the API for future use cases */} + {slug} + + ) +} diff --git a/src/components/TrackedSection.tsx b/src/components/TrackedSection.tsx new file mode 100644 index 0000000..f068746 --- /dev/null +++ b/src/components/TrackedSection.tsx @@ -0,0 +1,14 @@ +'use client' + +import { useSectionTracking } from '@/hooks/useSectionTracking' + +export default function TrackedSection({ + sectionId, configurationId, children, +}: { + sectionId: string + configurationId: string + children: React.ReactNode +}) { + useSectionTracking(sectionId, configurationId) + return <>{children} +} diff --git a/src/components/sections/AgendaSection.tsx b/src/components/sections/AgendaSection.tsx index 2578f2f..90ff725 100644 --- a/src/components/sections/AgendaSection.tsx +++ b/src/components/sections/AgendaSection.tsx @@ -2,11 +2,22 @@ import { useEffect, useState } from 'react' import Image from 'next/image' +import dynamic from 'next/dynamic' import { useRouter } from 'next/navigation' import { useVisitor } from '@/context/VisitorContext' import { t, tPlain } from '@/lib/i18n' import type { SectionDTO, EventAgendaDTO } from '@/lib/api/types' import AppBar from '@/components/ui/AppBar' +import { trackEvent } from '@/lib/stats' + +const EventMiniMap = dynamic(() => import('./agenda/EventMiniMap'), { + ssr: false, + loading: () => ( +
+ Chargement… +
+ ), +}) interface Props { section: SectionDTO @@ -17,8 +28,37 @@ interface Props { const MONTHS = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc'] -export default function AgendaSection({ section, slug, configId, languages }: Props) { - const { language, setAvailableLanguages } = useVisitor() +function eventImage(e: EventAgendaDTO): string | undefined { + return e.resource?.url +} + +function eventCoords(e: EventAgendaDTO): { lat: number; lng: number } | null { + const c = e.address?.geometry?.coordinates + if (!Array.isArray(c) || c.length < 2) return null + return { lat: c[1], lng: c[0] } +} + +function fullAddress(e: EventAgendaDTO): string { + const a = e.address + if (!a) return '' + return [ + [a.streetNumber, a.streetName].filter(Boolean).join(' '), + [a.postCode, a.city].filter(Boolean).join(' '), + a.country, + ].filter(Boolean).join(', ') +} + +function youtubeEmbedUrl(idOrUrl: string): string { + if (idOrUrl.includes('youtube.com') || idOrUrl.includes('youtu.be')) { + const m = idOrUrl.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/) + if (m) return `https://www.youtube.com/embed/${m[1]}` + return idOrUrl + } + return `https://www.youtube.com/embed/${idOrUrl}` +} + +export default function AgendaSection({ section, configId, languages }: Props) { + const { language, setAvailableLanguages, instanceId } = useVisitor() const router = useRouter() const events = section.agenda?.events ?? [] @@ -30,8 +70,8 @@ export default function AgendaSection({ section, slug, configId, languages }: Pr const [selected, setSelected] = useState(null) const filtered = events.filter((e) => { - if (!e.startDate) return false - const d = new Date(e.startDate) + if (!e.dateFrom) return false + const d = new Date(e.dateFrom) return d.getMonth() === selectedMonth && d.getFullYear() === selectedYear }) @@ -72,62 +112,206 @@ export default function AgendaSection({ section, slug, configId, languages }: Pr Aucun événement ce mois-ci

)} - {filtered.map((event) => ( -
- - ))} +
+

+ {event.dateFrom && ( +

+ {new Date(event.dateFrom).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })} +

+ )} +
+ + ) + })} {/* Event detail popup */} {selected && ( -
- setSelected(null)} /> -
- {selected.imageSource && ( -
- -
- )} -
- {selected.startDate && ( -

- {new Date(selected.startDate).toLocaleDateString(language.toLowerCase(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} - {selected.endDate && selected.endDate !== selected.startDate && ( - <> — {new Date(selected.endDate).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })} - )} -

- )} - {t(selected.description, language) && ( -
- )} -
-
-
+ setSelected(null)} + /> )}
) } + +function EventDetail({ + event, language, onClose, +}: { + event: EventAgendaDTO + language: string + onClose: () => void +}) { + const img = eventImage(event) + const coords = eventCoords(event) + const address = fullAddress(event) + const yt = event.idVideoYoutube + const videoLink = event.videoLink + const videoResource = event.videoResource?.url + + return ( +
+ +
+ {img && ( +
+ +
+ )} +
+ {event.dateFrom && ( +
+ {new Date(event.dateFrom).toLocaleDateString(language.toLowerCase(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} + {event.dateTo && event.dateTo !== event.dateFrom && ( + <> → {new Date(event.dateTo).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })} + )} +
+ )} + + {t(event.description, language) && ( +
+ )} + + {/* Video */} + {(yt || videoLink || videoResource) && ( +
+ {yt ? ( +