From 7edddad2cf55cdb95406d2df33a888a0982e4baa Mon Sep 17 00:00:00 2001 From: mcinquin Date: Thu, 5 Nov 2026 11:30:39 +0100 Subject: [PATCH] refactor: full refactor --- jest.config.ts | 25 +- jest.setup.dom.ts | 1 + package-lock.json | 796 ++++++++++++++++++ package.json | 5 + server.js | 72 +- src/app/account/layout.tsx | 11 +- src/app/account/page.tsx | 9 +- src/app/admin/layout.tsx | 15 +- src/app/admin/page.tsx | 9 +- src/app/api/admin/invites/route.ts | 8 +- src/app/api/admin/me/route.ts | 15 - src/app/api/admin/users/route.ts | 21 +- src/app/api/cards/[cardId]/check/route.ts | 37 +- src/app/api/cards/[cardId]/route.ts | 37 +- src/app/api/teams/[teamId]/cards/route.ts | 104 +-- src/app/api/teams/[teamId]/phrases/route.ts | 11 +- src/app/api/teams/[teamId]/route.ts | 21 +- src/app/api/teams/route.ts | 20 +- src/app/auth/signin/page.tsx | 41 +- src/app/auth/signup/page.tsx | 137 +-- src/app/dashboard/layout.tsx | 4 +- src/app/dashboard/page.tsx | 13 +- .../dashboard/teams/[teamId]/create/page.tsx | 663 +-------------- src/app/dashboard/teams/[teamId]/page.tsx | 273 +----- src/app/play/[cardId]/page.tsx | 579 +------------ .../__tests__/account-settings-form.test.tsx | 84 ++ .../account-settings-form.tsx | 164 ++-- src/components/admin/admin-client.tsx | 48 +- .../auth/__tests__/form-feedback.test.tsx | 21 + .../auth/__tests__/signup-form.test.tsx | 73 ++ src/components/auth/form-feedback.tsx | 25 + src/components/auth/signup-form.tsx | 132 +++ .../__tests__/emoji-picker.test.tsx | 80 ++ .../__tests__/grid-size-input.test.tsx | 46 + .../__tests__/phrase-chip.test.tsx | 46 + .../create-card/create-card-client.tsx | 410 +++++++++ src/components/create-card/emoji-picker.tsx | 123 +++ .../create-card/grid-size-input.tsx | 58 ++ src/components/create-card/phrase-chip.tsx | 73 ++ src/components/dashboard/dashboard-client.tsx | 48 +- src/components/navbar/navbar.tsx | 14 +- .../play/__tests__/bingo-cell.test.tsx | 63 ++ .../play/__tests__/card-state.test.ts | 66 ++ src/components/play/bingo-cell.tsx | 118 +++ src/components/play/card-state.ts | 47 ++ src/components/play/play-client.tsx | 317 +++++++ src/components/play/use-window-size.ts | 13 + .../team/__tests__/team-client.test.tsx | 100 +++ src/components/team/team-client.tsx | 259 ++++++ src/lib/__tests__/api-client.test.ts | 51 ++ src/lib/__tests__/authz.test.ts | 52 -- src/lib/__tests__/bingo-helpers.test.ts | 40 + src/lib/account-guard.ts | 27 +- src/lib/admin.ts | 12 - src/lib/api-auth.ts | 19 +- src/lib/api-client.ts | 45 + src/lib/authz-rules.ts | 13 + src/lib/authz.ts | 29 +- src/lib/bingo.ts | 31 + src/lib/data.ts | 109 ++- src/lib/realtime.ts | 13 + src/lib/schemas/parse.ts | 4 +- src/test-utils/mocks.ts | 34 + src/types/index.ts | 65 +- 64 files changed, 3696 insertions(+), 2203 deletions(-) create mode 100644 jest.setup.dom.ts delete mode 100644 src/app/api/admin/me/route.ts create mode 100644 src/components/account-settings-form/__tests__/account-settings-form.test.tsx create mode 100644 src/components/auth/__tests__/form-feedback.test.tsx create mode 100644 src/components/auth/__tests__/signup-form.test.tsx create mode 100644 src/components/auth/form-feedback.tsx create mode 100644 src/components/auth/signup-form.tsx create mode 100644 src/components/create-card/__tests__/emoji-picker.test.tsx create mode 100644 src/components/create-card/__tests__/grid-size-input.test.tsx create mode 100644 src/components/create-card/__tests__/phrase-chip.test.tsx create mode 100644 src/components/create-card/create-card-client.tsx create mode 100644 src/components/create-card/emoji-picker.tsx create mode 100644 src/components/create-card/grid-size-input.tsx create mode 100644 src/components/create-card/phrase-chip.tsx create mode 100644 src/components/play/__tests__/bingo-cell.test.tsx create mode 100644 src/components/play/__tests__/card-state.test.ts create mode 100644 src/components/play/bingo-cell.tsx create mode 100644 src/components/play/card-state.ts create mode 100644 src/components/play/play-client.tsx create mode 100644 src/components/play/use-window-size.ts create mode 100644 src/components/team/__tests__/team-client.test.tsx create mode 100644 src/components/team/team-client.tsx create mode 100644 src/lib/__tests__/api-client.test.ts create mode 100644 src/lib/__tests__/bingo-helpers.test.ts delete mode 100644 src/lib/admin.ts create mode 100644 src/lib/api-client.ts create mode 100644 src/lib/authz-rules.ts create mode 100644 src/lib/realtime.ts create mode 100644 src/test-utils/mocks.ts diff --git a/jest.config.ts b/jest.config.ts index bc52c85..7bcef53 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,13 +1,30 @@ -/** @type {import('jest').Config} */ -const config = { +import type { Config } from "jest"; + +const shared = { preset: "ts-jest", - testEnvironment: "node", roots: ["/src"], - testMatch: ["**/__tests__/**/*.test.ts"], moduleNameMapper: { "^@/(.*)$": "/src/$1", }, clearMocks: true, +} satisfies Config; + +const config: Config = { + projects: [ + { + ...shared, + displayName: "node", + testEnvironment: "node", + testMatch: ["**/__tests__/**/*.test.ts"], + }, + { + ...shared, + displayName: "dom", + testEnvironment: "jsdom", + testMatch: ["**/__tests__/**/*.test.tsx"], + setupFilesAfterEnv: ["/jest.setup.dom.ts"], + }, + ], }; export default config; diff --git a/jest.setup.dom.ts b/jest.setup.dom.ts new file mode 100644 index 0000000..d0de870 --- /dev/null +++ b/jest.setup.dom.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/package-lock.json b/package-lock.json index 6b2064a..c73ba41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,10 @@ "@semantic-release/git": "^11.0.1", "@semantic-release/github": "^12.0.10", "@tailwindcss/postcss": "^4.3.3", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", "@types/jest": "^30.0.0", "@types/node": "^26.6.6", "@types/pg": "^8.23.1", @@ -42,6 +46,7 @@ "eslint-config-next": "16.4.3", "husky": "^9.1.7", "jest": "^30.5.2", + "jest-environment-jsdom": "^30.5.2", "lint-staged": "^17.6.0", "markdownlint-cli2": "^0.24.0", "postcss": "8.5.32", @@ -95,6 +100,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -108,6 +120,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@auth/prisma-adapter": { "version": "2.11.3", "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.3.tgz", @@ -1099,6 +1132,121 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@electric-sql/pglite": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", @@ -2277,6 +2425,34 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.5.2.tgz", + "integrity": "sha512-8/P+8hKv7L4mhFoB6gpYLj/fuzXk8PWrde7TDHh8tJz5pE1ktlMbg0y6hDY/V8MfAnispFlPl/vPifowLy3JGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.2", + "@jest/fake-timers": "30.5.2", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-mock": "30.5.2", + "jest-util": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/jsdom": "*", + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/@jest/expect": { "version": "30.5.2", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.5.2.tgz", @@ -4787,6 +4963,149 @@ "tailwindcss": "4.3.3" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -4826,6 +5145,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5077,6 +5403,44 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@types/jsdom/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -5166,6 +5530,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", @@ -7418,6 +7789,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -7570,6 +7962,20 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -7641,6 +8047,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -7857,6 +8270,13 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -9917,6 +10337,19 @@ "node": "20 || >=22" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -10531,6 +10964,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -11408,6 +11848,30 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-environment-jsdom": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.5.2.tgz", + "integrity": "sha512-6NOZjEP+WNi4WXjbxb/1ROnfaMxn6Dhqu6uSC22o0H28z+uppejs/jZ9KCVoLepqqoCjcNZujtzQqC9jSV15jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.2", + "@jest/environment-jsdom-abstract": "30.5.2", + "@types/jsdom": "^21.1.7", + "jsdom": "^26.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jest-environment-node": { "version": "30.5.2", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.5.2.tgz", @@ -12150,6 +12614,110 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -12825,6 +13393,16 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -13785,6 +14363,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -16095,6 +16683,13 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/oauth": { "version": "0.9.15", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", @@ -17563,6 +18158,30 @@ "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", "license": "MIT" }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redent/node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -17755,6 +18374,13 @@ "devOptional": true, "license": "Unlicense" }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -17880,6 +18506,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -18841,6 +19480,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -18938,6 +19590,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", @@ -19257,6 +19916,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -19270,6 +19949,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/traverse": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", @@ -19888,6 +20593,19 @@ "node": ">= 0.8" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-worker": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", @@ -19895,6 +20613,67 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -20105,6 +20884,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", diff --git a/package.json b/package.json index 4501b62..21e8d1a 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,10 @@ "@semantic-release/git": "^11.0.1", "@semantic-release/github": "^12.0.10", "@tailwindcss/postcss": "^4.3.3", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", "@types/jest": "^30.0.0", "@types/node": "^26.6.6", "@types/pg": "^8.23.1", @@ -69,6 +73,7 @@ "eslint-config-next": "16.4.3", "husky": "^9.1.7", "jest": "^30.5.2", + "jest-environment-jsdom": "^30.5.2", "lint-staged": "^17.6.0", "markdownlint-cli2": "^0.24.0", "postcss": "8.5.32", diff --git a/server.js b/server.js index d00413b..74a9265 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,8 @@ const { createServer } = require("http"); const next = require("next"); const { Server } = require("socket.io"); const { getToken } = require("next-auth/jwt"); +const { PrismaClient } = require("@prisma/client"); +const { PrismaPg } = require("@prisma/adapter-pg"); const dev = process.env.NODE_ENV !== "production"; const hostname = process.env.HOSTNAME || "0.0.0.0"; @@ -10,7 +12,27 @@ const port = parseInt(process.env.PORT || "3000", 10); const app = next({ dev, hostname, port }); const handle = app.getRequestHandler(); +const roomName = (cardId) => `card:${cardId}`; + app.prepare().then(() => { + // Créé après prepare() : Next a chargé .env dans process.env + const prisma = new PrismaClient({ + adapter: new PrismaPg(process.env.DATABASE_URL), + log: ["warn", "error"], + }); + + /** Membre de l'équipe de la grille, ou admin (même règle que GET /api/cards/:id). */ + async function canAccessCard(userId, cardId) { + const [membership, user] = await Promise.all([ + prisma.teamMember.findFirst({ + where: { userId, team: { cards: { some: { id: cardId } } } }, + select: { id: true }, + }), + prisma.user.findUnique({ where: { id: userId }, select: { isAdmin: true } }), + ]); + return membership !== null || user?.isAdmin === true; + } + const httpServer = createServer(); // 1. Socket.io BEFORE Next.js to intercept /api/socketio requests first @@ -52,40 +74,56 @@ app.prepare().then(() => { } }); + /** cardId → Map des joueurs présents. */ const cardRooms = new Map(); + function broadcastMembers(cardId) { + const members = cardRooms.get(cardId); + io.to(roomName(cardId)).emit("members-updated", members ? Array.from(members.values()) : []); + } + + function leaveCard(socket, cardId) { + const members = cardRooms.get(cardId); + if (!members?.delete(socket.id)) return; + if (members.size === 0) cardRooms.delete(cardId); + socket.leave(roomName(cardId)); + broadcastMembers(cardId); + } + io.on("connection", (socket) => { const { userId, userName } = socket.data; - socket.on("join-card", ({ cardId }) => { + socket.on("join-card", async ({ cardId } = {}) => { if (!cardId || typeof cardId !== "string") return; - socket.join(`card:${cardId}`); + try { + if (!(await canAccessCard(userId, cardId))) return; + } catch (err) { + console.error("[socket] join-card", err); + return; + } + + socket.join(roomName(cardId)); if (!cardRooms.has(cardId)) cardRooms.set(cardId, new Map()); cardRooms.get(cardId).set(socket.id, { id: userId, name: userName }); - - const members = Array.from(cardRooms.get(cardId).values()); - io.to(`card:${cardId}`).emit("members-updated", members); + broadcastMembers(cardId); }); - socket.on("check-cell", ({ cardId, cellId, checked }) => { - if (!cardId || !cellId || typeof checked !== "boolean") return; - // Le nom vient du serveur, pas du client - socket.to(`card:${cardId}`).emit("cell-updated", { cellId, checked, userName }); + socket.on("leave-card", ({ cardId } = {}) => { + if (typeof cardId === "string") leaveCard(socket, cardId); }); - socket.on("bingo", ({ cardId, pattern }) => { - if (!cardId) return; + // Les cases cochées sont diffusées par POST /api/cards/:id/check, après écriture en BDD. + + socket.on("bingo", ({ cardId } = {}) => { + if (typeof cardId !== "string" || !socket.rooms.has(roomName(cardId))) return; // Aux autres joueurs seulement — le local a déjà fêté au clic - socket.to(`card:${cardId}`).emit("bingo-achieved", { userName, pattern }); + socket.to(roomName(cardId)).emit("bingo-achieved", { userName }); }); socket.on("disconnect", () => { - for (const [cardId, members] of cardRooms.entries()) { - if (members.has(socket.id)) { - members.delete(socket.id); - io.to(`card:${cardId}`).emit("members-updated", Array.from(members.values())); - } + for (const cardId of Array.from(cardRooms.keys())) { + leaveCard(socket, cardId); } }); }); diff --git a/src/app/account/layout.tsx b/src/app/account/layout.tsx index 7e6b1f0..5a0359a 100644 --- a/src/app/account/layout.tsx +++ b/src/app/account/layout.tsx @@ -1,14 +1,15 @@ -import { getAccountProfile } from "@/lib/account"; -import { requireSession } from "@/lib/account-guard"; +import { getSessionProfile } from "@/lib/account-guard"; import { Navbar } from "@/components/navbar/navbar"; export default async function AccountLayout({ children }: { children: React.ReactNode }) { - const session = await requireSession(); - const profile = await getAccountProfile(session.user.id); + const profile = await getSessionProfile(); return (
- +
{children}
); diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index 68e3e55..eb01fb2 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -1,5 +1,10 @@ +import { redirect } from "next/navigation"; +import { getSessionProfile } from "@/lib/account-guard"; import { AccountSettingsForm } from "@/components/account-settings-form/account-settings-form"; -export default function AccountPage() { - return ; +export default async function AccountPage() { + const profile = await getSessionProfile(); + if (!profile) redirect("/auth/signin"); + + return ; } diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index e6b9785..4f53898 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -1,21 +1,12 @@ -import { redirect } from "next/navigation"; -import { prisma } from "@/lib/prisma"; import { Navbar } from "@/components/navbar/navbar"; -import { requireAccountReady } from "@/lib/account-guard"; +import { requireAdmin } from "@/lib/account-guard"; export default async function AdminLayout({ children }: { children: React.ReactNode }) { - const { session } = await requireAccountReady(); - - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { isAdmin: true }, - }); - - if (!user?.isAdmin) redirect("/dashboard"); + await requireAdmin(); return (
- +
{children}
); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 20eedc9..ce8acdf 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,15 +1,12 @@ -import { requireAccountReady } from "@/lib/account-guard"; +import { requireAdmin } from "@/lib/account-guard"; import { getAdminInvites, getAdminUsers } from "@/lib/data"; import { AdminClient } from "@/components/admin/admin-client"; export default async function AdminPage() { - await requireAccountReady(); + await requireAdmin(); const [users, invites] = await Promise.all([getAdminUsers(), getAdminInvites()]); return ( - + ); } diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts index 6cfb4d0..57a105f 100644 --- a/src/app/api/admin/invites/route.ts +++ b/src/app/api/admin/invites/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { requireApiAdmin } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; +import { getAdminInvites } from "@/lib/data"; import { createInviteSchema, deleteInviteSchema } from "@/lib/schemas/admin"; import { parseJsonBody } from "@/lib/schemas/parse"; @@ -8,12 +9,7 @@ export async function GET() { const auth = await requireApiAdmin(); if (!auth.ok) return auth.response; - const invites = await prisma.inviteToken.findMany({ - orderBy: { expiresAt: "desc" }, - include: { createdBy: { select: { name: true, email: true } } }, - }); - - return NextResponse.json(invites); + return NextResponse.json(await getAdminInvites()); } export async function POST(req: Request) { diff --git a/src/app/api/admin/me/route.ts b/src/app/api/admin/me/route.ts deleted file mode 100644 index eeded49..0000000 --- a/src/app/api/admin/me/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NextResponse } from "next/server"; -import { requireApiSession } from "@/lib/api-auth"; -import { prisma } from "@/lib/prisma"; - -export async function GET() { - const auth = await requireApiSession(); - if (!auth.ok) return NextResponse.json({ isAdmin: false }); - - const user = await prisma.user.findUnique({ - where: { id: auth.session.user.id }, - select: { isAdmin: true }, - }); - - return NextResponse.json({ isAdmin: user?.isAdmin ?? false }); -} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index a295a25..6ca2219 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -1,27 +1,10 @@ import { NextResponse } from "next/server"; import { requireApiAdmin } from "@/lib/api-auth"; -import { prisma } from "@/lib/prisma"; +import { getAdminUsers } from "@/lib/data"; export async function GET() { const auth = await requireApiAdmin(); if (!auth.ok) return auth.response; - const users = await prisma.user.findMany({ - orderBy: { createdAt: "desc" }, - select: { - id: true, - name: true, - email: true, - isAdmin: true, - createdAt: true, - teams: { - select: { - role: true, - team: { select: { id: true, name: true } }, - }, - }, - }, - }); - - return NextResponse.json(users); + return NextResponse.json(await getAdminUsers()); } diff --git a/src/app/api/cards/[cardId]/check/route.ts b/src/app/api/cards/[cardId]/check/route.ts index 09a6312..e804077 100644 --- a/src/app/api/cards/[cardId]/check/route.ts +++ b/src/app/api/cards/[cardId]/check/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; import { getCardIfMember } from "@/lib/authz"; +import { emitCellUpdated } from "@/lib/realtime"; import { checkCellSchema } from "@/lib/schemas/card"; import { parseJsonBody } from "@/lib/schemas/parse"; @@ -12,8 +13,9 @@ export async function POST(req: Request, { params }: RouteContext) { if (!auth.ok) return auth.response; const { cardId } = await params; + const { user } = auth.session; - const access = await getCardIfMember(auth.session.user.id, cardId); + const access = await getCardIfMember(user.id, cardId); if (!access) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); const parsed = await parseJsonBody(req, checkCellSchema); @@ -23,37 +25,24 @@ export async function POST(req: Request, { params }: RouteContext) { const cell = await prisma.cell.findFirst({ where: { id: cellId, cardId }, + select: { id: true }, }); if (!cell) return NextResponse.json({ error: "Case introuvable" }, { status: 404 }); - if (checked) { - const existing = await prisma.checkedCell.findUnique({ where: { cellId } }); - if (existing) return NextResponse.json(existing); + const userName = user.name || user.email || "?"; - const checkedCell = await prisma.checkedCell.create({ - data: { cellId, userId: auth.session.user.id }, + if (checked) { + // Upsert natif (ON CONFLICT) : deux clics simultanés ne lèvent pas d'erreur d'unicité + const checkedCell = await prisma.checkedCell.upsert({ + where: { cellId }, + create: { cellId, userId: user.id }, + update: {}, }); - - if (global.io) { - global.io.to(`card:${cardId}`).emit("cell-updated", { - cellId, - checked: true, - userName: auth.session.user.name || auth.session.user.email, - }); - } - + emitCellUpdated(cardId, { cellId, checked: true, userName }); return NextResponse.json(checkedCell); } await prisma.checkedCell.deleteMany({ where: { cellId } }); - - if (global.io) { - global.io.to(`card:${cardId}`).emit("cell-updated", { - cellId, - checked: false, - userName: auth.session.user.name || auth.session.user.email, - }); - } - + emitCellUpdated(cardId, { cellId, checked: false, userName }); return NextResponse.json({ ok: true }); } diff --git a/src/app/api/cards/[cardId]/route.ts b/src/app/api/cards/[cardId]/route.ts index 2114708..7d968f0 100644 --- a/src/app/api/cards/[cardId]/route.ts +++ b/src/app/api/cards/[cardId]/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; -import { canDeleteCard, getCardIfMember } from "@/lib/authz"; -import { isAdmin } from "@/lib/admin"; +import { canDeleteOwnedResource, getCardIfMember } from "@/lib/authz"; +import { getPlayCard } from "@/lib/data"; import { updateCardSchema } from "@/lib/schemas/card"; import { parseJsonBody } from "@/lib/schemas/parse"; @@ -13,36 +13,12 @@ export async function GET(_req: Request, { params }: RouteContext) { if (!auth.ok) return auth.response; const { cardId } = await params; - const userId = auth.session.user.id; - const [access, userIsAdmin] = await Promise.all([ - getCardIfMember(userId, cardId), - isAdmin(userId), - ]); - if (!access && !userIsAdmin) { + if (!auth.profile.isAdmin && !(await getCardIfMember(auth.session.user.id, cardId))) { return NextResponse.json({ error: "Introuvable" }, { status: 404 }); } - const card = await prisma.bingoCard.findUnique({ - where: { id: cardId }, - include: { - cells: { - include: { - phrase: true, - checked: { include: { user: { select: { id: true, name: true } } } }, - }, - orderBy: { position: "asc" }, - }, - team: { - include: { - members: { - include: { user: { select: { id: true, name: true, email: true, image: true } } }, - }, - }, - }, - }, - }); - + const card = await getPlayCard(cardId); if (!card) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); return NextResponse.json(card); @@ -76,15 +52,14 @@ export async function DELETE(_req: Request, { params }: RouteContext) { if (!auth.ok) return auth.response; const { cardId } = await params; - const userId = auth.session.user.id; const card = await prisma.bingoCard.findUnique({ where: { id: cardId }, - select: { id: true, createdById: true }, + select: { createdById: true }, }); if (!card) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); - if (!(await canDeleteCard(userId, cardId))) { + if (!canDeleteOwnedResource(auth.session.user.id, card.createdById, auth.profile.isAdmin)) { return NextResponse.json({ error: "Suppression non autorisée" }, { status: 403 }); } diff --git a/src/app/api/teams/[teamId]/cards/route.ts b/src/app/api/teams/[teamId]/cards/route.ts index cb14775..e7d85de 100644 --- a/src/app/api/teams/[teamId]/cards/route.ts +++ b/src/app/api/teams/[teamId]/cards/route.ts @@ -3,11 +3,14 @@ import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; import { shuffleArray, getCenterPosition } from "@/lib/bingo"; import { getTeamMembership } from "@/lib/authz"; +import { getTeamCards, playCardSelect } from "@/lib/data"; import { createCardSchema } from "@/lib/schemas/card"; import { parseJsonBody } from "@/lib/schemas/parse"; type RouteContext = { params: Promise<{ teamId: string }> }; +const FREE_PHRASE_ID = "default-free"; + export async function GET(_req: Request, { params }: RouteContext) { const auth = await requireApiAccountReady(); if (!auth.ok) return auth.response; @@ -17,21 +20,7 @@ export async function GET(_req: Request, { params }: RouteContext) { const membership = await getTeamMembership(auth.session.user.id, teamId); if (!membership) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); - const cards = await prisma.bingoCard.findMany({ - where: { teamId }, - include: { - cells: { - include: { - phrase: true, - checked: { include: { user: { select: { id: true, name: true } } } }, - }, - orderBy: { position: "asc" }, - }, - }, - orderBy: { createdAt: "desc" }, - }); - - return NextResponse.json(cards); + return NextResponse.json(await getTeamCards(teamId)); } export async function POST(req: Request, { params }: RouteContext) { @@ -39,8 +28,9 @@ export async function POST(req: Request, { params }: RouteContext) { if (!auth.ok) return auth.response; const { teamId } = await params; + const userId = auth.session.user.id; - const membership = await getTeamMembership(auth.session.user.id, teamId); + const membership = await getTeamMembership(userId, teamId); if (!membership) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); const parsed = await parseJsonBody(req, createCardSchema); @@ -71,78 +61,36 @@ export async function POST(req: Request, { params }: RouteContext) { return NextResponse.json({ error: "Certaines phrases sont invalides" }, { status: 400 }); } - const validIds = validPhrases.map((p) => p.id); - const shuffled = shuffleArray(validIds).slice(0, neededPhrases); - - // Phrase dédiée à la case centrale FREE (affichée ★ FREE côté client) - const freePhrase = - centerPos !== null - ? await prisma.phrase.upsert({ - where: { id: "default-free" }, - update: {}, - create: { - id: "default-free", - text: "FREE", - emoji: "★", - isDefault: false, - }, - }) - : null; - - const cellsData: { phraseId: string; position: number }[] = []; - let phraseIndex = 0; - for (let pos = 0; pos < totalCells; pos++) { - if (centerPos !== null && freePhrase && pos === centerPos) { - cellsData.push({ phraseId: freePhrase.id, position: pos }); - continue; - } - cellsData.push({ phraseId: shuffled[phraseIndex], position: pos }); - phraseIndex++; + const shuffled = shuffleArray(validPhrases.map((p) => p.id)).slice(0, neededPhrases); + + if (centerPos !== null) { + // Phrase technique de la case FREE (affichée ★ FREE côté client) + await prisma.phrase.upsert({ + where: { id: FREE_PHRASE_ID }, + update: {}, + create: { id: FREE_PHRASE_ID, text: "FREE", emoji: "★", isDefault: false }, + }); } + let phraseIndex = 0; + const cells = Array.from({ length: totalCells }, (_, position) => + position === centerPos + ? { position, phraseId: FREE_PHRASE_ID, checked: { create: { userId } } } + : { position, phraseId: shuffled[phraseIndex++] } + ); + const card = await prisma.bingoCard.create({ data: { teamId, - createdById: auth.session.user.id, + createdById: userId, label, rows, cols, - freeCenter: centerPos !== null ? Boolean(freeCenter) : false, - cells: { create: cellsData }, - }, - include: { - cells: { - include: { - phrase: true, - checked: { include: { user: { select: { id: true, name: true } } } }, - }, - orderBy: { position: "asc" }, - }, + freeCenter: centerPos !== null, + cells: { create: cells }, }, + select: playCardSelect, }); - if (centerPos !== null && freeCenter) { - const freeCell = card.cells.find((cell) => cell.position === centerPos); - if (freeCell) { - await prisma.checkedCell.create({ - data: { cellId: freeCell.id, userId: auth.session.user.id }, - }); - // Recharger pour inclure la case FREE cochée dans la réponse - const refreshed = await prisma.bingoCard.findUnique({ - where: { id: card.id }, - include: { - cells: { - include: { - phrase: true, - checked: { include: { user: { select: { id: true, name: true } } } }, - }, - orderBy: { position: "asc" }, - }, - }, - }); - return NextResponse.json(refreshed, { status: 201 }); - } - } - return NextResponse.json(card, { status: 201 }); } diff --git a/src/app/api/teams/[teamId]/phrases/route.ts b/src/app/api/teams/[teamId]/phrases/route.ts index 2fab44a..6a67db5 100644 --- a/src/app/api/teams/[teamId]/phrases/route.ts +++ b/src/app/api/teams/[teamId]/phrases/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; import { getTeamMembership } from "@/lib/authz"; +import { getTeamPhrases } from "@/lib/data"; import { createPhraseSchema, deletePhraseSchema, @@ -20,15 +21,7 @@ export async function GET(_req: Request, { params }: RouteContext) { const membership = await getTeamMembership(auth.session.user.id, teamId); if (!membership) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); - const [defaults, custom] = await Promise.all([ - prisma.phrase.findMany({ where: { isDefault: true }, orderBy: { text: "asc" } }), - prisma.phrase.findMany({ - where: { teamId, isDefault: false }, - orderBy: { createdAt: "desc" }, - }), - ]); - - return NextResponse.json({ defaults, custom }); + return NextResponse.json(await getTeamPhrases(teamId)); } export async function POST(req: Request, { params }: RouteContext) { diff --git a/src/app/api/teams/[teamId]/route.ts b/src/app/api/teams/[teamId]/route.ts index 6f30154..da0799d 100644 --- a/src/app/api/teams/[teamId]/route.ts +++ b/src/app/api/teams/[teamId]/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; -import { canDeleteTeam, getTeamMembership } from "@/lib/authz"; -import { isAdmin } from "@/lib/admin"; +import { canDeleteOwnedResource, getTeamMembership } from "@/lib/authz"; +import { teamMembersInclude } from "@/lib/data"; type RouteContext = { params: Promise<{ teamId: string }> }; @@ -11,23 +11,15 @@ export async function GET(_req: Request, { params }: RouteContext) { if (!auth.ok) return auth.response; const { teamId } = await params; - const userId = auth.session.user.id; - const [membership, userIsAdmin] = await Promise.all([ - getTeamMembership(userId, teamId), - isAdmin(userId), - ]); - - if (!membership && !userIsAdmin) { + if (!auth.profile.isAdmin && !(await getTeamMembership(auth.session.user.id, teamId))) { return NextResponse.json({ error: "Introuvable" }, { status: 404 }); } const team = await prisma.team.findUnique({ where: { id: teamId }, include: { - members: { - include: { user: { select: { id: true, name: true, email: true, image: true } } }, - }, + ...teamMembersInclude, _count: { select: { cards: true } }, }, }); @@ -42,15 +34,14 @@ export async function DELETE(_req: Request, { params }: RouteContext) { if (!auth.ok) return auth.response; const { teamId } = await params; - const userId = auth.session.user.id; const team = await prisma.team.findUnique({ where: { id: teamId }, - select: { id: true, createdById: true }, + select: { createdById: true }, }); if (!team) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); - if (!(await canDeleteTeam(userId, teamId))) { + if (!canDeleteOwnedResource(auth.session.user.id, team.createdById, auth.profile.isAdmin)) { return NextResponse.json({ error: "Suppression non autorisée" }, { status: 403 }); } diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts index a025a51..d9c116e 100644 --- a/src/app/api/teams/route.ts +++ b/src/app/api/teams/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { requireApiAccountReady } from "@/lib/api-auth"; import { prisma } from "@/lib/prisma"; +import { getUserTeams, teamMembersInclude } from "@/lib/data"; import { createTeamSchema } from "@/lib/schemas/team"; import { parseJsonBody } from "@/lib/schemas/parse"; @@ -8,18 +9,7 @@ export async function GET() { const auth = await requireApiAccountReady(); if (!auth.ok) return auth.response; - const teams = await prisma.team.findMany({ - where: { members: { some: { userId: auth.session.user.id } } }, - include: { - members: { - include: { user: { select: { id: true, name: true, email: true, image: true } } }, - }, - _count: { select: { cards: true } }, - }, - orderBy: { createdAt: "desc" }, - }); - - return NextResponse.json(teams); + return NextResponse.json(await getUserTeams(auth.session.user.id)); } export async function POST(req: Request) { @@ -37,11 +27,7 @@ export async function POST(req: Request) { create: { userId: auth.session.user.id, role: "OWNER" }, }, }, - include: { - members: { - include: { user: { select: { id: true, name: true, email: true, image: true } } }, - }, - }, + include: teamMembersInclude, }); return NextResponse.json(team, { status: 201 }); diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 91e40ec..ab4d718 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -1,13 +1,15 @@ "use client"; -import { useState } from "react"; +import { useId, useState } from "react"; import { signIn } from "next-auth/react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { MarkItLogo } from "@/components/markit-logo/markit-logo"; +import { FormError } from "@/components/auth/form-feedback"; export default function SignInPage() { const router = useRouter(); + const fieldId = useId(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); @@ -25,20 +27,15 @@ export default function SignInPage() { redirect: false, }); - setLoading(false); - - if (result?.error) { + if (!result || result.error) { + setLoading(false); setError("Email ou mot de passe incorrect"); - } else { - const accountRes = await fetch("/api/account"); - if (accountRes.ok) { - const account = await accountRes.json(); - router.push(account.mustChangePassword ? "/account" : "/dashboard"); - } else { - router.push("/dashboard"); - } - router.refresh(); + return; } + + // Le garde serveur redirige vers /account si une configuration est requise + router.push("/dashboard"); + router.refresh(); } return ( @@ -53,21 +50,29 @@ export default function SignInPage() {
- + setEmail(e.target.value)} + autoComplete="email" required />
- +
- {error && ( -
- {error} -
- )} + - - -

- Déjà un compte ?{" "} - - Se connecter - -

-
- - ); -} - -export default function SignUpPage() { - return ( - - - - ); + return ; } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 1768d4b..17967f7 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -2,11 +2,11 @@ import { Navbar } from "@/components/navbar/navbar"; import { requireAccountReady } from "@/lib/account-guard"; export default async function DashboardLayout({ children }: { children: React.ReactNode }) { - await requireAccountReady(); + const { profile } = await requireAccountReady(); return (
- +
{children}
); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 794a4d9..809b97a 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,19 +1,12 @@ import { requireAccountReady } from "@/lib/account-guard"; import { getUserTeams } from "@/lib/data"; -import { isAdmin } from "@/lib/admin"; import { DashboardClient } from "@/components/dashboard/dashboard-client"; export default async function DashboardPage() { - const { session } = await requireAccountReady(); - const [teams, userIsAdmin] = await Promise.all([ - getUserTeams(session.user.id), - isAdmin(session.user.id), - ]); + const { session, profile } = await requireAccountReady(); + const teams = await getUserTeams(session.user.id); return ( - + ); } diff --git a/src/app/dashboard/teams/[teamId]/create/page.tsx b/src/app/dashboard/teams/[teamId]/create/page.tsx index d2f32a0..cfb96e3 100644 --- a/src/app/dashboard/teams/[teamId]/create/page.tsx +++ b/src/app/dashboard/teams/[teamId]/create/page.tsx @@ -1,658 +1,17 @@ -"use client"; +import { notFound } from "next/navigation"; +import { requireAccountReady } from "@/lib/account-guard"; +import { getTeamMembership } from "@/lib/authz"; +import { getTeamPhrases } from "@/lib/data"; +import { CreateCardClient } from "@/components/create-card/create-card-client"; -import { useState, useEffect, useCallback, useRef } from "react"; -import { useParams, useRouter } from "next/navigation"; -import Link from "next/link"; -import { motion, AnimatePresence } from "framer-motion"; -import { EMOJI_CATEGORIES } from "@/lib/phrase-emojis"; +type Props = { params: Promise<{ teamId: string }> }; -interface Phrase { - id: string; - text: string; - emoji: string | null; - isDefault: boolean; -} - -const GRID_SIZE_MIN = 2; -const GRID_SIZE_MAX = 10; - -function clampGridSize(value: number): number { - if (Number.isNaN(value)) return GRID_SIZE_MIN; - return Math.max(GRID_SIZE_MIN, Math.min(GRID_SIZE_MAX, value)); -} - -function GridSizeInput({ - label, - value, - onChange, -}: { - label: string; - value: number; - onChange: (next: number) => void; -}) { - const [draft, setDraft] = useState(String(value)); - - useEffect(() => { - setDraft(String(value)); - }, [value]); - - return ( -
- - e.target.select()} - onChange={(e) => { - const raw = e.target.value; - setDraft(raw); - if (raw === "") return; - const parsed = parseInt(raw, 10); - if (Number.isNaN(parsed)) return; - if (parsed >= GRID_SIZE_MIN && parsed <= GRID_SIZE_MAX) { - onChange(parsed); - } - }} - onBlur={() => { - const next = clampGridSize(parseInt(draft, 10)); - setDraft(String(next)); - onChange(next); - }} - /> -
- ); -} - -export default function CreateCardPage() { - const { teamId } = useParams<{ teamId: string }>(); - const router = useRouter(); - - const [label, setLabel] = useState(`Réunion du ${new Date().toLocaleDateString("fr-FR", { day: "numeric", month: "long" })}`); - const [rows, setRows] = useState(5); - const [cols, setCols] = useState(5); - const [freeCenter, setFreeCenter] = useState(true); - const [defaults, setDefaults] = useState([]); - const [custom, setCustom] = useState([]); - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [newPhraseText, setNewPhraseText] = useState(""); - const [newPhraseEmoji, setNewPhraseEmoji] = useState(""); - const [editingPhraseId, setEditingPhraseId] = useState(null); - const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); - const [emojiCategoryId, setEmojiCategoryId] = useState(EMOJI_CATEGORIES[0].id); - const [addingPhrase, setAddingPhrase] = useState(false); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(""); - const emojiPickerRef = useRef(null); - const phraseFormRef = useRef(null); - const activeEmojiCategory = - EMOJI_CATEGORIES.find((category) => category.id === emojiCategoryId) ?? - EMOJI_CATEGORIES[0]; - - const totalCells = rows * cols; - const canHaveFreeCenter = rows % 2 !== 0 && cols % 2 !== 0; - const centerPos = canHaveFreeCenter - ? Math.floor(rows / 2) * cols + Math.floor(cols / 2) - : null; - const needed = freeCenter && centerPos !== null ? totalCells - 1 : totalCells; - - const fetchPhrases = useCallback(async () => { - const res = await fetch(`/api/teams/${teamId}/phrases`); - const data = await res.json(); - setDefaults(data.defaults || []); - setCustom(data.custom || []); - }, [teamId]); - - useEffect(() => { - fetchPhrases(); - }, [fetchPhrases]); - - useEffect(() => { - if (!emojiPickerOpen) return; - - function handlePointerDown(event: MouseEvent | TouchEvent) { - const target = event.target as Node; - if (emojiPickerRef.current && !emojiPickerRef.current.contains(target)) { - setEmojiPickerOpen(false); - } - } - - function handleEscape(event: KeyboardEvent) { - if (event.key === "Escape") setEmojiPickerOpen(false); - } - - document.addEventListener("mousedown", handlePointerDown); - document.addEventListener("touchstart", handlePointerDown); - document.addEventListener("keydown", handleEscape); - return () => { - document.removeEventListener("mousedown", handlePointerDown); - document.removeEventListener("touchstart", handlePointerDown); - document.removeEventListener("keydown", handleEscape); - }; - }, [emojiPickerOpen]); - - function togglePhrase(id: string) { - setSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - } - - function selectAll() { - const all = [...defaults, ...custom].map((p) => p.id); - setSelectedIds(new Set(all)); - } - - function selectNone() { - setSelectedIds(new Set()); - } - - function resetPhraseForm() { - setEditingPhraseId(null); - setNewPhraseText(""); - setNewPhraseEmoji(""); - setEmojiPickerOpen(false); - } - - function startEditPhrase(phrase: Phrase) { - setEditingPhraseId(phrase.id); - setNewPhraseText(phrase.text); - setNewPhraseEmoji(phrase.emoji ?? ""); - setEmojiPickerOpen(false); - setError(""); - phraseFormRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }); - } - - async function savePhrase(e: React.FormEvent) { - e.preventDefault(); - if (!newPhraseText.trim()) return; - setAddingPhrase(true); - setError(""); - const trimmedEmoji = newPhraseEmoji.trim(); - - if (editingPhraseId) { - const res = await fetch(`/api/teams/${teamId}/phrases`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - phraseId: editingPhraseId, - text: newPhraseText.trim(), - emoji: trimmedEmoji || null, - }), - }); - if (res.ok) { - const phrase = await res.json(); - setCustom((prev) => prev.map((p) => (p.id === phrase.id ? phrase : p))); - resetPhraseForm(); - } else { - const data = await res.json().catch(() => ({})); - setError(data.error ?? "Impossible de modifier la phrase"); - } - setAddingPhrase(false); - return; - } - - const res = await fetch(`/api/teams/${teamId}/phrases`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - text: newPhraseText.trim(), - ...(trimmedEmoji ? { emoji: trimmedEmoji } : {}), - }), - }); - if (res.ok) { - const phrase = await res.json(); - setCustom((prev) => [phrase, ...prev]); - setSelectedIds((prev) => new Set(Array.from(prev).concat(phrase.id))); - resetPhraseForm(); - } else { - const data = await res.json().catch(() => ({})); - setError(data.error ?? "Impossible d'ajouter la phrase"); - } - setAddingPhrase(false); - } - - async function deletePhrase(phrase: Phrase) { - if ( - !confirm( - `Supprimer la phrase « ${phrase.text} » ? Cette action est définitive.` - ) - ) { - return; - } - setError(""); - const res = await fetch(`/api/teams/${teamId}/phrases`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ phraseId: phrase.id }), - }); - if (res.ok) { - setCustom((prev) => prev.filter((p) => p.id !== phrase.id)); - setSelectedIds((prev) => { - const next = new Set(prev); - next.delete(phrase.id); - return next; - }); - if (editingPhraseId === phrase.id) resetPhraseForm(); - } else { - const data = await res.json().catch(() => ({})); - setError(data.error ?? "Impossible de supprimer la phrase"); - } - } - - async function createCard() { - setError(""); - if (selectedIds.size < needed) { - setError(`Sélectionne au moins ${needed} phrases pour remplir la grille ${rows}×${cols}`); - return; - } - setCreating(true); - const res = await fetch(`/api/teams/${teamId}/cards`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - label, - rows, - cols, - freeCenter: freeCenter && centerPos !== null, - phraseIds: Array.from(selectedIds), - }), - }); - setCreating(false); - if (res.ok) { - const card = await res.json(); - router.push(`/play/${card.id}`); - } else { - const d = await res.json(); - setError(d.error); - } - } - - const allPhrases = [...custom, ...defaults]; - - return ( -
-
- - ← Retour à l'équipe - -

Nouvelle grille.

-

Compose ta grille post-it pour cette semaine

-
- -
- {/* Config column */} -
- {/* Card settings */} -
-

Configuration

- -
-
- - setLabel(e.target.value)} - placeholder="Ex: Réunion du lundi" - /> -
- -
- - -
- - {canHaveFreeCenter && ( - - )} -
- - {/* Grid preview */} -
-

Aperçu de la grille

-
- {Array.from({ length: totalCells }).map((_, i) => ( -
- {freeCenter && centerPos !== null && i === centerPos ? "★" : ""} -
- ))} -
-
-
- - {/* Summary */} -
-

Résumé

-
-
- Taille - {rows} × {cols} = {totalCells} cases -
-
- Phrases nécessaires - {needed} -
-
- Phrases sélectionnées - = needed ? "text-green-600" : "text-red-500"}`}> - {selectedIds.size} / {needed} - -
-
- - {error && ( -
- ❌ {error} -
- )} - - -
-
- - {/* Phrase bank column */} -
-
-
-

💬 Banque de phrases

-
- - | - -
-
- - {/* Add / edit custom phrase */} -
- {editingPhraseId && ( -

- Modification d'une phrase personnalisée -

- )} -
-
- - - - {emojiPickerOpen && ( - -
-

- Choisir un emoji -

- {newPhraseEmoji && ( - - )} -
- -
- {EMOJI_CATEGORIES.map((category) => ( - - ))} -
- -

- {activeEmojiCategory.label} -

- -
- {activeEmojiCategory.emojis.map((emoji) => ( - - ))} -
-
- )} -
-
- setNewPhraseText(e.target.value)} - /> - - {editingPhraseId && ( - - )}
-
- - {/* Custom phrases */} - {custom.length > 0 && ( -
-

- Phrases personnalisées ({custom.length}) -

-
- - {custom.map((phrase) => ( - togglePhrase(phrase.id)} - onEdit={() => startEditPhrase(phrase)} - onDelete={() => deletePhrase(phrase)} - /> - ))} - -
-
- )} - - {/* Default phrases */} -
-

- Phrases classiques ({defaults.length}) -

-
- {allPhrases.filter((p) => p.isDefault).map((phrase) => ( - togglePhrase(phrase.id)} - /> - ))} -
-
-
-
-
-
- ); -} +export default async function CreateCardPage({ params }: Props) { + const [{ teamId }, { session }] = await Promise.all([params, requireAccountReady()]); -function PhraseChip({ - phrase, - selected, - editing, - onClick, - onEdit, - onDelete, -}: { - phrase: Phrase; - selected: boolean; - editing?: boolean; - onClick: () => void; - onEdit?: () => void; - onDelete?: () => void; -}) { - const isEditable = Boolean(onEdit || onDelete); + if (!(await getTeamMembership(session.user.id, teamId))) notFound(); - return ( - - - {phrase.emoji && {phrase.emoji}} - {phrase.text} - + const { defaults, custom } = await getTeamPhrases(teamId); - {isEditable && ( -
- {onEdit && ( - - )} - {onDelete && ( - - )} -
- )} -
- ); + return ; } diff --git a/src/app/dashboard/teams/[teamId]/page.tsx b/src/app/dashboard/teams/[teamId]/page.tsx index 8c88a42..20bf988 100644 --- a/src/app/dashboard/teams/[teamId]/page.tsx +++ b/src/app/dashboard/teams/[teamId]/page.tsx @@ -1,263 +1,26 @@ -"use client"; +import { notFound } from "next/navigation"; +import { requireAccountReady } from "@/lib/account-guard"; +import { getTeamMembership } from "@/lib/authz"; +import { getTeamCards, getTeamOverview } from "@/lib/data"; +import { TeamClient } from "@/components/team/team-client"; -import { useState, useEffect, useCallback } from "react"; -import { useParams, useRouter } from "next/navigation"; -import Link from "next/link"; -import { useSession } from "next-auth/react"; -import { motion } from "framer-motion"; -import { formatDistanceToNow } from "../../utils/date"; +type Props = { params: Promise<{ teamId: string }> }; -interface Card { - id: string; - label: string; - rows: number; - cols: number; - isActive: boolean; - createdAt: string; - createdById: string | null; - playedAt: string | null; - cells: { checked: { id: string }[] }[]; -} - -interface Team { - id: string; - name: string; - inviteCode: string; - createdById: string | null; - members: { user: { id: string; name: string | null; email: string } }[]; -} - -export default function TeamPage() { - const { teamId } = useParams<{ teamId: string }>(); - const router = useRouter(); - const { data: session } = useSession(); - const userId = session?.user?.id; - - const [team, setTeam] = useState(null); - const [cards, setCards] = useState([]); - const [isAdmin, setIsAdmin] = useState(false); - const [loading, setLoading] = useState(true); - const [copied, setCopied] = useState(false); - const [deletingTeam, setDeletingTeam] = useState(false); - const [deletingCardId, setDeletingCardId] = useState(null); - const [error, setError] = useState(""); - - const fetchData = useCallback(async () => { - const [teamsRes, cardsRes, adminRes] = await Promise.all([ - fetch("/api/teams"), - fetch(`/api/teams/${teamId}/cards`), - fetch("/api/admin/me"), - ]); - const teamsData = await teamsRes.json(); - const cardsData = await cardsRes.json(); - const adminData = adminRes.ok ? await adminRes.json() : { isAdmin: false }; - setTeam(teamsData.find((t: Team) => t.id === teamId) || null); - setCards(cardsData); - setIsAdmin(adminData.isAdmin === true); - setLoading(false); - }, [teamId]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - function canDelete(createdById: string | null | undefined) { - if (isAdmin) return true; - return Boolean(userId && createdById && createdById === userId); - } - - function copyInviteCode() { - if (team) { - navigator.clipboard.writeText(team.inviteCode); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - } +export default async function TeamPage({ params }: Props) { + const [{ teamId }, { session, profile }] = await Promise.all([params, requireAccountReady()]); - async function deleteTeam() { - if (!team) return; - if ( - !confirm( - `Supprimer l'équipe « ${team.name} » et toutes ses grilles ? Cette action est définitive.` - ) - ) { - return; - } - setDeletingTeam(true); - setError(""); - const res = await fetch(`/api/teams/${team.id}`, { method: "DELETE" }); - setDeletingTeam(false); - if (res.ok) { - router.push("/dashboard"); - return; - } - const d = await res.json().catch(() => ({})); - setError(d.error || "Suppression impossible"); - } + const membership = await getTeamMembership(session.user.id, teamId); + if (!membership) notFound(); - async function deleteCard(card: Card) { - if (!confirm(`Supprimer la grille « ${card.label} » ? Cette action est définitive.`)) { - return; - } - setDeletingCardId(card.id); - setError(""); - const res = await fetch(`/api/cards/${card.id}`, { method: "DELETE" }); - setDeletingCardId(null); - if (res.ok) { - setCards((prev) => prev.filter((c) => c.id !== card.id)); - } else { - const d = await res.json().catch(() => ({})); - setError(d.error || "Suppression impossible"); - } - } - - function getCheckedCount(card: Card): number { - return card.cells.filter((c) => c.checked.length > 0).length; - } - - function getProgress(card: Card): number { - if (!card.cells.length) return 0; - return Math.round((getCheckedCount(card) / card.cells.length) * 100); - } - - if (loading) { - return ( -
-
…
-
- ); - } + const [team, cards] = await Promise.all([getTeamOverview(teamId), getTeamCards(teamId)]); + if (!team) notFound(); return ( -
-
-
- - ← Mes équipes - -

- {team?.name} - . -

-

- {team?.members.length} membre{(team?.members.length ?? 0) > 1 ? "s" : ""} ·{" "} - {cards.length} grille{cards.length !== 1 ? "s" : ""} -

-
- -
- - - + Nouvelle grille - - {team && canDelete(team.createdById) && ( - - )} -
-
- - {error &&

{error}

} - - {cards.length === 0 ? ( - -

Pas encore de grilles

-

Colle ta première grille pour cette semaine.

- - Créer la première grille - -
- ) : ( -
- {cards.map((card, i) => { - const progress = getProgress(card); - const checkedCount = getCheckedCount(card); - return ( - - - {canDelete(card.createdById) && ( - - )} - - ); - })} -
- )} -
+ ); } diff --git a/src/app/play/[cardId]/page.tsx b/src/app/play/[cardId]/page.tsx index 93bb040..03ee980 100644 --- a/src/app/play/[cardId]/page.tsx +++ b/src/app/play/[cardId]/page.tsx @@ -1,575 +1,20 @@ -"use client"; +import { notFound } from "next/navigation"; +import { requireAccountReady } from "@/lib/account-guard"; +import { getCardIfMember } from "@/lib/authz"; +import { getPlayCard } from "@/lib/data"; +import { PlayClient } from "@/components/play/play-client"; -import { useState, useEffect, useCallback, useRef } from "react"; -import { useParams } from "next/navigation"; -import { useSession } from "next-auth/react"; -import { motion, AnimatePresence } from "framer-motion"; -import Link from "next/link"; -import Confetti from "react-confetti"; -import { detectBingo, getCenterPosition } from "@/lib/bingo"; -import { BingoPattern } from "@/types"; +type Props = { params: Promise<{ cardId: string }> }; -interface CellData { - id: string; - position: number; - phrase: { id: string; text: string; emoji: string | null }; - checked: { id: string; userId: string; user: { id: string; name: string | null } }[]; -} - -interface CardData { - id: string; - label: string; - rows: number; - cols: number; - freeCenter: boolean; - isActive: boolean; - team: { - id: string; - name: string; - members: { user: { id: string; name: string | null; email: string } }[]; - }; - cells: CellData[]; -} - -export default function PlayPage() { - const { cardId } = useParams<{ cardId: string }>(); - const { data: session } = useSession(); - - const [card, setCard] = useState(null); - const [checkedCellIds, setCheckedCellIds] = useState>(new Set()); - const [bingoPatterns, setBingoPatterns] = useState([]); - const [showBingo, setShowBingo] = useState(false); - const [bingoWinner, setBingoWinner] = useState(null); - const [loading, setLoading] = useState(true); - const [onlineMembers, setOnlineMembers] = useState<{ id: string; name: string }[]>([]); - const [windowSize, setWindowSize] = useState({ width: 0, height: 0 }); - const socketRef = useRef(null); - const prevBingoCount = useRef(0); - const hideBingoTimer = useRef | null>(null); - const celebratingRef = useRef(false); - const cardRef = useRef(null); - - useEffect(() => { - cardRef.current = card; - }, [card]); - - useEffect(() => { - setWindowSize({ width: window.innerWidth, height: window.innerHeight }); - const handler = () => setWindowSize({ width: window.innerWidth, height: window.innerHeight }); - window.addEventListener("resize", handler); - return () => window.removeEventListener("resize", handler); - }, []); - - const celebrateBingo = useCallback((userName: string) => { - if (celebratingRef.current) return; - celebratingRef.current = true; - setBingoWinner(userName); - setShowBingo(true); - if (hideBingoTimer.current) clearTimeout(hideBingoTimer.current); - hideBingoTimer.current = setTimeout(() => { - setShowBingo(false); - celebratingRef.current = false; - }, 4500); - }, []); - - /** Met à jour les motifs ; optionnellement déclenche confettis (clic local). */ - const syncBingoFromChecked = useCallback( - ( - nextChecked: Set, - data: CardData, - options?: { celebrateAs?: string; broadcast?: boolean } - ) => { - const centerPos = - data.freeCenter ? getCenterPosition(data.rows, data.cols) : null; - const positions = new Set(); - for (const cell of data.cells) { - const isFree = centerPos !== null && cell.position === centerPos; - if (isFree || nextChecked.has(cell.id)) positions.add(cell.position); - } - - const patterns = detectBingo(positions, data.rows, data.cols); - setBingoPatterns(patterns); - - const isNewBingo = patterns.length > prevBingoCount.current; - prevBingoCount.current = patterns.length; - - if (isNewBingo && options?.celebrateAs) { - celebrateBingo(options.celebrateAs); - if (options.broadcast) { - socketRef.current?.emit("bingo", { - cardId: data.id, - pattern: patterns[patterns.length - 1], - }); - } - } - }, - [celebrateBingo] - ); - - const applyCheckedFromCard = useCallback((data: CardData) => { - const centerPos = - data.freeCenter ? getCenterPosition(data.rows, data.cols) : null; - const checked = new Set(); - const positions = new Set(); - - for (const cell of data.cells) { - const isFree = centerPos !== null && cell.position === centerPos; - if (isFree || cell.checked.length > 0) { - checked.add(cell.id); - positions.add(cell.position); - } - } - - setCheckedCellIds(checked); - const patterns = detectBingo(positions, data.rows, data.cols); - setBingoPatterns(patterns); - prevBingoCount.current = patterns.length; - }, []); - - const fetchCard = useCallback( - async (options?: { silent?: boolean }) => { - const res = await fetch(`/api/cards/${cardId}`); - if (!res.ok) return; - const data: CardData = await res.json(); - setCard(data); - applyCheckedFromCard(data); - if (!options?.silent) setLoading(false); - }, - [cardId, applyCheckedFromCard] - ); - - useEffect(() => { - fetchCard(); - }, [fetchCard]); - - // Temps réel : rejoindre la room à chaque (re)connexion + polling de secours - useEffect(() => { - if (!session?.user?.id || !cardId) return; - - let cancelled = false; - let pollTimer: ReturnType | null = null; - - const onCellUpdated = ({ - cellId, - checked, - userName, - }: { - cellId: string; - checked: boolean; - userName?: string; - }) => { - const data = cardRef.current; - if (!data) return; - - setCheckedCellIds((prev) => { - const next = new Set(prev); - if (checked) next.add(cellId); - else next.delete(cellId); - queueMicrotask(() => syncBingoFromChecked(next, data)); - return next; - }); - - // Met à jour le nom affiché sur la case sans refetch complet - setCard((prev) => { - if (!prev) return prev; - return { - ...prev, - cells: prev.cells.map((c) => { - if (c.id !== cellId) return c; - if (!checked) return { ...c, checked: [] }; - return { - ...c, - checked: [ - { - id: c.checked[0]?.id ?? `live-${cellId}`, - userId: c.checked[0]?.userId ?? "", - user: { - id: c.checked[0]?.user.id ?? "", - name: userName ?? c.checked[0]?.user.name ?? "?", - }, - }, - ], - }; - }), - }; - }); - }; - - const onMembersUpdated = (members: { id: string; name: string }[]) => { - setOnlineMembers(members); - }; - - const onBingoAchieved = ({ userName }: { userName: string }) => { - celebrateBingo(userName); - }; - - let joinRoom: (() => void) | null = null; - - const initSocket = async () => { - const { getSocket } = await import("@/lib/socket"); - if (cancelled) return; - - const socket = getSocket(); - socketRef.current = socket; - - joinRoom = () => { - socket.emit("join-card", { cardId }); - }; +export default async function PlayPage({ params }: Props) { + const [{ cardId }, { session, profile }] = await Promise.all([params, requireAccountReady()]); - if (cancelled) return; + if (!profile.isAdmin && !(await getCardIfMember(session.user.id, cardId))) notFound(); - socket.on("connect", joinRoom); - socket.on("cell-updated", onCellUpdated); - socket.on("members-updated", onMembersUpdated); - socket.on("bingo-achieved", onBingoAchieved); - - // Déjà connecté (singleton) ou en cours : rejoindre immédiatement - if (socket.connected) joinRoom(); - else socket.connect(); - }; - - void initSocket(); - - // Filet si le WS est down (après rejoin-on-connect, connected ⇒ dans la room) - pollTimer = setInterval(() => { - if (cancelled) return; - if (document.visibilityState === "hidden") return; - if (socketRef.current?.connected) return; - void fetchCard({ silent: true }); - }, 3000); - - const onVisible = () => { - if (document.visibilityState !== "visible") return; - if (socketRef.current?.connected) { - socketRef.current.emit("join-card", { cardId }); - return; - } - void fetchCard({ silent: true }); - }; - document.addEventListener("visibilitychange", onVisible); - - return () => { - cancelled = true; - if (pollTimer) clearInterval(pollTimer); - document.removeEventListener("visibilitychange", onVisible); - const socket = socketRef.current; - if (socket) { - if (joinRoom) socket.off("connect", joinRoom); - socket.off("cell-updated", onCellUpdated); - socket.off("members-updated", onMembersUpdated); - socket.off("bingo-achieved", onBingoAchieved); - } - if (hideBingoTimer.current) clearTimeout(hideBingoTimer.current); - }; - }, [cardId, session?.user?.id, celebrateBingo, syncBingoFromChecked, fetchCard]); - - async function handleCellClick(cell: CellData) { - if (!card) return; - - const centerPos = getCenterPosition(card.rows, card.cols); - if (card.freeCenter && cell.position === centerPos) return; - - const isChecked = checkedCellIds.has(cell.id); - const newChecked = !isChecked; - - const next = new Set(checkedCellIds); - if (newChecked) next.add(cell.id); - else next.delete(cell.id); - - // Case + confettis dans le même tick (pas d'attente useEffect) - setCheckedCellIds(next); - syncBingoFromChecked(next, card, { - celebrateAs: newChecked ? session?.user?.name || "Toi" : undefined, - broadcast: newChecked, - }); - - socketRef.current?.emit("check-cell", { - cardId: card.id, - cellId: cell.id, - checked: newChecked, - }); - - await fetch(`/api/cards/${cardId}/check`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cellId: cell.id, checked: newChecked }), - }); - } - - if (loading || !card) { - return ( -
-
Chargement…
-
- ); - } - - const centerPos = getCenterPosition(card.rows, card.cols); - const winningPositions = new Set(bingoPatterns.flatMap((p) => p.positions)); - const checkedCount = checkedCellIds.size; - const totalCells = card.cells.length; - const progress = Math.round((checkedCount / totalCells) * 100); + const card = await getPlayCard(cardId); + if (!card) notFound(); return ( -
- {showBingo && windowSize.width > 0 && ( - - )} - - - {showBingo && ( - -
- - BINGO! - -

- {bingoWinner} a collé la ligne gagnante -

-
-
- )} -
- - - - {bingoPatterns.length > 0 && ( -
-
- {bingoPatterns.map((p, i) => ( - - {p.type === "row" - ? `Ligne ${p.index + 1}` - : p.type === "column" - ? `Colonne ${p.index + 1}` - : p.index === 0 - ? "Diag ↘" - : "Diag ↗"} - - ))} -
-
- )} - -
-
- {card.cells.map((cell, index) => { - const isChecked = checkedCellIds.has(cell.id); - const isFree = card.freeCenter && cell.position === centerPos; - const isWinning = winningPositions.has(cell.position); - - return ( - handleCellClick(cell)} - cols={card.cols} - tilt={index % 3 === 0 ? -1.5 : index % 3 === 1 ? 1.2 : -0.6} - /> - ); - })} -
- -
-
-
-

{checkedCount}

-

Cochées

-
-
-

{totalCells - checkedCount}

-

Restantes

-
-
-

{bingoPatterns.length}

-

Bingo{bingoPatterns.length !== 1 ? "s" : ""}

-
-
-

- Grille {card.rows}×{card.cols} -

-
-
-
- ); -} - -function BingoCell({ - cell, - isChecked, - isFree, - isWinning, - onClick, - cols, - tilt, -}: { - cell: CellData; - isChecked: boolean; - isFree: boolean; - isWinning: boolean; - onClick: () => void; - cols: number; - tilt: number; -}) { - const fontSize = cols <= 4 ? "text-sm" : cols <= 6 ? "text-xs" : "text-[10px]"; - const emojiSize = cols <= 4 ? "text-2xl" : cols <= 6 ? "text-xl" : "text-base"; - - return ( - - {/* coin post-it */} - - - {isChecked && !isFree && ( - - ✓ - - )} - - {isFree ? ( - <> - ★ - FREE - - ) : ( - <> - {cell.phrase.emoji && {cell.phrase.emoji}} - - {cell.phrase.text} - - {isChecked && cell.checked[0] && ( - - {cell.checked[0].user.name?.split(" ")[0] || "?"} - - )} - - )} - + ); } 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 new file mode 100644 index 0000000..fbde0fc --- /dev/null +++ b/src/components/account-settings-form/__tests__/account-settings-form.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { jsonResponse, mockFetch, routerMock, sentJson } from "@/test-utils/mocks"; +import type { AccountProfile } from "@/types"; +import { AccountSettingsForm } from "../account-settings-form"; + +const mockUpdateSession = jest.fn(); + +jest.mock("next/navigation", () => ({ + useRouter: () => jest.requireActual("@/test-utils/mocks").routerMock, +})); +jest.mock("next-auth/react", () => ({ + useSession: () => ({ data: null, update: mockUpdateSession }), +})); + +const profile: AccountProfile = { + id: "u1", + name: "Alex", + email: "alex@example.com", + isAdmin: false, + mustChangePassword: false, +}; + +describe("AccountSettingsForm", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = mockFetch(); + }); + + it("pré-remplit le profil reçu du serveur", () => { + render(); + expect(screen.getByLabelText("Prénom")).toHaveValue("Alex"); + expect(screen.getByText("alex@example.com")).toBeInTheDocument(); + }); + + it("refuse d'enregistrer sans modification", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Enregistrer" })); + + expect(screen.getByRole("alert")).toHaveTextContent("Aucune modification à enregistrer"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("vérifie la confirmation du mot de passe", async () => { + const user = userEvent.setup(); + render(); + + 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!"); + await user.click(screen.getByRole("button", { name: "Enregistrer" })); + + expect(screen.getByRole("alert")).toHaveTextContent("Les mots de passe ne correspondent pas"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("enregistre le prénom et met à jour la session", async () => { + const user = userEvent.setup(); + fetchMock.mockResolvedValue(jsonResponse({ ...profile, name: "Sam" })); + render(); + + await user.clear(screen.getByLabelText("Prénom")); + await user.type(screen.getByLabelText("Prénom"), "Sam"); + await user.click(screen.getByRole("button", { name: "Enregistrer" })); + + expect(sentJson(fetchMock)).toEqual({ name: "Sam" }); + expect(mockUpdateSession).toHaveBeenCalledWith({ name: "Sam", mustChangePassword: false }); + expect(await screen.findByRole("status")).toHaveTextContent("Compte mis à jour"); + }); + + it("termine la configuration initiale et redirige", async () => { + const user = userEvent.setup(); + fetchMock.mockResolvedValue(jsonResponse({ ...profile, mustChangePassword: false })); + render(); + + expect(screen.queryByLabelText("Mot de passe actuel")).not.toBeInTheDocument(); + await user.type(screen.getByLabelText("Nouveau mot de passe"), "nouveau-mdp-solide"); + await user.type(screen.getByLabelText("Confirmer le mot de passe"), "nouveau-mdp-solide"); + await user.click(screen.getByRole("button", { name: "Terminer la configuration" })); + + expect(sentJson(fetchMock)).toEqual({ name: "Alex", newPassword: "nouveau-mdp-solide" }); + expect(routerMock.push).toHaveBeenCalledWith("/dashboard"); + }); +}); diff --git a/src/components/account-settings-form/account-settings-form.tsx b/src/components/account-settings-form/account-settings-form.tsx index 85f7fd4..ae69ece 100644 --- a/src/components/account-settings-form/account-settings-form.tsx +++ b/src/components/account-settings-form/account-settings-form.tsx @@ -1,103 +1,94 @@ "use client"; -import { useEffect, useState } from "react"; +import { useId, useState } from "react"; import { useRouter } from "next/navigation"; import { useSession } from "next-auth/react"; import Link from "next/link"; +import { apiRequest } from "@/lib/api-client"; +import { FormError, FormSuccess } from "@/components/auth/form-feedback"; import type { AccountProfile } from "@/types"; -export function AccountSettingsForm() { +const MIN_PASSWORD_LENGTH = 12; + +type AccountPayload = { name: string; newPassword?: string; currentPassword?: string }; + +/** Prénom et mot de passe ; sert aussi de configuration à la première connexion. */ +export function AccountSettingsForm({ initialProfile }: { initialProfile: AccountProfile }) { const router = useRouter(); - const { data: session, update } = useSession(); + const { update } = useSession(); + const fieldId = useId(); - const [profile, setProfile] = useState(null); - const [loading, setLoading] = useState(true); + const [profile, setProfile] = useState(initialProfile); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); - const [name, setName] = useState(""); + const [name, setName] = useState(initialProfile.name ?? ""); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - useEffect(() => { - fetch("/api/account") - .then(async (res) => { - if (!res.ok) throw new Error("fetch"); - return res.json() as Promise; - }) - .then((data) => { - setProfile(data); - setName(data.name ?? ""); - }) - .catch(() => setError("Impossible de charger le compte")) - .finally(() => setLoading(false)); - }, []); - - const isSetup = profile?.mustChangePassword ?? false; + const isSetup = profile.mustChangePassword; + const id = (field: string) => `${fieldId}-${field}`; + + function validate(): string | null { + if (newPassword && newPassword !== confirmPassword) { + return "Les mots de passe ne correspondent pas"; + } + if (isSetup && !newPassword) { + return "Choisis un nouveau mot de passe pour continuer"; + } + if (!isSetup && !newPassword && name.trim() === (profile.name ?? "").trim()) { + return "Aucune modification à enregistrer"; + } + return null; + } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); setSuccess(""); - if (newPassword && newPassword !== confirmPassword) { - setError("Les mots de passe ne correspondent pas"); + const validationError = validate(); + if (validationError) { + setError(validationError); return; } - if (isSetup && !newPassword) { - setError("Choisis un nouveau mot de passe pour continuer"); - return; - } - - const payload: Record = { name: name.trim() }; + const payload: AccountPayload = { name: name.trim() }; if (newPassword) { payload.newPassword = newPassword; - if (!isSetup) { - payload.currentPassword = currentPassword; - } - } - - if (!isSetup && !newPassword && name.trim() === (profile?.name ?? "").trim()) { - setError("Aucune modification à enregistrer"); - return; + if (!isSetup) payload.currentPassword = currentPassword; } setSaving(true); - - const res = await fetch("/api/account", { + const result = await apiRequest("/api/account", { method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + json: payload, + fallbackError: "Erreur lors de la mise à jour", }); - - const data = await res.json(); setSaving(false); - if (!res.ok) { - setError(data.error || "Erreur lors de la mise à jour"); + if (!result.ok) { + setError(result.error); return; } - setProfile(data); + const updated = result.data; + setProfile(updated); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); - await update({ - name: data.name, - mustChangePassword: data.mustChangePassword, - }); + await update({ name: updated.name, mustChangePassword: updated.mustChangePassword }); - if (data.mustChangePassword) { + if (updated.mustChangePassword) { setSuccess("Enregistré"); return; } if (isSetup) { - router.push(data.isAdmin ? "/admin" : "/dashboard"); + router.push(updated.isAdmin ? "/admin" : "/dashboard"); router.refresh(); return; } @@ -105,20 +96,8 @@ export function AccountSettingsForm() { setSuccess("Compte mis à jour"); } - if (loading) { - return ( -
- Chargement… -
- ); - } - return ( -
+

{isSetup ? "Configure ton compte" : "Mon compte"} @@ -128,89 +107,98 @@ export function AccountSettingsForm() { ? "Première connexion : prénom et nouveau mot de passe." : "Modifie ton prénom ou ton mot de passe."}

- {session?.user?.email && ( -

{session.user.email}

- )} +

{profile.email}

- + setName(e.target.value)} + autoComplete="given-name" + maxLength={100} required />
{!isSetup && (
-
)}
-
{(isSetup || newPassword) && (
-
)} - {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} + +
+ {error && ( +

+ {error} +

+ )} +
{( [ @@ -212,7 +232,7 @@ export function AdminClient({ initialUsers, initialInvites }: Props) { key={invite.id} invite={invite} copied={copiedId === invite.id} - onCopy={() => copyLink(invite)} + onCopy={() => void copyLink(invite)} onRevoke={() => revokeInvite(invite.id)} status="active" /> diff --git a/src/components/auth/__tests__/form-feedback.test.tsx b/src/components/auth/__tests__/form-feedback.test.tsx new file mode 100644 index 0000000..c1b15cd --- /dev/null +++ b/src/components/auth/__tests__/form-feedback.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import { FormError, FormSuccess } from "../form-feedback"; + +describe("FormError", () => { + it("n'affiche rien sans message", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("annonce le message comme alerte", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("Email invalide"); + }); +}); + +describe("FormSuccess", () => { + it("annonce le message comme statut", () => { + render(); + expect(screen.getByRole("status")).toHaveTextContent("Compte mis à jour"); + }); +}); diff --git a/src/components/auth/__tests__/signup-form.test.tsx b/src/components/auth/__tests__/signup-form.test.tsx new file mode 100644 index 0000000..ddcce1e --- /dev/null +++ b/src/components/auth/__tests__/signup-form.test.tsx @@ -0,0 +1,73 @@ +import { render, 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 { SignUpForm } from "../signup-form"; + +jest.mock("next/navigation", () => ({ + useRouter: () => jest.requireActual("@/test-utils/mocks").routerMock, +})); +jest.mock("next-auth/react", () => ({ signIn: jest.fn() })); + +const signInMock = signIn as jest.Mock; + +async function fillAndSubmit() { + const user = userEvent.setup(); + await user.type(screen.getByLabelText("Prénom"), "Alex"); + await user.type(screen.getByLabelText("Email"), "alex@example.com"); + await user.type(screen.getByLabelText("Mot de passe"), "un-mot-de-passe-solide"); + await user.click(screen.getByRole("button", { name: "Créer mon compte" })); +} + +describe("SignUpForm", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = mockFetch(); + }); + + it("inscrit, connecte puis redirige vers le dashboard", async () => { + fetchMock.mockResolvedValue(jsonResponse({ id: "u1" }, 201)); + signInMock.mockResolvedValue({ ok: true, error: null }); + render(); + + await fillAndSubmit(); + + expect(fetchMock).toHaveBeenCalledWith("/api/auth/register", expect.anything()); + expect(sentJson(fetchMock)).toEqual({ + name: "Alex", + email: "alex@example.com", + password: "un-mot-de-passe-solide", + inviteToken: "tok-123", + }); + expect(signInMock).toHaveBeenCalledWith("credentials", { + email: "alex@example.com", + password: "un-mot-de-passe-solide", + redirect: false, + }); + expect(routerMock.push).toHaveBeenCalledWith("/dashboard"); + }); + + it("affiche l'erreur de l'API sans se connecter", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ error: "Ce lien d'invitation a expiré" }, 403) + ); + render(); + + await fillAndSubmit(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Ce lien d'invitation a expiré"); + expect(signInMock).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Créer mon compte" })).toBeEnabled(); + }); + + it("renvoie vers la connexion si la connexion automatique échoue", async () => { + fetchMock.mockResolvedValue(jsonResponse({ id: "u1" }, 201)); + signInMock.mockResolvedValue({ ok: false, error: "CredentialsSignin" }); + render(); + + await fillAndSubmit(); + + expect(routerMock.push).toHaveBeenCalledWith("/auth/signin"); + }); +}); diff --git a/src/components/auth/form-feedback.tsx b/src/components/auth/form-feedback.tsx new file mode 100644 index 0000000..02ada00 --- /dev/null +++ b/src/components/auth/form-feedback.tsx @@ -0,0 +1,25 @@ +/** Message d'erreur de formulaire (rien si vide). */ +export function FormError({ message }: { message: string }) { + if (!message) return null; + return ( +
+ {message} +
+ ); +} + +/** Message de succès de formulaire (rien si vide). */ +export function FormSuccess({ message }: { message: string }) { + if (!message) return null; + return ( +
+ {message} +
+ ); +} diff --git a/src/components/auth/signup-form.tsx b/src/components/auth/signup-form.tsx new file mode 100644 index 0000000..17c1b29 --- /dev/null +++ b/src/components/auth/signup-form.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useId, useState } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { MarkItLogo } from "@/components/markit-logo/markit-logo"; +import { apiRequest } from "@/lib/api-client"; +import { FormError } from "./form-feedback"; + +/** Inscription sur invitation, puis connexion automatique. */ +export function SignUpForm({ inviteToken }: { inviteToken: string }) { + const router = useRouter(); + const fieldId = useId(); + + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + + const result = await apiRequest("/api/auth/register", { + method: "POST", + json: { name, email, password, inviteToken }, + fallbackError: "Erreur lors de l'inscription", + }); + if (!result.ok) { + setError(result.error); + setLoading(false); + return; + } + + const signInResult = await signIn("credentials", { email, password, redirect: false }); + if (signInResult?.error) { + router.push("/auth/signin"); + return; + } + + // Le garde serveur redirige vers /account si une configuration est requise + router.push("/dashboard"); + router.refresh(); + } + + return ( +
+
+
+ +

+ Créer ton compte +

+

Tu as reçu une invitation.

+
+ + +
+ + setName(e.target.value)} + autoComplete="given-name" + maxLength={100} + required + /> +
+ +
+ + setEmail(e.target.value)} + autoComplete="email" + required + /> +
+ +
+ + setPassword(e.target.value)} + autoComplete="new-password" + aria-describedby={`${fieldId}-password-hint`} + required + minLength={12} + maxLength={128} + /> +

+ Minimum 12 caractères +

+
+ + + + + + +

+ Déjà un compte ?{" "} + + Se connecter + +

+
+
+ ); +} diff --git a/src/components/create-card/__tests__/emoji-picker.test.tsx b/src/components/create-card/__tests__/emoji-picker.test.tsx new file mode 100644 index 0000000..7e2da43 --- /dev/null +++ b/src/components/create-card/__tests__/emoji-picker.test.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { EMOJI_CATEGORIES } from "@/lib/phrase-emojis"; +import { EmojiPicker } from "../emoji-picker"; + +function ControlledPicker({ onChange }: { onChange: (emoji: string) => void }) { + const [value, setValue] = useState(""); + const [open, setOpen] = useState(false); + return ( +
+ { + setValue(emoji); + onChange(emoji); + }} + open={open} + onOpenChange={setOpen} + /> +

Dehors

+
+ ); +} + +function setup() { + const onChange = jest.fn(); + render(); + const trigger = screen.getByRole("button", { name: "Choisir un emoji" }); + return { onChange, trigger, user: userEvent.setup() }; +} + +const firstEmoji = EMOJI_CATEGORIES[0].emojis[0]; + +describe("EmojiPicker", () => { + it("s'ouvre au clic", async () => { + const { trigger, user } = setup(); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + + await user.click(trigger); + + expect(trigger).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("Choisir un emoji")).toBeInTheDocument(); + }); + + it("sélectionne un emoji et se referme", async () => { + const { trigger, onChange, user } = setup(); + await user.click(trigger); + await user.click(screen.getByRole("button", { name: firstEmoji })); + + expect(onChange).toHaveBeenCalledWith(firstEmoji); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(trigger).toHaveTextContent(firstEmoji); + }); + + it("change de catégorie", async () => { + const { trigger, user } = setup(); + const second = EMOJI_CATEGORIES[1]; + await user.click(trigger); + await user.click(screen.getByRole("button", { name: second.label })); + + expect(screen.getByRole("button", { name: second.label })).toHaveAttribute( + "aria-pressed", + "true" + ); + expect(screen.getByRole("button", { name: second.emojis[0] })).toBeInTheDocument(); + }); + + it("se ferme avec Échap et au clic extérieur", async () => { + const { trigger, user } = setup(); + + await user.click(trigger); + await user.keyboard("{Escape}"); + await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "false")); + + await user.click(trigger); + await user.click(screen.getByText("Dehors")); + await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "false")); + }); +}); diff --git a/src/components/create-card/__tests__/grid-size-input.test.tsx b/src/components/create-card/__tests__/grid-size-input.test.tsx new file mode 100644 index 0000000..c67ca31 --- /dev/null +++ b/src/components/create-card/__tests__/grid-size-input.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { GridSizeInput } from "../grid-size-input"; + +function setup(value = 5) { + const onChange = jest.fn(); + render(); + return { onChange, input: screen.getByLabelText("Lignes"), user: userEvent.setup() }; +} + +describe("GridSizeInput", () => { + it("est relié à son label", () => { + const { input } = setup(); + expect(input).toHaveValue(5); + }); + + it("propage une valeur valide pendant la saisie", async () => { + const { input, onChange, user } = setup(); + await user.clear(input); + await user.type(input, "7"); + expect(onChange).toHaveBeenLastCalledWith(7); + }); + + it("ne propage pas une valeur hors bornes avant le blur", async () => { + const { input, onChange, user } = setup(); + await user.clear(input); + await user.type(input, "42"); + expect(onChange).not.toHaveBeenCalledWith(42); + }); + + it("borne la valeur au blur", async () => { + const { input, onChange, user } = setup(); + await user.clear(input); + await user.type(input, "42"); + await user.tab(); + expect(onChange).toHaveBeenLastCalledWith(10); + expect(input).toHaveValue(10); + }); + + it("revient au minimum si le champ est vidé", async () => { + const { input, onChange, user } = setup(); + await user.clear(input); + await user.tab(); + expect(onChange).toHaveBeenLastCalledWith(2); + }); +}); diff --git a/src/components/create-card/__tests__/phrase-chip.test.tsx b/src/components/create-card/__tests__/phrase-chip.test.tsx new file mode 100644 index 0000000..5995fa7 --- /dev/null +++ b/src/components/create-card/__tests__/phrase-chip.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { PhraseChip } from "../phrase-chip"; + +const phrase = { id: "p1", text: "Tu es en mute", emoji: "🔇", isDefault: false }; + +describe("PhraseChip", () => { + it("reflète la sélection et déclenche onClick", async () => { + const onClick = jest.fn(); + render(); + + const chip = screen.getByRole("button", { name: /Tu es en mute/ }); + expect(chip).toHaveAttribute("aria-pressed", "true"); + + await userEvent.click(chip); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("n'affiche pas les actions sans handlers (phrase classique)", () => { + render(); + expect(screen.queryByRole("button", { name: /Modifier/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Supprimer/ })).not.toBeInTheDocument(); + }); + + it("déclenche modifier / supprimer sans sélectionner la phrase", async () => { + const onClick = jest.fn(); + const onEdit = jest.fn(); + const onDelete = jest.fn(); + render( + + ); + + await userEvent.click(screen.getByRole("button", { name: "Modifier « Tu es en mute »" })); + await userEvent.click(screen.getByRole("button", { name: "Supprimer « Tu es en mute »" })); + + expect(onEdit).toHaveBeenCalledTimes(1); + expect(onDelete).toHaveBeenCalledTimes(1); + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/create-card/create-card-client.tsx b/src/components/create-card/create-card-client.tsx new file mode 100644 index 0000000..68d7e4c --- /dev/null +++ b/src/components/create-card/create-card-client.tsx @@ -0,0 +1,410 @@ +"use client"; + +import { useId, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { AnimatePresence } from "framer-motion"; +import { apiRequest } from "@/lib/api-client"; +import { getCenterPosition } from "@/lib/bingo"; +import type { TeamPhrase } from "@/lib/data"; +import { EmojiPicker } from "./emoji-picker"; +import { GridSizeInput } from "./grid-size-input"; +import { PhraseChip } from "./phrase-chip"; + +type Props = { + teamId: string; + initialDefaults: TeamPhrase[]; + initialCustom: TeamPhrase[]; +}; + +function defaultCardLabel() { + return `Réunion du ${new Date().toLocaleDateString("fr-FR", { day: "numeric", month: "long" })}`; +} + +/** Configuration d'une nouvelle grille et gestion de la banque de phrases de l'équipe. */ +export function CreateCardClient({ teamId, initialDefaults, initialCustom }: Props) { + const router = useRouter(); + const labelId = useId(); + const phraseInputId = useId(); + + const [label, setLabel] = useState(defaultCardLabel); + const [rows, setRows] = useState(5); + const [cols, setCols] = useState(5); + const [freeCenter, setFreeCenter] = useState(true); + const defaults = initialDefaults; + const [custom, setCustom] = useState(initialCustom); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [newPhraseText, setNewPhraseText] = useState(""); + const [newPhraseEmoji, setNewPhraseEmoji] = useState(""); + const [editingPhraseId, setEditingPhraseId] = useState(null); + const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); + const [savingPhrase, setSavingPhrase] = useState(false); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(""); + const phraseFormRef = useRef(null); + + const totalCells = rows * cols; + const centerPos = getCenterPosition(rows, cols); + const hasFreeCenter = freeCenter && centerPos !== null; + const needed = hasFreeCenter ? totalCells - 1 : totalCells; + const phrasesUrl = `/api/teams/${teamId}/phrases`; + + function togglePhrase(id: string) { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function resetPhraseForm() { + setEditingPhraseId(null); + setNewPhraseText(""); + setNewPhraseEmoji(""); + setEmojiPickerOpen(false); + } + + function startEditPhrase(phrase: TeamPhrase) { + setEditingPhraseId(phrase.id); + setNewPhraseText(phrase.text); + setNewPhraseEmoji(phrase.emoji ?? ""); + setEmojiPickerOpen(false); + setError(""); + phraseFormRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + + async function savePhrase(e: React.FormEvent) { + e.preventDefault(); + const text = newPhraseText.trim(); + if (!text) return; + setSavingPhrase(true); + setError(""); + const emoji = newPhraseEmoji.trim() || null; + + if (editingPhraseId) { + const result = await apiRequest(phrasesUrl, { + method: "PATCH", + json: { phraseId: editingPhraseId, text, emoji }, + fallbackError: "Impossible de modifier la phrase", + }); + if (result.ok) { + setCustom((prev) => prev.map((p) => (p.id === result.data.id ? result.data : p))); + resetPhraseForm(); + } else { + setError(result.error); + } + } else { + const result = await apiRequest(phrasesUrl, { + method: "POST", + json: { text, emoji }, + fallbackError: "Impossible d'ajouter la phrase", + }); + if (result.ok) { + setCustom((prev) => [result.data, ...prev]); + setSelectedIds((prev) => new Set(prev).add(result.data.id)); + resetPhraseForm(); + } else { + setError(result.error); + } + } + setSavingPhrase(false); + } + + async function deletePhrase(phrase: TeamPhrase) { + if (!confirm(`Supprimer la phrase « ${phrase.text} » ? Cette action est définitive.`)) { + return; + } + setError(""); + const result = await apiRequest(phrasesUrl, { + method: "DELETE", + json: { phraseId: phrase.id }, + fallbackError: "Impossible de supprimer la phrase", + }); + if (!result.ok) { + setError(result.error); + return; + } + setCustom((prev) => prev.filter((p) => p.id !== phrase.id)); + setSelectedIds((prev) => { + const next = new Set(prev); + next.delete(phrase.id); + return next; + }); + if (editingPhraseId === phrase.id) resetPhraseForm(); + } + + async function createCard() { + setError(""); + if (selectedIds.size < needed) { + setError(`Sélectionne au moins ${needed} phrases pour remplir la grille ${rows}×${cols}`); + return; + } + setCreating(true); + const result = await apiRequest<{ id: string }>(`/api/teams/${teamId}/cards`, { + method: "POST", + json: { + label, + rows, + cols, + freeCenter: hasFreeCenter, + phraseIds: Array.from(selectedIds), + }, + fallbackError: "Impossible de créer la grille", + }); + if (!result.ok) { + setCreating(false); + setError(result.error); + return; + } + router.push(`/play/${result.data.id}`); + } + + return ( +
+
+ + ← Retour à l'équipe + +

+ Nouvelle grille. +

+

Compose ta grille post-it pour cette semaine

+
+ +
+
+
+

Configuration

+ +
+
+ + setLabel(e.target.value)} + placeholder="Ex: Réunion du lundi" + maxLength={150} + /> +
+ +
+ + +
+ + {centerPos !== null && ( + + )} +
+ + +
+ +
+

Résumé

+
+
+
Taille
+
+ {rows} × {cols} = {totalCells} cases +
+
+
+
Phrases nécessaires
+
{needed}
+
+
+
Phrases sélectionnées
+
= needed ? "text-green-600" : "text-red-500"}`} + > + {selectedIds.size} / {needed} +
+
+
+ + {error && ( +
+ ❌ {error} +
+ )} + + +
+
+ +
+
+
+

💬 Banque de phrases

+
+ + + | + + +
+
+ +
+ {editingPhraseId && ( +

+ Modification d'une phrase personnalisée +

+ )} +
+ + + setNewPhraseText(e.target.value)} + maxLength={200} + /> + + {editingPhraseId && ( + + )} +
+
+ + {custom.length > 0 && ( + + + {custom.map((phrase) => ( + togglePhrase(phrase.id)} + onEdit={() => startEditPhrase(phrase)} + onDelete={() => void deletePhrase(phrase)} + /> + ))} + + + )} + + + {defaults.map((phrase) => ( + togglePhrase(phrase.id)} + /> + ))} + +
+
+
+
+ ); +} + +function PhraseSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function GridPreview({ + rows, + cols, + freePosition, +}: { + rows: number; + cols: number; + freePosition: number | null; +}) { + return ( +
+

Aperçu de la grille

+
+ {Array.from({ length: rows * cols }, (_, i) => ( +
+ {i === freePosition ? "★" : ""} +
+ ))} +
+
+ ); +} diff --git a/src/components/create-card/emoji-picker.tsx b/src/components/create-card/emoji-picker.tsx new file mode 100644 index 0000000..ffb23cd --- /dev/null +++ b/src/components/create-card/emoji-picker.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { EMOJI_CATEGORIES } from "@/lib/phrase-emojis"; + +type Props = { + value: string; + onChange: (emoji: string) => void; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +/** Bouton + popover de sélection d'emoji (fermeture au clic extérieur et à Échap). */ +export function EmojiPicker({ value, onChange, open, onOpenChange }: Props) { + const [categoryId, setCategoryId] = useState(EMOJI_CATEGORIES[0].id); + const containerRef = useRef(null); + const activeCategory = + EMOJI_CATEGORIES.find((category) => category.id === categoryId) ?? EMOJI_CATEGORIES[0]; + + useEffect(() => { + if (!open) return; + + function handlePointerDown(event: MouseEvent | TouchEvent) { + if (!containerRef.current?.contains(event.target as Node)) onOpenChange(false); + } + function handleEscape(event: KeyboardEvent) { + if (event.key === "Escape") onOpenChange(false); + } + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("touchstart", handlePointerDown); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("touchstart", handlePointerDown); + document.removeEventListener("keydown", handleEscape); + }; + }, [open, onOpenChange]); + + function select(emoji: string) { + onChange(emoji); + onOpenChange(false); + } + + return ( +
+ + + + {open && ( + +
+

+ Choisir un emoji +

+ {value && ( + + )} +
+ +
+ {EMOJI_CATEGORIES.map((category) => ( + + ))} +
+ +

{activeCategory.label}

+ +
+ {activeCategory.emojis.map((emoji) => ( + + ))} +
+
+ )} +
+
+ ); +} diff --git a/src/components/create-card/grid-size-input.tsx b/src/components/create-card/grid-size-input.tsx new file mode 100644 index 0000000..065d499 --- /dev/null +++ b/src/components/create-card/grid-size-input.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect, useId, useState } from "react"; + +export const GRID_SIZE_MIN = 2; +export const GRID_SIZE_MAX = 10; + +function clampGridSize(value: number): number { + if (Number.isNaN(value)) return GRID_SIZE_MIN; + return Math.max(GRID_SIZE_MIN, Math.min(GRID_SIZE_MAX, value)); +} + +/** Champ numérique tolérant la saisie libre, borné au blur. */ +export function GridSizeInput({ + label, + value, + onChange, +}: { + label: string; + value: number; + onChange: (next: number) => void; +}) { + const id = useId(); + const [draft, setDraft] = useState(String(value)); + + useEffect(() => { + setDraft(String(value)); + }, [value]); + + return ( +
+ + e.target.select()} + onChange={(e) => { + const raw = e.target.value; + setDraft(raw); + const parsed = parseInt(raw, 10); + if (parsed >= GRID_SIZE_MIN && parsed <= GRID_SIZE_MAX) onChange(parsed); + }} + onBlur={() => { + const next = clampGridSize(parseInt(draft, 10)); + setDraft(String(next)); + onChange(next); + }} + /> +
+ ); +} diff --git a/src/components/create-card/phrase-chip.tsx b/src/components/create-card/phrase-chip.tsx new file mode 100644 index 0000000..0eb58ea --- /dev/null +++ b/src/components/create-card/phrase-chip.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { motion } from "framer-motion"; +import type { TeamPhrase } from "@/lib/data"; + +type Props = { + phrase: TeamPhrase; + selected: boolean; + editing?: boolean; + onClick: () => void; + onEdit?: () => void; + onDelete?: () => void; +}; + +/** Phrase sélectionnable ; boutons modifier / supprimer pour les phrases d'équipe. */ +export function PhraseChip({ phrase, selected, editing, onClick, onEdit, onDelete }: Props) { + const isHighlighted = selected || editing; + + return ( + + + {phrase.emoji && {phrase.emoji}} + {phrase.text} + + + {(onEdit || onDelete) && ( +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+ )} +
+ ); +} diff --git a/src/components/dashboard/dashboard-client.tsx b/src/components/dashboard/dashboard-client.tsx index 7a00a5f..8d8927d 100644 --- a/src/components/dashboard/dashboard-client.tsx +++ b/src/components/dashboard/dashboard-client.tsx @@ -4,6 +4,8 @@ import { useState } from "react"; import Link from "next/link"; import { useSession } from "next-auth/react"; import { motion, AnimatePresence } from "framer-motion"; +import { apiRequest } from "@/lib/api-client"; +import { canDeleteOwnedResource } from "@/lib/authz-rules"; import type { DashboardTeam } from "@/types"; type Props = { @@ -24,54 +26,50 @@ export function DashboardClient({ initialTeams, isAdmin = false }: Props) { const [deletingId, setDeletingId] = useState(null); const [error, setError] = useState(""); - function canDeleteTeam(team: DashboardTeam) { - if (isAdmin) return true; - return Boolean(userId && team.createdById && team.createdById === userId); - } + const canDeleteTeam = (team: DashboardTeam) => + canDeleteOwnedResource(userId, team.createdById, isAdmin); async function fetchTeams() { - const res = await fetch("/api/teams"); - if (res.ok) setTeams(await res.json()); + const result = await apiRequest("/api/teams"); + if (result.ok) setTeams(result.data); } async function createTeam(e: React.FormEvent) { e.preventDefault(); setCreating(true); setError(""); - const res = await fetch("/api/teams", { + const result = await apiRequest("/api/teams", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: teamName }), + json: { name: teamName }, + fallbackError: "Création impossible", }); - setCreating(false); - if (res.ok) { + if (result.ok) { await fetchTeams(); setShowCreate(false); setTeamName(""); } else { - const d = await res.json(); - setError(d.error); + setError(result.error); } + setCreating(false); } async function joinTeam(e: React.FormEvent) { e.preventDefault(); setJoining(true); setError(""); - const res = await fetch("/api/teams/join", { + const result = await apiRequest("/api/teams/join", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ inviteCode }), + json: { inviteCode }, + fallbackError: "Impossible de rejoindre l'équipe", }); - setJoining(false); - if (res.ok) { + if (result.ok) { await fetchTeams(); setShowJoin(false); setInviteCode(""); } else { - const d = await res.json(); - setError(d.error); + setError(result.error); } + setJoining(false); } async function deleteTeam(team: DashboardTeam) { @@ -84,13 +82,15 @@ export function DashboardClient({ initialTeams, isAdmin = false }: Props) { } setDeletingId(team.id); setError(""); - const res = await fetch(`/api/teams/${team.id}`, { method: "DELETE" }); + const result = await apiRequest(`/api/teams/${team.id}`, { + method: "DELETE", + fallbackError: "Suppression impossible", + }); setDeletingId(null); - if (res.ok) { + if (result.ok) { setTeams((prev) => prev.filter((t) => t.id !== team.id)); } else { - const d = await res.json().catch(() => ({})); - setError(d.error || "Suppression impossible"); + setError(result.error); } } diff --git a/src/components/navbar/navbar.tsx b/src/components/navbar/navbar.tsx index 5d82c18..6378e2c 100644 --- a/src/components/navbar/navbar.tsx +++ b/src/components/navbar/navbar.tsx @@ -2,25 +2,17 @@ import { signOut, useSession } from "next-auth/react"; import Link from "next/link"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { MarkItBrand } from "@/components/markit-logo/markit-logo"; type NavbarProps = { minimal?: boolean; + isAdmin?: boolean; }; -export function Navbar({ minimal = false }: NavbarProps) { +export function Navbar({ minimal = false, isAdmin = false }: NavbarProps) { const { data: session } = useSession(); const [menuOpen, setMenuOpen] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); - - useEffect(() => { - if (!session?.user?.id || minimal) return; - fetch("/api/admin/me") - .then((r) => r.json()) - .then((d) => setIsAdmin(d.isAdmin === true)) - .catch(() => {}); - }, [session?.user?.id, minimal]); return (