Wip most of things, 95% done
This commit is contained in:
parent
67d8ce94d6
commit
e3fbd06e71
2
.gitignore
vendored
2
.gitignore
vendored
@ -40,3 +40,5 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
certificates
|
||||
37
BUGS.md
37
BUGS.md
@ -1,34 +1,17 @@
|
||||
# Bugs & problèmes connus — visitapp-web
|
||||
|
||||
## Sections — données non récupérées
|
||||
## Bugs fixés — suivi / actions restantes
|
||||
|
||||
> **Fix appliqué** : le backend retourne un JSON plat (toutes les DTOs héritent de SectionDTO),
|
||||
> mais le TypeScript attendait une structure imbriquée (`section.map.points` etc.).
|
||||
> `normalizeSectionDTO()` dans `client.ts` restructure la réponse. À valider en prod.
|
||||
- [x] **manager-app — Sauvegarde section Event (500)** — `StartDate`/`EndDate` stockés comme `DateTime.MinValue` (0001-01-01), renvoyés à Flutter qui les convertissait en UTC année 0000, rejeté par Newtonsoft.Json. Fix : dates rendues nullable dans `SectionEvent` + `SectionEventDTO`, désérialisation tolérante, sanitisation côté Flutter dans `event_config.dart`.
|
||||
- **⚠️ Migration encore à faire** : `dotnet ef migrations add MakeEventDatesNullable` dans `manager-service`
|
||||
|
||||
- [x] **Section Carte** — fix normalization + `CategorieDTO.label` (était `name`)
|
||||
- [x] **Section Slider** — fix normalization
|
||||
- [x] **Section Menu** — fix normalization (récursif pour les sous-sections)
|
||||
- [x] **Section Quiz** — fix normalization
|
||||
- [x] **Section Agenda** — fix normalization
|
||||
- [x] **Section Météo** — fix normalization
|
||||
## À implémenter plus tard
|
||||
|
||||
## Affichage
|
||||
- [ ] **Section Event** — page de détail à créer dans visitapp-web (type `SectionType.Event`). Dispatcher par type dans `sections/[sectionId]/page.tsx` à brancher. Voir l'équivalent Flutter dans `mymuseum-visitapp`.
|
||||
|
||||
- [x] **Section Agenda — visuel des cartes** — liste horizontale minimaliste remplacée par une grille 2 colonnes avec image pleine largeur + badge date (calquée sur Flutter `event_list_item.dart`)
|
||||
- [x] **Section PDF** — remplacé `<iframe>` par `<embed type="application/pdf">` avec `#toolbar=0&navpanes=0` pour masquer la barre Adobe/Chrome
|
||||
- [ ] **Contrastes section detail** — les titres et le bouton "back" sont peu visibles (contraste insuffisant)
|
||||
- [x] **Bouton scanner** dans une configuration detail — ombre renforcée + anneau blanc semi-transparent pour contraster quel que soit le fond
|
||||
## À tester
|
||||
|
||||
## Section Menu
|
||||
|
||||
- [ ] **Images non affichées** — les images des items du menu ne s'affichent pas
|
||||
- [ ] **Clic sur un enfant → 404** — la navigation vers une sous-section du menu aboutit à une page 404
|
||||
|
||||
## Section Quiz
|
||||
|
||||
- [ ] **Questions non affichées** — aucune question n'apparaît dans la section Quiz
|
||||
|
||||
## UX
|
||||
|
||||
- [ ] **Puzzle** — l'auto-assignation des pièces manque d'aide visuelle pour l'utilisateur ; ajouter des indications / guides pour faciliter la prise en main
|
||||
- [ ] **Section Agenda — images** — sync `SyncedImageUrl` via Hangfire + vérifier que les images s'affichent bien dans les cartes (web + Flutter)
|
||||
- [ ] **Section Agenda — filtrage par langue** — vérifier que le fetch `?language=XX` retourne bien uniquement les events de la bonne langue, et que changer de langue re-fetche correctement
|
||||
- [ ] **Section Agenda — tri et events passés** — vérifier que les events sont bien triés par date et que les events passés n'apparaissent plus
|
||||
- [ ] **Flutter — langue agenda** — régénérer le client API, passer `language` dans `sectionAgendaGetUpcomingEvents`, vérifier le filtre strict (pas de fallback FR)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ['192.168.31.228'],
|
||||
reactCompiler: true,
|
||||
turbopack: {
|
||||
root: __dirname,
|
||||
|
||||
384
package-lock.json
generated
384
package-lock.json
generated
@ -15,7 +15,8 @@
|
||||
"qr-scanner": "^1.4.2",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-leaflet": "^5.0.0"
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-pdf": "^10.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "^0.95.0",
|
||||
@ -731,6 +732,256 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
|
||||
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
|
||||
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
|
||||
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
|
||||
@ -1198,7 +1449,7 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@ -1350,6 +1601,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-support": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
@ -1412,7 +1672,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/default-browser": {
|
||||
@ -1465,6 +1725,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/destr": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||
@ -1635,6 +1904,12 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
@ -1915,6 +2190,18 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@ -1925,6 +2212,41 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/make-cancellable-promise": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
|
||||
"integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-event-props": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
|
||||
"integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-refs": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz",
|
||||
"integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@ -2101,6 +2423,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.296",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.16.0 || >=22.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||
@ -2223,6 +2557,35 @@
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-pdf": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.4.1.tgz",
|
||||
"integrity": "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"dequal": "^2.0.3",
|
||||
"make-cancellable-promise": "^2.0.0",
|
||||
"make-event-props": "^2.0.0",
|
||||
"merge-refs": "^2.0.0",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"tiny-invariant": "^1.0.0",
|
||||
"warning": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
@ -2400,6 +2763,12 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
|
||||
@ -2437,6 +2806,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/warning": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev:https": "next dev --experimental-https",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
@ -15,7 +16,8 @@
|
||||
"qr-scanner": "^1.4.2",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-leaflet": "^5.0.0"
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-pdf": "^10.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "^0.95.0",
|
||||
|
||||
@ -35,8 +35,10 @@ export default async function SectionPage({
|
||||
notFound()
|
||||
}
|
||||
|
||||
const section: SectionDTO | undefined = sections.find((s) => s.id === sectionId)
|
||||
if (!section || section.isActive === false) notFound()
|
||||
const section: SectionDTO | undefined =
|
||||
sections.find((s) => s.id === sectionId) ??
|
||||
sections.flatMap((s) => s.menu?.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) {
|
||||
@ -52,7 +54,7 @@ export default async function SectionPage({
|
||||
if (section.type === 'Game' && section.gameType === 'Escape') {
|
||||
try {
|
||||
const paths = await getGuidedPathsForGame(section.id, instance.publicApiKey!)
|
||||
section.game = { ...section.game, guidedPaths: paths }
|
||||
section.guidedPaths = paths
|
||||
} catch {
|
||||
// Silently ignore
|
||||
}
|
||||
@ -68,7 +70,7 @@ export default async function SectionPage({
|
||||
}
|
||||
}
|
||||
|
||||
const props = { section, slug, configId, languages: config.languages ?? ['FR'] }
|
||||
const props = { section, slug, configId, languages: config.languages ?? ['FR'], apiKey: instance.publicApiKey! }
|
||||
|
||||
let content: React.ReactNode
|
||||
switch (section.type) {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getInstanceBySlug, getConfigurations } from '@/lib/api/client'
|
||||
import { notFound } from 'next/navigation'
|
||||
import ConfigurationGrid from '@/components/ConfigurationGrid'
|
||||
import FeaturedEvent from '@/components/FeaturedEvent'
|
||||
import HomeHero from '@/components/HomeHero'
|
||||
import QRScannerButton from '@/components/QRScannerButton'
|
||||
|
||||
export default async function HomePage({
|
||||
@ -20,15 +20,17 @@ export default async function HomePage({
|
||||
}
|
||||
|
||||
const featuredEvent = instance.sectionEventDTO
|
||||
// The featured event is rendered inside its parent configuration's context
|
||||
const featuredConfigId = featuredEvent?.configurationId
|
||||
|
||||
const languages = [...new Set(configurations.flatMap((c) => c.languages ?? []))]
|
||||
|
||||
return (
|
||||
<>
|
||||
{featuredEvent && featuredConfigId && (
|
||||
<FeaturedEvent event={featuredEvent} slug={slug} configurationId={featuredConfigId} />
|
||||
{(featuredEvent || instance.mainImageUrl) && (
|
||||
<HomeHero
|
||||
featuredEvent={featuredEvent ?? undefined}
|
||||
mainImageUrl={instance.mainImageUrl}
|
||||
slug={slug}
|
||||
configurationId={featuredEvent?.configurationId ?? undefined}
|
||||
/>
|
||||
)}
|
||||
<ConfigurationGrid configurations={configurations} slug={slug} languages={languages} />
|
||||
<QRScannerButton slug={slug} />
|
||||
|
||||
33
src/app/api/pdf/route.ts
Normal file
33
src/app/api/pdf/route.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const ALLOWED_HOSTS = [
|
||||
'firebasestorage.googleapis.com',
|
||||
'storage.googleapis.com',
|
||||
]
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = req.nextUrl.searchParams.get('url')
|
||||
if (!url) return new NextResponse('Missing url', { status: 400 })
|
||||
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
return new NextResponse('Invalid url', { status: 400 })
|
||||
}
|
||||
|
||||
if (!ALLOWED_HOSTS.includes(parsed.hostname)) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return new NextResponse('Upstream error', { status: res.status })
|
||||
|
||||
const body = await res.arrayBuffer()
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -6,7 +6,6 @@ import Image from 'next/image'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
import type { ConfigurationDTO } from '@/lib/api/types'
|
||||
import AppBar from '@/components/ui/AppBar'
|
||||
|
||||
interface Props {
|
||||
configurations: ConfigurationDTO[]
|
||||
@ -22,8 +21,6 @@ export default function ConfigurationGrid({ configurations, slug, languages }: P
|
||||
|
||||
return (
|
||||
<main className="min-h-screen" style={{ background: 'var(--color-background)' }}>
|
||||
<AppBar />
|
||||
|
||||
<div className="p-4 columns-2 gap-3">
|
||||
{active.map((config) => (
|
||||
<Link
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
117
src/components/HomeHero.tsx
Normal file
117
src/components/HomeHero.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
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'
|
||||
import LanguageSelector from '@/components/ui/LanguageSelector'
|
||||
|
||||
interface Props {
|
||||
featuredEvent?: SectionDTO
|
||||
mainImageUrl?: string
|
||||
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 HomeHero({ featuredEvent, mainImageUrl, slug, configurationId }: Props) {
|
||||
const { language } = useVisitor()
|
||||
const heroRef = useRef<HTMLDivElement>(null)
|
||||
const [opacity, setOpacity] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
if (!heroRef.current) return
|
||||
const heroHeight = heroRef.current.offsetHeight
|
||||
setOpacity(Math.max(0, 1 - window.scrollY / heroHeight))
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
const imageUrl = featuredEvent?.imageSource ?? mainImageUrl
|
||||
const isClickable = !!featuredEvent && !!configurationId
|
||||
|
||||
const dateLabel = formatDateRange(
|
||||
featuredEvent?.event?.startDate,
|
||||
featuredEvent?.event?.endDate,
|
||||
language.toLowerCase()
|
||||
)
|
||||
|
||||
const inner = (
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-b-3xl"
|
||||
style={{
|
||||
height: '50vh',
|
||||
minHeight: 200,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.35)',
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={featuredEvent ? tPlain(featuredEvent.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.72) 0%, rgba(0,0,0,0.08) 55%)' }}
|
||||
/>
|
||||
|
||||
{featuredEvent && (
|
||||
<div className="absolute bottom-4 left-4 right-12 flex flex-col gap-2">
|
||||
<h2
|
||||
className="text-white text-xl font-bold leading-tight [&_p]:m-0 line-clamp-2"
|
||||
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
|
||||
dangerouslySetInnerHTML={{ __html: t(featuredEvent.title, language) || '' }}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={heroRef} style={{ opacity, position: 'relative' }}>
|
||||
{isClickable ? (
|
||||
<Link href={`/${slug}/${configurationId}/sections/${featuredEvent!.id}`} className="block">
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
inner
|
||||
)}
|
||||
<div className="absolute top-3 right-3 z-10">
|
||||
<LanguageSelector overlay />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -11,19 +11,21 @@ interface Props {
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Flutter generates URLs as: https://web.myinfomate.be/{slug}/{configId}/{sectionId}
|
||||
// Next.js routing expects: /{slug}/{configId}/sections/{sectionId}
|
||||
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
|
||||
const m = payload.match(/(?:https?:\/\/[^/]+)?(\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*)/)
|
||||
if (!m?.[1]) return null
|
||||
const parts = m[1].split('/').filter(Boolean)
|
||||
// 3 segments without "sections" = Flutter format → rewrite to Next.js route
|
||||
if (parts.length === 3 && parts[1] !== 'sections') {
|
||||
return `/${parts[0]}/${parts[1]}/sections/${parts[2]}`
|
||||
}
|
||||
return m[1]
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default function QRScannerButton({ slug, configurationId }: Props) {
|
||||
@ -47,7 +49,12 @@ export default function QRScannerButton({ slug, configurationId }: Props) {
|
||||
videoRef.current,
|
||||
(result) => {
|
||||
const payload = typeof result === 'string' ? result : result.data
|
||||
const path = parseQrToPath(payload)
|
||||
let path = parseQrToPath(payload)
|
||||
// QR codes from Flutter encode the instanceId (ObjectId), not the webSlug.
|
||||
// Replace it with the known slug so Next.js routing resolves correctly.
|
||||
if (path && instanceId) {
|
||||
path = path.replace(new RegExp(`^/${instanceId}/`), `/${slug}/`)
|
||||
}
|
||||
if (instanceId) {
|
||||
trackEvent({
|
||||
instanceId,
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain, stripHtml } from '@/lib/i18n'
|
||||
import type { SectionDTO, TranslationDTO } from '@/lib/api/types'
|
||||
@ -22,7 +22,7 @@ export default function SectionList({
|
||||
sections, slug, configId, configTitle, configImageSource, configPrimaryColor, languages,
|
||||
}: Props) {
|
||||
const { language, setAvailableLanguages } = useVisitor()
|
||||
const router = useRouter()
|
||||
const back = useBack()
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchNumber, setSearchNumber] = useState('')
|
||||
|
||||
@ -51,11 +51,12 @@ export default function SectionList({
|
||||
)}
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
onClick={back}
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: 44, left: 10, width: 50, height: 50,
|
||||
background: primaryColor,
|
||||
top: 44, left: 10, width: 44, height: 44,
|
||||
background: 'rgba(0,0,0,0.35)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
|
||||
@ -3,12 +3,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
import { t, tPlain, ui } from '@/lib/i18n'
|
||||
import type { SectionDTO, EventAgendaDTO } from '@/lib/api/types'
|
||||
import AppBar from '@/components/ui/AppBar'
|
||||
import { trackEvent } from '@/lib/stats'
|
||||
import { getAgendaEvents } from '@/lib/api/client'
|
||||
|
||||
const EventMiniMap = dynamic(() => import('./agenda/EventMiniMap'), {
|
||||
ssr: false,
|
||||
@ -24,6 +25,7 @@ interface Props {
|
||||
slug: string
|
||||
configId: string
|
||||
languages: string[]
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
function formatMonthHeader(year: number, month: number, lang: string): string {
|
||||
@ -60,25 +62,29 @@ function youtubeEmbedUrl(idOrUrl: string): string {
|
||||
return `https://www.youtube.com/embed/${idOrUrl}`
|
||||
}
|
||||
|
||||
export default function AgendaSection({ section, configId, languages }: Props) {
|
||||
export default function AgendaSection({ section, configId, languages, apiKey }: Props) {
|
||||
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||
const router = useRouter()
|
||||
const events = section.agenda?.events ?? []
|
||||
const back = useBack()
|
||||
const [events, setEvents] = useState<EventAgendaDTO[]>([])
|
||||
|
||||
useEffect(() => { setAvailableLanguages([]) }, [languages])
|
||||
|
||||
useEffect(() => {
|
||||
getAgendaEvents(section.id, language, apiKey).then(setEvents).catch(() => setEvents([]))
|
||||
}, [section.id, language, apiKey])
|
||||
|
||||
const now = new Date()
|
||||
const [selectedMonth, setSelectedMonth] = useState(now.getMonth())
|
||||
const [selectedYear, setSelectedYear] = useState(now.getFullYear())
|
||||
const [selected, setSelected] = useState<EventAgendaDTO | null>(null)
|
||||
|
||||
useEffect(() => { console.log('[Agenda] events:', events.map(e => ({ id: e.id, label: e.label, resourceId: e.resourceId, resource: e.resource }))) }, [events])
|
||||
|
||||
const filtered = events.filter((e) => {
|
||||
const filtered = events
|
||||
.filter((e) => {
|
||||
if (!e.dateFrom) return false
|
||||
const d = new Date(e.dateFrom)
|
||||
return d.getMonth() === selectedMonth && d.getFullYear() === selectedYear
|
||||
})
|
||||
.sort((a, b) => new Date(a.dateFrom!).getTime() - new Date(b.dateFrom!).getTime())
|
||||
|
||||
function prevMonth() {
|
||||
if (selectedMonth === 0) { setSelectedMonth(11); setSelectedYear(y => y - 1) }
|
||||
@ -91,7 +97,7 @@ export default function AgendaSection({ section, configId, languages }: Props) {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
|
||||
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
|
||||
<AppBar title={tPlain(section.title, language)} onBack={back} />
|
||||
|
||||
{/* Month selector */}
|
||||
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||
@ -114,7 +120,7 @@ export default function AgendaSection({ section, configId, languages }: Props) {
|
||||
<main className="flex-1 overflow-y-auto p-3">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-center py-12 text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Aucun événement ce mois-ci
|
||||
{ui('noEventsThisMonth', language)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@ -189,6 +195,11 @@ function EventDetail({
|
||||
language: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [])
|
||||
|
||||
const img = eventImage(event)
|
||||
const coords = eventCoords(event)
|
||||
const address = fullAddress(event)
|
||||
@ -197,25 +208,56 @@ function EventDetail({
|
||||
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">
|
||||
<div className="fixed inset-0 z-50" style={{ background: 'var(--color-background)' }}>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<AppBar title={img ? '' : tPlain(event.label, language)} onBack={onClose} overlay={!!img} />
|
||||
{img && (
|
||||
<div className="relative w-full" style={{ height: 220 }}>
|
||||
<div className="relative w-full" style={{ height: 300, marginTop: -56 }}>
|
||||
<Image src={img} alt="" fill className="object-cover" sizes="100vw" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'linear-gradient(to bottom, rgba(0,0,0,0.45) 0%, transparent 45%, transparent 55%, rgba(0,0,0,0.55) 100%)' }}
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 px-5 pb-5">
|
||||
<h1 className="text-2xl font-bold text-white" style={{ textShadow: '0 2px 10px rgba(0,0,0,0.7)' }}>
|
||||
{tPlain(event.label, language)}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div className="p-5 flex flex-col gap-6">
|
||||
{!img && (
|
||||
<h1 className="text-xl font-bold" style={{ color: 'var(--color-text)' }}>
|
||||
{tPlain(event.label, language)}
|
||||
</h1>
|
||||
)}
|
||||
{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)' }}
|
||||
className="flex items-center gap-3 p-3 rounded-xl"
|
||||
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<div
|
||||
className="shrink-0 w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{ background: 'var(--color-primary-light)' }}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--color-primary)' }}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
{new Date(event.dateFrom).toLocaleDateString(language.toLowerCase(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</div>
|
||||
{event.dateTo && event.dateTo !== event.dateFrom && (
|
||||
<> → {new Date(event.dateTo).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long' })}</>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
|
||||
→ {new Date(event.dateTo).toLocaleDateString(language.toLowerCase(), { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{t(event.description, language) && (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import ResourceViewer from '@/components/ui/ResourceViewer'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
@ -18,7 +18,7 @@ interface Props {
|
||||
|
||||
export default function ArticleSection({ section, configId, languages }: Props) {
|
||||
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||
const router = useRouter()
|
||||
const back = useBack()
|
||||
const article = section.article
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
@ -70,7 +70,7 @@ export default function ArticleSection({ section, configId, languages }: Props)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
|
||||
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
|
||||
<AppBar title={tPlain(section.title, language)} onBack={back} />
|
||||
|
||||
<main ref={scrollRef} className="flex-1 overflow-y-auto pb-24">
|
||||
{/* Carousel images */}
|
||||
|
||||
@ -2,9 +2,11 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
import LanguageSelector from '@/components/ui/LanguageSelector'
|
||||
import type { SectionDTO, ProgrammeBlock, MapAnnotationDTO, GuidedPathDTO } from '@/lib/api/types'
|
||||
|
||||
const EventMap = dynamic(() => import('./event/EventMap'), {
|
||||
@ -42,8 +44,9 @@ function formatTime(iso?: string): string {
|
||||
export default function EventSection({ section, slug, configId, languages }: Props) {
|
||||
const { language, setAvailableLanguages } = useVisitor()
|
||||
const router = useRouter()
|
||||
const back = useBack()
|
||||
|
||||
useEffect(() => { setAvailableLanguages([]) }, [languages])
|
||||
useEffect(() => { setAvailableLanguages(languages) }, [languages])
|
||||
|
||||
const event = section.event
|
||||
const programme = useMemo(
|
||||
@ -102,9 +105,9 @@ export default function EventSection({ section, slug, configId, languages }: Pro
|
||||
)}
|
||||
<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)' }}>
|
||||
<div className="relative z-10 flex items-center justify-between px-3 py-3" style={{ paddingTop: 'max(env(safe-area-inset-top), 12px)' }}>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
onClick={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"
|
||||
@ -113,6 +116,7 @@ export default function EventSection({ section, slug, configId, languages }: Pro
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<LanguageSelector overlay />
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-5 left-5 right-5 flex flex-col gap-2">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
import type { SectionDTO, TranslationDTO } from '@/lib/api/types'
|
||||
@ -30,7 +30,7 @@ function detectKind(raw?: string): GameKind {
|
||||
|
||||
export default function GameSection({ section, configId, languages }: Props) {
|
||||
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||
const router = useRouter()
|
||||
const back = useBack()
|
||||
|
||||
useEffect(() => { setAvailableLanguages([]) }, [languages])
|
||||
|
||||
@ -93,7 +93,7 @@ export default function GameSection({ section, configId, languages }: Props) {
|
||||
|
||||
<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()}
|
||||
onClick={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"
|
||||
@ -216,7 +216,7 @@ export default function GameSection({ section, configId, languages }: Props) {
|
||||
}
|
||||
onClose={restart}
|
||||
primaryAction={{ label: 'Recommencer', onClick: restart }}
|
||||
secondaryAction={{ label: 'Retour', onClick: () => router.back() }}
|
||||
secondaryAction={{ label: 'Retour', onClick: back }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
@ -34,7 +34,7 @@ type Mode = 'map' | 'list'
|
||||
|
||||
export default function MapSection({ section, configId, languages }: Props) {
|
||||
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||
const router = useRouter()
|
||||
const back = useBack()
|
||||
|
||||
useEffect(() => { setAvailableLanguages([]) }, [languages])
|
||||
|
||||
@ -186,7 +186,7 @@ export default function MapSection({ section, configId, languages }: Props) {
|
||||
className="absolute top-0 left-0 right-0 flex items-center gap-2 px-3 py-2 z-[1000]"
|
||||
style={{ background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))', color: 'var(--color-on-primary)' }}
|
||||
>
|
||||
<button onClick={() => router.back()} className="w-10 h-10 rounded-full flex items-center justify-center" aria-label="Retour">
|
||||
<button onClick={back} className="w-10 h-10 rounded-full flex items-center justify-center" aria-label="Retour">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
@ -247,7 +247,7 @@ export default function MapSection({ section, configId, languages }: Props) {
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
onClick={back}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center"
|
||||
style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)' }}
|
||||
aria-label="Retour"
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
import type { SectionDTO } from '@/lib/api/types'
|
||||
@ -19,7 +19,7 @@ interface Props {
|
||||
|
||||
export default function MenuSection({ section, slug, configId, languages }: Props) {
|
||||
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||
const router = useRouter()
|
||||
const back = useBack()
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
function handleItemClick(sub: SectionDTO) {
|
||||
@ -45,7 +45,7 @@ export default function MenuSection({ section, slug, configId, languages }: Prop
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
|
||||
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
|
||||
<AppBar title={tPlain(section.title, language)} onBack={back} />
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { tPlain } from '@/lib/i18n'
|
||||
import type { SectionDTO, OrderedTranslationAndResourceDTO } from '@/lib/api/types'
|
||||
import AppBar from '@/components/ui/AppBar'
|
||||
|
||||
const PdfViewer = dynamic(() => import('./pdf/PdfViewer'), { ssr: false })
|
||||
|
||||
interface Props {
|
||||
section: SectionDTO
|
||||
slug: string
|
||||
@ -16,7 +19,7 @@ interface Props {
|
||||
|
||||
export default function PdfSection({ section, slug, configId, languages }: Props) {
|
||||
const { language, setAvailableLanguages } = useVisitor()
|
||||
const router = useRouter()
|
||||
const back = useBack()
|
||||
|
||||
useEffect(() => { setAvailableLanguages([]) }, [languages])
|
||||
|
||||
@ -35,7 +38,7 @@ export default function PdfSection({ section, slug, configId, languages }: Props
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: 'var(--color-background)' }}>
|
||||
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
|
||||
<AppBar title={tPlain(section.title, language)} onBack={back} />
|
||||
|
||||
{pdfs.length > 1 && (
|
||||
<div className="flex gap-2 px-4 py-2 overflow-x-auto shrink-0">
|
||||
@ -62,11 +65,7 @@ export default function PdfSection({ section, slug, configId, languages }: Props
|
||||
Aucun PDF à afficher
|
||||
</div>
|
||||
) : (
|
||||
<embed
|
||||
src={`${currentUrl}#toolbar=0&navpanes=0`}
|
||||
type="application/pdf"
|
||||
style={{ width: '100%', height: '100%', display: 'block' }}
|
||||
/>
|
||||
<PdfViewer url={currentUrl} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import Image from 'next/image'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
import { trackEvent } from '@/lib/stats'
|
||||
import type { SectionDTO, QuestionDTO } from '@/lib/api/types'
|
||||
import type { SectionDTO, TranslationAndResourceDTO, ResourceDTO } from '@/lib/api/types'
|
||||
import AppBar from '@/components/ui/AppBar'
|
||||
import ResourceViewer from '@/components/ui/ResourceViewer'
|
||||
|
||||
interface Props {
|
||||
section: SectionDTO
|
||||
@ -17,9 +19,24 @@ interface Props {
|
||||
|
||||
type Phase = 'quiz' | 'result' | 'review'
|
||||
|
||||
function isAudio(r: ResourceDTO) {
|
||||
const t = r.type as string | number | undefined
|
||||
return t === 'Audio' || t === 4
|
||||
}
|
||||
|
||||
function isVideo(r: ResourceDTO) {
|
||||
const t = r.type as string | number | undefined
|
||||
return t === 'Video' || t === 'VideoUrl' || t === 1 || t === 3
|
||||
}
|
||||
|
||||
function isImage(r: ResourceDTO) {
|
||||
const t = r.type as string | number | undefined
|
||||
return t === 'Image' || t === 'ImageUrl' || t === 0 || t === 2
|
||||
}
|
||||
|
||||
export default function QuizSection({ section, configId, languages }: Props) {
|
||||
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||
const router = useRouter()
|
||||
const back = useBack()
|
||||
|
||||
useEffect(() => { setAvailableLanguages([]) }, [languages])
|
||||
|
||||
@ -29,12 +46,13 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
const [phase, setPhase] = useState<Phase>('quiz')
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [answers, setAnswers] = useState<Record<number, number>>({})
|
||||
const [modalResource, setModalResource] = useState<ResourceDTO | null>(null)
|
||||
const trackedRef = useRef(false)
|
||||
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
|
||||
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
|
||||
<AppBar title={tPlain(section.title, language)} onBack={back} />
|
||||
<div className="flex-1 flex items-center justify-center text-sm p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Aucune question disponible.
|
||||
</div>
|
||||
@ -89,9 +107,8 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
const levelText = t(getLevel(), language)
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
|
||||
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
|
||||
<AppBar title={tPlain(section.title, language)} onBack={back} />
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
|
||||
{/* Score */}
|
||||
<div
|
||||
className="flex flex-col items-center justify-center rounded-full"
|
||||
style={{
|
||||
@ -108,7 +125,6 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Level text */}
|
||||
{levelText && (
|
||||
<div
|
||||
className="w-full rounded-2xl px-5 py-4 text-sm text-center [&_p]:m-0"
|
||||
@ -121,7 +137,6 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<button
|
||||
onClick={restart}
|
||||
@ -155,14 +170,18 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
const canGoNext = isReview || chosen !== undefined
|
||||
const isLast = currentIndex === totalQuestions - 1
|
||||
|
||||
const questionEntry = getLabelEntry(question.label, language)
|
||||
const questionText = questionEntry?.value ?? ''
|
||||
const questionResource = questionEntry?.resource
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
|
||||
<AppBar
|
||||
title={isReview ? tPlain(section.title, language) : undefined}
|
||||
onBack={isReview ? () => { setCurrentIndex(0); setPhase('result') } : () => router.back()}
|
||||
onBack={isReview ? () => { setCurrentIndex(0); setPhase('result') } : back}
|
||||
/>
|
||||
|
||||
{/* Question card */}
|
||||
<div className="flex-1 flex flex-col relative overflow-hidden">
|
||||
{/* Background image */}
|
||||
{question.imageBackgroundResourceUrl && (
|
||||
@ -178,17 +197,45 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex flex-col h-full p-4 gap-3">
|
||||
{/* Question media */}
|
||||
{questionResource?.url && (
|
||||
isAudio(questionResource) ? (
|
||||
<div style={{ background: 'white', borderRadius: 12, padding: '12px 16px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
|
||||
<audio controls src={questionResource.url} style={{ width: '100%' }} />
|
||||
</div>
|
||||
) : isVideo(questionResource) ? (
|
||||
<button
|
||||
onClick={() => setModalResource(questionResource)}
|
||||
style={{ height: 160, borderRadius: 12, overflow: 'hidden', border: 'none', cursor: 'pointer', flexShrink: 0, padding: 0, display: 'block', width: '100%' }}
|
||||
>
|
||||
<VideoPreview resource={questionResource} height={160} playSize={52} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setModalResource(questionResource)}
|
||||
style={{ position: 'relative', height: 160, borderRadius: 12, overflow: 'hidden', border: 'none', cursor: 'pointer', flexShrink: 0 }}
|
||||
>
|
||||
<Image src={questionResource.url} alt="" fill className="object-contain" sizes="100vw" />
|
||||
<div style={{ position: 'absolute', bottom: 8, right: 8, background: 'rgba(0,0,0,0.45)', borderRadius: 8, padding: '3px 7px', backdropFilter: 'blur(4px)' }}>
|
||||
<ZoomIcon />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Question text */}
|
||||
{questionText && (
|
||||
<div
|
||||
className="rounded-2xl px-4 py-4 text-sm font-medium text-center [&_p]:m-0"
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#1a1a1a',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
minHeight: 80,
|
||||
minHeight: 72,
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: t(question.label, language) }}
|
||||
dangerouslySetInnerHTML={{ __html: questionText }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Answer buttons */}
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
@ -196,10 +243,13 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
const isSelected = chosen === i
|
||||
const bg = getAnswerBg(isReview, isSelected, response.isCorrect)
|
||||
const textColor = isSelected || (isReview && response.isCorrect) ? 'white' : '#1a1a1a'
|
||||
const responseEntry = getLabelEntry(response.label, language)
|
||||
const responseText = responseEntry?.value ?? ''
|
||||
const responseResource = responseEntry?.resource
|
||||
|
||||
return (
|
||||
<button
|
||||
key={response.id}
|
||||
key={response.id ?? i}
|
||||
onClick={() => !isReview && chosen === undefined && selectAnswer(currentIndex, i)}
|
||||
className="w-full rounded-2xl px-4 py-3 text-sm font-medium text-left [&_p]:m-0 transition-colors"
|
||||
style={{
|
||||
@ -209,8 +259,35 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
cursor: isReview || chosen !== undefined ? 'default' : 'pointer',
|
||||
minHeight: 52,
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: t(response.label, language) }}
|
||||
/>
|
||||
>
|
||||
{responseResource?.url && (
|
||||
isAudio(responseResource) ? (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ marginBottom: responseText ? 8 : 0 }}
|
||||
>
|
||||
<audio controls src={responseResource.url} style={{ width: '100%' }} />
|
||||
</div>
|
||||
) : isVideo(responseResource) ? (
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); setModalResource(responseResource) }}
|
||||
style={{ height: 90, borderRadius: 8, overflow: 'hidden', marginBottom: responseText ? 8 : 0, cursor: 'pointer' }}
|
||||
>
|
||||
<VideoPreview resource={responseResource} height={90} playSize={38} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); setModalResource(responseResource) }}
|
||||
style={{ position: 'relative', height: 80, borderRadius: 8, overflow: 'hidden', marginBottom: responseText ? 8 : 0, cursor: 'pointer' }}
|
||||
>
|
||||
<Image src={responseResource.url} alt="" fill className="object-contain" sizes="100vw" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{responseText && (
|
||||
<div dangerouslySetInnerHTML={{ __html: responseText }} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@ -221,10 +298,7 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
onClick={() => currentIndex > 0 && setCurrentIndex(currentIndex - 1)}
|
||||
disabled={currentIndex === 0}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center transition-opacity"
|
||||
style={{
|
||||
background: 'var(--color-primary)',
|
||||
opacity: currentIndex === 0 ? 0.3 : 1,
|
||||
}}
|
||||
style={{ background: 'var(--color-primary)', opacity: currentIndex === 0 ? 0.3 : 1 }}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
@ -239,19 +313,92 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
onClick={() => canGoNext && !isLast && setCurrentIndex(currentIndex + 1)}
|
||||
disabled={!canGoNext || isLast}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center transition-opacity"
|
||||
style={{
|
||||
background: 'var(--color-primary)',
|
||||
opacity: !canGoNext || isLast ? 0.3 : 1,
|
||||
}}
|
||||
style={{ background: 'var(--color-primary)', opacity: !canGoNext || isLast ? 0.3 : 1 }}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Restart button (review only) */}
|
||||
{isReview && (
|
||||
<button
|
||||
onClick={restart}
|
||||
className="w-full py-3 rounded-2xl font-semibold text-sm"
|
||||
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
|
||||
>
|
||||
Recommencer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media modal */}
|
||||
{modalResource && (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 50, background: 'rgba(0,0,0,0.93)', display: 'flex', flexDirection: 'column' }}
|
||||
onClick={() => setModalResource(null)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: 16, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setModalResource(null)}
|
||||
style={{ width: 40, height: 40, borderRadius: '50%', border: 'none', background: 'rgba(255,255,255,0.15)', color: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<svg width="20" height="20" 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>
|
||||
</div>
|
||||
<div
|
||||
style={{ flex: 1, minHeight: 0, position: 'relative' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ResourceViewer resource={modalResource} objectFit="contain" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function youtubeThumbnail(url: string): string | null {
|
||||
const match = url.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/)
|
||||
return match?.[1] ? `https://img.youtube.com/vi/${match[1]}/mqdefault.jpg` : null
|
||||
}
|
||||
|
||||
function VideoPreview({ resource, height, playSize }: { resource: ResourceDTO; height: number; playSize: number }) {
|
||||
const thumbnail = resource.url ? youtubeThumbnail(resource.url) : null
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height, background: thumbnail ? '#000' : 'linear-gradient(135deg, #1c1c2e 0%, #2d2d44 100%)' }}>
|
||||
{thumbnail && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={thumbnail} alt="" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }} />
|
||||
)}
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.32)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<PlayIcon size={playSize} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlayIcon({ size = 48 }: { size?: number }) {
|
||||
const r = size / 2
|
||||
return (
|
||||
<div style={{ width: size, height: size, borderRadius: '50%', background: 'rgba(255,255,255,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '2px solid rgba(255,255,255,0.4)' }}>
|
||||
<svg width={r} height={r} viewBox="0 0 24 24" fill="white">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ZoomIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@ -263,3 +410,12 @@ function getAnswerBg(isReview: boolean, isSelected: boolean, isCorrect?: boolean
|
||||
if (isSelected) return '#f44336'
|
||||
return '#f5f5f5'
|
||||
}
|
||||
|
||||
function getLabelEntry(labels: TranslationAndResourceDTO[] | undefined, lang: string) {
|
||||
if (!labels || labels.length === 0) return null
|
||||
return (
|
||||
labels.find((l) => l.language === lang) ??
|
||||
labels.find((l) => l.language === 'FR') ??
|
||||
labels[0]
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { t, tPlain } from '@/lib/i18n'
|
||||
import type { SectionDTO } from '@/lib/api/types'
|
||||
@ -17,7 +17,7 @@ interface Props {
|
||||
|
||||
export default function SliderSection({ section, languages }: Props) {
|
||||
const { language, setAvailableLanguages } = useVisitor()
|
||||
const router = useRouter()
|
||||
const back = useBack()
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
useEffect(() => { setAvailableLanguages([]) }, [languages])
|
||||
@ -28,21 +28,24 @@ export default function SliderSection({ section, languages }: Props) {
|
||||
if (contents.length === 0) {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: 'var(--color-background)' }}>
|
||||
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
|
||||
<AppBar title={tPlain(section.title, language)} onBack={back} />
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, color: 'var(--color-text-muted)' }}>
|
||||
Aucun contenu à afficher
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
const current = contents[index]
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: 'var(--color-background)' }}>
|
||||
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--color-background)' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 }}>
|
||||
<AppBar title={tPlain(section.title, language)} onBack={back} overlay />
|
||||
</div>
|
||||
|
||||
<main style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<main style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* Image zone — ~75% of remaining height */}
|
||||
<div style={{ flex: 3, minHeight: 0, position: 'relative' }}>
|
||||
{current?.resource && (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { tPlain } from '@/lib/i18n'
|
||||
import type { SectionDTO } from '@/lib/api/types'
|
||||
@ -36,7 +36,7 @@ function vimeoEmbedUrl(url: string): string {
|
||||
|
||||
export default function VideoSection({ section, slug, configId, languages }: Props) {
|
||||
const { language, setAvailableLanguages } = useVisitor()
|
||||
const router = useRouter()
|
||||
const back = useBack()
|
||||
|
||||
useEffect(() => { setAvailableLanguages([]) }, [languages])
|
||||
|
||||
@ -55,7 +55,7 @@ export default function VideoSection({ section, slug, configId, languages }: Pro
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
onClick={back}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { tPlain } from '@/lib/i18n'
|
||||
import type { SectionDTO, WeatherData, WeatherForecast } from '@/lib/api/types'
|
||||
@ -220,7 +220,7 @@ function TempChart({ forecasts }: { forecasts: WeatherForecast[] }) {
|
||||
|
||||
export default function WeatherSection({ section, languages }: Props) {
|
||||
const { language, setAvailableLanguages } = useVisitor()
|
||||
const router = useRouter()
|
||||
const back = useBack()
|
||||
const [selectedDay, setSelectedDay] = useState(0)
|
||||
|
||||
useEffect(() => { setAvailableLanguages([]) }, [languages])
|
||||
@ -237,7 +237,7 @@ export default function WeatherSection({ section, languages }: Props) {
|
||||
if (!weatherData || days.length === 0) {
|
||||
return (
|
||||
<div className="h-dvh flex flex-col" style={{ background: 'var(--color-background)' }}>
|
||||
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
|
||||
<AppBar title={tPlain(section.title, language)} onBack={back} />
|
||||
<div className="flex-1 flex items-center justify-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Aucune donnée météo
|
||||
</div>
|
||||
@ -252,7 +252,7 @@ export default function WeatherSection({ section, languages }: Props) {
|
||||
|
||||
return (
|
||||
<div className="h-dvh flex flex-col overflow-hidden" style={{ background: '#E8F4FD' }}>
|
||||
<AppBar title={tPlain(section.title, language)} onBack={() => router.back()} />
|
||||
<AppBar title={tPlain(section.title, language)} onBack={back} />
|
||||
|
||||
<main className="flex-1 flex flex-col overflow-y-auto pb-6 gap-3 pt-3">
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useBack } from '@/hooks/useBack'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
import { tPlain } from '@/lib/i18n'
|
||||
import type { SectionDTO } from '@/lib/api/types'
|
||||
@ -15,7 +15,7 @@ interface Props {
|
||||
|
||||
export default function WebSection({ section, slug, configId, languages }: Props) {
|
||||
const { language, setAvailableLanguages } = useVisitor()
|
||||
const router = useRouter()
|
||||
const back = useBack()
|
||||
|
||||
useEffect(() => { setAvailableLanguages([]) }, [languages])
|
||||
|
||||
@ -38,7 +38,7 @@ export default function WebSection({ section, slug, configId, languages }: Props
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
onClick={back}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
|
||||
@ -119,7 +119,8 @@ export default function PuzzleGame({ imageUrl, rows, cols, showHint, onWin }: Pr
|
||||
const targetY = p.row * ph
|
||||
const dx = p.x - targetX
|
||||
const dy = p.y - targetY
|
||||
if (Math.abs(dx) < 18 && Math.abs(dy) < 18) {
|
||||
const snapThreshold = Math.min(pw, ph) * 0.35
|
||||
if (Math.abs(dx) < snapThreshold && Math.abs(dy) < snapThreshold) {
|
||||
return { ...p, x: targetX, y: targetY, placed: true, z: 0 }
|
||||
}
|
||||
return p
|
||||
|
||||
@ -88,7 +88,7 @@ function FitToTargets({ target, fitBounds }: { target: [number, number] | null;
|
||||
}
|
||||
if (target) {
|
||||
const current = map.getZoom()
|
||||
map.flyTo(target, current < 16 ? 17 : current, { duration: 1.2 })
|
||||
map.flyTo(target, Math.max(current, 15), { duration: 1.2 })
|
||||
}
|
||||
}, [target, fitBounds, map])
|
||||
return null
|
||||
|
||||
66
src/components/sections/pdf/PdfViewer.tsx
Normal file
66
src/components/sections/pdf/PdfViewer.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Document, Page, pdfjs } from 'react-pdf'
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css'
|
||||
import 'react-pdf/dist/Page/TextLayer.css'
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url,
|
||||
).toString()
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
}
|
||||
|
||||
function proxyUrl(url: string): string {
|
||||
return `/api/pdf?url=${encodeURIComponent(url)}`
|
||||
}
|
||||
|
||||
export default function PdfViewer({ url }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
const [numPages, setNumPages] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width))
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ height: '100%', overflowY: 'auto', background: '#e5e7eb' }}>
|
||||
<Document
|
||||
file={proxyUrl(url)}
|
||||
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
|
||||
loading={
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full border-2 border-t-transparent animate-spin"
|
||||
style={{ borderColor: 'var(--color-primary)', borderTopColor: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
error={
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--color-text-muted)' }}>
|
||||
Impossible de charger le PDF
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{Array.from({ length: numPages }, (_, i) => (
|
||||
<div key={i + 1} style={{ marginBottom: 8, display: 'flex', justifyContent: 'center' }}>
|
||||
<Page
|
||||
pageNumber={i + 1}
|
||||
width={containerWidth || undefined}
|
||||
renderTextLayer={false}
|
||||
renderAnnotationLayer={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Document>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,119 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
|
||||
const FLAG: Record<string, string> = {
|
||||
FR: '🇫🇷', NL: '🇧🇪', EN: '🇬🇧', DE: '🇩🇪',
|
||||
IT: '🇮🇹', ES: '🇪🇸', PL: '🇵🇱', CN: '🇨🇳', AR: '🇸🇦', UK: '🇺🇦',
|
||||
}
|
||||
|
||||
const LABEL: Record<string, string> = {
|
||||
FR: 'Français', NL: 'Nederlands', EN: 'English', DE: 'Deutsch',
|
||||
IT: 'Italiano', ES: 'Español', PL: 'Polski', CN: '中文', AR: 'العربية', UK: 'Українська',
|
||||
}
|
||||
import { useRouter } from 'next/navigation'
|
||||
import LanguageSelector from '@/components/ui/LanguageSelector'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
onBack?: () => void
|
||||
overlay?: boolean
|
||||
}
|
||||
|
||||
export default function AppBar({ title, onBack }: Props) {
|
||||
const { language, setLanguage, availableLanguages } = useVisitor()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
const showSelector = availableLanguages.length > 1
|
||||
|
||||
export default function AppBar({ title, onBack, overlay }: Props) {
|
||||
return (
|
||||
<header
|
||||
className="flex items-center justify-between px-4 sticky top-0 z-50"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))',
|
||||
background: overlay ? 'transparent' : 'var(--color-surface)',
|
||||
minHeight: 56,
|
||||
color: 'var(--color-on-primary)',
|
||||
color: overlay ? 'white' : 'var(--color-text)',
|
||||
boxShadow: overlay ? 'none' : '0 1px 0 var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="shrink-0 p-1 rounded-full hover:opacity-70 transition-opacity"
|
||||
className="shrink-0 flex items-center justify-center rounded-full hover:opacity-80 transition-opacity"
|
||||
style={overlay
|
||||
? { background: 'rgba(0,0,0,0.35)', backdropFilter: 'blur(8px)', padding: 6 }
|
||||
: { background: 'var(--color-primary-light)', color: 'var(--color-primary)', padding: 6 }
|
||||
}
|
||||
aria-label="Retour"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<span className="text-base font-semibold truncate" style={{ textShadow: '0 1px 3px rgba(0,0,0,0.4)' }}>
|
||||
<span
|
||||
className="text-base font-semibold truncate"
|
||||
style={overlay ? { textShadow: '0 1px 6px rgba(0,0,0,0.8)', color: 'white' } : undefined}
|
||||
>
|
||||
{title ?? 'MyInfoMate'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showSelector && (
|
||||
<div ref={ref} className="relative shrink-0 ml-3">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1.5 rounded-xl px-3 py-1.5 transition-all"
|
||||
style={{ background: 'rgba(255,255,255,0.18)', color: 'var(--color-on-primary)' }}
|
||||
>
|
||||
<span className="text-lg leading-none">{FLAG[language] ?? language}</span>
|
||||
<span className="text-xs font-semibold tracking-wide">{language}</span>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="currentColor"
|
||||
style={{ transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'rotate(0deg)', opacity: 0.8 }}
|
||||
>
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 mt-1 rounded-2xl overflow-hidden"
|
||||
style={{
|
||||
background: 'white',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
||||
minWidth: 160,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
{availableLanguages.map((lang) => {
|
||||
const active = lang === language
|
||||
return (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => { setLanguage(lang); setOpen(false) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors"
|
||||
style={{
|
||||
background: active ? 'var(--color-primary-light)' : 'transparent',
|
||||
color: active ? 'var(--color-primary)' : '#333',
|
||||
fontWeight: active ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<span className="text-xl leading-none">{FLAG[lang] ?? '🌐'}</span>
|
||||
<span className="text-sm">{LABEL[lang] ?? lang}</span>
|
||||
{active && (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="ml-auto" style={{ color: 'var(--color-primary)' }}>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="ml-3">
|
||||
<LanguageSelector overlay={overlay} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
93
src/components/ui/LanguageSelector.tsx
Normal file
93
src/components/ui/LanguageSelector.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useVisitor } from '@/context/VisitorContext'
|
||||
|
||||
const FLAG: Record<string, string> = {
|
||||
FR: '🇫🇷', NL: '🇧🇪', EN: '🇬🇧', DE: '🇩🇪',
|
||||
IT: '🇮🇹', ES: '🇪🇸', PL: '🇵🇱', CN: '🇨🇳', AR: '🇸🇦', UK: '🇺🇦',
|
||||
}
|
||||
|
||||
const LABEL: Record<string, string> = {
|
||||
FR: 'Français', NL: 'Nederlands', EN: 'English', DE: 'Deutsch',
|
||||
IT: 'Italiano', ES: 'Español', PL: 'Polski', CN: '中文', AR: 'العربية', UK: 'Українська',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
overlay?: boolean
|
||||
}
|
||||
|
||||
export default function LanguageSelector({ overlay }: Props) {
|
||||
const { language, setLanguage, availableLanguages } = useVisitor()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
if (availableLanguages.length <= 1) return null
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1.5 rounded-xl px-3 py-1.5 transition-all"
|
||||
style={overlay
|
||||
? { background: 'rgba(0,0,0,0.35)', backdropFilter: 'blur(8px)', color: 'white' }
|
||||
: { background: 'var(--color-primary-light)', color: 'var(--color-primary)' }
|
||||
}
|
||||
>
|
||||
<span className="text-lg leading-none">{FLAG[language] ?? language}</span>
|
||||
<span className="text-xs font-semibold tracking-wide">{language}</span>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="currentColor"
|
||||
style={{ transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'rotate(0deg)', opacity: 0.8 }}
|
||||
>
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 mt-1 rounded-2xl overflow-hidden"
|
||||
style={{
|
||||
background: 'white',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
||||
minWidth: 160,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
{availableLanguages.map((lang) => {
|
||||
const active = lang === language
|
||||
return (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => { setLanguage(lang); setOpen(false) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors"
|
||||
style={{
|
||||
background: active ? 'var(--color-primary-light)' : 'transparent',
|
||||
color: active ? 'var(--color-primary)' : '#333',
|
||||
fontWeight: active ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<span className="text-xl leading-none">{FLAG[lang] ?? '🌐'}</span>
|
||||
<span className="text-sm">{LABEL[lang] ?? lang}</span>
|
||||
{active && (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="ml-auto" style={{ color: 'var(--color-primary)' }}>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
src/hooks/useBack.ts
Normal file
20
src/hooks/useBack.ts
Normal file
@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
|
||||
export function useBack() {
|
||||
const router = useRouter()
|
||||
const params = useParams<{ slug?: string; configId?: string; sectionId?: string }>()
|
||||
|
||||
return () => {
|
||||
if (params.slug && params.configId && params.sectionId) {
|
||||
router.push(`/${params.slug}/${params.configId}`)
|
||||
} else if (params.slug && params.configId) {
|
||||
router.push(`/${params.slug}`)
|
||||
} else if (params.slug) {
|
||||
router.push(`/${params.slug}`)
|
||||
} else {
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,13 +2,13 @@ import type { ApplicationInstanceDTO, ConfigurationDTO, SectionDTO, GuidedPathDT
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL
|
||||
|
||||
async function apiFetch<T>(path: string, apiKey?: string): Promise<T> {
|
||||
async function apiFetch<T>(path: string, apiKey?: string, cache?: RequestCache): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (apiKey) headers['X-Api-Key'] = apiKey
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
headers,
|
||||
next: { revalidate: 60 },
|
||||
...(cache ? { cache } : { next: { revalidate: 60 } }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`API error ${res.status}: ${path}`)
|
||||
return res.json()
|
||||
@ -133,7 +133,15 @@ function normalizeSectionDTO(raw: any): SectionDTO {
|
||||
}
|
||||
|
||||
export async function getInstanceBySlug(slug: string): Promise<ApplicationInstanceDTO> {
|
||||
return apiFetch(`/api/instance/slug/${slug}`)
|
||||
const raw = await apiFetch<any>(`/api/instance/slug/${slug}`)
|
||||
const webInstance = raw.applicationInstanceDTOs?.find((i: any) => i.appType === 'Web') ?? {}
|
||||
return {
|
||||
...webInstance,
|
||||
id: raw.id,
|
||||
publicApiKey: raw.publicApiKey,
|
||||
webSlug: raw.webSlug,
|
||||
label: raw.name,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfigurations(instanceId: string, apiKey: string): Promise<ConfigurationDTO[]> {
|
||||
@ -145,10 +153,18 @@ export async function getConfiguration(configId: string, apiKey: string): Promis
|
||||
}
|
||||
|
||||
export async function getSections(configId: string, apiKey: string): Promise<SectionDTO[]> {
|
||||
const raw: any[] = await apiFetch(`/api/Section/configuration/${configId}/detail`, apiKey)
|
||||
const raw: any[] = await apiFetch(`/api/Section/configuration/${configId}/detail`, apiKey, 'no-store')
|
||||
return raw.map(normalizeSectionDTO)
|
||||
}
|
||||
|
||||
export async function getAgendaEvents(sectionId: string, language: string, apiKey: string): Promise<any[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/SectionAgenda/${sectionId}/events/upcoming?language=${encodeURIComponent(language)}`, {
|
||||
headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },
|
||||
})
|
||||
if (!res.ok) throw new Error(`API error ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function getGuidedPaths(sectionMapId: string, apiKey: string): Promise<GuidedPathDTO[]> {
|
||||
return apiFetch(`/api/SectionMap/${sectionMapId}/GuidedPath`, apiKey)
|
||||
}
|
||||
|
||||
@ -146,14 +146,14 @@ export interface MenuDTO {
|
||||
|
||||
export interface QuestionResponseDTO {
|
||||
id: number
|
||||
label?: TranslationDTO[]
|
||||
label?: TranslationAndResourceDTO[]
|
||||
isCorrect?: boolean
|
||||
order?: number
|
||||
}
|
||||
|
||||
export interface QuestionDTO {
|
||||
id: number
|
||||
label?: TranslationDTO[]
|
||||
label?: TranslationAndResourceDTO[]
|
||||
responses?: QuestionResponseDTO[]
|
||||
imageBackgroundResourceUrl?: string
|
||||
order?: number
|
||||
@ -278,6 +278,7 @@ export interface AgendaDTO {
|
||||
|
||||
export interface TranslationAndResourceDTO {
|
||||
language?: string
|
||||
value?: string
|
||||
resourceId?: string
|
||||
resource?: ResourceDTO
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { TranslationDTO } from './api/types'
|
||||
type AnyTranslation = { language?: string; value?: string }
|
||||
|
||||
export function t(translations: TranslationDTO[] | undefined, lang: string): string {
|
||||
export function t(translations: AnyTranslation[] | undefined, lang: string): string {
|
||||
if (!translations || translations.length === 0) return ''
|
||||
return (
|
||||
translations.find((t) => t.language === lang)?.value ||
|
||||
@ -10,10 +10,25 @@ export function t(translations: TranslationDTO[] | undefined, lang: string): str
|
||||
)
|
||||
}
|
||||
|
||||
const UI_STRINGS: Record<string, Record<string, string>> = {
|
||||
noEventsThisMonth: {
|
||||
FR: 'Aucun événement ce mois-ci',
|
||||
NL: 'Geen evenementen deze maand',
|
||||
EN: 'No events this month',
|
||||
DE: 'Keine Veranstaltungen diesen Monat',
|
||||
ES: 'No hay eventos este mes',
|
||||
IT: 'Nessun evento questo mese',
|
||||
},
|
||||
}
|
||||
|
||||
export function ui(key: keyof typeof UI_STRINGS, lang: string): string {
|
||||
return UI_STRINGS[key]?.[lang] ?? UI_STRINGS[key]?.['FR'] ?? key
|
||||
}
|
||||
|
||||
export function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, '').trim()
|
||||
}
|
||||
|
||||
export function tPlain(translations: TranslationDTO[] | undefined, lang: string): string {
|
||||
export function tPlain(translations: AnyTranslation[] | undefined, lang: string): string {
|
||||
return stripHtml(t(translations, lang))
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user