diff --git a/jest.setup.dom.ts b/jest.setup.dom.ts index d0de870..69eb9e5 100644 --- a/jest.setup.dom.ts +++ b/jest.setup.dom.ts @@ -1 +1,3 @@ import "@testing-library/jest-dom"; + +jest.mock("next-intl", () => jest.requireActual("@/test-utils/next-intl-mock")); diff --git a/next.config.js b/next.config.js index b930f3e..4c68e53 100644 --- a/next.config.js +++ b/next.config.js @@ -1,3 +1,9 @@ +// Config CJS (module.exports) : require nécessaire pour le plugin next-intl. +// eslint-disable-next-line @typescript-eslint/no-require-imports -- next.config.js is CommonJS +const createNextIntlPlugin = require("next-intl/plugin"); + +const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); + /** @type {import('next').NextConfig} */ const isProd = process.env.NODE_ENV === "production"; @@ -64,4 +70,4 @@ const nextConfig = { poweredByHeader: false, }; -module.exports = nextConfig; +module.exports = withNextIntl(nextConfig); diff --git a/package-lock.json b/package-lock.json index 0bfaacf..816947f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "framer-motion": "^14.9.0", "next": "16.4.3", "next-auth": "^4.24.13", + "next-intl": "^4.14.9", "pg": "^8.23.1", "pino": "^10.4.0", "react": "19.3.1", @@ -1277,6 +1278,31 @@ "@electric-sql/pglite": "0.4.3" } }, + "node_modules/@eloqnt/config": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@eloqnt/config/-/config-0.1.1.tgz", + "integrity": "sha512-YRDigSaiJEzKDfzTth5kG+L/PKfB3mo8h7yNzpXExPfvQmcD05WoZT57PdhJE0jI+Vva6eb0OCU8hnHHgdME3g==", + "license": "MIT" + }, + "node_modules/@eloqnt/format-json": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@eloqnt/format-json/-/format-json-0.1.0.tgz", + "integrity": "sha512-4Pkb+HyzgWNJXoU48aJhUyNZSjZvMGooPCNG1ZFmBm55xTiNNPQCNXH66Ob6sXS6RvbWZrRpCf+Uo/Jz9K9g+A==", + "license": "MIT", + "dependencies": { + "@eloqnt/config": "^0.1.0" + } + }, + "node_modules/@eloqnt/format-po": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@eloqnt/format-po/-/format-po-0.1.1.tgz", + "integrity": "sha512-aLtmIoqXGEaeTT0RcsLN8sZ3aIrsT/c/dVs28zrLhDjO3+vA3KNP+r98N0xq8w/k36+/GIUt4uZNbqcA71GVWw==", + "license": "MIT", + "dependencies": { + "@eloqnt/config": "^0.1.1", + "po-parser": "^2.2.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1404,6 +1430,36 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", + "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==", + "license": "MIT" + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.22.tgz", + "integrity": "sha512-YuwN+dDKgssfuYwtOyGQD0CSn7K46g9cv1Fp0VgKpjZDDFvv8b8crzP990IcJhRPWHhKzCGsbUSJVIguyyFA5A==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.12" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.12.tgz", + "integrity": "sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==", + "license": "MIT" + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.14.tgz", + "integrity": "sha512-8O7V38QmbB11+ryJ3KM/znrzXtiqgpXzqNoqDsndHZZ5/zDKTTL+cf5F0mzXkQEwhx8IlWnqYvEovf2S5EwLTw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -3296,7 +3352,6 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", - "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3334,7 +3389,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3355,7 +3409,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3376,7 +3429,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3397,7 +3449,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3418,7 +3469,6 @@ "cpu": [ "arm" ], - "dev": true, "libc": [ "glibc" ], @@ -3442,7 +3492,6 @@ "cpu": [ "arm" ], - "dev": true, "libc": [ "musl" ], @@ -3466,7 +3515,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -3490,7 +3538,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -3514,7 +3561,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -3538,7 +3584,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -3562,7 +3607,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3583,7 +3627,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3601,7 +3644,6 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -4107,6 +4149,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -4605,6 +4653,262 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@swc/core": { + "version": "1.16.22", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.22.tgz", + "integrity": "sha512-kFqhtSmmNSZqJXIK0l9rF+oNg2efaZoeNbN/4qwbNTWfMRKApc6n7rKx827bw8K+4kmu7iH0LZ144QJTQzulHw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.28" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.16.22", + "@swc/core-darwin-x64": "1.16.22", + "@swc/core-linux-arm-gnueabihf": "1.16.22", + "@swc/core-linux-arm64-gnu": "1.16.22", + "@swc/core-linux-arm64-musl": "1.16.22", + "@swc/core-linux-ppc64-gnu": "1.16.22", + "@swc/core-linux-s390x-gnu": "1.16.22", + "@swc/core-linux-x64-gnu": "1.16.22", + "@swc/core-linux-x64-musl": "1.16.22", + "@swc/core-win32-arm64-msvc": "1.16.22", + "@swc/core-win32-ia32-msvc": "1.16.22", + "@swc/core-win32-x64-msvc": "1.16.22" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.16.22", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.22.tgz", + "integrity": "sha512-PddkOjOK3MK01AVOvqG309iAJ0hn5vK37ug9g5Zt5e2aWF3IhVLdDLjMePXts1zi8yc4ild2h9FYCkVYb5+Qcw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.16.22", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.22.tgz", + "integrity": "sha512-Zzia5cuIQFLTfZyf5mC1JvRMPW1VlxbvkZ0qNESoD4okE6T6Us2ZNVZ5XMVELqkYNw9eL2GeuMJgDIHBdYo7Gg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.16.22", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.22.tgz", + "integrity": "sha512-GI6VICiuYc7mb61XenLtiEGoOXTXDdUda8w0bu29Mh3Z6rhuTkAiBAdDFpdww2QNV0iACY5ylUSL127I6Nx8yQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.16.22", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.22.tgz", + "integrity": "sha512-Ic+qrMmV78QdlG9irJuc/37xSqY7+5ZFhg4kaYbF82UQkaEfWgqGSUbU75Y4To21eaBB07c05kbXmTWTrvPURA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.16.22", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.22.tgz", + "integrity": "sha512-ophleEwlj0SqkzYPDJ7xMPK0+MnWfc4+u9GzDYLLFG1TkIJuwj0rHNWto4MmKo00TylHFwGx9+5bLs/+YZ00IQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.16.22", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.22.tgz", + "integrity": "sha512-/4kR7SafFsVqY8N4HSepTs4Ni9YA0OY6n/SVBk/V15PhDiw97stG9SVqvgc8EzC+3XOe4pTmwlvFLWzNYfuNWg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.16.22", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.22.tgz", + "integrity": "sha512-b3GQqKmHBHUFQby1sh/QLtNQMMXTSBUhnmXcJ5qSrWbkYnhljIW3Y+30eMrrxCfy7HXLLDug1sjlhJDhf3tPhA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.16.22", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.22.tgz", + "integrity": "sha512-u9hdfNEI3oI/dTRJ+In8G32EgfFpZDvMoI5elIERl6/XfaIzE9gpv4WomhKblAXA1+EYyLfDCS1EFFKQlBXTQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.16.22", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.22.tgz", + "integrity": "sha512-C3YVRL717kKSSU8n6HaC7sesFSGnHjRuHN3609Wl4epPYVDDutzD6aj7CMcx9cg3WWJ/oE8woDZ8qrCfzmxvnw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.16.22", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.22.tgz", + "integrity": "sha512-+MpNuE45LMFo+6RF8vMUqZPmaOfRQIuzCUHJ+jSXwEhiR5KHeZiQ9MaJglZRDeMuML67voCiFLrxqYTqGCbcCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.16.22", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.22.tgz", + "integrity": "sha512-z1p/CMDSRqvsE5Gs7axHVl6p+2zjoQTYuGcZ/q+3PMjAxx2bo/+lkpG9ot+725e6lj9os3WwYQrncmOWKVDrEQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.16.22", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.22.tgz", + "integrity": "sha512-IyBPJn/SpXsh7i5ZLfQUPJ+R+fuELPI7Sz51igmYMTv2wdnDn/tqnIlAUwQe6AgnclbuJdbOb6crUbyN/zwMzw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -4614,6 +4918,15 @@ "tslib": "^2.8.0" } }, + "node_modules/@swc/types": { + "version": "0.1.28", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz", + "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -7559,10 +7872,9 @@ "license": "ISC" }, "node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "dev": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "engines": { "node": ">=18" @@ -8204,7 +8516,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -10434,6 +10745,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/icu-minify": { + "version": "4.14.9", + "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.14.9.tgz", + "integrity": "sha512-PmO+fNnC3K3mfPKTnm3RrPERrhQZnjm1AlpyZQ0ShbXi8Y/kYZ3YOde8TEjCLz9k3P2DmzkBn8lykGrCAi1Ajw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/icu-messageformat-parser": "^3.4.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -10584,6 +10910,31 @@ "node": ">=12" } }, + "node_modules/intl-messageformat": { + "version": "11.2.15", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.15.tgz", + "integrity": "sha512-W4VNytsXyWgW5lvVchmsb9xgA5rSKqoflu3/IQ9UCtB0z4R+Bx9MoEkwFT/+30RCl/KzUoH0QGGe9lppF5SuvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7", + "@formatjs/icu-messageformat-parser": "3.5.18" + } + }, + "node_modules/intl-messageformat/node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.18.tgz", + "integrity": "sha512-wX1efcL8d7K5QapOTRsP/OLTA/kpwe4LwqWyNxDNg5iRs8i9jJbCyxB5P8jc4KOW5oiLzz2DJr+BRY5xMV9+bA==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.11" + } + }, + "node_modules/intl-messageformat/node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.11.tgz", + "integrity": "sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==", + "license": "MIT" + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -10793,7 +11144,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10859,7 +11209,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -14630,11 +14979,66 @@ } } }, + "node_modules/next-intl": { + "version": "4.14.9", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.14.9.tgz", + "integrity": "sha512-uauRtUC+TrYNA1OL/QcwL9irndLrI9jK3Wdaa9W0B5AeKh9cqVdNeT0IWgLnhBqBRrVwppVdI0M+C1+JdGPQ3w==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@eloqnt/config": "^0.1.1", + "@eloqnt/format-json": "^0.1.0", + "@eloqnt/format-po": "^0.1.1", + "@formatjs/intl-localematcher": "^0.8.1", + "@parcel/watcher": "^2.4.1", + "@swc/core": "~1.16.0", + "icu-minify": "^4.14.9", + "negotiator": "^1.0.0", + "next-intl-swc-plugin-extractor": "4.14.9", + "use-intl": "^4.14.9" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/next-intl-swc-plugin-extractor": { + "version": "4.14.9", + "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.14.9.tgz", + "integrity": "sha512-8jAkmDwQJo6RDavBHm9QRl1YTn2WXf+rI4SVhMaZxZcxopnwZic6fKTXOoNIo3kzC+a6vJySkOkXmHTVFLAYtg==", + "license": "MIT" + }, + "node_modules/next-intl/node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, "license": "MIT" }, "node_modules/node-emoji": { @@ -17563,6 +17967,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/po-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.2.0.tgz", + "integrity": "sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -20520,6 +20930,33 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/use-intl": { + "version": "4.14.9", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.14.9.tgz", + "integrity": "sha512-37ktvdCCz+6JY2VFPl/GV69Ds4ahiUT2c2xEZZRZYxdgGLxFsUbdxzjQVRm3YMIOUU0VPs6zvlifb4Yjk4IxIg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^3.1.0", + "@schummar/icu-type-parser": "1.21.5", + "icu-minify": "^4.14.9", + "intl-messageformat": "^11.1.0" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 344d838..0d65f2a 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "framer-motion": "^14.9.0", "next": "16.4.3", "next-auth": "^4.24.13", + "next-intl": "^4.14.9", "pg": "^8.23.1", "pino": "^10.4.0", "react": "19.3.1", diff --git a/prisma/migrations/20261105150000_add_user_locale/migration.sql b/prisma/migrations/20261105150000_add_user_locale/migration.sql new file mode 100644 index 0000000..1acef6e --- /dev/null +++ b/prisma/migrations/20261105150000_add_user_locale/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "locale" TEXT NOT NULL DEFAULT 'fr'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 30b1276..3e073f5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,6 +18,7 @@ model User { password String? isAdmin Boolean @default(false) mustChangePassword Boolean @default(false) + locale String @default("fr") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/api/account/route.ts b/src/app/api/account/route.ts index ae83ffd..e7528e7 100644 --- a/src/app/api/account/route.ts +++ b/src/app/api/account/route.ts @@ -1,8 +1,12 @@ import { NextResponse } from "next/server"; +import { apiError } from "@/lib/api-errors"; import { requireApiSession } from "@/lib/api-auth"; import { getAccountProfile, updateAccount } from "@/lib/account"; import { updateAccountSchema } from "@/lib/schemas/account"; import { parseJsonBody } from "@/lib/schemas/parse"; +import { LOCALE_COOKIE } from "@/i18n/config"; + +const LOCALE_COOKIE_MAX_AGE = 365 * 24 * 60 * 60; export async function GET() { const auth = await requireApiSession(); @@ -10,7 +14,7 @@ export async function GET() { const profile = await getAccountProfile(auth.session.user.id); if (!profile) { - return NextResponse.json({ error: "Compte introuvable" }, { status: 404 }); + return apiError("accountNotFound", 404); } return NextResponse.json(profile); @@ -26,8 +30,17 @@ export async function PATCH(req: Request) { const result = await updateAccount(auth.session.user.id, parsed.data); if (!result.ok) { - return NextResponse.json({ error: result.error }, { status: result.status }); + return apiError(result.error, result.status); } - return NextResponse.json(result.profile); + const res = NextResponse.json(result.profile); + if (parsed.data.locale) { + // Garde la langue après déconnexion (pages publiques) + res.cookies.set(LOCALE_COOKIE, result.profile.locale, { + path: "/", + maxAge: LOCALE_COOKIE_MAX_AGE, + sameSite: "lax", + }); + } + return res; } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 15bb04e..b85657b 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { apiError } from "@/lib/api-errors"; import { prisma } from "@/lib/prisma"; import { hashPassword } from "@/lib/account"; import { registerSchema } from "@/lib/schemas/auth"; @@ -16,18 +17,18 @@ export async function POST(req: Request) { }); if (!invite) { - return NextResponse.json({ error: "Lien d'invitation invalide" }, { status: 403 }); + return await apiError("inviteInvalid", 403); } if (invite.usedAt) { - return NextResponse.json({ error: "Ce lien d'invitation a déjà été utilisé" }, { status: 403 }); + return await apiError("inviteUsed", 403); } if (invite.expiresAt < new Date()) { - return NextResponse.json({ error: "Ce lien d'invitation a expiré" }, { status: 403 }); + return await apiError("inviteExpired", 403); } const existing = await prisma.user.findUnique({ where: { email } }); if (existing) { - return NextResponse.json({ error: "Impossible de créer ce compte" }, { status: 409 }); + return await apiError("accountCreationFailed", 409); } const hashedPassword = await hashPassword(password); @@ -52,6 +53,6 @@ export async function POST(req: Request) { return NextResponse.json({ id: user.id, email: user.email, name: user.name }, { status: 201 }); } catch (error) { console.error("[register]", error); - return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + return apiError("serverError", 500); } } diff --git a/src/app/api/cards/[cardId]/check/route.ts b/src/app/api/cards/[cardId]/check/route.ts index e804077..0343306 100644 --- a/src/app/api/cards/[cardId]/check/route.ts +++ b/src/app/api/cards/[cardId]/check/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { apiError } from "@/lib/api-errors"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; import { getCardIfMember } from "@/lib/authz"; @@ -16,7 +17,7 @@ export async function POST(req: Request, { params }: RouteContext) { const { user } = auth.session; const access = await getCardIfMember(user.id, cardId); - if (!access) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!access) return apiError("notFound", 404); const parsed = await parseJsonBody(req, checkCellSchema); if (!parsed.ok) return parsed.response; @@ -27,7 +28,7 @@ export async function POST(req: Request, { params }: RouteContext) { where: { id: cellId, cardId }, select: { id: true }, }); - if (!cell) return NextResponse.json({ error: "Case introuvable" }, { status: 404 }); + if (!cell) return apiError("cellNotFound", 404); const userName = user.name || user.email || "?"; diff --git a/src/app/api/cards/[cardId]/route.ts b/src/app/api/cards/[cardId]/route.ts index 3a25ac3..db05d78 100644 --- a/src/app/api/cards/[cardId]/route.ts +++ b/src/app/api/cards/[cardId]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { apiError } from "@/lib/api-errors"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; import { getCardIfMember, getTeamActor } from "@/lib/authz"; @@ -16,11 +17,11 @@ export async function GET(_req: Request, { params }: RouteContext) { const { cardId } = await params; if (!auth.profile.isAdmin && !(await getCardIfMember(auth.session.user.id, cardId))) { - return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + return apiError("notFound", 404); } const card = await getPlayCard(cardId); - if (!card) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!card) return apiError("notFound", 404); return NextResponse.json(card); } @@ -32,7 +33,7 @@ export async function PATCH(req: Request, { params }: RouteContext) { const { cardId } = await params; const access = await getCardIfMember(auth.session.user.id, cardId); - if (!access) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!access) return apiError("notFound", 404); const parsed = await parseJsonBody(req, updateCardSchema); if (!parsed.ok) return parsed.response; @@ -58,13 +59,13 @@ export async function DELETE(_req: Request, { params }: RouteContext) { where: { id: cardId }, select: { teamId: true, createdById: true }, }); - if (!card) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!card) return apiError("notFound", 404); const actor = await getTeamActor(auth.session.user.id, card.teamId, auth.profile.isAdmin); - if (!actor) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!actor) return apiError("notFound", 404); if (!canDeleteCard(actor, card.createdById)) { - return NextResponse.json({ error: "Suppression non autorisée" }, { status: 403 }); + return apiError("deleteForbidden", 403); } await prisma.bingoCard.delete({ where: { id: cardId } }); diff --git a/src/app/api/teams/[teamId]/cards/route.ts b/src/app/api/teams/[teamId]/cards/route.ts index e7d85de..d95bb16 100644 --- a/src/app/api/teams/[teamId]/cards/route.ts +++ b/src/app/api/teams/[teamId]/cards/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { apiError } from "@/lib/api-errors"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; import { shuffleArray, getCenterPosition } from "@/lib/bingo"; @@ -18,7 +19,7 @@ export async function GET(_req: Request, { params }: RouteContext) { const { teamId } = await params; const membership = await getTeamMembership(auth.session.user.id, teamId); - if (!membership) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!membership) return apiError("notFound", 404); return NextResponse.json(await getTeamCards(teamId)); } @@ -31,7 +32,7 @@ export async function POST(req: Request, { params }: RouteContext) { const userId = auth.session.user.id; const membership = await getTeamMembership(userId, teamId); - if (!membership) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!membership) return apiError("notFound", 404); const parsed = await parseJsonBody(req, createCardSchema); if (!parsed.ok) return parsed.response; @@ -43,10 +44,7 @@ export async function POST(req: Request, { params }: RouteContext) { const neededPhrases = centerPos !== null ? totalCells - 1 : totalCells; if (phraseIds.length < neededPhrases) { - return NextResponse.json( - { error: `Il faut au moins ${neededPhrases} phrases` }, - { status: 400 } - ); + return apiError("notEnoughPhrases", 400, { values: { count: neededPhrases } }); } const validPhrases = await prisma.phrase.findMany({ @@ -58,7 +56,7 @@ export async function POST(req: Request, { params }: RouteContext) { }); if (validPhrases.length < neededPhrases) { - return NextResponse.json({ error: "Certaines phrases sont invalides" }, { status: 400 }); + return apiError("invalidPhrases", 400); } const shuffled = shuffleArray(validPhrases.map((p) => p.id)).slice(0, neededPhrases); diff --git a/src/app/api/teams/[teamId]/members/[memberId]/route.ts b/src/app/api/teams/[teamId]/members/[memberId]/route.ts index dde8d8e..1fb6adf 100644 --- a/src/app/api/teams/[teamId]/members/[memberId]/route.ts +++ b/src/app/api/teams/[teamId]/members/[memberId]/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { Prisma } from "@prisma/client"; +import { apiError } from "@/lib/api-errors"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; import { getTeamActor } from "@/lib/authz"; @@ -18,12 +19,9 @@ export async function PATCH(req: Request, { params }: RouteContext) { const { teamId, memberId } = await params; const actor = await getTeamActor(auth.session.user.id, teamId, auth.profile.isAdmin); - if (!actor) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!actor) return apiError("notFound", 404); if (!hasTeamRole(actor, "OWNER")) { - return NextResponse.json( - { error: "Seul le propriétaire de l'équipe peut changer les rôles" }, - { status: 403 } - ); + return apiError("ownerOnlyRoles", 403); } const parsed = await parseJsonBody(req, updateMemberRoleSchema); @@ -34,7 +32,7 @@ export async function PATCH(req: Request, { params }: RouteContext) { return await updateRole(actor, teamId, memberId, role); } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2034") { - return NextResponse.json({ error: "Modification concurrente, réessaie" }, { status: 409 }); + return apiError("concurrentUpdate", 409); } throw err; } @@ -48,11 +46,11 @@ async function updateRole(actor: TeamActor, teamId: string, memberId: string, ro where: { id: memberId, teamId }, select: { id: true, role: true }, }); - if (!target) return { status: 404, error: "Membre introuvable" } as const; + if (!target) return { status: 404, error: "memberNotFound" } as const; const ownerCount = await tx.teamMember.count({ where: { teamId, role: "OWNER" } }); if (!canChangeRole(actor, target, role, ownerCount)) { - return { status: 409, error: "L'équipe doit garder au moins un propriétaire" } as const; + return { status: 409, error: "lastOwner" } as const; } const member = await tx.teamMember.update({ @@ -66,7 +64,7 @@ async function updateRole(actor: TeamActor, teamId: string, memberId: string, ro ); if (result.status !== 200) { - return NextResponse.json({ error: result.error }, { status: result.status }); + return apiError(result.error, result.status); } return NextResponse.json(result.member); } diff --git a/src/app/api/teams/[teamId]/phrases/route.ts b/src/app/api/teams/[teamId]/phrases/route.ts index 2615270..097ba6b 100644 --- a/src/app/api/teams/[teamId]/phrases/route.ts +++ b/src/app/api/teams/[teamId]/phrases/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { apiError } from "@/lib/api-errors"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; import { getTeamActor, getTeamMembership } from "@/lib/authz"; @@ -18,12 +19,9 @@ type ReadyAuth = Extract>, { o /** 404 hors de l'équipe, 403 sous le rôle ADMIN ; null si autorisé. */ async function requirePhraseManager(auth: ReadyAuth, teamId: string) { const actor = await getTeamActor(auth.session.user.id, teamId, auth.profile.isAdmin); - if (!actor) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!actor) return apiError("notFound", 404); if (!canManagePhrases(actor)) { - return NextResponse.json( - { error: "Seuls les admins de l'équipe peuvent modifier les phrases" }, - { status: 403 } - ); + return apiError("adminOnlyPhrases", 403); } return null; } @@ -35,7 +33,7 @@ export async function GET(_req: Request, { params }: RouteContext) { const { teamId } = await params; const membership = await getTeamMembership(auth.session.user.id, teamId); - if (!membership) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!membership) return apiError("notFound", 404); return NextResponse.json(await getTeamPhrases(teamId)); } @@ -47,7 +45,7 @@ export async function POST(req: Request, { params }: RouteContext) { const { teamId } = await params; const membership = await getTeamMembership(auth.session.user.id, teamId); - if (!membership) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!membership) return apiError("notFound", 404); const parsed = await parseJsonBody(req, createPhraseSchema); if (!parsed.ok) return parsed.response; @@ -76,7 +74,7 @@ export async function PATCH(req: Request, { params }: RouteContext) { const existing = await prisma.phrase.findUnique({ where: { id: phraseId } }); if (!existing || existing.isDefault || existing.teamId !== teamId) { - return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + return apiError("notFound", 404); } const phrase = await prisma.phrase.update({ @@ -108,17 +106,11 @@ export async function DELETE(req: Request, { params }: RouteContext) { }); if (!phrase || phrase.isDefault || phrase.teamId !== teamId) { - return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + return apiError("notFound", 404); } if (phrase._count.cells > 0) { - return NextResponse.json( - { - error: - "Cette phrase est déjà utilisée dans une grille. Modifie-la plutôt, ou supprime d'abord la grille.", - }, - { status: 409 } - ); + return apiError("phraseInUse", 409); } await prisma.phrase.delete({ where: { id: parsed.data.phraseId } }); diff --git a/src/app/api/teams/[teamId]/route.ts b/src/app/api/teams/[teamId]/route.ts index b06b736..eccbe0b 100644 --- a/src/app/api/teams/[teamId]/route.ts +++ b/src/app/api/teams/[teamId]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { apiError } from "@/lib/api-errors"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; import { getTeamActor, getTeamMembership } from "@/lib/authz"; @@ -14,7 +15,7 @@ export async function GET(_req: Request, { params }: RouteContext) { const { teamId } = await params; if (!auth.profile.isAdmin && !(await getTeamMembership(auth.session.user.id, teamId))) { - return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + return apiError("notFound", 404); } const team = await prisma.team.findUnique({ @@ -25,7 +26,7 @@ export async function GET(_req: Request, { params }: RouteContext) { }, }); - if (!team) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!team) return apiError("notFound", 404); return NextResponse.json(team); } @@ -37,13 +38,13 @@ export async function DELETE(_req: Request, { params }: RouteContext) { const { teamId } = await params; const team = await prisma.team.findUnique({ where: { id: teamId }, select: { id: true } }); - if (!team) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!team) return apiError("notFound", 404); const actor = await getTeamActor(auth.session.user.id, teamId, auth.profile.isAdmin); - if (!actor) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + if (!actor) return apiError("notFound", 404); if (!canDeleteTeam(actor)) { - return NextResponse.json({ error: "Suppression non autorisée" }, { status: 403 }); + return apiError("deleteForbidden", 403); } await prisma.team.delete({ where: { id: teamId } }); diff --git a/src/app/api/teams/join/route.ts b/src/app/api/teams/join/route.ts index 130363d..8eba8e8 100644 --- a/src/app/api/teams/join/route.ts +++ b/src/app/api/teams/join/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { apiError } from "@/lib/api-errors"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; import { joinTeamSchema } from "@/lib/schemas/team"; @@ -12,12 +13,12 @@ export async function POST(req: Request) { if (!parsed.ok) return parsed.response; const team = await prisma.team.findUnique({ where: { inviteCode: parsed.data.inviteCode } }); - if (!team) return NextResponse.json({ error: "Code invalide" }, { status: 404 }); + if (!team) return apiError("inviteCodeInvalid", 404); const existing = await prisma.teamMember.findUnique({ where: { userId_teamId: { userId: auth.session.user.id, teamId: team.id } }, }); - if (existing) return NextResponse.json({ error: "Tu es déjà membre de cette équipe" }, { status: 409 }); + if (existing) return apiError("alreadyMember", 409); await prisma.teamMember.create({ data: { userId: auth.session.user.id, teamId: team.id, role: "MEMBER" }, diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index ab4d718..9024043 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -2,6 +2,7 @@ import { useId, useState } from "react"; import { signIn } from "next-auth/react"; +import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { MarkItLogo } from "@/components/markit-logo/markit-logo"; @@ -9,6 +10,7 @@ import { FormError } from "@/components/auth/form-feedback"; export default function SignInPage() { const router = useRouter(); + const t = useTranslations("signIn"); const fieldId = useId(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -29,7 +31,7 @@ export default function SignInPage() { if (!result || result.error) { setLoading(false); - setError("Email ou mot de passe incorrect"); + setError(t("invalidCredentials")); return; } @@ -45,19 +47,19 @@ export default function SignInPage() { -

Reconnecte-toi — la grille t'attend

+

{t("tagline")}

setEmail(e.target.value)} autoComplete="email" @@ -67,7 +69,7 @@ export default function SignInPage() {
setShowPassword((visible) => !visible)} - aria-label={showPassword ? "Masquer le mot de passe" : "Afficher le mot de passe"} + aria-label={showPassword ? t("hidePassword") : t("showPassword")} aria-pressed={showPassword} > {showPassword ? ( @@ -127,16 +129,14 @@ export default function SignInPage() { -

- L'inscription se fait uniquement sur invitation. -

+

{t("inviteOnly")}

- Retour à l'accueil + {t("backHome")}

diff --git a/src/app/dashboard/utils/date.ts b/src/app/dashboard/utils/date.ts index c3756cb..e73a531 100644 --- a/src/app/dashboard/utils/date.ts +++ b/src/app/dashboard/utils/date.ts @@ -1,13 +1,19 @@ -export function formatDistanceToNow(date: string | Date): string { +/** Traducteur pour `dates.relative.*` (signature assouplie pour `useTranslations`). */ +type RelativeTranslator = (key: string, values?: Record) => string; + +/** + * Distance relative en langage naturel (`dates.relative.*`). + */ +export function formatDistanceToNow(date: string | Date, t: RelativeTranslator): string { const d = new Date(date); const now = new Date(); const diffMs = now.getTime() - d.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - if (diffDays === 0) return "aujourd'hui"; - if (diffDays === 1) return "hier"; - if (diffDays < 7) return `il y a ${diffDays} jours`; - if (diffDays < 30) return `il y a ${Math.floor(diffDays / 7)} semaine${diffDays >= 14 ? "s" : ""}`; - if (diffDays < 365) return `il y a ${Math.floor(diffDays / 30)} mois`; - return `il y a ${Math.floor(diffDays / 365)} an${diffDays >= 730 ? "s" : ""}`; + if (diffDays === 0) return t("today"); + if (diffDays === 1) return t("yesterday"); + if (diffDays < 7) return t("days", { count: diffDays }); + if (diffDays < 30) return t("weeks", { count: Math.floor(diffDays / 7) }); + if (diffDays < 365) return t("months", { count: Math.floor(diffDays / 30) }); + return t("years", { count: Math.floor(diffDays / 365) }); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 77ed89f..17a1ceb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next"; import { Space_Grotesk, Source_Sans_3 } from "next/font/google"; +import { getLocale, getMessages, getTranslations, getTimeZone } from "next-intl/server"; +import { toLocale } from "@/i18n/config"; import "./globals.css"; import { Providers } from "./providers"; @@ -17,19 +19,31 @@ const sourceSans = Source_Sans_3({ display: "swap", }); -export const metadata: Metadata = { - title: "MarkIt – Meeting Bingo", - description: "Bingo de réunion en temps réel pour vos équipes.", - icons: { - icon: [{ url: "/icon.svg", type: "image/svg+xml" }], - }, -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("metadata"); + return { + title: t("title"), + description: t("description"), + icons: { + icon: [{ url: "/icon.svg", type: "image/svg+xml" }], + }, + }; +} + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const [localeRaw, messages, timeZone] = await Promise.all([ + getLocale(), + getMessages(), + getTimeZone(), + ]); + const locale = toLocale(localeRaw); -export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + - {children} + + {children} + ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 365e698..788adcc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,5 @@ import { getServerSession } from "next-auth"; +import { getTranslations } from "next-intl/server"; import { authOptions } from "@/lib/auth"; import { redirect } from "next/navigation"; import { LandingHero } from "@/components/landing-hero/landing-hero"; @@ -10,34 +11,23 @@ export default async function HomePage() { redirect(session.user.mustChangePassword ? "/account" : "/dashboard"); } + const t = await getTranslations("landing"); + + const steps = [ + { step: "01", title: t("steps.team.title"), desc: t("steps.team.desc") }, + { step: "02", title: t("steps.card.title"), desc: t("steps.card.desc") }, + { step: "03", title: t("steps.bingo.title"), desc: t("steps.bingo.desc") }, + ]; + return (
-

Trois étapes, zéro slide

-

- Crée une équipe, balance une grille avec vos phrases de réunion, joue pendant le call. - Les cases se cochent pour tout le monde en direct. -

+

{t("stepsTitle")}

+

{t("stepsIntro")}

    - {[ - { - step: "01", - title: "Équipe", - desc: "Invite tes collègues avec un code — en 10 secondes.", - }, - { - step: "02", - title: "Grille", - desc: "Pioche dans la banque de phrases (ou invente les tiennes).", - }, - { - step: "03", - title: "Bingo", - desc: "Coche, regarde les confettis, gagne la réunion.", - }, - ].map((item, i) => ( + {steps.map((item, i) => (
  1. {children}; +type Props = { + children: React.ReactNode; + locale: AppLocale; + messages: React.ComponentProps["messages"]; + timeZone?: string; +}; + +export function Providers({ children, locale, messages, timeZone }: Props) { + return ( + + {children} + + ); } diff --git a/src/components/account-settings-form/__tests__/account-settings-form.test.tsx b/src/components/account-settings-form/__tests__/account-settings-form.test.tsx index fbde0fc..e08c143 100644 --- a/src/components/account-settings-form/__tests__/account-settings-form.test.tsx +++ b/src/components/account-settings-form/__tests__/account-settings-form.test.tsx @@ -1,6 +1,7 @@ -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { jsonResponse, mockFetch, routerMock, sentJson } from "@/test-utils/mocks"; +import { renderWithIntl } from "@/test-utils/i18n"; import type { AccountProfile } from "@/types"; import { AccountSettingsForm } from "../account-settings-form"; @@ -19,6 +20,7 @@ const profile: AccountProfile = { email: "alex@example.com", isAdmin: false, mustChangePassword: false, + locale: "fr", }; describe("AccountSettingsForm", () => { @@ -29,13 +31,13 @@ describe("AccountSettingsForm", () => { }); it("pré-remplit le profil reçu du serveur", () => { - render(); + renderWithIntl(); expect(screen.getByLabelText("Prénom")).toHaveValue("Alex"); expect(screen.getByText("alex@example.com")).toBeInTheDocument(); }); it("refuse d'enregistrer sans modification", async () => { - render(); + renderWithIntl(); await userEvent.click(screen.getByRole("button", { name: "Enregistrer" })); expect(screen.getByRole("alert")).toHaveTextContent("Aucune modification à enregistrer"); @@ -44,7 +46,7 @@ describe("AccountSettingsForm", () => { it("vérifie la confirmation du mot de passe", async () => { const user = userEvent.setup(); - render(); + renderWithIntl(); await user.type(screen.getByLabelText("Nouveau mot de passe (optionnel)"), "nouveau-mdp-solide"); await user.type(screen.getByLabelText("Confirmer le mot de passe"), "autre-mdp-solide!"); @@ -57,7 +59,7 @@ describe("AccountSettingsForm", () => { it("enregistre le prénom et met à jour la session", async () => { const user = userEvent.setup(); fetchMock.mockResolvedValue(jsonResponse({ ...profile, name: "Sam" })); - render(); + renderWithIntl(); await user.clear(screen.getByLabelText("Prénom")); await user.type(screen.getByLabelText("Prénom"), "Sam"); @@ -71,7 +73,7 @@ describe("AccountSettingsForm", () => { it("termine la configuration initiale et redirige", async () => { const user = userEvent.setup(); fetchMock.mockResolvedValue(jsonResponse({ ...profile, mustChangePassword: false })); - render(); + renderWithIntl(); expect(screen.queryByLabelText("Mot de passe actuel")).not.toBeInTheDocument(); await user.type(screen.getByLabelText("Nouveau mot de passe"), "nouveau-mdp-solide"); @@ -81,4 +83,15 @@ describe("AccountSettingsForm", () => { expect(sentJson(fetchMock)).toEqual({ name: "Alex", newPassword: "nouveau-mdp-solide" }); expect(routerMock.push).toHaveBeenCalledWith("/dashboard"); }); + + it("enregistre le changement de langue immédiatement", async () => { + fetchMock.mockResolvedValue(jsonResponse({ ...profile, locale: "en" })); + renderWithIntl(); + + await userEvent.selectOptions(screen.getByLabelText("Langue"), "en"); + + expect(sentJson(fetchMock)).toEqual({ locale: "en" }); + expect(mockUpdateSession).toHaveBeenCalledWith({ locale: "en" }); + expect(routerMock.refresh).toHaveBeenCalled(); + }); }); diff --git a/src/components/account-settings-form/account-settings-form.tsx b/src/components/account-settings-form/account-settings-form.tsx index ae69ece..d9a779e 100644 --- a/src/components/account-settings-form/account-settings-form.tsx +++ b/src/components/account-settings-form/account-settings-form.tsx @@ -1,29 +1,43 @@ "use client"; -import { useId, useState } from "react"; +import { useId, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { useSession } from "next-auth/react"; +import { useLocale, useTranslations } from "next-intl"; import Link from "next/link"; import { apiRequest } from "@/lib/api-client"; import { FormError, FormSuccess } from "@/components/auth/form-feedback"; +import { LOCALES, LOCALE_LABELS, type AppLocale } from "@/i18n/config"; import type { AccountProfile } from "@/types"; const MIN_PASSWORD_LENGTH = 12; -type AccountPayload = { name: string; newPassword?: string; currentPassword?: string }; +type AccountPayload = { + name?: string; + newPassword?: string; + currentPassword?: string; + locale?: AppLocale; +}; -/** Prénom et mot de passe ; sert aussi de configuration à la première connexion. */ +/** Prénom, langue et mot de passe ; sert aussi de configuration à la première connexion. */ export function AccountSettingsForm({ initialProfile }: { initialProfile: AccountProfile }) { const router = useRouter(); const { update } = useSession(); + const t = useTranslations("account"); + const tCommon = useTranslations("common"); + const tApi = useTranslations("apiClient"); + const activeLocale = useLocale(); const fieldId = useId(); + const [, startTransition] = useTransition(); const [profile, setProfile] = useState(initialProfile); const [saving, setSaving] = useState(false); + const [savingLocale, setSavingLocale] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const [name, setName] = useState(initialProfile.name ?? ""); + const [locale, setLocale] = useState(initialProfile.locale); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); @@ -33,17 +47,48 @@ export function AccountSettingsForm({ initialProfile }: { initialProfile: Accoun function validate(): string | null { if (newPassword && newPassword !== confirmPassword) { - return "Les mots de passe ne correspondent pas"; + return t("passwordMismatch"); } if (isSetup && !newPassword) { - return "Choisis un nouveau mot de passe pour continuer"; + return t("newPasswordRequired"); } if (!isSetup && !newPassword && name.trim() === (profile.name ?? "").trim()) { - return "Aucune modification à enregistrer"; + return t("noChanges"); } return null; } + async function patchAccount(payload: AccountPayload, fallbackError: string) { + return apiRequest("/api/account", { + method: "PATCH", + json: payload, + fallbackError, + networkError: tApi("network"), + }); + } + + async function handleLocaleChange(next: AppLocale) { + if (next === locale) return; + setLocale(next); + setError(""); + setSuccess(""); + setSavingLocale(true); + + const result = await patchAccount({ locale: next }, t("language.fallbackError")); + setSavingLocale(false); + + if (!result.ok) { + setLocale(locale); + setError(result.error); + return; + } + + setProfile(result.data); + await update({ locale: result.data.locale }); + // Recharge les messages serveur (layout) dans la nouvelle langue + startTransition(() => router.refresh()); + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); @@ -62,11 +107,7 @@ export function AccountSettingsForm({ initialProfile }: { initialProfile: Accoun } setSaving(true); - const result = await apiRequest("/api/account", { - method: "PATCH", - json: payload, - fallbackError: "Erreur lors de la mise à jour", - }); + const result = await patchAccount(payload, t("fallbackError")); setSaving(false); if (!result.ok) { @@ -83,7 +124,7 @@ export function AccountSettingsForm({ initialProfile }: { initialProfile: Accoun await update({ name: updated.name, mustChangePassword: updated.mustChangePassword }); if (updated.mustChangePassword) { - setSuccess("Enregistré"); + setSuccess(t("saved")); return; } @@ -93,33 +134,53 @@ export function AccountSettingsForm({ initialProfile }: { initialProfile: Accoun return; } - setSuccess("Compte mis à jour"); + setSuccess(t("updated")); } return (

    - {isSetup ? "Configure ton compte" : "Mon compte"} + {isSetup ? t("setupTitle") : t("title")}

    -

    - {isSetup - ? "Première connexion : prénom et nouveau mot de passe." - : "Modifie ton prénom ou ton mot de passe."} -

    +

    {isSetup ? t("setupSubtitle") : t("subtitle")}

    {profile.email}

    +
    + + +

    + {t("language.hint")} + {activeLocale !== locale ? ` · ${LOCALE_LABELS[locale]}` : ""} +

    +
    +
    setName(e.target.value)} autoComplete="given-name" @@ -130,11 +191,8 @@ export function AccountSettingsForm({ initialProfile }: { initialProfile: Accoun {!isSetup && (
    -
    )}

    - Minimum {MIN_PASSWORD_LENGTH} caractères + {t("passwordHint", { min: MIN_PASSWORD_LENGTH })}

    {(isSetup || newPassword) && (
    -
  2. @@ -175,26 +183,20 @@ export function AdminClient({ initialUsers, initialInvites }: Props) { {activeTab === "invites" && (
    -

    - Générer un lien d'invitation -

    +

    {t("generateTitle")}

    - + setNote(e.target.value)} maxLength={100} />
    - +
    @@ -215,10 +217,10 @@ export function AdminClient({ initialUsers, initialInvites }: Props) {

    - Actives ({activeInvites.length}) + {t("activeInvites", { count: activeInvites.length })}

    {activeInvites.length === 0 ? ( -

    Aucune invitation active

    +

    {t("noActiveInvites")}

    ) : (
    @@ -240,7 +242,7 @@ export function AdminClient({ initialUsers, initialInvites }: Props) { {usedInvites.length > 0 && (

    - Utilisées ({usedInvites.length}) + {t("usedInvites", { count: usedInvites.length })}

    {usedInvites.map((invite) => ( @@ -258,7 +260,7 @@ export function AdminClient({ initialUsers, initialInvites }: Props) { {expiredInvites.length > 0 && (

    - Expirées ({expiredInvites.length}) + {t("expiredInvites", { count: expiredInvites.length })}

    {expiredInvites.map((invite) => ( @@ -292,12 +294,28 @@ function InviteRow({ onCopy?: () => void; onRevoke: () => void; }) { - const expiresDate = new Date(invite.expiresAt).toLocaleDateString("fr-FR", { + const t = useTranslations("admin"); + const tCommon = useTranslations("common"); + const format = useFormatter(); + const expiresDate = format.dateTime(new Date(invite.expiresAt), { day: "numeric", month: "short", year: "numeric", }); + const statusText = + status === "used" + ? t("usedOn", { + date: format.dateTime(new Date(invite.usedAt!), { + day: "numeric", + month: "short", + year: "numeric", + }), + }) + : status === "expired" + ? t("expiredOn", { date: expiresDate }) + : t("expiresOn", { date: expiresDate }); + return ( {invite.note &&

    {invite.note}

    }

    - token: {invite.token.slice(0, 16)}… -

    -

    - {status === "used" - ? `Utilisé le ${new Date(invite.usedAt!).toLocaleDateString("fr-FR")}` - : status === "expired" - ? `Expiré le ${expiresDate}` - : `Expire le ${expiresDate}`} + {t("token", { token: invite.token.slice(0, 16) })}

    +

    {statusText}

    {status === "active" && onCopy && ( )}
    diff --git a/src/components/auth/__tests__/signup-form.test.tsx b/src/components/auth/__tests__/signup-form.test.tsx index ddcce1e..c57416e 100644 --- a/src/components/auth/__tests__/signup-form.test.tsx +++ b/src/components/auth/__tests__/signup-form.test.tsx @@ -1,7 +1,8 @@ -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { signIn } from "next-auth/react"; import { jsonResponse, mockFetch, routerMock, sentJson } from "@/test-utils/mocks"; +import { renderWithIntl } from "@/test-utils/i18n"; import { SignUpForm } from "../signup-form"; jest.mock("next/navigation", () => ({ @@ -29,7 +30,7 @@ describe("SignUpForm", () => { it("inscrit, connecte puis redirige vers le dashboard", async () => { fetchMock.mockResolvedValue(jsonResponse({ id: "u1" }, 201)); signInMock.mockResolvedValue({ ok: true, error: null }); - render(); + renderWithIntl(); await fillAndSubmit(); @@ -52,7 +53,7 @@ describe("SignUpForm", () => { fetchMock.mockResolvedValue( jsonResponse({ error: "Ce lien d'invitation a expiré" }, 403) ); - render(); + renderWithIntl(); await fillAndSubmit(); @@ -64,7 +65,7 @@ describe("SignUpForm", () => { it("renvoie vers la connexion si la connexion automatique échoue", async () => { fetchMock.mockResolvedValue(jsonResponse({ id: "u1" }, 201)); signInMock.mockResolvedValue({ ok: false, error: "CredentialsSignin" }); - render(); + renderWithIntl(); await fillAndSubmit(); diff --git a/src/components/auth/signup-form.tsx b/src/components/auth/signup-form.tsx index 17c1b29..b9bc102 100644 --- a/src/components/auth/signup-form.tsx +++ b/src/components/auth/signup-form.tsx @@ -2,6 +2,7 @@ import { useId, useState } from "react"; import { signIn } from "next-auth/react"; +import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { MarkItLogo } from "@/components/markit-logo/markit-logo"; @@ -11,6 +12,8 @@ import { FormError } from "./form-feedback"; /** Inscription sur invitation, puis connexion automatique. */ export function SignUpForm({ inviteToken }: { inviteToken: string }) { const router = useRouter(); + const t = useTranslations("signUp"); + const tApi = useTranslations("apiClient"); const fieldId = useId(); const [name, setName] = useState(""); @@ -27,7 +30,8 @@ export function SignUpForm({ inviteToken }: { inviteToken: string }) { const result = await apiRequest("/api/auth/register", { method: "POST", json: { name, email, password, inviteToken }, - fallbackError: "Erreur lors de l'inscription", + fallbackError: t("fallbackError"), + networkError: tApi("network"), }); if (!result.ok) { setError(result.error); @@ -51,22 +55,20 @@ export function SignUpForm({ inviteToken }: { inviteToken: string }) {
    -

    - Créer ton compte -

    -

    Tu as reçu une invitation.

    +

    {t("title")}

    +

    {t("subtitle")}

    setName(e.target.value)} autoComplete="given-name" @@ -77,13 +79,13 @@ export function SignUpForm({ inviteToken }: { inviteToken: string }) {
    setEmail(e.target.value)} autoComplete="email" @@ -93,7 +95,7 @@ export function SignUpForm({ inviteToken }: { inviteToken: string }) {

    - Minimum 12 caractères + {t("passwordHint", { min: 12 })}

    - Déjà un compte ?{" "} + {t("hasAccount")}{" "} - Se connecter + {t("signIn")}

    diff --git a/src/components/create-card/__tests__/emoji-picker.test.tsx b/src/components/create-card/__tests__/emoji-picker.test.tsx index 7e2da43..b242b7b 100644 --- a/src/components/create-card/__tests__/emoji-picker.test.tsx +++ b/src/components/create-card/__tests__/emoji-picker.test.tsx @@ -1,7 +1,9 @@ import { useState } from "react"; -import { render, screen, waitFor } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithIntl } from "@/test-utils/i18n"; import userEvent from "@testing-library/user-event"; import { EMOJI_CATEGORIES } from "@/lib/phrase-emojis"; +import fr from "@/i18n/messages/fr.json"; import { EmojiPicker } from "../emoji-picker"; function ControlledPicker({ onChange }: { onChange: (emoji: string) => void }) { @@ -25,7 +27,7 @@ function ControlledPicker({ onChange }: { onChange: (emoji: string) => void }) { function setup() { const onChange = jest.fn(); - render(); + renderWithIntl(); const trigger = screen.getByRole("button", { name: "Choisir un emoji" }); return { onChange, trigger, user: userEvent.setup() }; } @@ -56,10 +58,11 @@ describe("EmojiPicker", () => { it("change de catégorie", async () => { const { trigger, user } = setup(); const second = EMOJI_CATEGORIES[1]; + const secondLabel = fr.emojiPicker.categories[second.id]; await user.click(trigger); - await user.click(screen.getByRole("button", { name: second.label })); + await user.click(screen.getByRole("button", { name: secondLabel })); - expect(screen.getByRole("button", { name: second.label })).toHaveAttribute( + expect(screen.getByRole("button", { name: secondLabel })).toHaveAttribute( "aria-pressed", "true" ); diff --git a/src/components/create-card/__tests__/grid-size-input.test.tsx b/src/components/create-card/__tests__/grid-size-input.test.tsx index c67ca31..0905296 100644 --- a/src/components/create-card/__tests__/grid-size-input.test.tsx +++ b/src/components/create-card/__tests__/grid-size-input.test.tsx @@ -1,10 +1,11 @@ -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; +import { renderWithIntl } from "@/test-utils/i18n"; import userEvent from "@testing-library/user-event"; import { GridSizeInput } from "../grid-size-input"; function setup(value = 5) { const onChange = jest.fn(); - render(); + renderWithIntl(); return { onChange, input: screen.getByLabelText("Lignes"), user: userEvent.setup() }; } diff --git a/src/components/create-card/__tests__/phrase-chip.test.tsx b/src/components/create-card/__tests__/phrase-chip.test.tsx index 5995fa7..dd56e2b 100644 --- a/src/components/create-card/__tests__/phrase-chip.test.tsx +++ b/src/components/create-card/__tests__/phrase-chip.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; +import { renderWithIntl } from "@/test-utils/i18n"; import userEvent from "@testing-library/user-event"; import { PhraseChip } from "../phrase-chip"; @@ -7,7 +8,7 @@ const phrase = { id: "p1", text: "Tu es en mute", emoji: "🔇", isDefault: fals describe("PhraseChip", () => { it("reflète la sélection et déclenche onClick", async () => { const onClick = jest.fn(); - render(); + renderWithIntl(); const chip = screen.getByRole("button", { name: /Tu es en mute/ }); expect(chip).toHaveAttribute("aria-pressed", "true"); @@ -17,7 +18,7 @@ describe("PhraseChip", () => { }); it("n'affiche pas les actions sans handlers (phrase classique)", () => { - render(); + renderWithIntl(); expect(screen.queryByRole("button", { name: /Modifier/ })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Supprimer/ })).not.toBeInTheDocument(); }); @@ -26,7 +27,7 @@ describe("PhraseChip", () => { const onClick = jest.fn(); const onEdit = jest.fn(); const onDelete = jest.fn(); - render( + renderWithIntl( + t("defaultLabel", { + date: format.dateTime(new Date(), { day: "numeric", month: "long" }), + }) + ); const [rows, setRows] = useState(5); const [cols, setCols] = useState(5); const [freeCenter, setFreeCenter] = useState(true); @@ -93,7 +98,8 @@ export function CreateCardClient({ const result = await apiRequest(phrasesUrl, { method: "PATCH", json: { phraseId: editingPhraseId, text, emoji }, - fallbackError: "Impossible de modifier la phrase", + fallbackError: t("editFailed"), + networkError: tApi("network"), }); if (result.ok) { setCustom((prev) => prev.map((p) => (p.id === result.data.id ? result.data : p))); @@ -105,7 +111,8 @@ export function CreateCardClient({ const result = await apiRequest(phrasesUrl, { method: "POST", json: { text, emoji }, - fallbackError: "Impossible d'ajouter la phrase", + fallbackError: t("addFailed"), + networkError: tApi("network"), }); if (result.ok) { setCustom((prev) => [result.data, ...prev]); @@ -119,14 +126,15 @@ export function CreateCardClient({ } async function deletePhrase(phrase: TeamPhrase) { - if (!confirm(`Supprimer la phrase « ${phrase.text} » ? Cette action est définitive.`)) { + if (!confirm(t("confirmDeletePhrase", { text: phrase.text }))) { return; } setError(""); const result = await apiRequest(phrasesUrl, { method: "DELETE", json: { phraseId: phrase.id }, - fallbackError: "Impossible de supprimer la phrase", + fallbackError: t("deleteFailed"), + networkError: tApi("network"), }); if (!result.ok) { setError(result.error); @@ -144,7 +152,7 @@ export function CreateCardClient({ async function createCard() { setError(""); if (selectedIds.size < needed) { - setError(`Sélectionne au moins ${needed} phrases pour remplir la grille ${rows}×${cols}`); + setError(t("notEnoughSelected", { needed, rows, cols })); return; } setCreating(true); @@ -157,7 +165,8 @@ export function CreateCardClient({ freeCenter: hasFreeCenter, phraseIds: Array.from(selectedIds), }, - fallbackError: "Impossible de créer la grille", + fallbackError: t("createFailed"), + networkError: tApi("network"), }); if (!result.ok) { setCreating(false); @@ -174,37 +183,38 @@ export function CreateCardClient({ href={`/dashboard/teams/${teamId}`} className="mb-2 inline-block text-sm font-bold text-accent hover:underline" > - ← Retour à l'équipe + {t("back")}

    - Nouvelle grille. + {t("title")} + .

    -

    Compose ta grille post-it pour cette semaine

    +

    {t("subtitle")}

    -

    Configuration

    +

    {t("configuration")}

    setLabel(e.target.value)} - placeholder="Ex: Réunion du lundi" + placeholder={t("cardNamePlaceholder")} maxLength={150} />
    - - + +
    {centerPos !== null && ( @@ -221,29 +231,34 @@ export function CreateCardClient({ > {freeCenter && ✓} - Case centrale FREE ⭐ + {t("freeCenter")} )}
    - +
    -

    Résumé

    +

    {t("summary")}

    -
    Taille
    +
    {t("size")}
    - {rows} × {cols} = {totalCells} cases + {t("sizeValue", { rows, cols, total: totalCells })}
    -
    Phrases nécessaires
    +
    {t("neededPhrases")}
    {needed}
    -
    Phrases sélectionnées
    +
    {t("selectedPhrases")}
    = needed ? "text-green-600" : "text-red-500"}`} > @@ -264,7 +279,7 @@ export function CreateCardClient({ className="btn-primary mt-4 w-full" disabled={creating || selectedIds.size < needed} > - {creating ? "Génération..." : `Générer la grille (${selectedIds.size}/${needed})`} + {creating ? t("generating") : t("generate", { selected: selectedIds.size, needed })}
    @@ -272,14 +287,14 @@ export function CreateCardClient({
    -

    💬 Banque de phrases

    +

    {t("phraseBank")}

    | @@ -289,16 +304,14 @@ export function CreateCardClient({ onClick={() => setSelectedIds(new Set())} className="font-bold text-ink-faint hover:underline" > - Tout désélectionner + {t("deselectAll")}
    {editingPhraseId && ( -

    - Modification d'une phrase personnalisée -

    +

    {t("editingPhrase")}

    )}
    setNewPhraseText(e.target.value)} @@ -325,7 +338,7 @@ export function CreateCardClient({ className="btn-primary px-4 py-2" disabled={savingPhrase || !newPhraseText.trim()} > - {savingPhrase ? "..." : editingPhraseId ? "Enregistrer" : "+ Ajouter"} + {savingPhrase ? "..." : editingPhraseId ? tCommon("save") : t("add")} {editingPhraseId && ( )}
    {custom.length > 0 && ( - + {custom.map((phrase) => ( )} - + {defaults.map((phrase) => ( -

    Aperçu de la grille

    + {previewLabel}
    (null); const activeCategory = EMOJI_CATEGORIES.find((category) => category.id === categoryId) ?? EMOJI_CATEGORIES[0]; + const activeLabel = t(`categories.${activeCategory.id}`); useEffect(() => { if (!open) return; @@ -48,7 +51,7 @@ export function EmojiPicker({ value, onChange, open, onOpenChange }: Props) { )}
    - {EMOJI_CATEGORIES.map((category) => ( - - ))} + {EMOJI_CATEGORIES.map((category) => { + const label = t(`categories.${category.id}`); + return ( + + ); + })}
    -

    {activeCategory.label}

    +

    {activeLabel}

    {activeCategory.emojis.map((emoji) => ( diff --git a/src/components/create-card/phrase-chip.tsx b/src/components/create-card/phrase-chip.tsx index 0eb58ea..a7b423e 100644 --- a/src/components/create-card/phrase-chip.tsx +++ b/src/components/create-card/phrase-chip.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; import { motion } from "framer-motion"; import type { TeamPhrase } from "@/lib/data"; @@ -14,6 +15,7 @@ type Props = { /** Phrase sélectionnable ; boutons modifier / supprimer pour les phrases d'équipe. */ export function PhraseChip({ phrase, selected, editing, onClick, onEdit, onDelete }: Props) { + const t = useTranslations("phraseChip"); const isHighlighted = selected || editing; return ( @@ -47,8 +49,8 @@ export function PhraseChip({ phrase, selected, editing, onClick, onEdit, onDelet {onEdit && (
    @@ -147,12 +153,10 @@ export function DashboardClient({ initialTeams, isAdmin = false }: Props) { {showCreate && (
    - + setTeamName(e.target.value)} autoFocus @@ -160,10 +164,10 @@ export function DashboardClient({ initialTeams, isAdmin = false }: Props) { />
    )} @@ -171,12 +175,10 @@ export function DashboardClient({ initialTeams, isAdmin = false }: Props) { {showJoin && (
    - + setInviteCode(e.target.value)} autoFocus @@ -184,10 +186,10 @@ export function DashboardClient({ initialTeams, isAdmin = false }: Props) { />
    )} @@ -203,10 +205,8 @@ export function DashboardClient({ initialTeams, isAdmin = false }: Props) { {teams.length === 0 ? (
    -

    - Personne ici… pour l'instant -

    -

    Crée ou rejoins une équipe pour ouvrir le bal.

    +

    {t("emptyTitle")}

    +

    {t("emptySubtitle")}

    ) : (
      @@ -222,13 +222,11 @@ export function DashboardClient({ initialTeams, isAdmin = false }: Props) {

      {team.name}

      -

      - {team.members.length} membre{team.members.length > 1 ? "s" : ""} -

      +

      {t("memberCount", { count: team.members.length })}

    - {team._count.cards} grille{team._count.cards !== 1 ? "s" : ""} → + {t("cardCount", { count: team._count.cards })} →

    {canDeleteTeam(team) && ( @@ -242,7 +240,7 @@ export function DashboardClient({ initialTeams, isAdmin = false }: Props) { void deleteTeam(team); }} > - {deletingId === team.id ? "…" : "Supprimer"} + {deletingId === team.id ? "…" : tCommon("delete")} )} diff --git a/src/components/landing-hero/landing-hero.tsx b/src/components/landing-hero/landing-hero.tsx index 48dc6c9..24e29bf 100644 --- a/src/components/landing-hero/landing-hero.tsx +++ b/src/components/landing-hero/landing-hero.tsx @@ -2,21 +2,23 @@ import Link from "next/link"; import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; import { MarkItLogo } from "@/components/markit-logo/markit-logo"; -const DEMO_PHRASES = [ - "Quick win", - "On est alignés ?", - "Synergies", - "Deep dive", - "Roadmap", - "FREE", - "KPIs", - "Agile", - "Offline", -]; +const DEMO_PHRASE_KEYS = [ + "quickWin", + "aligned", + "synergies", + "deepDive", + "roadmap", + "free", + "kpis", + "agile", + "offline", +] as const; export function LandingHero() { + const t = useTranslations("landing"); const [ready, setReady] = useState(false); useEffect(() => { @@ -33,13 +35,14 @@ export function LandingHero() { }`} >
    - {DEMO_PHRASES.map((text, i) => { - const isFree = text === "FREE"; + {DEMO_PHRASE_KEYS.map((key, i) => { + const text = t(`demoPhrases.${key}`); + const isFree = key === "free"; const isChecked = isFree || i === 1 || i === 4 || i === 7; const tilt = i % 3 === 0 ? "rotate-2" : i % 3 === 1 ? "-rotate-1" : "rotate-1"; return (

    - Le bingo qui sauve les réunions interminables. + {t("tagline")}

    - Compose ta grille, coche en live avec l'équipe, et crie bingo avant la fin du - « quick sync ». + {t("pitch")}

    - C'est parti + {t("cta")}
    -

    Sur invitation — pas de spam, juste du bingo.

    +

    {t("inviteOnly")}

    diff --git a/src/components/navbar/navbar.tsx b/src/components/navbar/navbar.tsx index 6378e2c..f3219d1 100644 --- a/src/components/navbar/navbar.tsx +++ b/src/components/navbar/navbar.tsx @@ -2,6 +2,7 @@ import { signOut, useSession } from "next-auth/react"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { MarkItBrand } from "@/components/markit-logo/markit-logo"; @@ -12,6 +13,8 @@ type NavbarProps = { export function Navbar({ minimal = false, isAdmin = false }: NavbarProps) { const { data: session } = useSession(); + const t = useTranslations("navbar"); + const tCommon = useTranslations("common"); const [menuOpen, setMenuOpen] = useState(false); return ( @@ -27,7 +30,7 @@ export function Navbar({ minimal = false, isAdmin = false }: NavbarProps) {
    {!minimal && (

    - {session?.user?.name?.split(" ")[0] || "Compte"} + {session?.user?.name?.split(" ")[0] || t("accountFallback")}

    )} @@ -37,12 +40,12 @@ export function Navbar({ minimal = false, isAdmin = false }: NavbarProps) { onClick={() => setMenuOpen(!menuOpen)} className="flex items-center gap-2 rounded-sm border border-paper-line bg-note px-2.5 py-1.5 shadow-[1px_2px_0_rgba(15,23,42,0.06)] transition-all hover:-rotate-1 hover:border-accent" aria-expanded={menuOpen} - aria-label="Menu compte" + aria-label={t("accountMenu")} > {session?.user?.name?.[0]?.toUpperCase() || "?"} - {!minimal && isAdmin && Admin} + {!minimal && isAdmin && {tCommon("admin")}} {menuOpen && ( @@ -58,7 +61,7 @@ export function Navbar({ minimal = false, isAdmin = false }: NavbarProps) { onClick={() => setMenuOpen(false)} className="block px-3 py-2 text-sm font-semibold text-ink-muted hover:bg-accent-mist hover:text-accent" > - Mon compte + {t("myAccount")} {isAdmin && ( setMenuOpen(false)} className="block px-3 py-2 text-sm font-semibold text-ink-muted hover:bg-accent-mist hover:text-accent" > - Administration + {t("administration")} )} @@ -76,7 +79,7 @@ export function Navbar({ minimal = false, isAdmin = false }: NavbarProps) { onClick={() => signOut({ callbackUrl: "/" })} className="w-full px-3 py-2 text-left text-sm font-semibold text-danger hover:bg-danger-soft" > - Déconnexion + {t("signOut")}
    )} diff --git a/src/components/play/__tests__/bingo-cell.test.tsx b/src/components/play/__tests__/bingo-cell.test.tsx index c4a0771..16346f7 100644 --- a/src/components/play/__tests__/bingo-cell.test.tsx +++ b/src/components/play/__tests__/bingo-cell.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; +import { renderWithIntl } from "@/test-utils/i18n"; import userEvent from "@testing-library/user-event"; import type { PlayCell } from "@/lib/data"; import { BingoCell } from "../bingo-cell"; @@ -17,7 +18,7 @@ const checkedCell: PlayCell = { function renderCell(props: Partial> = {}) { const onClick = jest.fn(); - render( + renderWithIntl( ★ - FREE + {t("free")} ) : ( <> diff --git a/src/components/play/play-client.tsx b/src/components/play/play-client.tsx index 0c80406..dd2b835 100644 --- a/src/components/play/play-client.tsx +++ b/src/components/play/play-client.tsx @@ -3,10 +3,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import { motion, AnimatePresence } from "framer-motion"; import type { Socket } from "socket.io-client"; import { apiRequest } from "@/lib/api-client"; -import { getFreeCenterPosition, getPatternLabel } from "@/lib/bingo"; +import { getFreeCenterPosition, getPatternMessage } from "@/lib/bingo"; import type { PlayCard, PlayCell } from "@/lib/data"; import type { CellUpdatedEvent } from "@/lib/realtime"; import { BingoCell } from "./bingo-cell"; @@ -29,6 +30,8 @@ type Props = { /** Écran de jeu temps réel (Socket.io + polling de secours si le WebSocket tombe). */ export function PlayClient({ initialCard, currentUser }: Props) { + const t = useTranslations("play"); + const tApi = useTranslations("apiClient"); const cardId = initialCard.id; const [card, setCard] = useState(initialCard); const [showBingo, setShowBingo] = useState(false); @@ -127,14 +130,15 @@ export function PlayClient({ initialCard, currentUser }: Props) { // Confettis dans le même tick que le clic ; les autres joueurs sont prévenus via le socket if (isNowChecked && getCardPatterns(nextCard).length > bingoPatterns.length) { - celebrateBingo(currentUser.name || "Toi"); + celebrateBingo(currentUser.name || t("you")); socketRef.current?.emit("bingo", { cardId }); } const result = await apiRequest(`/api/cards/${cardId}/check`, { method: "POST", json: { cellId: cell.id, checked: isNowChecked }, - fallbackError: "La case n'a pas pu être enregistrée", + fallbackError: t("saveFailed"), + networkError: tApi("network"), }); if (!result.ok) { setError(result.error); @@ -178,7 +182,7 @@ export function PlayClient({ initialCard, currentUser }: Props) { animate={{ rotate: [-1.5, 1.5, 0] }} transition={{ duration: 0.4 }} > - BINGO! + {t("bingo")}

    {bingoWinner} a collé la ligne gagnante @@ -204,7 +208,7 @@ export function PlayClient({ initialCard, currentUser }: Props) {

    - +
    @@ -222,15 +226,18 @@ export function PlayClient({ initialCard, currentUser }: Props) { {bingoPatterns.length > 0 && (
    - {bingoPatterns.map((pattern, i) => ( - - {getPatternLabel(pattern)} - - ))} + {bingoPatterns.map((pattern, i) => { + const msg = getPatternMessage(pattern); + return ( + + {t(`patterns.${msg.key}`, { n: msg.n })} + + ); + })}
    )} @@ -262,16 +269,16 @@ export function PlayClient({ initialCard, currentUser }: Props) {
    - - + +

    - Grille {card.rows}×{card.cols} + {t("gridSize", { rows: card.rows, cols: card.cols })}

    @@ -288,7 +295,7 @@ function Stat({ value, label, className }: { value: number; label: string; class ); } -function OnlineMembers({ members }: { members: OnlineMember[] }) { +function OnlineMembers({ members, onlineLabel }: { members: OnlineMember[]; onlineLabel: string }) { const hiddenCount = members.length - MAX_VISIBLE_MEMBERS; return ( @@ -309,7 +316,7 @@ function OnlineMembers({ members }: { members: OnlineMember[] }) { )} {members.length > 0 && ( - ● {members.length} en ligne + {onlineLabel} )}
    diff --git a/src/components/team/__tests__/team-client.test.tsx b/src/components/team/__tests__/team-client.test.tsx index 3711b24..c05f518 100644 --- a/src/components/team/__tests__/team-client.test.tsx +++ b/src/components/team/__tests__/team-client.test.tsx @@ -1,9 +1,10 @@ -import { render, screen, within } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { TeamActor } from "@/lib/authz-rules"; import type { TeamCardSummary, TeamOverview } from "@/lib/data"; import type { TeamRole } from "@/lib/team-roles"; import { jsonResponse, mockFetch, routerMock, sentJson } from "@/test-utils/mocks"; +import { renderWithIntl } from "@/test-utils/i18n"; import { TeamClient } from "../team-client"; jest.mock("next/navigation", () => ({ @@ -60,7 +61,7 @@ describe("TeamClient", () => { }); it("affiche l'équipe et la progression des grilles", () => { - render(); + renderWithIntl(); expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Produit"); expect(screen.getByText(/3 membres · 2 grilles/)).toBeInTheDocument(); @@ -69,7 +70,7 @@ describe("TeamClient", () => { }); it("ne propose à un MEMBER que la suppression de ses propres grilles", () => { - render(); + renderWithIntl(); expect(screen.queryByRole("button", { name: "Supprimer l'équipe" })).not.toBeInTheDocument(); expect(within(cardItem("Grille c1")).getByRole("button", { name: "Supprimer" })).toBeInTheDocument(); @@ -77,7 +78,7 @@ describe("TeamClient", () => { }); it("propose à un ADMIN toutes les grilles, mais pas l'équipe", () => { - render(); + renderWithIntl(); expect(screen.queryByRole("button", { name: "Supprimer l'équipe" })).not.toBeInTheDocument(); expect(screen.getAllByRole("button", { name: "Supprimer" })).toHaveLength(2); @@ -85,7 +86,7 @@ describe("TeamClient", () => { it("propose toutes les suppressions à l'OWNER et à l'admin du site", () => { for (const who of [owner, siteAdmin]) { - const { unmount } = render(); + const { unmount } = renderWithIntl(); expect(screen.getByRole("button", { name: "Supprimer l'équipe" })).toBeInTheDocument(); expect(screen.getAllByRole("button", { name: "Supprimer" })).toHaveLength(2); unmount(); @@ -94,7 +95,7 @@ describe("TeamClient", () => { it("supprime une grille après confirmation", async () => { fetchMock.mockResolvedValue(jsonResponse({ ok: true })); - render(); + renderWithIntl(); await userEvent.click(within(cardItem("Grille c1")).getByRole("button", { name: "Supprimer" })); @@ -104,7 +105,7 @@ describe("TeamClient", () => { it("garde la grille et affiche l'erreur si la suppression échoue", async () => { fetchMock.mockResolvedValue(jsonResponse({ error: "Suppression non autorisée" }, 403)); - render(); + renderWithIntl(); await userEvent.click(within(cardItem("Grille c1")).getByRole("button", { name: "Supprimer" })); @@ -114,7 +115,7 @@ describe("TeamClient", () => { it("n'appelle pas l'API si l'utilisateur annule", async () => { jest.spyOn(window, "confirm").mockReturnValue(false); - render(); + renderWithIntl(); await userEvent.click(screen.getByRole("button", { name: "Supprimer l'équipe" })); @@ -131,7 +132,7 @@ describe("TeamClient — membres", () => { }); it("affiche les rôles sans sélecteur pour un MEMBER", () => { - render(); + renderWithIntl(); const list = screen.getByRole("region", { name: "Membres" }); expect(within(list).getByText("Propriétaire")).toBeInTheDocument(); @@ -139,7 +140,7 @@ describe("TeamClient — membres", () => { }); it("donne un sélecteur de rôle à l'OWNER et protège le dernier OWNER", () => { - render(); + renderWithIntl(); expect(screen.getAllByRole("combobox")).toHaveLength(3); const ownerSelect = screen.getByRole("combobox", { name: "Rôle de Olga" }); @@ -149,7 +150,7 @@ describe("TeamClient — membres", () => { it("change le rôle d'un membre", async () => { fetchMock.mockResolvedValue(jsonResponse({ id: "m-other", role: "ADMIN" })); - render(); + renderWithIntl(); const select = screen.getByRole("combobox", { name: "Rôle de Sam" }); await userEvent.selectOptions(select, "ADMIN"); @@ -164,7 +165,7 @@ describe("TeamClient — membres", () => { it("rétablit le rôle et affiche l'erreur si l'API refuse", async () => { fetchMock.mockResolvedValue(jsonResponse({ error: "Refusé" }, 403)); - render(); + renderWithIntl(); const select = screen.getByRole("combobox", { name: "Rôle de Sam" }); await userEvent.selectOptions(select, "ADMIN"); diff --git a/src/components/team/team-client.tsx b/src/components/team/team-client.tsx index a55b4e7..c3fbb10 100644 --- a/src/components/team/team-client.tsx +++ b/src/components/team/team-client.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import { motion } from "framer-motion"; import { apiRequest } from "@/lib/api-client"; import { canDeleteCard, canDeleteTeam, type TeamActor } from "@/lib/authz-rules"; @@ -28,6 +29,10 @@ function getProgress(card: TeamCardSummary): number { /** Vue d'une équipe : code d'invitation, membres, liste des grilles, suppressions. */ export function TeamClient({ team, initialCards, actor }: Props) { const router = useRouter(); + const t = useTranslations("team"); + const tCommon = useTranslations("common"); + const tApi = useTranslations("apiClient"); + const tDates = useTranslations("dates.relative"); const [cards, setCards] = useState(initialCards); const [copied, setCopied] = useState(false); const [deletingTeam, setDeletingTeam] = useState(false); @@ -40,23 +45,18 @@ export function TeamClient({ team, initialCards, actor }: Props) { setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { - setError("Copie impossible, le code est : " + team.inviteCode); + setError(t("copyFailed", { code: team.inviteCode })); } } async function deleteTeam() { - if ( - !confirm( - `Supprimer l'équipe « ${team.name} » et toutes ses grilles ? Cette action est définitive.` - ) - ) { - return; - } + if (!confirm(t("confirmDeleteTeam", { name: team.name }))) return; setDeletingTeam(true); setError(""); const result = await apiRequest(`/api/teams/${team.id}`, { method: "DELETE", - fallbackError: "Suppression impossible", + fallbackError: t("deleteFailed"), + networkError: tApi("network"), }); setDeletingTeam(false); if (!result.ok) { @@ -68,14 +68,13 @@ export function TeamClient({ team, initialCards, actor }: Props) { } async function deleteCard(card: TeamCardSummary) { - if (!confirm(`Supprimer la grille « ${card.label} » ? Cette action est définitive.`)) { - return; - } + if (!confirm(t("confirmDeleteCard", { label: card.label }))) return; setDeletingCardId(card.id); setError(""); const result = await apiRequest(`/api/cards/${card.id}`, { method: "DELETE", - fallbackError: "Suppression impossible", + fallbackError: t("deleteFailed"), + networkError: tApi("network"), }); setDeletingCardId(null); if (!result.ok) { @@ -95,28 +94,23 @@ export function TeamClient({ team, initialCards, actor }: Props) { href="/dashboard" className="mb-2 inline-block text-sm font-bold text-accent hover:underline" > - ← Mes équipes + {t("back")}

    {team.name} .

    - {memberCount} membre{memberCount > 1 ? "s" : ""} · {cards.length} grille - {cards.length !== 1 ? "s" : ""} + {t("summary", { members: memberCount, cards: cards.length })}

    - - + Nouvelle grille + {t("newCard")} {canDeleteTeam(actor) && ( )}
    @@ -145,10 +139,10 @@ export function TeamClient({ team, initialCards, actor }: Props) { animate={{ opacity: 1, y: 0 }} className="surface -rotate-1 py-16 text-center" > -

    Pas encore de grilles

    -

    Colle ta première grille pour cette semaine.

    +

    {t("emptyTitle")}

    +

    {t("emptySubtitle")}

    - Créer la première grille + {t("createFirst")} ) : ( @@ -161,6 +155,16 @@ export function TeamClient({ team, initialCards, actor }: Props) { canDelete={canDeleteCard(actor, card.createdById)} isDeleting={deletingCardId === card.id} onDelete={() => void deleteCard(card)} + activeLabel={t("active")} + cardMeta={t("cardMeta", { + rows: card.rows, + cols: card.cols, + created: formatDistanceToNow(card.createdAt, (key, values) => + values ? tDates(key as "days", values) : tDates(key as "today") + ), + })} + cellsLabel={t("cells")} + deleteLabel={tCommon("delete")} /> ))} @@ -175,12 +179,20 @@ function TeamCardRow({ canDelete, isDeleting, onDelete, + activeLabel, + cardMeta, + cellsLabel, + deleteLabel, }: { card: TeamCardSummary; index: number; canDelete: boolean; isDeleting: boolean; onDelete: () => void; + activeLabel: string; + cardMeta: string; + cellsLabel: string; + deleteLabel: string; }) { const progress = getProgress(card); const checkedCount = getCheckedCount(card); @@ -195,10 +207,7 @@ function TeamCardRow({ index % 2 === 0 ? "hover:rotate-1" : "hover:-rotate-1" } ${card.isActive ? "border-accent/40 bg-accent-mist" : ""}`} > - +

    {card.label}

    - {card.isActive && En cours} + {card.isActive && {activeLabel}}
    -

    - Grille {card.rows}×{card.cols} · Créée {formatDistanceToNow(card.createdAt)} -

    +

    {cardMeta}

    @@ -227,7 +234,7 @@ function TeamCardRow({

    {checkedCount}/{card.cells.length}

    -

    cases

    +

    {cellsLabel}

    @@ -250,7 +257,7 @@ function TeamCardRow({ disabled={isDeleting} onClick={onDelete} > - {isDeleting ? "…" : "Supprimer"} + {isDeleting ? "…" : deleteLabel} )} diff --git a/src/components/team/team-members.tsx b/src/components/team/team-members.tsx index 29f3f93..df7ac30 100644 --- a/src/components/team/team-members.tsx +++ b/src/components/team/team-members.tsx @@ -2,10 +2,11 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import { apiRequest } from "@/lib/api-client"; import { canChangeRole, hasTeamRole, type TeamActor } from "@/lib/authz-rules"; import type { TeamOverviewMember } from "@/lib/data"; -import { ROLE_LABELS, TEAM_ROLES, type TeamRole } from "@/lib/team-roles"; +import { TEAM_ROLES, type TeamRole } from "@/lib/team-roles"; type Props = { teamId: string; @@ -16,6 +17,9 @@ type Props = { /** Liste des membres ; l'OWNER (ou l'admin du site) peut changer les rôles. */ export function TeamMembers({ teamId, initialMembers, actor }: Props) { const router = useRouter(); + const t = useTranslations("team.members"); + const tRoles = useTranslations("roles"); + const tApi = useTranslations("apiClient"); const [members, setMembers] = useState(initialMembers); const [savingId, setSavingId] = useState(null); const [error, setError] = useState(""); @@ -33,7 +37,8 @@ export function TeamMembers({ teamId, initialMembers, actor }: Props) { const result = await apiRequest(`/api/teams/${teamId}/members/${member.id}`, { method: "PATCH", json: { role }, - fallbackError: "Changement de rôle impossible", + fallbackError: t("changeFailed"), + networkError: tApi("network"), }); setSavingId(null); @@ -49,7 +54,7 @@ export function TeamMembers({ teamId, initialMembers, actor }: Props) { return (

    - Membres + {t("title")}

    {error && ( @@ -60,18 +65,18 @@ export function TeamMembers({ teamId, initialMembers, actor }: Props) {
      {members.map((member) => { - const name = member.user.name ?? "Sans nom"; + const name = member.user.name ?? t("noName"); const isSelf = member.user.id === actor.userId; return (
    • {name} - {isSelf && (toi)} + {isSelf && {t("you")}} {canEditRoles ? ( ) : ( - {ROLE_LABELS[member.role]} + {tRoles(member.role)} )}
    • diff --git a/src/i18n/config.ts b/src/i18n/config.ts new file mode 100644 index 0000000..07bcf50 --- /dev/null +++ b/src/i18n/config.ts @@ -0,0 +1,23 @@ +export const LOCALES = ["fr", "en"] as const; + +export type AppLocale = (typeof LOCALES)[number]; + +export const DEFAULT_LOCALE: AppLocale = "fr"; + +/** Cookie lu quand aucune session n'indique de préférence (pages publiques, après déconnexion). */ +export const LOCALE_COOKIE = "NEXT_LOCALE"; + +/** Nom de chaque langue dans sa propre langue, pour le sélecteur. */ +export const LOCALE_LABELS: Record = { + fr: "Français", + en: "English", +}; + +export function isLocale(value: unknown): value is AppLocale { + return typeof value === "string" && (LOCALES as readonly string[]).includes(value); +} + +/** Langue valide, ou la langue par défaut. */ +export function toLocale(value: unknown): AppLocale { + return isLocale(value) ? value : DEFAULT_LOCALE; +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json new file mode 100644 index 0000000..9b11f2b --- /dev/null +++ b/src/i18n/messages/en.json @@ -0,0 +1,327 @@ +{ + "metadata": { + "title": "MarkIt – Meeting Bingo", + "description": "Real-time meeting bingo for your teams." + }, + "common": { + "admin": "Admin", + "cancel": "Cancel", + "copied": "Copied", + "copy": "Copy", + "delete": "Delete", + "save": "Save", + "backToDashboard": "Back to dashboard" + }, + "apiClient": { + "network": "Connection failed, please try again", + "fallback": "Something went wrong" + }, + "roles": { + "OWNER": "Owner", + "ADMIN": "Admin", + "MEMBER": "Member" + }, + "errors": { + "unauthorized": "Unauthorized", + "invalidSession": "Invalid session, please sign in again", + "accountSetupRequired": "Account setup required", + "adminRequired": "Admin access required", + "accountNotFound": "Account not found", + "notFound": "Not found", + "cellNotFound": "Cell not found", + "memberNotFound": "Member not found", + "deleteForbidden": "You are not allowed to delete this", + "serverError": "Server error", + "invalidJson": "Invalid JSON", + "invalidData": "Invalid data", + "inviteInvalid": "Invalid invitation link", + "inviteUsed": "This invitation link has already been used", + "inviteExpired": "This invitation link has expired", + "inviteRequired": "Invitation link required", + "accountCreationFailed": "Unable to create this account", + "nameRequired": "First name is required", + "nameLength": "First name must be between 1 and {maxName} characters", + "emailInvalid": "Invalid email", + "emailFormat": "Invalid email format", + "passwordRequired": "Password is required", + "passwordTooShort": "Password must be at least {minPassword} characters long", + "passwordTooLong": "Password is too long", + "currentPasswordRequired": "Current password required", + "currentPasswordIncorrect": "Current password is incorrect", + "newPasswordRequired": "You must set a new password to continue", + "noChanges": "No changes requested", + "localeInvalid": "Unsupported language", + "idRequired": "ID required", + "teamNameRequired": "Name required", + "teamNameTooLong": "Name too long (max {maxTeamName} characters)", + "inviteCodeRequired": "Code required", + "inviteCodeInvalid": "Invalid code", + "alreadyMember": "You are already a member of this team", + "roleInvalid": "Invalid role", + "ownerOnlyRoles": "Only the team owner can change roles", + "lastOwner": "The team must keep at least one owner", + "concurrentUpdate": "Concurrent update, please try again", + "labelRequired": "Label required", + "labelTooLong": "Label too long (max {maxLabel} characters)", + "phrasesRequired": "Phrases required", + "notEnoughPhrases": "At least {count} phrases are needed", + "invalidPhrases": "Some phrases are invalid", + "phraseTextRequired": "Text required", + "phraseTextTooLong": "Text too long (max {maxPhrase} characters)", + "emojiInvalid": "Invalid emoji", + "adminOnlyPhrases": "Only team admins can edit phrases", + "phraseInUse": "This phrase is already used in a card. Edit it instead, or delete the card first." + }, + "navbar": { + "accountFallback": "Account", + "accountMenu": "Account menu", + "myAccount": "My account", + "administration": "Administration", + "signOut": "Sign out" + }, + "landing": { + "tagline": "The bingo that saves never-ending meetings.", + "pitch": "Build your card, tick cells live with your team, and shout bingo before the “quick sync” is over.", + "cta": "Let's go", + "inviteOnly": "Invite only — no spam, just bingo.", + "demoPhrases": { + "quickWin": "Quick win", + "aligned": "Are we aligned?", + "synergies": "Synergies", + "deepDive": "Deep dive", + "roadmap": "Roadmap", + "free": "FREE", + "kpis": "KPIs", + "agile": "Agile", + "offline": "Offline" + }, + "stepsTitle": "Three steps, zero slides", + "stepsIntro": "Create a team, throw in a card with your meeting phrases, play during the call. Cells get ticked for everyone in real time.", + "steps": { + "team": { + "title": "Team", + "desc": "Invite your colleagues with a code — in 10 seconds." + }, + "card": { + "title": "Card", + "desc": "Pick from the phrase bank (or make up your own)." + }, + "bingo": { + "title": "Bingo", + "desc": "Tick, watch the confetti, win the meeting." + } + } + }, + "signIn": { + "tagline": "Sign back in — your card is waiting", + "email": "Email", + "emailPlaceholder": "you@company.com", + "password": "Password", + "showPassword": "Show password", + "hidePassword": "Hide password", + "submit": "Let's go", + "submitting": "Signing in…", + "invalidCredentials": "Incorrect email or password", + "inviteOnly": "Sign-up is by invitation only.", + "backHome": "Back to home" + }, + "signUp": { + "title": "Create your account", + "subtitle": "You've received an invitation.", + "name": "First name", + "namePlaceholder": "Alex", + "email": "Email", + "emailPlaceholder": "you@company.com", + "password": "Password", + "passwordHint": "At least {min} characters", + "submit": "Create my account", + "submitting": "Creating…", + "fallbackError": "Sign-up failed", + "hasAccount": "Already have an account?", + "signIn": "Sign in" + }, + "account": { + "setupTitle": "Set up your account", + "title": "My account", + "setupSubtitle": "First sign-in: first name and new password.", + "subtitle": "Change your first name or your password.", + "name": "First name", + "namePlaceholder": "Alex", + "currentPassword": "Current password", + "currentPasswordHint": "Only required if you change your password", + "newPassword": "New password", + "newPasswordOptional": "New password (optional)", + "passwordHint": "At least {min} characters", + "confirmPassword": "Confirm password", + "passwordMismatch": "Passwords do not match", + "newPasswordRequired": "Choose a new password to continue", + "noChanges": "Nothing to save", + "fallbackError": "Update failed", + "saved": "Saved", + "updated": "Account updated", + "saving": "Saving…", + "finishSetup": "Finish setup", + "language": { + "label": "Language", + "hint": "Language of the interface and error messages", + "fallbackError": "Unable to change the language" + } + }, + "dashboard": { + "title": "My teams", + "subtitle": "Pick your side and start a card", + "join": "Join", + "create": "Create", + "creating": "Creating…", + "teamName": "Team name", + "teamNamePlaceholder": "Product team", + "inviteCode": "Invite code", + "inviteCodePlaceholder": "Paste the code here", + "emptyTitle": "Nobody here… yet", + "emptySubtitle": "Create or join a team to get the party started.", + "memberCount": "{count, plural, one {# member} other {# members}}", + "cardCount": "{count, plural, one {# card} other {# cards}}", + "confirmDeleteTeam": "Delete the team “{name}” and all its cards? This cannot be undone.", + "createFailed": "Unable to create the team", + "joinFailed": "Unable to join the team", + "deleteFailed": "Unable to delete" + }, + "team": { + "back": "← My teams", + "summary": "{members, plural, one {# member} other {# members}} · {cards, plural, one {# card} other {# cards}}", + "inviteCode": "Invite code", + "copyFailed": "Copy failed, the code is: {code}", + "newCard": "+ New card", + "deleteTeam": "Delete team", + "confirmDeleteTeam": "Delete the team “{name}” and all its cards? This cannot be undone.", + "confirmDeleteCard": "Delete the card “{label}”? This cannot be undone.", + "deleteFailed": "Unable to delete", + "emptyTitle": "No cards yet", + "emptySubtitle": "Stick up your first card for this week.", + "createFirst": "Create the first card", + "active": "In progress", + "cardMeta": "{rows}×{cols} card · Created {created}", + "cells": "cells", + "members": { + "title": "Members", + "noName": "No name", + "you": "(you)", + "roleOf": "Role of {name}", + "changeFailed": "Unable to change the role" + } + }, + "createCard": { + "back": "← Back to team", + "title": "New card", + "subtitle": "Build your sticky-note card for this week", + "configuration": "Settings", + "cardName": "Card name", + "cardNamePlaceholder": "e.g. Monday meeting", + "defaultLabel": "Meeting of {date}", + "rows": "Rows", + "cols": "Columns", + "freeCenter": "FREE center cell ⭐", + "preview": "Card preview", + "summary": "Summary", + "size": "Size", + "sizeValue": "{rows} × {cols} = {total} cells", + "neededPhrases": "Phrases needed", + "selectedPhrases": "Phrases selected", + "generating": "Generating...", + "generate": "Generate card ({selected}/{needed})", + "notEnoughSelected": "Select at least {needed} phrases to fill the {rows}×{cols} card", + "phraseBank": "💬 Phrase bank", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "editingPhrase": "Editing a custom phrase", + "editPhrase": "Edit phrase", + "newPhrase": "New phrase", + "editPhrasePlaceholder": "Edit the phrase...", + "newPhrasePlaceholder": "Add a custom phrase...", + "add": "+ Add", + "customPhrases": "Custom phrases ({count})", + "defaultPhrases": "Classic phrases ({count})", + "confirmDeletePhrase": "Delete the phrase “{text}”? This cannot be undone.", + "editFailed": "Unable to edit the phrase", + "addFailed": "Unable to add the phrase", + "deleteFailed": "Unable to delete the phrase", + "createFailed": "Unable to create the card" + }, + "phraseChip": { + "edit": "Edit", + "editLabel": "Edit “{text}”", + "delete": "Delete", + "deleteLabel": "Delete “{text}”" + }, + "emojiPicker": { + "choose": "Choose an emoji", + "clear": "Clear", + "categories": { + "smileys": "Smileys", + "gestures": "Gestures", + "people": "People", + "animals": "Animals", + "food": "Food", + "travel": "Travel", + "activities": "Activities", + "objects": "Objects", + "symbols": "Symbols" + } + }, + "play": { + "bingo": "BINGO!", + "winner": "{name} nailed the winning line", + "you": "You", + "saveFailed": "The cell could not be saved", + "checked": "Ticked", + "remaining": "Remaining", + "bingos": "{count, plural, one {Bingo} other {Bingos}}", + "gridSize": "{rows}×{cols} card", + "online": "● {count} online", + "free": "FREE", + "patterns": { + "row": "Row {n}", + "column": "Column {n}", + "diagonalMain": "Diag ↘", + "diagonalAnti": "Diag ↗" + } + }, + "admin": { + "title": "Administration", + "userCount": "{count, plural, one {# user} other {# users}}", + "tabs": { + "users": "Users", + "invites": "Invitations" + }, + "noUsers": "No users", + "registeredOn": "Joined on {date}", + "noTeam": "No team", + "generateTitle": "Generate an invitation link", + "note": "Note (optional)", + "notePlaceholder": "For Mary from the design team", + "validity": "Validity (days)", + "generate": "Generate", + "activeInvites": "Active ({count})", + "noActiveInvites": "No active invitations", + "usedInvites": "Used ({count})", + "expiredInvites": "Expired ({count})", + "token": "token: {token}…", + "usedOn": "Used on {date}", + "expiredOn": "Expired on {date}", + "expiresOn": "Expires on {date}", + "confirmRevoke": "Revoke this invitation link?", + "createFailed": "Unable to generate the invitation", + "deleteFailed": "Unable to delete the invitation", + "copyFailed": "Copy is not supported in this browser" + }, + "dates": { + "relative": { + "today": "today", + "yesterday": "yesterday", + "days": "{count, plural, one {# day ago} other {# days ago}}", + "weeks": "{count, plural, one {# week ago} other {# weeks ago}}", + "months": "{count, plural, one {# month ago} other {# months ago}}", + "years": "{count, plural, one {# year ago} other {# years ago}}" + } + } +} diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json new file mode 100644 index 0000000..abeeb1c --- /dev/null +++ b/src/i18n/messages/fr.json @@ -0,0 +1,327 @@ +{ + "metadata": { + "title": "MarkIt – Meeting Bingo", + "description": "Bingo de réunion en temps réel pour vos équipes." + }, + "common": { + "admin": "Admin", + "cancel": "Annuler", + "copied": "Copié", + "copy": "Copier", + "delete": "Supprimer", + "save": "Enregistrer", + "backToDashboard": "Retour au tableau de bord" + }, + "apiClient": { + "network": "Connexion impossible, réessaie", + "fallback": "Une erreur est survenue" + }, + "roles": { + "OWNER": "Propriétaire", + "ADMIN": "Admin", + "MEMBER": "Membre" + }, + "errors": { + "unauthorized": "Non autorisé", + "invalidSession": "Session invalide, reconnecte-toi", + "accountSetupRequired": "Configuration du compte requise", + "adminRequired": "Accès admin requis", + "accountNotFound": "Compte introuvable", + "notFound": "Introuvable", + "cellNotFound": "Case introuvable", + "memberNotFound": "Membre introuvable", + "deleteForbidden": "Suppression non autorisée", + "serverError": "Erreur serveur", + "invalidJson": "JSON invalide", + "invalidData": "Données invalides", + "inviteInvalid": "Lien d'invitation invalide", + "inviteUsed": "Ce lien d'invitation a déjà été utilisé", + "inviteExpired": "Ce lien d'invitation a expiré", + "inviteRequired": "Lien d'invitation requis", + "accountCreationFailed": "Impossible de créer ce compte", + "nameRequired": "Le prénom est requis", + "nameLength": "Le prénom doit faire entre 1 et {maxName} caractères", + "emailInvalid": "Email invalide", + "emailFormat": "Format d'email invalide", + "passwordRequired": "Le mot de passe est requis", + "passwordTooShort": "Le mot de passe doit contenir au moins {minPassword} caractères", + "passwordTooLong": "Mot de passe trop long", + "currentPasswordRequired": "Mot de passe actuel requis", + "currentPasswordIncorrect": "Mot de passe actuel incorrect", + "newPasswordRequired": "Tu dois définir un nouveau mot de passe pour continuer", + "noChanges": "Aucune modification demandée", + "localeInvalid": "Langue non prise en charge", + "idRequired": "ID requis", + "teamNameRequired": "Nom requis", + "teamNameTooLong": "Nom trop long (max {maxTeamName} caractères)", + "inviteCodeRequired": "Code requis", + "inviteCodeInvalid": "Code invalide", + "alreadyMember": "Tu es déjà membre de cette équipe", + "roleInvalid": "Rôle invalide", + "ownerOnlyRoles": "Seul le propriétaire de l'équipe peut changer les rôles", + "lastOwner": "L'équipe doit garder au moins un propriétaire", + "concurrentUpdate": "Modification concurrente, réessaie", + "labelRequired": "Label requis", + "labelTooLong": "Label trop long (max {maxLabel} caractères)", + "phrasesRequired": "Phrases requises", + "notEnoughPhrases": "Il faut au moins {count} phrases", + "invalidPhrases": "Certaines phrases sont invalides", + "phraseTextRequired": "Texte requis", + "phraseTextTooLong": "Texte trop long (max {maxPhrase} caractères)", + "emojiInvalid": "Emoji invalide", + "adminOnlyPhrases": "Seuls les admins de l'équipe peuvent modifier les phrases", + "phraseInUse": "Cette phrase est déjà utilisée dans une grille. Modifie-la plutôt, ou supprime d'abord la grille." + }, + "navbar": { + "accountFallback": "Compte", + "accountMenu": "Menu compte", + "myAccount": "Mon compte", + "administration": "Administration", + "signOut": "Déconnexion" + }, + "landing": { + "tagline": "Le bingo qui sauve les réunions interminables.", + "pitch": "Compose ta grille, coche en live avec l'équipe, et crie bingo avant la fin du « quick sync ».", + "cta": "C'est parti", + "inviteOnly": "Sur invitation — pas de spam, juste du bingo.", + "demoPhrases": { + "quickWin": "Quick win", + "aligned": "On est alignés ?", + "synergies": "Synergies", + "deepDive": "Deep dive", + "roadmap": "Roadmap", + "free": "FREE", + "kpis": "KPIs", + "agile": "Agile", + "offline": "Offline" + }, + "stepsTitle": "Trois étapes, zéro slide", + "stepsIntro": "Crée une équipe, balance une grille avec vos phrases de réunion, joue pendant le call. Les cases se cochent pour tout le monde en direct.", + "steps": { + "team": { + "title": "Équipe", + "desc": "Invite tes collègues avec un code — en 10 secondes." + }, + "card": { + "title": "Grille", + "desc": "Pioche dans la banque de phrases (ou invente les tiennes)." + }, + "bingo": { + "title": "Bingo", + "desc": "Coche, regarde les confettis, gagne la réunion." + } + } + }, + "signIn": { + "tagline": "Reconnecte-toi — la grille t'attend", + "email": "Email", + "emailPlaceholder": "toi@entreprise.com", + "password": "Mot de passe", + "showPassword": "Afficher le mot de passe", + "hidePassword": "Masquer le mot de passe", + "submit": "C'est parti", + "submitting": "Connexion…", + "invalidCredentials": "Email ou mot de passe incorrect", + "inviteOnly": "L'inscription se fait uniquement sur invitation.", + "backHome": "Retour à l'accueil" + }, + "signUp": { + "title": "Créer ton compte", + "subtitle": "Tu as reçu une invitation.", + "name": "Prénom", + "namePlaceholder": "Alex", + "email": "Email", + "emailPlaceholder": "toi@entreprise.com", + "password": "Mot de passe", + "passwordHint": "Minimum {min} caractères", + "submit": "Créer mon compte", + "submitting": "Création…", + "fallbackError": "Erreur lors de l'inscription", + "hasAccount": "Déjà un compte ?", + "signIn": "Se connecter" + }, + "account": { + "setupTitle": "Configure ton compte", + "title": "Mon compte", + "setupSubtitle": "Première connexion : prénom et nouveau mot de passe.", + "subtitle": "Modifie ton prénom ou ton mot de passe.", + "name": "Prénom", + "namePlaceholder": "Alex", + "currentPassword": "Mot de passe actuel", + "currentPasswordHint": "Requis uniquement si tu changes le mot de passe", + "newPassword": "Nouveau mot de passe", + "newPasswordOptional": "Nouveau mot de passe (optionnel)", + "passwordHint": "Minimum {min} caractères", + "confirmPassword": "Confirmer le mot de passe", + "passwordMismatch": "Les mots de passe ne correspondent pas", + "newPasswordRequired": "Choisis un nouveau mot de passe pour continuer", + "noChanges": "Aucune modification à enregistrer", + "fallbackError": "Erreur lors de la mise à jour", + "saved": "Enregistré", + "updated": "Compte mis à jour", + "saving": "Enregistrement…", + "finishSetup": "Terminer la configuration", + "language": { + "label": "Langue", + "hint": "Langue de l'interface et des messages d'erreur", + "fallbackError": "Impossible de changer la langue" + } + }, + "dashboard": { + "title": "Mes équipes", + "subtitle": "Choisis ton camp et lance une grille", + "join": "Rejoindre", + "create": "Créer", + "creating": "Création…", + "teamName": "Nom de l'équipe", + "teamNamePlaceholder": "Équipe Produit", + "inviteCode": "Code d'invitation", + "inviteCodePlaceholder": "Colle le code ici", + "emptyTitle": "Personne ici… pour l'instant", + "emptySubtitle": "Crée ou rejoins une équipe pour ouvrir le bal.", + "memberCount": "{count, plural, one {# membre} other {# membres}}", + "cardCount": "{count, plural, one {# grille} other {# grilles}}", + "confirmDeleteTeam": "Supprimer l'équipe « {name} » et toutes ses grilles ? Cette action est définitive.", + "createFailed": "Création impossible", + "joinFailed": "Impossible de rejoindre l'équipe", + "deleteFailed": "Suppression impossible" + }, + "team": { + "back": "← Mes équipes", + "summary": "{members, plural, one {# membre} other {# membres}} · {cards, plural, one {# grille} other {# grilles}}", + "inviteCode": "Code d'invitation", + "copyFailed": "Copie impossible, le code est : {code}", + "newCard": "+ Nouvelle grille", + "deleteTeam": "Supprimer l'équipe", + "confirmDeleteTeam": "Supprimer l'équipe « {name} » et toutes ses grilles ? Cette action est définitive.", + "confirmDeleteCard": "Supprimer la grille « {label} » ? Cette action est définitive.", + "deleteFailed": "Suppression impossible", + "emptyTitle": "Pas encore de grilles", + "emptySubtitle": "Colle ta première grille pour cette semaine.", + "createFirst": "Créer la première grille", + "active": "En cours", + "cardMeta": "Grille {rows}×{cols} · Créée {created}", + "cells": "cases", + "members": { + "title": "Membres", + "noName": "Sans nom", + "you": "(toi)", + "roleOf": "Rôle de {name}", + "changeFailed": "Changement de rôle impossible" + } + }, + "createCard": { + "back": "← Retour à l'équipe", + "title": "Nouvelle grille", + "subtitle": "Compose ta grille post-it pour cette semaine", + "configuration": "Configuration", + "cardName": "Nom de la grille", + "cardNamePlaceholder": "Ex: Réunion du lundi", + "defaultLabel": "Réunion du {date}", + "rows": "Lignes", + "cols": "Colonnes", + "freeCenter": "Case centrale FREE ⭐", + "preview": "Aperçu de la grille", + "summary": "Résumé", + "size": "Taille", + "sizeValue": "{rows} × {cols} = {total} cases", + "neededPhrases": "Phrases nécessaires", + "selectedPhrases": "Phrases sélectionnées", + "generating": "Génération...", + "generate": "Générer la grille ({selected}/{needed})", + "notEnoughSelected": "Sélectionne au moins {needed} phrases pour remplir la grille {rows}×{cols}", + "phraseBank": "💬 Banque de phrases", + "selectAll": "Tout sélectionner", + "deselectAll": "Tout désélectionner", + "editingPhrase": "Modification d'une phrase personnalisée", + "editPhrase": "Modifier la phrase", + "newPhrase": "Nouvelle phrase", + "editPhrasePlaceholder": "Modifier la phrase...", + "newPhrasePlaceholder": "Ajouter une phrase personnalisée...", + "add": "+ Ajouter", + "customPhrases": "Phrases personnalisées ({count})", + "defaultPhrases": "Phrases classiques ({count})", + "confirmDeletePhrase": "Supprimer la phrase « {text} » ? Cette action est définitive.", + "editFailed": "Impossible de modifier la phrase", + "addFailed": "Impossible d'ajouter la phrase", + "deleteFailed": "Impossible de supprimer la phrase", + "createFailed": "Impossible de créer la grille" + }, + "phraseChip": { + "edit": "Modifier", + "editLabel": "Modifier « {text} »", + "delete": "Supprimer", + "deleteLabel": "Supprimer « {text} »" + }, + "emojiPicker": { + "choose": "Choisir un emoji", + "clear": "Effacer", + "categories": { + "smileys": "Smileys", + "gestures": "Gestes", + "people": "Personnes", + "animals": "Animaux", + "food": "Nourriture", + "travel": "Voyage", + "activities": "Activités", + "objects": "Objets", + "symbols": "Symboles" + } + }, + "play": { + "bingo": "BINGO!", + "winner": "{name} a collé la ligne gagnante", + "you": "Toi", + "saveFailed": "La case n'a pas pu être enregistrée", + "checked": "Cochées", + "remaining": "Restantes", + "bingos": "{count, plural, one {Bingo} other {Bingos}}", + "gridSize": "Grille {rows}×{cols}", + "online": "● {count} en ligne", + "free": "FREE", + "patterns": { + "row": "Ligne {n}", + "column": "Colonne {n}", + "diagonalMain": "Diag ↘", + "diagonalAnti": "Diag ↗" + } + }, + "admin": { + "title": "Administration", + "userCount": "{count, plural, one {# utilisateur} other {# utilisateurs}}", + "tabs": { + "users": "Utilisateurs", + "invites": "Invitations" + }, + "noUsers": "Aucun utilisateur", + "registeredOn": "Inscrit le {date}", + "noTeam": "Aucune équipe", + "generateTitle": "Générer un lien d'invitation", + "note": "Note (optionnel)", + "notePlaceholder": "Pour Marie du pôle design", + "validity": "Validité (jours)", + "generate": "Générer", + "activeInvites": "Actives ({count})", + "noActiveInvites": "Aucune invitation active", + "usedInvites": "Utilisées ({count})", + "expiredInvites": "Expirées ({count})", + "token": "token: {token}…", + "usedOn": "Utilisé le {date}", + "expiredOn": "Expiré le {date}", + "expiresOn": "Expire le {date}", + "confirmRevoke": "Révoquer ce lien d'invitation ?", + "createFailed": "Impossible de générer l'invitation", + "deleteFailed": "Impossible de supprimer l'invitation", + "copyFailed": "Copie impossible depuis ce navigateur" + }, + "dates": { + "relative": { + "today": "aujourd'hui", + "yesterday": "hier", + "days": "il y a {count} jours", + "weeks": "il y a {count, plural, one {# semaine} other {# semaines}}", + "months": "il y a {count} mois", + "years": "il y a {count, plural, one {# an} other {# ans}}" + } + } +} diff --git a/src/i18n/next-intl.d.ts b/src/i18n/next-intl.d.ts new file mode 100644 index 0000000..c30d5af --- /dev/null +++ b/src/i18n/next-intl.d.ts @@ -0,0 +1,9 @@ +import type messages from "./messages/fr.json"; +import type { AppLocale } from "./config"; + +declare module "next-intl" { + interface AppConfig { + Locale: AppLocale; + Messages: typeof messages; + } +} diff --git a/src/i18n/request.ts b/src/i18n/request.ts new file mode 100644 index 0000000..365aaa7 --- /dev/null +++ b/src/i18n/request.ts @@ -0,0 +1,22 @@ +import { cookies } from "next/headers"; +import { getServerSession } from "next-auth"; +import { getRequestConfig } from "next-intl/server"; +import { authOptions } from "@/lib/auth"; +import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE, type AppLocale } from "./config"; + +/** Préférence du compte connecté, sinon cookie, sinon français. */ +async function resolveLocale(): Promise { + const session = await getServerSession(authOptions); + if (isLocale(session?.user?.locale)) return session.user.locale; + + const cookieLocale = (await cookies()).get(LOCALE_COOKIE)?.value; + return isLocale(cookieLocale) ? cookieLocale : DEFAULT_LOCALE; +} + +export default getRequestConfig(async () => { + const locale = await resolveLocale(); + return { + locale, + messages: (await import(`./messages/${locale}.json`)).default, + }; +}); diff --git a/src/lib/__tests__/account.test.ts b/src/lib/__tests__/account.test.ts index ba03f48..0734fdc 100644 --- a/src/lib/__tests__/account.test.ts +++ b/src/lib/__tests__/account.test.ts @@ -6,13 +6,13 @@ describe("validateUserName", () => { }); it("refuse vide ou trop long", () => { - expect(validateUserName("")).toHaveProperty("error"); - expect(validateUserName(" ")).toHaveProperty("error"); - expect(validateUserName("a".repeat(101))).toHaveProperty("error"); + expect(validateUserName("")).toEqual({ error: "nameLength" }); + expect(validateUserName(" ")).toEqual({ error: "nameLength" }); + expect(validateUserName("a".repeat(101))).toEqual({ error: "nameLength" }); }); it("refuse non-string", () => { - expect(validateUserName(null)).toHaveProperty("error"); + expect(validateUserName(null)).toEqual({ error: "nameRequired" }); }); }); @@ -23,7 +23,7 @@ describe("validateNewPassword", () => { }); it("refuse trop court ou trop long", () => { - expect(validateNewPassword("short")).toHaveProperty("error"); - expect(validateNewPassword("a".repeat(129))).toHaveProperty("error"); + expect(validateNewPassword("short")).toEqual({ error: "passwordTooShort" }); + expect(validateNewPassword("a".repeat(129))).toEqual({ error: "passwordTooLong" }); }); }); diff --git a/src/lib/__tests__/bingo-helpers.test.ts b/src/lib/__tests__/bingo-helpers.test.ts index 251766a..299a7be 100644 --- a/src/lib/__tests__/bingo-helpers.test.ts +++ b/src/lib/__tests__/bingo-helpers.test.ts @@ -1,4 +1,4 @@ -import { getCheckedPositions, getFreeCenterPosition, getPatternLabel } from "@/lib/bingo"; +import { getCheckedPositions, getFreeCenterPosition, getPatternMessage } from "@/lib/bingo"; describe("getFreeCenterPosition", () => { it("retourne le centre si activé sur grille impaire", () => { @@ -30,11 +30,23 @@ describe("getCheckedPositions", () => { }); }); -describe("getPatternLabel", () => { - it("formate chaque type de motif", () => { - expect(getPatternLabel({ type: "row", index: 0, positions: [] })).toBe("Ligne 1"); - expect(getPatternLabel({ type: "column", index: 2, positions: [] })).toBe("Colonne 3"); - expect(getPatternLabel({ type: "diagonal", index: 0, positions: [] })).toBe("Diag ↘"); - expect(getPatternLabel({ type: "diagonal", index: 1, positions: [] })).toBe("Diag ↗"); +describe("getPatternMessage", () => { + it("associe chaque type de motif à une clé de message", () => { + expect(getPatternMessage({ type: "row", index: 0, positions: [] })).toEqual({ + key: "row", + n: 1, + }); + expect(getPatternMessage({ type: "column", index: 2, positions: [] })).toEqual({ + key: "column", + n: 3, + }); + expect(getPatternMessage({ type: "diagonal", index: 0, positions: [] })).toEqual({ + key: "diagonalMain", + n: 1, + }); + expect(getPatternMessage({ type: "diagonal", index: 1, positions: [] })).toEqual({ + key: "diagonalAnti", + n: 2, + }); }); }); diff --git a/src/lib/account.ts b/src/lib/account.ts index cc80ec3..c355ffb 100644 --- a/src/lib/account.ts +++ b/src/lib/account.ts @@ -1,5 +1,7 @@ import { compare, hash } from "bcryptjs"; import { prisma } from "@/lib/prisma"; +import type { ErrorKey } from "@/lib/error-keys"; +import { toLocale, type AppLocale } from "@/i18n/config"; export const MIN_PASSWORD_LENGTH = 12; export const MAX_PASSWORD_LENGTH = 128; @@ -12,34 +14,47 @@ export type AccountProfile = { email: string; isAdmin: boolean; mustChangePassword: boolean; + locale: AppLocale; }; -export function validateUserName(name: unknown): { value: string } | { error: string } { +const profileSelect = { + id: true, + name: true, + email: true, + isAdmin: true, + mustChangePassword: true, + locale: true, +} as const; + +/** La colonne `locale` est un texte libre en BDD : on la ramène à une langue supportée. */ +function toProfile(user: T): T & { locale: AppLocale } { + return { ...user, locale: toLocale(user.locale) }; +} + +export function validateUserName(name: unknown): { value: string } | { error: ErrorKey } { if (typeof name !== "string") { - return { error: "Le prénom est requis" }; + return { error: "nameRequired" }; } const value = name.trim(); if (value.length < 1 || value.length > MAX_NAME_LENGTH) { - return { error: `Le prénom doit faire entre 1 et ${MAX_NAME_LENGTH} caractères` }; + return { error: "nameLength" }; } return { value }; } -export function validateNewPassword(password: unknown): { value: string } | { error: string } { +export function validateNewPassword(password: unknown): { value: string } | { error: ErrorKey } { if (typeof password !== "string") { - return { error: "Le mot de passe est requis" }; + return { error: "passwordRequired" }; } if (password.length < MIN_PASSWORD_LENGTH) { - return { - error: `Le mot de passe doit contenir au moins ${MIN_PASSWORD_LENGTH} caractères`, - }; + return { error: "passwordTooShort" }; } if (password.length > MAX_PASSWORD_LENGTH) { - return { error: "Mot de passe trop long" }; + return { error: "passwordTooLong" }; } return { value: password }; @@ -54,32 +69,24 @@ export async function verifyPassword(plain: string, hashed: string): Promise { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { - id: true, - name: true, - email: true, - isAdmin: true, - mustChangePassword: true, - }, - }); - - return user; + const user = await prisma.user.findUnique({ where: { id: userId }, select: profileSelect }); + return user && toProfile(user); } type UpdateAccountInput = { name?: unknown; currentPassword?: unknown; newPassword?: unknown; + locale?: AppLocale; }; type UpdateAccountResult = | { ok: true; profile: AccountProfile } - | { ok: false; error: string; status: number }; + | { ok: false; error: ErrorKey; status: number }; /** - * Met à jour le profil et/ou le mot de passe (première connexion ou changement volontaire). + * Met à jour le profil, la langue et/ou le mot de passe (première connexion ou changement volontaire). + * La langue seule peut changer avant la fin de la configuration du compte. */ export async function updateAccount( userId: string, @@ -87,21 +94,23 @@ export async function updateAccount( ): Promise { const user = await prisma.user.findUnique({ where: { id: userId }, - select: { - id: true, - name: true, - email: true, - password: true, - isAdmin: true, - mustChangePassword: true, - }, + select: { ...profileSelect, password: true }, }); if (!user || !user.password) { - return { ok: false, error: "Compte introuvable", status: 404 }; + return { ok: false, error: "accountNotFound", status: 404 }; } - const data: { name?: string; password?: string; mustChangePassword?: boolean } = {}; + const data: { + name?: string; + password?: string; + mustChangePassword?: boolean; + locale?: AppLocale; + } = {}; + + if (input.locale !== undefined) { + data.locale = input.locale; + } if (input.name !== undefined) { const nameResult = validateUserName(input.name); @@ -119,12 +128,12 @@ export async function updateAccount( if (!user.mustChangePassword) { if (typeof input.currentPassword !== "string" || !input.currentPassword) { - return { ok: false, error: "Mot de passe actuel requis", status: 400 }; + return { ok: false, error: "currentPasswordRequired", status: 400 }; } const matches = await verifyPassword(input.currentPassword, user.password); if (!matches) { - return { ok: false, error: "Mot de passe actuel incorrect", status: 401 }; + return { ok: false, error: "currentPasswordIncorrect", status: 401 }; } } @@ -132,29 +141,19 @@ export async function updateAccount( data.mustChangePassword = false; } - if (user.mustChangePassword && input.newPassword === undefined) { - return { - ok: false, - error: "Tu dois définir un nouveau mot de passe pour continuer", - status: 400, - }; + if (user.mustChangePassword && input.newPassword === undefined && input.name !== undefined) { + return { ok: false, error: "newPasswordRequired", status: 400 }; } if (Object.keys(data).length === 0) { - return { ok: false, error: "Aucune modification demandée", status: 400 }; + return { ok: false, error: "noChanges", status: 400 }; } const updated = await prisma.user.update({ where: { id: userId }, data, - select: { - id: true, - name: true, - email: true, - isAdmin: true, - mustChangePassword: true, - }, + select: profileSelect, }); - return { ok: true, profile: updated }; + return { ok: true, profile: toProfile(updated) }; } diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts index 33f7c96..d4b4ad5 100644 --- a/src/lib/api-auth.ts +++ b/src/lib/api-auth.ts @@ -1,7 +1,9 @@ -import { NextResponse } from "next/server"; +import type { NextResponse } from "next/server"; import { getServerSession, type Session } from "next-auth"; import { authOptions } from "@/lib/auth"; import { getAccountProfile, type AccountProfile } from "@/lib/account"; +import { apiError } from "@/lib/api-errors"; + type ApiAuthFailure = { ok: false; response: NextResponse; @@ -16,10 +18,7 @@ export type ApiAuthResult = T | ApiAu export async function requireApiSession(): Promise { const session = await getServerSession(authOptions); if (!session?.user?.id) { - return { - ok: false, - response: NextResponse.json({ error: "Non autorisé" }, { status: 401 }), - }; + return { ok: false, response: await apiError("unauthorized", 401) }; } return { ok: true, session }; } @@ -37,20 +36,14 @@ export async function requireApiAccountReady(): Promise> if (!auth.ok) return auth; if (!auth.profile.isAdmin) { - return { - ok: false, - response: NextResponse.json({ error: "Accès admin requis" }, { status: 403 }), - }; + return { ok: false, response: await apiError("adminRequired", 403) }; } return auth; diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index ca80a65..f62b103 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -7,17 +7,23 @@ type ApiRequestOptions = Omit & { json?: unknown; /** Message affiché si l'API ne renvoie pas de champ `error`. */ fallbackError?: string; + /** Message si `fetch` échoue (réseau). */ + networkError?: string; }; -const NETWORK_ERROR = "Connexion impossible, réessaie"; -const DEFAULT_ERROR = "Une erreur est survenue"; - /** * `fetch` côté client qui ne lève jamais : renvoie les données ou un message d'erreur affichable. + * Les messages de repli doivent être déjà traduits (voir `apiClient.*` dans les catalogues). */ export async function apiRequest( url: string, - { json, fallbackError = DEFAULT_ERROR, headers, ...init }: ApiRequestOptions = {} + { + json, + fallbackError = "Une erreur est survenue", + networkError = "Connexion impossible, réessaie", + headers, + ...init + }: ApiRequestOptions = {} ): Promise> { let res: Response; try { @@ -28,7 +34,7 @@ export async function apiRequest( body: json === undefined ? undefined : JSON.stringify(json), }); } catch { - return { ok: false, error: NETWORK_ERROR, status: 0 }; + return { ok: false, error: networkError, status: 0 }; } const body: unknown = await res.json().catch(() => null); diff --git a/src/lib/api-errors.ts b/src/lib/api-errors.ts new file mode 100644 index 0000000..b22cf49 --- /dev/null +++ b/src/lib/api-errors.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { getTranslations } from "next-intl/server"; +import { MAX_NAME_LENGTH, MIN_PASSWORD_LENGTH } from "@/lib/account"; +import type { ErrorKey } from "@/lib/error-keys"; +import { MAX_CARD_LABEL_LENGTH } from "@/lib/schemas/card"; +import { MAX_PHRASE_LENGTH } from "@/lib/schemas/phrase"; +import { MAX_TEAM_NAME_LENGTH } from "@/lib/schemas/team"; + +/** Bornes interpolées dans les messages (ignorées par ceux qui ne les utilisent pas). */ +const LIMIT_VALUES = { + maxName: MAX_NAME_LENGTH, + minPassword: MIN_PASSWORD_LENGTH, + maxLabel: MAX_CARD_LABEL_LENGTH, + maxPhrase: MAX_PHRASE_LENGTH, + maxTeamName: MAX_TEAM_NAME_LENGTH, +}; + +type ApiErrorOptions = { + /** Valeurs interpolées en plus des bornes. */ + values?: Record; + /** Code stable, indépendant de la langue, pour les clients qui doivent réagir à l'erreur. */ + code?: string; +}; + +/** Réponse `{ error }` traduite dans la langue de la requête. */ +export async function apiError(key: ErrorKey, status: number, { values, code }: ApiErrorOptions = {}) { + const t = await getTranslations("errors"); + const error = t(key, { ...LIMIT_VALUES, ...values }); + return NextResponse.json(code ? { error, code } : { error }, { status }); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 531e671..1f2b654 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,6 +3,7 @@ import { PrismaAdapter } from "@auth/prisma-adapter"; import CredentialsProvider from "next-auth/providers/credentials"; import { compare } from "bcryptjs"; import { prisma } from "./prisma"; +import { isLocale, toLocale } from "@/i18n/config"; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(prisma) as NextAuthOptions["adapter"], @@ -41,6 +42,7 @@ export const authOptions: NextAuthOptions = { name: user.name, image: user.image, mustChangePassword: user.mustChangePassword, + locale: toLocale(user.locale), }; } catch (error) { console.error("[auth] Échec authorize (schéma BDD à jour ? npm run db:push) :", error); @@ -55,6 +57,7 @@ export const authOptions: NextAuthOptions = { token.id = user.id; token.sub = user.id; token.mustChangePassword = user.mustChangePassword ?? false; + token.locale = user.locale; } // Credentials + JWT : toujours exposer un id stable (sub en secours) @@ -69,6 +72,9 @@ export const authOptions: NextAuthOptions = { if (session.mustChangePassword !== undefined) { token.mustChangePassword = session.mustChangePassword; } + if (isLocale(session.locale)) { + token.locale = session.locale; + } } return token; @@ -80,6 +86,9 @@ export const authOptions: NextAuthOptions = { session.user.id = userId; } session.user.mustChangePassword = token.mustChangePassword === true; + if (isLocale(token.locale)) { + session.user.locale = token.locale; + } if (token.name) { session.user.name = token.name as string; } diff --git a/src/lib/bingo.ts b/src/lib/bingo.ts index 068544a..cdda2b1 100644 --- a/src/lib/bingo.ts +++ b/src/lib/bingo.ts @@ -89,11 +89,17 @@ export function getCheckedPositions( return positions; } -/** Human-readable (French) label for a winning pattern. */ -export function getPatternLabel(pattern: BingoPattern): string { - if (pattern.type === "row") return `Ligne ${pattern.index + 1}`; - if (pattern.type === "column") return `Colonne ${pattern.index + 1}`; - return pattern.index === 0 ? "Diag ↘" : "Diag ↗"; +export type PatternMessage = { + key: "row" | "column" | "diagonalMain" | "diagonalAnti"; + /** 1-based row / column number. */ + n: number; +}; + +/** Message key (under `play.patterns`) and number describing a winning pattern. */ +export function getPatternMessage(pattern: BingoPattern): PatternMessage { + const n = pattern.index + 1; + if (pattern.type === "row" || pattern.type === "column") return { key: pattern.type, n }; + return { key: pattern.index === 0 ? "diagonalMain" : "diagonalAnti", n }; } /** diff --git a/src/lib/error-keys.ts b/src/lib/error-keys.ts new file mode 100644 index 0000000..aa719c8 --- /dev/null +++ b/src/lib/error-keys.ts @@ -0,0 +1,11 @@ +import catalog from "@/i18n/messages/fr.json"; + +/** Clé d'un message `errors.*` : les schémas et la logique serveur renvoient des clés, traduites à la réponse. */ +export type ErrorKey = keyof (typeof catalog)["errors"]; + +export function isErrorKey(value: unknown): value is ErrorKey { + return typeof value === "string" && Object.hasOwn(catalog.errors, value); +} + +/** Identité typée : garantit qu'un message Zod est une clé existante. */ +export const errorKey = (key: ErrorKey) => key; diff --git a/src/lib/phrase-emojis.ts b/src/lib/phrase-emojis.ts index 9a7b05f..7a1fdac 100644 --- a/src/lib/phrase-emojis.ts +++ b/src/lib/phrase-emojis.ts @@ -1,6 +1,17 @@ +export type EmojiCategoryId = + | "smileys" + | "gestures" + | "people" + | "animals" + | "food" + | "travel" + | "activities" + | "objects" + | "symbols"; + +/** Le libellé de chaque catégorie est traduit sous `emojiPicker.categories.`. */ export type EmojiCategory = { - id: string; - label: string; + id: EmojiCategoryId; icon: string; emojis: string[]; }; @@ -9,7 +20,6 @@ export type EmojiCategory = { export const EMOJI_CATEGORIES: EmojiCategory[] = [ { id: "smileys", - label: "Smileys", icon: "😀", emojis: [ "😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", @@ -28,7 +38,6 @@ export const EMOJI_CATEGORIES: EmojiCategory[] = [ }, { id: "gestures", - label: "Gestes", icon: "👍", emojis: [ "👋", "🤚", "🖐️", "✋", "🖖", "👌", "🤌", "🤏", "✌️", "🤞", @@ -41,7 +50,6 @@ export const EMOJI_CATEGORIES: EmojiCategory[] = [ }, { id: "people", - label: "Personnes", icon: "👤", emojis: [ "👶", "🧒", "👦", "👧", "🧑", "👱", "👨", "🧔", "👩", "🧓", @@ -56,7 +64,6 @@ export const EMOJI_CATEGORIES: EmojiCategory[] = [ }, { id: "animals", - label: "Animaux", icon: "🐶", emojis: [ "🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐻‍❄️", "🐨", @@ -83,7 +90,6 @@ export const EMOJI_CATEGORIES: EmojiCategory[] = [ }, { id: "food", - label: "Nourriture", icon: "🍕", emojis: [ "🍏", "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", @@ -103,7 +109,6 @@ export const EMOJI_CATEGORIES: EmojiCategory[] = [ }, { id: "travel", - label: "Voyage", icon: "✈️", emojis: [ "🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐", @@ -122,7 +127,6 @@ export const EMOJI_CATEGORIES: EmojiCategory[] = [ }, { id: "activities", - label: "Activités", icon: "⚽", emojis: [ "⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🥏", "🎱", @@ -138,7 +142,6 @@ export const EMOJI_CATEGORIES: EmojiCategory[] = [ }, { id: "objects", - label: "Objets", icon: "💡", emojis: [ "⌚", "📱", "📲", "💻", "⌨️", "🖥️", "🖨️", "🖱️", "🖲️", "🕹️", @@ -167,7 +170,6 @@ export const EMOJI_CATEGORIES: EmojiCategory[] = [ }, { id: "symbols", - label: "Symboles", icon: "💯", emojis: [ "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", diff --git a/src/lib/schemas/account.ts b/src/lib/schemas/account.ts index 9155e77..e765cf2 100644 --- a/src/lib/schemas/account.ts +++ b/src/lib/schemas/account.ts @@ -1,23 +1,28 @@ import { z } from "zod"; import { MAX_NAME_LENGTH, MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH } from "@/lib/account"; +import { errorKey as msg } from "@/lib/error-keys"; +import { LOCALES } from "@/i18n/config"; export const updateAccountSchema = z .object({ name: z .string() .trim() - .min(1, "Le prénom est requis") - .max(MAX_NAME_LENGTH, `Le prénom doit faire entre 1 et ${MAX_NAME_LENGTH} caractères`) + .min(1, msg("nameRequired")) + .max(MAX_NAME_LENGTH, msg("nameLength")) .optional(), currentPassword: z.string().optional(), newPassword: z .string() - .min(MIN_PASSWORD_LENGTH, `Le mot de passe doit contenir au moins ${MIN_PASSWORD_LENGTH} caractères`) - .max(MAX_PASSWORD_LENGTH, "Mot de passe trop long") + .min(MIN_PASSWORD_LENGTH, msg("passwordTooShort")) + .max(MAX_PASSWORD_LENGTH, msg("passwordTooLong")) .optional(), + locale: z.enum(LOCALES, { error: msg("localeInvalid") }).optional(), }) - .refine((data) => data.name !== undefined || data.newPassword !== undefined, { - message: "Aucune modification demandée", - }); + .refine( + (data) => + data.name !== undefined || data.newPassword !== undefined || data.locale !== undefined, + { message: msg("noChanges") } + ); export type UpdateAccountInput = z.infer; diff --git a/src/lib/schemas/admin.ts b/src/lib/schemas/admin.ts index 0332621..8a076a4 100644 --- a/src/lib/schemas/admin.ts +++ b/src/lib/schemas/admin.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { errorKey as msg } from "@/lib/error-keys"; export const createInviteSchema = z.object({ note: z.string().trim().max(100).optional().nullable(), @@ -6,5 +7,5 @@ export const createInviteSchema = z.object({ }); export const deleteInviteSchema = z.object({ - id: z.string({ error: "ID requis" }).min(1, "ID requis"), + id: z.string({ error: msg("idRequired") }).min(1, msg("idRequired")), }); diff --git a/src/lib/schemas/auth.ts b/src/lib/schemas/auth.ts index ccc36b0..f9344b7 100644 --- a/src/lib/schemas/auth.ts +++ b/src/lib/schemas/auth.ts @@ -1,23 +1,24 @@ import { z } from "zod"; import { MAX_NAME_LENGTH, MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH } from "@/lib/account"; +import { errorKey as msg } from "@/lib/error-keys"; export const registerSchema = z.object({ name: z - .string({ error: "Le prénom est requis" }) + .string({ error: msg("nameRequired") }) .trim() - .min(1, "Le prénom est requis") - .max(MAX_NAME_LENGTH, `Le prénom doit faire entre 1 et ${MAX_NAME_LENGTH} caractères`), + .min(1, msg("nameRequired")) + .max(MAX_NAME_LENGTH, msg("nameLength")), email: z - .string({ error: "Email invalide" }) + .string({ error: msg("emailInvalid") }) .trim() - .email("Format d'email invalide") - .max(254, "Email invalide") + .email(msg("emailFormat")) + .max(254, msg("emailInvalid")) .transform((v) => v.toLowerCase()), password: z - .string({ error: "Le mot de passe est requis" }) - .min(MIN_PASSWORD_LENGTH, `Le mot de passe doit contenir au moins ${MIN_PASSWORD_LENGTH} caractères`) - .max(MAX_PASSWORD_LENGTH, "Mot de passe trop long"), - inviteToken: z.string({ error: "Lien d'invitation requis" }).min(1, "Lien d'invitation requis"), + .string({ error: msg("passwordRequired") }) + .min(MIN_PASSWORD_LENGTH, msg("passwordTooShort")) + .max(MAX_PASSWORD_LENGTH, msg("passwordTooLong")), + inviteToken: z.string({ error: msg("inviteRequired") }).min(1, msg("inviteRequired")), }); export type RegisterInput = z.infer; diff --git a/src/lib/schemas/card.ts b/src/lib/schemas/card.ts index f1e1bd3..bdba9da 100644 --- a/src/lib/schemas/card.ts +++ b/src/lib/schemas/card.ts @@ -1,24 +1,27 @@ import { z } from "zod"; +import { errorKey as msg } from "@/lib/error-keys"; + +export const MAX_CARD_LABEL_LENGTH = 150; export const createCardSchema = z.object({ label: z - .string({ error: "Label requis" }) + .string({ error: msg("labelRequired") }) .trim() - .min(1, "Label requis") - .max(150, "Label trop long (max 150 caractères)"), + .min(1, msg("labelRequired")) + .max(MAX_CARD_LABEL_LENGTH, msg("labelTooLong")), rows: z.coerce.number().int().min(2).max(10), cols: z.coerce.number().int().min(2).max(10), freeCenter: z.boolean().optional().default(true), - phraseIds: z.array(z.string().min(1)).min(1, "Phrases requises"), + phraseIds: z.array(z.string().min(1)).min(1, msg("phrasesRequired")), }); export const updateCardSchema = z.object({ - isActive: z.boolean({ error: "Champ isActive invalide" }), + isActive: z.boolean({ error: msg("invalidData") }), }); export const checkCellSchema = z.object({ - cellId: z.string({ error: "cellId invalide" }).min(1, "cellId invalide"), - checked: z.boolean({ error: "checked invalide" }), + cellId: z.string({ error: msg("invalidData") }).min(1, msg("invalidData")), + checked: z.boolean({ error: msg("invalidData") }), }); export type CreateCardInput = z.infer; diff --git a/src/lib/schemas/parse.ts b/src/lib/schemas/parse.ts index ee323f8..28ecb11 100644 --- a/src/lib/schemas/parse.ts +++ b/src/lib/schemas/parse.ts @@ -1,9 +1,12 @@ -import { NextResponse } from "next/server"; +import type { NextResponse } from "next/server"; import { ZodError, type ZodType } from "zod"; +import { apiError } from "@/lib/api-errors"; +import { isErrorKey } from "@/lib/error-keys"; -export function zodErrorResponse(error: ZodError) { - const message = error.issues[0]?.message ?? "Données invalides"; - return NextResponse.json({ error: message }, { status: 400 }); +/** Les messages des schémas sont des clés `errors.*` ; les messages Zod par défaut deviennent « Données invalides ». */ +export async function zodErrorResponse(error: ZodError) { + const message = error.issues[0]?.message; + return apiError(isErrorKey(message) ? message : "invalidData", 400); } export async function parseJsonBody( @@ -14,15 +17,12 @@ export async function parseJsonBody( try { raw = await req.json(); } catch { - return { - ok: false, - response: NextResponse.json({ error: "JSON invalide" }, { status: 400 }), - }; + return { ok: false, response: await apiError("invalidJson", 400) }; } const parsed = schema.safeParse(raw); if (!parsed.success) { - return { ok: false, response: zodErrorResponse(parsed.error) }; + return { ok: false, response: await zodErrorResponse(parsed.error) }; } return { ok: true, data: parsed.data }; diff --git a/src/lib/schemas/phrase.ts b/src/lib/schemas/phrase.ts index a75d8c3..a5ebdec 100644 --- a/src/lib/schemas/phrase.ts +++ b/src/lib/schemas/phrase.ts @@ -1,30 +1,33 @@ import { z } from "zod"; +import { errorKey as msg } from "@/lib/error-keys"; + +export const MAX_PHRASE_LENGTH = 200; + +const phraseText = z + .string({ error: msg("phraseTextRequired") }) + .trim() + .min(1, msg("phraseTextRequired")) + .max(MAX_PHRASE_LENGTH, msg("phraseTextTooLong")); + +const phraseId = z.string({ error: msg("idRequired") }).min(1, msg("idRequired")); export const createPhraseSchema = z.object({ - text: z - .string({ error: "Texte requis" }) - .trim() - .min(1, "Texte requis") - .max(200, "Texte trop long (max 200 caractères)"), + text: phraseText, emoji: z.preprocess( (val) => (val === null || val === "" ? undefined : val), - z.string().trim().max(16, "Emoji invalide").optional() + z.string().trim().max(16, msg("emojiInvalid")).optional() ), }); export const deletePhraseSchema = z.object({ - phraseId: z.string({ error: "ID requis" }).min(1, "ID requis"), + phraseId, }); export const updatePhraseSchema = z.object({ - phraseId: z.string({ error: "ID requis" }).min(1, "ID requis"), - text: z - .string({ error: "Texte requis" }) - .trim() - .min(1, "Texte requis") - .max(200, "Texte trop long (max 200 caractères)"), + phraseId, + text: phraseText, emoji: z.preprocess( (val) => (val === null || val === "" ? null : val), - z.union([z.string().trim().max(16, "Emoji invalide"), z.null()]).optional() + z.union([z.string().trim().max(16, msg("emojiInvalid")), z.null()]).optional() ), }); diff --git a/src/lib/schemas/team.ts b/src/lib/schemas/team.ts index fca1c07..10381fe 100644 --- a/src/lib/schemas/team.ts +++ b/src/lib/schemas/team.ts @@ -1,20 +1,23 @@ import { z } from "zod"; import { TEAM_ROLES } from "@/lib/team-roles"; +import { errorKey as msg } from "@/lib/error-keys"; + +export const MAX_TEAM_NAME_LENGTH = 100; export const createTeamSchema = z.object({ name: z - .string({ error: "Nom requis" }) + .string({ error: msg("teamNameRequired") }) .trim() - .min(1, "Nom requis") - .max(100, "Nom trop long (max 100 caractères)"), + .min(1, msg("teamNameRequired")) + .max(MAX_TEAM_NAME_LENGTH, msg("teamNameTooLong")), }); export const joinTeamSchema = z.object({ - inviteCode: z.string({ error: "Code requis" }).trim().min(1, "Code requis"), + inviteCode: z.string({ error: msg("inviteCodeRequired") }).trim().min(1, msg("inviteCodeRequired")), }); export const updateMemberRoleSchema = z.object({ - role: z.enum(TEAM_ROLES, { error: "Rôle invalide" }), + role: z.enum(TEAM_ROLES, { error: msg("roleInvalid") }), }); export type CreateTeamInput = z.infer; diff --git a/src/lib/team-roles.ts b/src/lib/team-roles.ts index 352c9df..4162fdd 100644 --- a/src/lib/team-roles.ts +++ b/src/lib/team-roles.ts @@ -3,12 +3,6 @@ export const TEAM_ROLES = ["MEMBER", "ADMIN", "OWNER"] as const; export type TeamRole = (typeof TEAM_ROLES)[number]; -export const ROLE_LABELS: Record = { - OWNER: "Propriétaire", - ADMIN: "Admin", - MEMBER: "Membre", -}; - export function isTeamRole(value: unknown): value is TeamRole { return typeof value === "string" && (TEAM_ROLES as readonly string[]).includes(value); } diff --git a/src/test-utils/i18n.tsx b/src/test-utils/i18n.tsx new file mode 100644 index 0000000..86c5d81 --- /dev/null +++ b/src/test-utils/i18n.tsx @@ -0,0 +1,12 @@ +import { render, type RenderOptions } from "@testing-library/react"; +import { NextIntlClientProvider } from "next-intl"; + +type Options = Omit; + +/** + * Rend un composant sous le provider i18n. + * En tests DOM, `next-intl` est mocké (messages FR) — le provider est un no-op. + */ +export function renderWithIntl(ui: React.ReactElement, options?: Options) { + return render({ui}, options); +} diff --git a/src/test-utils/next-intl-mock.tsx b/src/test-utils/next-intl-mock.tsx new file mode 100644 index 0000000..b7e2ec0 --- /dev/null +++ b/src/test-utils/next-intl-mock.tsx @@ -0,0 +1,58 @@ +import fr from "@/i18n/messages/fr.json"; + +type Messages = typeof fr; + +function lookup(messages: Messages, namespace: string | undefined, key: string): string { + const path = namespace ? `${namespace}.${key}` : key; + const parts = path.split("."); + let current: unknown = messages; + for (const part of parts) { + if (!current || typeof current !== "object" || !(part in current)) { + return path; + } + current = (current as Record)[part]; + } + return typeof current === "string" ? current : path; +} + +/** Remplace les pluriels ICU les plus simples utilisés dans les catalogues. */ +function formatMessage(template: string, values?: Record): string { + if (!values) return template; + + let result = template.replace( + /\{(\w+),\s*plural,\s*one\s*\{([^}]*)\}\s*other\s*\{([^}]*)\}\}/g, + (_match, name: string, one: string, other: string) => { + const count = Number(values[name] ?? 0); + const branch = count === 1 ? one : other; + return branch.replace(/#/g, String(count)); + } + ); + + for (const [name, value] of Object.entries(values)) { + result = result.replaceAll(`{${name}}`, String(value)); + } + return result; +} + +export function createTranslator(namespace?: string) { + return (key: string, values?: Record) => + formatMessage(lookup(fr, namespace, key), values); +} + +export const NextIntlClientProvider = ({ children }: { children: React.ReactNode }) => children; + +export function useTranslations(namespace?: string) { + return createTranslator(namespace); +} + +export function useLocale() { + return "fr"; +} + +export function useFormatter() { + return { + dateTime(date: Date, options?: Intl.DateTimeFormatOptions) { + return new Intl.DateTimeFormat("fr-FR", options).format(date); + }, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 67ec4fe..b324980 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ import type { AccountProfile } from "@/lib/account"; +import type { AppLocale } from "@/i18n/config"; export interface BingoPattern { type: "row" | "column" | "diagonal"; @@ -57,11 +58,13 @@ declare module "next-auth" { email?: string | null; image?: string | null; mustChangePassword?: boolean; + locale?: AppLocale; }; } interface User { mustChangePassword?: boolean; + locale?: AppLocale; } } @@ -69,5 +72,6 @@ declare module "next-auth/jwt" { interface JWT { id: string; mustChangePassword?: boolean; + locale?: AppLocale; } }