From 5fd6e60b7d0e19057f18f8f022bd5ff5dcf739fa Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Tue, 14 Jul 2026 17:11:32 +0200 Subject: [PATCH] misc + bento layout + SectionParcours (mostly done) --- package-lock.json | 1484 +++++++++- package.json | 6 +- src/app/[slug]/[configId]/layout.tsx | 9 +- .../[configId]/sections/[sectionId]/page.tsx | 26 +- src/app/[slug]/page.tsx | 16 +- src/components/ConfigurationGrid.tsx | 109 +- src/components/SectionList.tsx | 11 +- src/components/SplashScreen.tsx | 39 + src/components/sections/AgendaSection.tsx | 64 +- src/components/sections/ArticleSection.tsx | 78 +- src/components/sections/EventSection.tsx | 9 +- src/components/sections/GameSection.tsx | 48 +- src/components/sections/MapSection.tsx | 622 +--- src/components/sections/MenuSection.tsx | 12 +- src/components/sections/ParcoursSection.tsx | 2497 +++++++++++++++++ src/components/sections/QuizSection.tsx | 111 +- src/components/sections/VideoSection.tsx | 9 +- src/components/sections/WebSection.tsx | 9 +- .../sections/game/EscapeProgression.tsx | 379 --- .../sections/game/QuestionPuzzle.tsx | 71 + src/components/sections/map/LeafletMap.tsx | 81 +- src/components/sections/map/StepQuiz.tsx | 107 - src/components/sections/map/StepTimer.tsx | 33 +- src/components/sections/map/Toast.tsx | 37 - src/components/ui/AppBar.tsx | 11 +- src/components/ui/ChevronLeftIcon.tsx | 12 + src/lib/api/client.ts | 62 +- src/lib/api/types.ts | 37 +- src/lib/geo.ts | 50 + 29 files changed, 4570 insertions(+), 1469 deletions(-) create mode 100644 src/components/SplashScreen.tsx create mode 100644 src/components/sections/ParcoursSection.tsx delete mode 100644 src/components/sections/game/EscapeProgression.tsx create mode 100644 src/components/sections/game/QuestionPuzzle.tsx delete mode 100644 src/components/sections/map/StepQuiz.tsx delete mode 100644 src/components/sections/map/Toast.tsx create mode 100644 src/components/ui/ChevronLeftIcon.tsx diff --git a/package-lock.json b/package-lock.json index 7dae34f..bb040bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "@types/react-dom": "^19", "babel-plugin-react-compiler": "1.0.0", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3" } }, "node_modules/@alloc/quick-lru": { @@ -86,6 +87,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hey-api/codegen-core": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.4.tgz", @@ -1127,6 +1570,356 @@ "react-dom": "^19.0.0" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1407,6 +2200,31 @@ "tailwindcss": "4.2.4" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -1465,6 +2283,121 @@ "@types/react": "^19.2.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -1482,6 +2415,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/babel-plugin-react-compiler": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", @@ -1549,6 +2492,16 @@ } } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001791", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", @@ -1569,6 +2522,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -1675,6 +2655,34 @@ "devOptional": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -1778,6 +2786,75 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -1785,6 +2862,39 @@ "dev": true, "license": "MIT" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -2202,6 +3312,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2247,6 +3364,13 @@ } } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2423,6 +3547,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pdfjs-dist": { "version": "5.4.296", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", @@ -2448,6 +3582,19 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkg-types": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", @@ -2610,6 +3757,51 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -2710,6 +3902,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2719,6 +3918,40 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -2769,6 +4002,13 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", @@ -2779,6 +4019,53 @@ "node": ">=18" } }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2806,6 +4093,184 @@ "dev": true, "license": "MIT" }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", @@ -2831,6 +4296,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wsl-utils": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", diff --git a/package.json b/package.json index 7196d21..2e72b0b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "dev:https": "next dev --experimental-https", "build": "next build", - "start": "next start" + "start": "next start", + "test": "vitest run" }, "dependencies": { "@types/leaflet": "^1.9.21", @@ -27,6 +28,7 @@ "@types/react-dom": "^19", "babel-plugin-react-compiler": "1.0.0", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3" } } diff --git a/src/app/[slug]/[configId]/layout.tsx b/src/app/[slug]/[configId]/layout.tsx index f7ae2c4..bfec46e 100644 --- a/src/app/[slug]/[configId]/layout.tsx +++ b/src/app/[slug]/[configId]/layout.tsx @@ -1,6 +1,7 @@ import { notFound } from 'next/navigation' import { getInstanceBySlug, getConfiguration } from '@/lib/api/client' import { resolveColors } from '@/lib/theme' +import SplashScreen from '@/components/SplashScreen' export default async function ConfigLayout({ children, @@ -20,6 +21,12 @@ export default async function ConfigLayout({ } const theme = resolveColors(instance, config) + const loaderImageUrl = config.loaderImageUrl ?? instance.loaderImageUrl - return
{children}
+ return ( +
+ {loaderImageUrl && } + {children} +
+ ) } diff --git a/src/app/[slug]/[configId]/sections/[sectionId]/page.tsx b/src/app/[slug]/[configId]/sections/[sectionId]/page.tsx index c1c3deb..1a43ccf 100644 --- a/src/app/[slug]/[configId]/sections/[sectionId]/page.tsx +++ b/src/app/[slug]/[configId]/sections/[sectionId]/page.tsx @@ -1,5 +1,5 @@ import { notFound } from 'next/navigation' -import { getInstanceBySlug, getConfiguration, getSections, getGuidedPaths, getGuidedPathsForGame } from '@/lib/api/client' +import { getInstanceBySlug, getConfiguration, getSections, getGuidedPaths, getGuidedPathsForParcours } from '@/lib/api/client' import type { SectionDTO } from '@/lib/api/types' // Section components @@ -15,6 +15,7 @@ import WebSection from '@/components/sections/WebSection' import QuizSection from '@/components/sections/QuizSection' import GameSection from '@/components/sections/GameSection' import EventSection from '@/components/sections/EventSection' +import ParcoursSection from '@/components/sections/ParcoursSection' import TrackedSection from '@/components/TrackedSection' export default async function SectionPage({ @@ -40,21 +41,11 @@ export default async function SectionPage({ 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) { + // Parcours sections expose guided paths via their own endpoint + if (section.type === 'Parcours' && section.parcours) { try { - const paths = await getGuidedPaths(section.id, instance.publicApiKey!) - section.map = { ...section.map, guidedPaths: paths } - } catch { - // Silently ignore — Map still works without paths - } - } - - // Escape Games also expose guided paths - if (section.type === 'Game' && section.gameType === 'Escape') { - try { - const paths = await getGuidedPathsForGame(section.id, instance.publicApiKey!) - section.guidedPaths = paths + const paths = await getGuidedPathsForParcours(section.id, instance.publicApiKey!) + section.parcours = { ...section.parcours, guidedPaths: paths } } catch { // Silently ignore } @@ -85,8 +76,9 @@ if (!section || section.isActive === false) notFound() case 'Weather': content = ; break case 'Quiz': content = ; break case 'Game': content = ; break - case 'Event': content = ; break - case 'Web': content = ; break + case 'Event': content = ; break + case 'Parcours': content = ; break + case 'Web': content = ; break default: content = (
diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index 87e73a5..c4d26a5 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -1,4 +1,4 @@ -import { getInstanceBySlug, getConfigurations } from '@/lib/api/client' +import { getInstanceBySlug, getConfigurationLinks } from '@/lib/api/client' import { notFound } from 'next/navigation' import ConfigurationGrid from '@/components/ConfigurationGrid' import HomeHero from '@/components/HomeHero' @@ -11,16 +11,16 @@ export default async function HomePage({ }) { const { slug } = await params - let instance, configurations + let instance, items try { instance = await getInstanceBySlug(slug) - configurations = await getConfigurations(instance.id, instance.publicApiKey!) + items = await getConfigurationLinks(instance.applicationInstanceId!, instance.publicApiKey!) } catch { notFound() } const featuredEvent = instance.sectionEventDTO - const languages = [...new Set(configurations.flatMap((c) => c.languages ?? []))] + const languages = [...new Set(items.flatMap((i) => i.configuration.languages ?? []))] return ( <> @@ -32,7 +32,13 @@ export default async function HomePage({ configurationId={featuredEvent?.configurationId ?? undefined} /> )} - + ) diff --git a/src/components/ConfigurationGrid.tsx b/src/components/ConfigurationGrid.tsx index b54467e..99c3108 100644 --- a/src/components/ConfigurationGrid.tsx +++ b/src/components/ConfigurationGrid.tsx @@ -5,76 +5,79 @@ import Link from 'next/link' import Image from 'next/image' import { useVisitor } from '@/context/VisitorContext' import { t, tPlain } from '@/lib/i18n' -import type { ConfigurationDTO } from '@/lib/api/types' +import type { WeightedConfiguration } from '@/lib/api/client' interface Props { - configurations: ConfigurationDTO[] + items: WeightedConfiguration[] slug: string languages: string[] + instancePrimaryColor?: string + instanceSecondaryColor?: string } -export default function ConfigurationGrid({ configurations, slug, languages }: Props) { +// Chaque section porte ses propres spans (col × row) — la taille joue sur la +// largeur ET la hauteur. On borne à 1..2 pour rester dans une grille lisible. +function clampSpan(v: number | undefined): number { + if (!v || v < 1) return 1 + return v > 2 ? 2 : v +} + +export default function ConfigurationGrid({ items, slug, languages, instancePrimaryColor }: Props) { const { language, setAvailableLanguages } = useVisitor() - const active = configurations.filter((c) => !c.isOffline) useEffect(() => { setAvailableLanguages(languages) }, [languages]) return (
-
- {active.map((config) => ( - - {config.imageSource ? ( -
+
+ {items.map((item) => { + const config = item.configuration + const col = clampSpan(item.colSpan) + const row = clampSpan(item.rowSpan) + const fallbackColor = config.primaryColor ?? instancePrimaryColor ?? '#28415a' + + return ( + + {config.imageSource && ( {tPlain(config.title, -
-
- - - -
- ) : ( -
-
- - - -
- )} - - ))} + )} +
+
+ + + + + ) + })}
) diff --git a/src/components/SectionList.tsx b/src/components/SectionList.tsx index 8778edc..fb857d1 100644 --- a/src/components/SectionList.tsx +++ b/src/components/SectionList.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import Image from 'next/image' import Link from 'next/link' import { useBack } from '@/hooks/useBack' +import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon' import { useVisitor } from '@/context/VisitorContext' import { t, tPlain, stripHtml } from '@/lib/i18n' import type { SectionDTO, TranslationDTO } from '@/lib/api/types' @@ -54,15 +55,13 @@ export default function SectionList({ onClick={back} className="absolute flex items-center justify-center" style={{ - top: 44, left: 10, width: 44, height: 44, - background: 'rgba(0,0,0,0.35)', + top: 'max(env(safe-area-inset-top), 16px)', left: 16, width: 40, height: 40, + background: 'rgba(255,255,255,0.2)', backdropFilter: 'blur(8px)', - borderRadius: '50%', + borderRadius: 16, }} > - - - +
diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx new file mode 100644 index 0000000..12b42fe --- /dev/null +++ b/src/components/SplashScreen.tsx @@ -0,0 +1,39 @@ +'use client' + +import { useEffect, useState } from 'react' + +export default function SplashScreen({ imageUrl, configId }: { imageUrl: string; configId: string }) { + const [phase, setPhase] = useState<'init' | 'visible' | 'fading' | 'gone'>('init') + + useEffect(() => { + const key = `splash_shown_${configId}` + if (sessionStorage.getItem(key)) { + setPhase('gone') + return + } + sessionStorage.setItem(key, '1') + setPhase('visible') + const fade = setTimeout(() => setPhase('fading'), 1800) + const gone = setTimeout(() => setPhase('gone'), 2300) + return () => { clearTimeout(fade); clearTimeout(gone) } + }, [configId]) + + if (phase === 'init' || phase === 'gone') return null + + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ ) +} diff --git a/src/components/sections/AgendaSection.tsx b/src/components/sections/AgendaSection.tsx index 78ce819..ca0c207 100644 --- a/src/components/sections/AgendaSection.tsx +++ b/src/components/sections/AgendaSection.tsx @@ -96,24 +96,22 @@ export default function AgendaSection({ section, configId, languages, apiKey }: } return ( -
+
{/* Month selector */} -
- - - {formatMonthHeader(selectedYear, selectedMonth, language)} - - +
+
+ + + {formatMonthHeader(selectedYear, selectedMonth, language)} + + +
{/* Events grid */} @@ -139,8 +137,8 @@ export default function AgendaSection({ section, configId, languages, apiKey }: }) } }} - className="rounded-2xl overflow-hidden text-left flex flex-col" - style={{ background: 'var(--color-surface)', boxShadow: '0 2px 8px rgba(0,0,0,0.10)' }} + className="overflow-hidden text-left flex flex-col" + style={{ background: '#fff', borderRadius: 20, border: '1px solid #EFEAE2', boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }} > {/* Image */}
@@ -152,7 +150,7 @@ export default function AgendaSection({ section, configId, languages, apiKey }: {event.dateFrom && (
@@ -208,7 +206,7 @@ function EventDetail({ const videoResource = event.videoResource?.url return ( -
+
{img && ( @@ -233,12 +231,12 @@ function EventDetail({ )} {event.dateFrom && (
@@ -300,7 +298,7 @@ function EventDetail({ {/* Mini map */} {coords && ( -
+
)} @@ -311,11 +309,11 @@ function EventDetail({ href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`} target="_blank" rel="noreferrer" - className="flex items-center gap-3 p-3 rounded-xl" - style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }} + className="flex items-center gap-3 p-3" + style={{ background: '#fff', border: '1px solid #EFEAE2', borderRadius: 16, boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }} > - {address} + {address} )} @@ -325,8 +323,8 @@ function EventDetail({ {event.phone && ( {event.phone} @@ -335,8 +333,8 @@ function EventDetail({ {event.email && ( {event.email} @@ -347,8 +345,8 @@ function EventDetail({ href={event.website.startsWith('http') ? event.website : `https://${event.website}`} target="_blank" rel="noreferrer" - className="flex items-center gap-3 p-3 rounded-xl" - style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }} + className="flex items-center gap-3 p-3" + style={{ background: '#fff', border: '1px solid #EFEAE2', borderRadius: 16, boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)', color: 'var(--color-primary)' }} > {event.website} diff --git a/src/components/sections/ArticleSection.tsx b/src/components/sections/ArticleSection.tsx index 35a6213..9ad1f00 100644 --- a/src/components/sections/ArticleSection.tsx +++ b/src/components/sections/ArticleSection.tsx @@ -69,7 +69,7 @@ export default function ArticleSection({ section, configId, languages }: Props) const htmlContent = t(article?.content, language) return ( -
+
@@ -98,35 +98,35 @@ export default function ArticleSection({ section, configId, languages }: Props) onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)} onEnded={() => setIsPlaying(false)} /> -
- -
-
-
+
+
+
+ +
+ GUIDE AUDIO +
+ +
+
+ + {duration > 0 ? formatTime(duration) : '--:--'} +
+ {duration > 0 && ( +
+
+
+ )}
- - {formatTime(currentTime)} / {formatTime(duration)} -
)} @@ -152,16 +152,16 @@ function ImageCarousel({ contents, language }: { contents: NonNullable setIndex((i) => Math.max(0, i - 1))} disabled={index === 0} - className="absolute left-2 top-1/2 -translate-y-1/2 bg-black/40 text-white rounded-full p-1 disabled:opacity-20" + style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', background: 'rgba(0,0,0,0.35)', border: 'none', cursor: 'pointer', borderRadius: '50%', width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: index === 0 ? 0.3 : 1 }} > - +
{contents.map((_, i) => ( @@ -174,6 +174,20 @@ function ImageCarousel({ contents, language }: { contents: NonNullable 0 ? currentTime / duration : 0 + const total = bars.length * 5 + return ( + + {bars.map((h, i) => ( + + ))} + + ) +} + function formatTime(s: number): string { if (!s || isNaN(s)) return '0:00' const m = Math.floor(s / 60) diff --git a/src/components/sections/EventSection.tsx b/src/components/sections/EventSection.tsx index 25c8bf2..94b0066 100644 --- a/src/components/sections/EventSection.tsx +++ b/src/components/sections/EventSection.tsx @@ -7,6 +7,7 @@ import dynamic from 'next/dynamic' import { useVisitor } from '@/context/VisitorContext' import { t, tPlain } from '@/lib/i18n' import LanguageSelector from '@/components/ui/LanguageSelector' +import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon' import type { SectionDTO, ProgrammeBlock, MapAnnotationDTO, GuidedPathDTO } from '@/lib/api/types' const EventMap = dynamic(() => import('./event/EventMap'), { @@ -108,13 +109,11 @@ export default function EventSection({ section, slug, configId, languages }: Pro
diff --git a/src/components/sections/GameSection.tsx b/src/components/sections/GameSection.tsx index 90f5b73..4850719 100644 --- a/src/components/sections/GameSection.tsx +++ b/src/components/sections/GameSection.tsx @@ -2,13 +2,13 @@ import { useEffect, useRef, useState } from 'react' import { useBack } from '@/hooks/useBack' +import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon' import { useVisitor } from '@/context/VisitorContext' -import { t, tPlain } from '@/lib/i18n' +import { t } from '@/lib/i18n' import type { SectionDTO, TranslationDTO } from '@/lib/api/types' import PuzzleGame from './game/PuzzleGame' import SlidingPuzzle from './game/SlidingPuzzle' import MessageDialog from './game/MessageDialog' -import EscapeProgression from './game/EscapeProgression' import { trackEvent } from '@/lib/stats' interface Props { @@ -18,12 +18,11 @@ interface Props { languages: string[] } -type GameKind = 'Puzzle' | 'SlidingPuzzle' | 'Escape' | 'Unknown' +type GameKind = 'Puzzle' | 'SlidingPuzzle' | 'Unknown' function detectKind(raw?: string): GameKind { if (!raw) return 'Unknown' if (raw === 'SlidingPuzzle') return 'SlidingPuzzle' - if (raw === 'Escape') return 'Escape' if (raw === 'Puzzle') return 'Puzzle' return 'Unknown' } @@ -94,13 +93,11 @@ export default function GameSection({ section, configId, languages }: Props) {
@@ -133,13 +130,6 @@ export default function GameSection({ section, configId, languages }: Props) { onWin={handleWin} /> )} - {kind === 'Escape' && ( - (section.guidedPaths?.length ?? 0) > 0 ? ( - - ) : ( - - ) - )} {kind === 'Unknown' && (
Type de jeu inconnu. @@ -221,29 +211,3 @@ export default function GameSection({ section, configId, languages }: Props) {
) } - -function EscapeStub({ title, description }: { title: string; description: string }) { - return ( -
-
- - - -
-
{title}
- {description && ( -
- )} -
- Parcours guidé bientôt disponible. -
-
- ) -} diff --git a/src/components/sections/MapSection.tsx b/src/components/sections/MapSection.tsx index 6d09922..32505d2 100644 --- a/src/components/sections/MapSection.tsx +++ b/src/components/sections/MapSection.tsx @@ -2,16 +2,12 @@ import { useEffect, useMemo, useState } from 'react' import { useBack } from '@/hooks/useBack' +import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon' import dynamic from 'next/dynamic' import { useVisitor } from '@/context/VisitorContext' import { t, tPlain } from '@/lib/i18n' -import type { SectionDTO, GeoPointDTO, GuidedPathDTO, GuidedStepDTO } from '@/lib/api/types' -import { useGeolocation } from '@/hooks/useGeolocation' -import { haversineMeters, formatDistance } from '@/lib/geo' +import type { SectionDTO, GeoPointDTO } from '@/lib/api/types' import { trackEvent } from '@/lib/stats' -import StepTimer from './map/StepTimer' -import StepQuiz from './map/StepQuiz' -import Toast from './map/Toast' import './map/map.css' const LeafletMap = dynamic(() => import('./map/LeafletMap'), { @@ -42,100 +38,12 @@ export default function MapSection({ section, configId, languages }: Props) { const hasData = !!map && (map.points?.length ?? 0) > 0 const points = useMemo(() => map?.points ?? [], [map]) const categories = useMemo(() => map?.categories ?? [], [map]) - const guidedPaths = useMemo(() => map?.guidedPaths ?? [], [map]) const [mode, setMode] = useState('map') const [selectedId, setSelectedId] = useState(null) const [search, setSearch] = useState('') const [activeCats, setActiveCats] = useState>(new Set()) const [filterOpen, setFilterOpen] = useState(false) - const [pathListOpen, setPathListOpen] = useState(false) - const [activePathId, setActivePathId] = useState(null) - const [activeStepId, setActiveStepId] = useState(null) - - const activePath = guidedPaths.find((p) => p.id === activePathId) ?? null - const activeSteps = useMemo( - () => [...(activePath?.steps ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)), - [activePath] - ) - const activeStep = activeSteps.find((s) => s.id === activeStepId) ?? null - - const [completedSteps, setCompletedSteps] = useState>(new Set()) - const [quizPassedSteps, setQuizPassedSteps] = useState>(new Set()) - const [timerExpiredSteps, setTimerExpiredSteps] = useState>(new Set()) - const [zoneNotifiedSteps, setZoneNotifiedSteps] = useState>(new Set()) - const [toastMessage, setToastMessage] = useState(null) - const [showEndModal, setShowEndModal] = useState(false) - const [geoEnabledKey, setGeoEnabledKey] = useState(0) - - // Reset progression when path changes - useEffect(() => { - setCompletedSteps(new Set()) - setQuizPassedSteps(new Set()) - setTimerExpiredSteps(new Set()) - setZoneNotifiedSteps(new Set()) - setShowEndModal(false) - setToastMessage(null) - }, [activePathId]) - - // First non-completed step in order = current - const currentStepId = useMemo(() => { - if (!activePath) return null - const next = activeSteps.find((s) => !completedSteps.has(s.id)) - return next?.id ?? null - }, [activePath, activeSteps, completedSteps]) - - // Hide future steps if path requires it; also hide isHiddenInitially steps not yet current/completed - const visibleSteps = useMemo(() => { - if (!activePath) return [] - let list = activeSteps - if (activePath.hideNextStepsUntilComplete) { - const idx = currentStepId ? activeSteps.findIndex((s) => s.id === currentStepId) : activeSteps.length - list = activeSteps.slice(0, idx + 1) - } - return list.filter((s) => { - if (!s.isHiddenInitially) return true - return completedSteps.has(s.id) || s.id === currentStepId - }) - }, [activePath, activeSteps, currentStepId, completedSteps]) - - function markStepComplete(stepId: string) { - setCompletedSteps((prev) => { - const next = new Set(prev) - next.add(stepId) - return next - }) - const idx = activeSteps.findIndex((s) => s.id === stepId) - if (idx === activeSteps.length - 1) { - setShowEndModal(true) - setActiveStepId(null) - } else { - const nextStep = activeSteps[idx + 1] - if (nextStep) setActiveStepId(nextStep.id) - } - } - - const geo = useGeolocation(!!activePath, geoEnabledKey) - - // Toast on first entry into the current step's zone - useEffect(() => { - if (!activePath || !currentStepId) return - const step = activeSteps.find((s) => s.id === currentStepId) - if (!step) return - const radius = step.zoneRadiusMeters ?? 0 - const coords = step.geometry?.coordinates - if (radius <= 0 || !coords || geo.lat == null || geo.lng == null) return - const dist = haversineMeters({ lat: geo.lat, lng: geo.lng }, { lat: coords[1], lng: coords[0] }) - if (dist <= radius && !zoneNotifiedSteps.has(currentStepId)) { - const title = tPlain(step.title, language) || 'cette étape' - setToastMessage(`Vous êtes arrivé à « ${title} »`) - setZoneNotifiedSteps((prev) => { - const next = new Set(prev) - next.add(currentStepId) - return next - }) - } - }, [activePath, currentStepId, activeSteps, geo.lat, geo.lng, zoneNotifiedSteps, language]) const norm = (s: string) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() @@ -186,10 +94,8 @@ 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)' }} > - {tPlain(section.title, language)}
@@ -211,7 +117,7 @@ export default function MapSection({ section, configId, languages }: Props) { zoom={zoom} selectedId={selectedId} onSelect={(id) => { - setSelectedId(id); setActiveStepId(null) + setSelectedId(id) const p = points.find((pp) => pp.id === id) if (instanceId && p) { trackEvent({ @@ -222,12 +128,6 @@ export default function MapSection({ section, configId, languages }: Props) { } }} primaryColor={primaryColor} - pathSteps={visibleSteps} - selectedStepId={activeStepId} - onSelectStep={(id) => { setActiveStepId(id); setSelectedId(null) }} - currentStepId={currentStepId} - completedStepIds={completedSteps} - userPosition={geo.lat != null && geo.lng != null ? { lat: geo.lat, lng: geo.lng } : null} /> ) : ( {tPlain(section.title, language)} - {guidedPaths.length > 0 && ( - - )} )} - {/* Quitter parcours button (when active) */} - {activePath && !pathListOpen && !activeStep && !selected && ( - - )} - {/* Detail sheet (POI) */} {selected && ( setSelectedId(null)} /> )} - - {/* Step detail sheet (parcours) */} - {activeStep && activePath && ( - s.id === activeStep.id)} - totalSteps={activeSteps.length} - language={language} - geo={geo} - isCurrent={activeStep.id === currentStepId} - isCompleted={completedSteps.has(activeStep.id)} - quizPassed={quizPassedSteps.has(activeStep.id)} - timerExpired={timerExpiredSteps.has(activeStep.id)} - requireSuccess={!!activePath.requireSuccessToAdvance} - isLinear={!!activePath.isLinear} - onQuizResult={(passed) => { - if (passed) { - setQuizPassedSteps((prev) => { - const next = new Set(prev) - next.add(activeStep.id) - return next - }) - } - }} - onTimerExpired={() => { - setTimerExpiredSteps((prev) => { - const next = new Set(prev) - next.add(activeStep.id) - return next - }) - }} - onRetryGeo={() => setGeoEnabledKey((k) => k + 1)} - onClose={() => setActiveStepId(null)} - onMarkComplete={() => markStepComplete(activeStep.id)} - onPrev={() => { - const i = activeSteps.findIndex((s) => s.id === activeStep.id) - if (i > 0) setActiveStepId(activeSteps[i - 1].id) - }} - onNext={() => { - const i = activeSteps.findIndex((s) => s.id === activeStep.id) - if (i < activeSteps.length - 1) setActiveStepId(activeSteps[i + 1].id) - }} - /> - )} - - {/* Toast (entry into zone) */} - {toastMessage && ( - setToastMessage(null)} /> - )} - - {/* End of path modal */} - {showEndModal && activePath && ( - { - setCompletedSteps(new Set()) - setShowEndModal(false) - if (activeSteps[0]) setActiveStepId(activeSteps[0].id) - }} - onLeave={() => { - setActivePathId(null) - setActiveStepId(null) - setShowEndModal(false) - }} - /> - )} - - {/* Path list bottom sheet */} - {pathListOpen && ( - { - setActivePathId(id) - setActiveStepId(null) - setSelectedId(null) - setPathListOpen(false) - }} - onLeave={() => { - setActivePathId(null) - setActiveStepId(null) - setPathListOpen(false) - }} - onClose={() => setPathListOpen(false)} - /> - )}
) } -// ── Path list bottom sheet ────────────────────────────────────────────────── -function PathListSheet({ - paths, activePathId, language, onSelect, onLeave, onClose, -}: { - paths: GuidedPathDTO[] - activePathId: string | null - language: string - onSelect: (id: string) => void - onLeave: () => void - onClose: () => void -}) { - return ( - <> -
-
- -
-
-
-
-
Parcours
- {activePathId && ( - - )} -
-
- {paths.map((p) => { - const active = p.id === activePathId - const stepCount = p.steps?.length ?? 0 - return ( - - ) - })} -
-
- - ) -} - -// ── Step detail bottom sheet ──────────────────────────────────────────────── -function StepDetail({ - step, stepIndex, totalSteps, language, geo, - isCurrent, isCompleted, quizPassed, timerExpired, - requireSuccess, isLinear, - onQuizResult, onTimerExpired, onRetryGeo, - onClose, onMarkComplete, onPrev, onNext, -}: { - step: GuidedStepDTO - stepIndex: number - totalSteps: number - language: string - geo: ReturnType - isCurrent: boolean - isCompleted: boolean - quizPassed: boolean - timerExpired: boolean - requireSuccess: boolean - isLinear: boolean - onQuizResult: (passed: boolean) => void - onTimerExpired: () => void - onRetryGeo: () => void - onClose: () => void - onMarkComplete: () => void - onPrev: () => void - onNext: () => void -}) { - const hasPrev = stepIndex > 0 && !isLinear - const hasNext = stepIndex < totalSteps - 1 && !isLinear - - const stepCoords = step.geometry?.coordinates - const stepLat = stepCoords?.[1] - const stepLng = stepCoords?.[0] - const radius = step.zoneRadiusMeters ?? 0 - const distance = geo.lat != null && geo.lng != null && stepLat != null && stepLng != null - ? haversineMeters({ lat: geo.lat, lng: geo.lng }, { lat: stepLat, lng: stepLng }) - : null - const inZone = distance != null && radius > 0 && distance <= radius - - const hasQuiz = (step.quizQuestions?.length ?? 0) > 0 - const hasTimer = !!step.isStepTimer && (step.timerSeconds ?? 0) > 0 - const isLocked = !!step.isStepLocked - const geoUnavailable = geo.status === 'denied' || geo.status === 'unavailable' - - // canAdvance composé : - // - quiz : si présent + requireSuccess → doit être passé - // - zone : si présente + requireSuccess → doit être dans la zone (sauf si GPS indispo) - // - lock : si verrouillé + requireSuccess → doit avoir au moins une preuve (quiz ou zone) - const quizOk = !hasQuiz || quizPassed || !requireSuccess - const zoneOk = radius === 0 || inZone || !requireSuccess || geoUnavailable - const lockOk = !isLocked || !requireSuccess || quizPassed || inZone || geoUnavailable - const canAdvance = quizOk && zoneOk && lockOk - return ( - <> -
-
-
- {step.imageUrl && ( - // eslint-disable-next-line @next/next/no-img-element - - )} -
- -
- Étape {stepIndex + 1} / {totalSteps} -
-
-
- -
- {t(step.description, language) && ( -
- )} - {radius > 0 && ( -
-
- - - - {inZone - ? 'Vous êtes dans la zone' - : distance != null - ? `À ${formatDistance(distance)} de la zone (${radius} m)` - : `Zone à atteindre · ${radius} m`} -
- {geo.status === 'requesting' && ( - Localisation en cours… - )} - {geo.status === 'denied' && ( - - )} - {geo.status === 'unavailable' && ( - GPS indisponible - )} -
- )} - - {isLocked && !isCompleted && ( -
- 🔒 - {quizPassed || inZone ? 'Étape déverrouillée' : 'Étape verrouillée — validez la condition'} -
- )} - - {hasTimer && isCurrent && !isCompleted && ( -
- - {timerExpired && t(step.timerExpiredMessage, language) && ( -
- )} -
- )} - - {hasQuiz && isCurrent && !isCompleted && ( -
-
- Défi -
- - {quizPassed && ( -
- ✓ Défi réussi -
- )} -
- )} -
- -
- {isCompleted && ( -
- ✓ Étape terminée -
- )} - {isCurrent && !isCompleted && ( - - )} - {!isLinear && ( -
- - -
- )} -
-
- - ) -} - // ── Point list view ───────────────────────────────────────────────────────── function PointList({ points, language, categories, onSelect, @@ -1036,62 +489,3 @@ function Icon({ name }: { name: 'phone' | 'email' | 'web' | 'schedule' | 'euro' ) } - -// ── End of path modal ────────────────────────────────────────────────────── -function EndOfPathModal({ - pathTitle, stepCount, onRestart, onLeave, -}: { - pathTitle: string - stepCount: number - onRestart: () => void - onLeave: () => void -}) { - return ( -
-
- -
-
- - - -
-
Bravo !
-
- Tu as terminé le parcours « {pathTitle} » ({stepCount} étape{stepCount > 1 ? 's' : ''}). -
-
-
- - -
-
-
- ) -} diff --git a/src/components/sections/MenuSection.tsx b/src/components/sections/MenuSection.tsx index 1f7a0d6..7346a59 100644 --- a/src/components/sections/MenuSection.tsx +++ b/src/components/sections/MenuSection.tsx @@ -44,14 +44,14 @@ export default function MenuSection({ section, slug, configId, languages }: Prop }) return ( -
+
{/* Search */}
@@ -84,8 +84,8 @@ export default function MenuSection({ section, slug, configId, languages }: Prop key={sub.id} href={`/${slug}/${configId}/sections/${sub.id}`} onClick={() => handleItemClick(sub)} - className="rounded-2xl overflow-hidden block relative" - style={{ background: 'var(--color-surface)', minHeight: 120 }} + className="block relative overflow-hidden" + style={{ background: '#fff', minHeight: 120, borderRadius: 20, border: '1px solid #EFEAE2', boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }} > {sub.imageSource ? (
@@ -103,7 +103,7 @@ export default function MenuSection({ section, slug, configId, languages }: Prop
diff --git a/src/components/sections/ParcoursSection.tsx b/src/components/sections/ParcoursSection.tsx new file mode 100644 index 0000000..cbe2f71 --- /dev/null +++ b/src/components/sections/ParcoursSection.tsx @@ -0,0 +1,2497 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useBack } from '@/hooks/useBack' +import ChevronLeft from '@/components/ui/ChevronLeftIcon' +import { useVisitor } from '@/context/VisitorContext' +import { t, tPlain } from '@/lib/i18n' +import dynamic from 'next/dynamic' +import QuestionPuzzle from './game/QuestionPuzzle' +import StepTimer from './map/StepTimer' +import { useGeolocation } from '@/hooks/useGeolocation' +import { haversineMeters, formatDistance, getStepGeometryCenter } from '@/lib/geo' +import './map/map.css' +import type { SectionDTO, GuidedPathDTO, GuidedStepDTO, QuestionDTO, TranslationAndResourceDTO, ContentDTO } from '@/lib/api/types' + +const LeafletMap = dynamic(() => import('./map/LeafletMap'), { + ssr: false, + loading: () => ( +
+ Chargement de la carte… +
+ ), +}) + +interface Props { + section: SectionDTO + slug: string + configId: string + languages: string[] + apiKey: string +} + +type View = 'list' | 'start' | 'progress' | 'end' + +export default function ParcoursSection({ section, languages }: Props) { + const { language, setAvailableLanguages } = useVisitor() + const back = useBack() + + useEffect(() => { setAvailableLanguages(languages) }, [languages]) + + const parcours = section.parcours + const paths: GuidedPathDTO[] = [...(parcours?.guidedPaths ?? [])].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0) + ) + const [view, setView] = useState('list') + const [activePath, setActivePath] = useState(null) + const [stepIndex, setStepIndex] = useState(0) + const isGame = activePath?.isGameMode === true + const [completedSteps, setCompletedSteps] = useState>(new Set()) + const [primaryColor, setPrimaryColor] = useState('#264863') + + useEffect(() => { + const c = getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim() + if (c) setPrimaryColor(c) + }, []) + + const sectionTitle = tPlain(section.title, language) + const sectionDesc = t(section.description, language) + + const steps: GuidedStepDTO[] = activePath + ? [...(activePath.steps ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + : [] + + const currentStep = steps[stepIndex] ?? null + + function selectPath(path: GuidedPathDTO) { + setActivePath(path) + setStepIndex(0) + setCompletedSteps(new Set()) + setView('start') + } + + function startPath() { + setView('progress') + } + + function nextStep() { + if (currentStep) setCompletedSteps((s) => new Set(s).add(currentStep.id)) + if (stepIndex < steps.length - 1) { + setStepIndex((i) => i + 1) + } else { + setView('end') + } + } + + function prevStep() { + if (stepIndex > 0) setStepIndex((i) => i - 1) + } + + function restart() { + setView('list') + setActivePath(null) + setStepIndex(0) + setCompletedSteps(new Set()) + } + + const gameIntro = tRaw(activePath?.gameMessageDebut, language) + + // ── Render ───────────────────────────────────────────────────────────────── + + const stepsHaveGeo = steps.some((s) => !!extractStepPoint(s.geometry?.coordinates)) + const useMapView = section.parcours?.showMap === true && stepsHaveGeo + + if (view === 'progress' && activePath && currentStep) { + if (useMapView) { + return ( + setView('list')} + /> + ) + } + return ( + setView('list')} + /> + ) + } + + if (view === 'end' && activePath) { + return ( + + ) + } + + return ( +
+ {/* Hero */} +
+ {section.imageSource && ( + // eslint-disable-next-line @next/next/no-img-element + {sectionTitle} + )} +
+ {/* Back */} + +{/* Info overlay */} +
+ {paths.length > 0 && ( + + {paths.length} parcours + + )} +

+ {sectionTitle} +

+ {sectionDesc && ( +

+ )} +

+
+ + {/* Path list */} +
+ {paths.length === 0 ? ( +
+ +

Aucun parcours disponible

+
+ ) : ( + <> +

+ Choisir un parcours +

+
+ {paths.map((path) => ( + selectPath(path)} + /> + ))} +
+ + )} +
+ + {/* Start sheet (bottom drawer) */} + {view === 'start' && activePath && ( + setView('list')} + /> + )} +
+ ) +} + +// ── Map-based progression ───────────────────────────────────────────────────── + +function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primaryColor, isGame, language, sectionLat, sectionLng, onNext, onPrev, onBack }: { + path: GuidedPathDTO + steps: GuidedStepDTO[] + stepIndex: number + completedSteps: Set + primaryColor: string + isGame: boolean + language: string + sectionLat?: number + sectionLng?: number + onNext: () => void + onPrev: () => void + onBack: () => void +}) { + const currentStep = steps[stepIndex] + const [selectedStepId, setSelectedStepId] = useState(currentStep?.id ?? null) + const [showDetail, setShowDetail] = useState(false) + + // Quiz state + const [quizAnswered, setQuizAnswered] = useState>({}) + const [puzzleSolved, setPuzzleSolved] = useState>({}) + const [simpleAnswered, setSimpleAnswered] = useState>({}) + const [simpleChecked, setSimpleChecked] = useState>({}) + const [challengeIndex, setChallengeIndex] = useState(0) + const [showQuizResult, setShowQuizResult] = useState(false) + const [quizPassed, setQuizPassed] = useState(false) + const [showChallenge, setShowChallenge] = useState(false) + + // Audio state + const [isAudioPlaying, setIsAudioPlaying] = useState(false) + const [audioCurrentTime, setAudioCurrentTime] = useState(0) + const [audioDuration, setAudioDuration] = useState(0) + const audioRef = useRef(null) + + // Timer + geo state + const [timerStarted, setTimerStarted] = useState(false) + const [timerExpired, setTimerExpired] = useState(false) + const [expiredPopupDismissed, setExpiredPopupDismissed] = useState(false) + const [remainingSeconds, setRemainingSeconds] = useState(currentStep?.timerSeconds ?? 0) + const [toastMessage, setToastMessage] = useState(null) + const [geoKey, setGeoKey] = useState(0) + const geo = useGeolocation(true, geoKey) + + // Reset quiz + audio when step changes + useEffect(() => { + setQuizAnswered({}) + setPuzzleSolved({}) + setSimpleAnswered({}) + setSimpleChecked({}) + setChallengeIndex(0) + setShowQuizResult(false) + setQuizPassed(false) + setShowChallenge(false) + setIsAudioPlaying(false) + setAudioCurrentTime(0) + setAudioDuration(0) + setShowDetail(false) + setTimerStarted(false) + setTimerExpired(false) + setExpiredPopupDismissed(false) + setRemainingSeconds(currentStep?.timerSeconds ?? 0) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stepIndex]) + + // Follow current step on map when it changes + useEffect(() => { + setSelectedStepId(currentStep?.id ?? null) + }, [currentStep?.id]) + + const visibleSteps = path.hideNextStepsUntilComplete + ? steps.slice(0, stepIndex + 1) + : steps + + const center: [number, number] = (() => { + for (const s of [steps[stepIndex], ...steps]) { + const pt = extractStepPoint(s?.geometry?.coordinates) + if (pt) return [pt.lat, pt.lng] + } + return [sectionLat ?? 48.86, sectionLng ?? 2.35] + })() + + const stepTitle = tPlain(currentStep?.title, language) + const stepDesc = t(currentStep?.description, language) + const accentColor = isGame ? '#D4AF37' : primaryColor + const bgColor = isGame ? '#0B1018' : '#F6F3EE' + const mutedColor = isGame ? '#8a8068' : '#6B7B86' + + const quizQuestions = [...(currentStep?.quizQuestions ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + const mcqQuestions = quizQuestions.filter((q) => + (q.responses?.length ?? 0) > 0 && (q.validationQuestionType == null || q.validationQuestionType === 1) + ) + const puzzleQuestions = quizQuestions.filter((q) => + q.validationQuestionType === 2 && !!q.puzzleImage?.url + ) + const simpleQuestions = quizQuestions.filter((q) => + q.validationQuestionType === 0 && (q.responses?.length ?? 0) > 0 + ) + const hasQuiz = mcqQuestions.length > 0 || puzzleQuestions.length > 0 || simpleQuestions.length > 0 + const requireSuccess = path.requireSuccessToAdvance === true + const hasTimer = !!currentStep?.isStepTimer && (currentStep?.timerSeconds ?? 0) > 0 + + const allChallengeQuestions = [...mcqQuestions, ...puzzleQuestions, ...simpleQuestions] + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + const wrongChallengeCount = allChallengeQuestions + .filter((q) => !isChallengeItemCorrect(q, quizAnswered, puzzleSolved, simpleAnswered)).length + // Toutes les questions déjà répondues correctement, sans attendre le clic sur "Terminer le défi". + const allAnsweredCorrectly = hasQuiz && allChallengeQuestions.length > 0 && wrongChallengeCount === 0 + // Le timer tourne tant que le défi n'est pas résolu, y compris overlay du défi fermé. + const timerActive = timerStarted && !timerExpired && !(hasQuiz && (quizPassed || allAnsweredCorrectly)) + + const stepCenter = getStepGeometryCenter(currentStep?.geometry) + const radius = currentStep?.zoneRadiusMeters ?? 0 + const geoTriggered = !!currentStep?.isGeoTriggered && radius > 0 + const distance = geo.lat != null && geo.lng != null && stepCenter + ? haversineMeters({ lat: geo.lat, lng: geo.lng }, stepCenter) + : null + const inZone = distance != null && geoTriggered && distance <= radius + const geoUnavailable = geo.status === 'denied' || geo.status === 'unavailable' + + const quizOk = !hasQuiz || quizPassed || !requireSuccess + const zoneOk = !geoTriggered || inZone || !requireSuccess || geoUnavailable + const canAdvance = quizOk && zoneOk + + const audioUrlRaw = currentStep?.audioIds?.find((a) => a.language === language)?.value + ?? currentStep?.audioIds?.[0]?.value + const audioUrl = audioUrlRaw?.startsWith('http') ? audioUrlRaw : undefined + const stepContents = [...(currentStep?.contents ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + const hasCarousel = stepContents.length > 0 + + function toggleAudio() { + if (!audioRef.current) return + if (isAudioPlaying) { audioRef.current.pause(); setIsAudioPlaying(false) } + else { setIsAudioPlaying(true); audioRef.current.play().catch(() => setIsAudioPlaying(false)) } + } + + useEffect(() => { + if (!timerActive) return + const id = setInterval(() => { + setRemainingSeconds((s) => { + if (s <= 1) { + clearInterval(id) + setTimerExpired(true) + return 0 + } + return s - 1 + }) + }, 1000) + return () => clearInterval(id) + }, [timerActive]) + + function openChallenge() { + setShowChallenge(true) + if (hasTimer && !timerStarted) { + setTimerStarted(true) + setToastMessage('Le chrono a démarré !') + } + } + + function submitQuiz() { + setQuizPassed(wrongChallengeCount === 0) + setShowQuizResult(true) + } + + function retryWrong() { + const wrongIds = new Set( + allChallengeQuestions + .filter((q) => !isChallengeItemCorrect(q, quizAnswered, puzzleSolved, simpleAnswered)) + .map((q) => q.id) + ) + setQuizAnswered((a) => Object.fromEntries(Object.entries(a).filter(([id]) => !wrongIds.has(Number(id))))) + setSimpleAnswered((a) => Object.fromEntries(Object.entries(a).filter(([id]) => !wrongIds.has(Number(id))))) + setSimpleChecked((a) => Object.fromEntries(Object.entries(a).filter(([id]) => !wrongIds.has(Number(id))))) + const firstWrongIndex = allChallengeQuestions.findIndex((q) => wrongIds.has(q.id)) + setChallengeIndex(firstWrongIndex >= 0 ? firstWrongIndex : 0) + setShowQuizResult(false) + setQuizPassed(false) + } + + function handleNext() { + setShowDetail(false) + onNext() + } + + return ( +
+ {/* Map fills screen */} +
+ {}} + primaryColor={primaryColor} + pathSteps={visibleSteps} + selectedStepId={selectedStepId} + onSelectStep={setSelectedStepId} + currentStepId={currentStep?.id ?? null} + completedStepIds={completedSteps} + userPosition={geo.lat != null && geo.lng != null ? { lat: geo.lat, lng: geo.lng } : null} + /> +
+ + {/* Top header — floats over the map */} +
+ +
+ + {tPlain(path.title, language)} + + + {stepIndex + 1} / {steps.length} + +
+
+ + {/* Bottom sheet — current step */} +
+ {/* Handle */} +
+
+
+ + {/* Step card — tap to open detail */} + + + {/* Progress bar */} +
+
+
+
+
+ + {/* Action buttons */} +
+ {stepIndex > 0 && ( + + )} + {hasQuiz && ( + + )} + +
+
+ + {/* Detail overlay — slides up over the map */} + {showDetail && ( +
+ {/* Header */} +
+ +
+ {steps.map((s, i) => ( +
+ ))} +
+ + {stepIndex + 1} / {steps.length} + +
+ + {/* Hero */} + {hasCarousel ? ( + + ) : ( +
+ {(currentStep?.imageUrl || path.imageUrl) && ( + // eslint-disable-next-line @next/next/no-img-element + {stepTitle} + )} +
+
+ + Étape {stepIndex + 1} + +
+
+

+ {stepTitle} +

+
+
+ )} + + {/* Content */} +
+ {hasCarousel && ( +

+ {stepTitle} +

+ )} + + + {tPlain(path.title, language).toUpperCase() || 'PARCOURS'} + + + {/* Audio player */} + {audioUrl && ( + <> +
+ ) +} + +// ── Mini toast (timer notifications) ─────────────────────────────────────────── + +function MiniToast({ message, isGame, onDismiss }: { message: string; isGame: boolean; onDismiss: () => void }) { + useEffect(() => { + const id = setTimeout(onDismiss, 2500) + return () => clearTimeout(id) + }, [message, onDismiss]) + + return ( +
+
+ {message} +
+
+ ) +} + +function TimerExpiredModal({ html, isGame, accentColor, onClose }: { + html: string + isGame: boolean + accentColor: string + onClose: () => void +}) { + return ( +
+
e.stopPropagation()} + style={{ + width: '100%', maxWidth: 380, borderRadius: 24, + background: isGame ? '#0B1018' : '#fff', + border: isGame ? '1px solid rgba(212,175,55,0.3)' : 'none', + boxShadow: '0 20px 50px rgba(0,0,0,0.4)', + padding: '28px 24px 24px', + textAlign: 'center', + }} + > +
+ +
+

+ Temps écoulé ! +

+
+ +
+
+ ) +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function PathCard({ path, isGame, primaryColor, language, onClick }: { + path: GuidedPathDTO + isGame: boolean + primaryColor: string + language: string + onClick: () => void +}) { + const title = tPlain(path.title, language) + const desc = tPlain(path.description, language) + const stepCount = path.steps?.length ?? 0 + + return ( + + ) +} + +function StartSheet({ path, isGame, primaryColor, language, gameIntro, onStart, onClose }: { + path: GuidedPathDTO + isGame: boolean + primaryColor: string + language: string + gameIntro: string + onStart: () => void + onClose: () => void +}) { + const title = tPlain(path.title, language) + const desc = tPlain(path.description, language) + const stepCount = path.steps?.length ?? 0 + + return ( + <> + {/* Backdrop */} +
+ {/* Sheet */} +
+ {/* Handle */} +
+
+
+ + {isGame ? ( + /* ── Game start ── */ +
+
+ +
+

ESCAPE GAME

+

+ {title} +

+ {gameIntro && ( +

|<\/p>$/g, '')} »` }} + /> + )} +

+ {stepCount} énigme{stepCount > 1 ? 's' : ''} + {path.estimatedDurationMinutes && <>·~{path.estimatedDurationMinutes} min} +
+ +

Activez le son pour une immersion totale

+
+ ) : ( + /* ── Normal start ── */ +
+ {/* Illustration : image du parcours si dispo, sinon mini-carte décorative */} +
+ {path.imageUrl + ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + ) + : + } +
+ + DÉCOUVERTE LIBRE + +
+
+

+ {title} +

+
+
+ {/* Stats */} +
+ {path.estimatedDurationMinutes ? `~${path.estimatedDurationMinutes} min` : '—'} + {stepCount} étape{stepCount > 1 ? 's' : ''} +
+ {desc && ( +

{desc}

+ )} + +
+ )} +
+ + ) +} + +function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, isGame, language, onNext, onPrev, onBack }: { + path: GuidedPathDTO + steps: GuidedStepDTO[] + stepIndex: number + completedSteps: Set + primaryColor: string + isGame: boolean + language: string + onNext: () => void + onPrev: () => void + onBack: () => void +}) { + const step = steps[stepIndex] + const [quizAnswered, setQuizAnswered] = useState>({}) + const [puzzleSolved, setPuzzleSolved] = useState>({}) + const [simpleAnswered, setSimpleAnswered] = useState>({}) + const [simpleChecked, setSimpleChecked] = useState>({}) + const [challengeIndex, setChallengeIndex] = useState(0) + const [showQuizResult, setShowQuizResult] = useState(false) + const [quizPassed, setQuizPassed] = useState(false) + const [showChallenge, setShowChallenge] = useState(false) + const [isAudioPlaying, setIsAudioPlaying] = useState(false) + const [audioCurrentTime, setAudioCurrentTime] = useState(0) + const [audioDuration, setAudioDuration] = useState(0) + const audioRef = useRef(null) + const [timerStarted, setTimerStarted] = useState(false) + const [timerExpired, setTimerExpired] = useState(false) + const [expiredPopupDismissed, setExpiredPopupDismissed] = useState(false) + const [remainingSeconds, setRemainingSeconds] = useState(step?.timerSeconds ?? 0) + const [toastMessage, setToastMessage] = useState(null) + + const quizQuestions = [...(step?.quizQuestions ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + const mcqQuestions = quizQuestions.filter((q) => + (q.responses?.length ?? 0) > 0 && (q.validationQuestionType == null || q.validationQuestionType === 1) + ) + const puzzleQuestions = quizQuestions.filter((q) => + q.validationQuestionType === 2 && !!q.puzzleImage?.url + ) + const simpleQuestions = quizQuestions.filter((q) => + q.validationQuestionType === 0 && (q.responses?.length ?? 0) > 0 + ) + const hasQuiz = mcqQuestions.length > 0 || puzzleQuestions.length > 0 || simpleQuestions.length > 0 + const requireSuccess = path.requireSuccessToAdvance === true + const canAdvance = !hasQuiz || !requireSuccess || quizPassed + const hasTimer = !!step?.isStepTimer && (step?.timerSeconds ?? 0) > 0 + + const allChallengeQuestions = [...mcqQuestions, ...puzzleQuestions, ...simpleQuestions] + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + const wrongChallengeCount = allChallengeQuestions + .filter((q) => !isChallengeItemCorrect(q, quizAnswered, puzzleSolved, simpleAnswered)).length + // Toutes les questions déjà répondues correctement, sans attendre le clic sur "Terminer le défi". + const allAnsweredCorrectly = hasQuiz && allChallengeQuestions.length > 0 && wrongChallengeCount === 0 + // Le timer tourne tant que le défi n'est pas résolu, y compris overlay du défi fermé. + const timerActive = timerStarted && !timerExpired && !(hasQuiz && (quizPassed || allAnsweredCorrectly)) + + const stepTitle = tPlain(step?.title, language) + const stepDesc = t(step?.description, language) + const audioUrlRaw = step?.audioIds?.find((a) => a.language === language)?.value + ?? step?.audioIds?.[0]?.value + const audioUrl = audioUrlRaw?.startsWith('http') ? audioUrlRaw : undefined + const stepContents = [...(step?.contents ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + const hasCarousel = stepContents.length > 0 + + const bgColor = isGame ? '#0B1018' : '#F6F3EE' + const mutedColor = isGame ? '#8a8068' : '#6B7B86' + const accentColor = isGame ? '#D4AF37' : primaryColor + + useEffect(() => { + setQuizAnswered({}) + setPuzzleSolved({}) + setSimpleAnswered({}) + setSimpleChecked({}) + setChallengeIndex(0) + setShowQuizResult(false) + setQuizPassed(false) + setShowChallenge(false) + setIsAudioPlaying(false) + setAudioCurrentTime(0) + setTimerStarted(false) + setTimerExpired(false) + setExpiredPopupDismissed(false) + setRemainingSeconds(step?.timerSeconds ?? 0) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stepIndex]) + + useEffect(() => { + if (!timerActive) return + const id = setInterval(() => { + setRemainingSeconds((s) => { + if (s <= 1) { + clearInterval(id) + setTimerExpired(true) + return 0 + } + return s - 1 + }) + }, 1000) + return () => clearInterval(id) + }, [timerActive]) + + function openChallenge() { + setShowChallenge(true) + if (hasTimer && !timerStarted) { + setTimerStarted(true) + setToastMessage('Le chrono a démarré !') + } + } + + function toggleAudio() { + if (!audioRef.current) return + if (isAudioPlaying) { audioRef.current.pause(); setIsAudioPlaying(false) } + else { setIsAudioPlaying(true); audioRef.current.play().catch(() => setIsAudioPlaying(false)) } + } + + function submitQuiz() { + setQuizPassed(wrongChallengeCount === 0) + setShowQuizResult(true) + } + + function retryWrong() { + const wrongIds = new Set( + allChallengeQuestions + .filter((q) => !isChallengeItemCorrect(q, quizAnswered, puzzleSolved, simpleAnswered)) + .map((q) => q.id) + ) + setQuizAnswered((a) => Object.fromEntries(Object.entries(a).filter(([id]) => !wrongIds.has(Number(id))))) + setSimpleAnswered((a) => Object.fromEntries(Object.entries(a).filter(([id]) => !wrongIds.has(Number(id))))) + setSimpleChecked((a) => Object.fromEntries(Object.entries(a).filter(([id]) => !wrongIds.has(Number(id))))) + const firstWrongIndex = allChallengeQuestions.findIndex((q) => wrongIds.has(q.id)) + setChallengeIndex(firstWrongIndex >= 0 ? firstWrongIndex : 0) + setShowQuizResult(false) + setQuizPassed(false) + } + + return ( +
+ {/* Header */} +
+ +
+ {steps.map((s, i) => ( +
+ ))} +
+ + {stepIndex + 1} / {steps.length} + +
+ + {/* Hero — carousel si contents, sinon dégradé */} + {hasCarousel ? ( + + ) : ( +
+ {step?.imageUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {stepTitle} + )} +
+
+ + Étape {stepIndex + 1} + +
+
+

+ {stepTitle} +

+
+
+ )} + + {/* Contenu */} +
+ {/* Titre — uniquement en mode carousel (sinon il est dans le hero) */} + {hasCarousel && ( +

+ {stepTitle} +

+ )} + + {/* Label parcours */} + + {tPlain(path.title, language).toUpperCase() || 'PARCOURS'} + + + {/* Lecteur audio */} + {audioUrl && ( + <> +