diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index ac5942f58..ae0a9b481 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -1,14 +1,54 @@ -import globals from "globals"; -import pluginJs from "@eslint/js"; -import tseslint from "typescript-eslint"; -import pluginReact from "eslint-plugin-react"; +import typescript from '@typescript-eslint/eslint-plugin'; +import parser from '@typescript-eslint/parser'; +import react from 'eslint-plugin-react'; +import storybook from 'eslint-plugin-storybook'; +import configPrettier from 'eslint-config-prettier'; +const config = [ + { + files: ['src/**/*.{js,ts,jsx,tsx}'], + ignores: [ + 'dist/**', + 'node_modules/**', + 'coverage/**', + 'public/**', + 'jest.config.js', + 'jest.setup.ts', + 'netlify.toml', + ], + languageOptions: { + parser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + }, + plugins: { + '@typescript-eslint': typescript, + react, + storybook, + }, + rules: { + semi: ['error', 'always'], + quotes: ['error', 'single'], + 'no-console': 'warn', + 'react/react-in-jsx-scope': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_' }, + ], + '@typescript-eslint/explicit-function-return-type': 'off', + }, + settings: { + react: { + version: 'detect', + }, + }, + }, + configPrettier, +]; -/** @type {import('eslint').Linter.Config[]} */ -export default [ - {files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"]}, - {languageOptions: { globals: globals.browser }}, - pluginJs.configs.recommended, - ...tseslint.configs.recommended, - pluginReact.configs.flat.recommended, -]; \ No newline at end of file +export default config; diff --git a/frontend/jest.config.js b/frontend/jest.config.js index c3e6e5479..c72b66260 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -1,7 +1,8 @@ /** @type {import('ts-jest').JestConfigWithTsJest} **/ + module.exports = { setupFiles: ['/jest.setup.ts'], - testEnvironment: 'jsdom', + testEnvironment: 'jest-fixed-jsdom', transform: { '^.+\\.tsx?$': ['ts-jest', {}], '\\.(svg|png|jpg|jpeg|gif)$': 'jest-transform-stub', diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 82a04cca6..44e171a50 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,7 +15,9 @@ "@tanstack/react-query": "^5.66.0", "date-fns": "^4.1.0", "dotenv-webpack": "^8.1.0", + "jest-fixed-jsdom": "^0.0.9", "mixpanel-browser": "^2.60.0", + "msw": "^2.8.2", "react": "^19.0.0", "react-datepicker": "^8.1.0", "react-dom": "^19.0.0", @@ -50,6 +52,8 @@ "@types/react-refresh": "^0.14.6", "@types/webpack": "^5.28.5", "@types/webpack-dev-server": "^4.7.1", + "@typescript-eslint/eslint-plugin": "^8.32.1", + "@typescript-eslint/parser": "^8.32.1", "chromatic": "^11.25.0", "copy-webpack-plugin": "^13.0.0", "css-loader": "^7.1.2", @@ -57,6 +61,7 @@ "detect-port": "^2.1.0", "esbuild-loader": "^4.3.0", "eslint": "^9.17.0", + "eslint-config-prettier": "^10.1.5", "eslint-plugin-react": "^7.37.3", "eslint-plugin-storybook": "^0.11.2", "fork-ts-checker-webpack-plugin": "^9.0.2", @@ -74,7 +79,6 @@ "ts-jest": "^29.3.0", "tsx": "^4.19.2", "typescript": "^5.7.2", - "typescript-eslint": "^8.19.0", "webpack": "^5.97.1", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.0", @@ -594,6 +598,43 @@ "dev": true, "license": "MIT" }, + "node_modules/@bundled-es-modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", + "license": "ISC", + "dependencies": { + "cookie": "^0.7.2" + } + }, + "node_modules/@bundled-es-modules/cookie/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@bundled-es-modules/statuses": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", + "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", + "license": "ISC", + "dependencies": { + "statuses": "^2.0.1" + } + }, + "node_modules/@bundled-es-modules/tough-cookie": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz", + "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==", + "license": "ISC", + "dependencies": { + "@types/tough-cookie": "^4.0.5", + "tough-cookie": "^4.1.4" + } + }, "node_modules/@channel.io/channel-web-sdk-loader": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@channel.io/channel-web-sdk-loader/-/channel-web-sdk-loader-2.0.0.tgz", @@ -1107,9 +1148,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, "license": "MIT", "dependencies": { @@ -1352,6 +1393,106 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/confirm": { + "version": "5.1.10", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.10.tgz", + "integrity": "sha512-FxbQ9giWxUWKUk2O5XZ6PduVnH2CZ/fmMKMBkH71MHJvWr7WL5AHKevhzF1L5uYWB2P548o1RzVxrNd3dpmk6g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.11", + "@inquirer/type": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.11.tgz", + "integrity": "sha512-BXwI/MCqdtAhzNQlBEFE7CEflhPkl/BqvAuV/aK6lW3DClIfYVDWPP/kXuXHtBWC7/EEbNqd/1BGq2BGBBnuxw==", + "license": "MIT", + "dependencies": { + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.6", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.11.tgz", + "integrity": "sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.6.tgz", + "integrity": "sha512-/mKVCtVpyBu3IDarv0G+59KC4stsD5mDsGpYh+GKs1NZT88Jh52+cuoA1AtLk2Q0r/quNl+1cSUyLRHBFeD0XA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1508,7 +1649,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, "license": "MIT", "dependencies": { "@jest/fake-timers": "^29.7.0", @@ -1551,7 +1691,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -1645,7 +1784,6 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.27.8" @@ -1732,7 +1870,6 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -1886,6 +2023,23 @@ "react": ">=16" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.37.6", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", + "integrity": "sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==", + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1924,6 +2078,28 @@ "node": ">= 8" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "license": "MIT" + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz", @@ -2495,14 +2671,12 @@ "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, "license": "MIT" }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" @@ -2512,7 +2686,6 @@ "version": "10.3.0", "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.0" @@ -4048,7 +4221,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10" @@ -4357,14 +4529,12 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, "license": "MIT", "dependencies": { "@types/istanbul-lib-coverage": "*" @@ -4374,7 +4544,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/istanbul-lib-report": "*" @@ -4430,7 +4599,6 @@ "version": "20.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -4632,7 +4800,12 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/statuses": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.5.tgz", + "integrity": "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==", "license": "MIT" }, "node_modules/@types/stylis": { @@ -4652,7 +4825,6 @@ "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/uglify-js": { @@ -4736,7 +4908,6 @@ "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", - "dev": true, "license": "MIT", "dependencies": { "@types/yargs-parser": "*" @@ -4746,25 +4917,24 @@ "version": "21.0.3", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.0.tgz", - "integrity": "sha512-NggSaEZCdSrFddbctrVjkVZvFC6KGfKfNK0CU7mNK/iKHGKbzT4Wmgm08dKpcZECBu9f5FypndoMyRHkdqfT1Q==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz", + "integrity": "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.19.0", - "@typescript-eslint/type-utils": "8.19.0", - "@typescript-eslint/utils": "8.19.0", - "@typescript-eslint/visitor-keys": "8.19.0", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/type-utils": "8.32.1", + "@typescript-eslint/utils": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "ignore": "^7.0.0", "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4776,20 +4946,30 @@ "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", + "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.0.tgz", - "integrity": "sha512-6M8taKyOETY1TKHp0x8ndycipTVgmp4xtg5QpEZzXxDhNvvHOJi5rLRkLr8SK3jTgD5l4fTlvBiRdfsuWydxBw==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.1.tgz", + "integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.19.0", - "@typescript-eslint/types": "8.19.0", - "@typescript-eslint/typescript-estree": "8.19.0", - "@typescript-eslint/visitor-keys": "8.19.0", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/typescript-estree": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", "debug": "^4.3.4" }, "engines": { @@ -4801,18 +4981,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.19.0.tgz", - "integrity": "sha512-hkoJiKQS3GQ13TSMEiuNmSCvhz7ujyqD1x3ShbaETATHrck+9RaDdUbt+osXaUuns9OFwrDTTrjtwsU8gJyyRA==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz", + "integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.19.0", - "@typescript-eslint/visitor-keys": "8.19.0" + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4823,16 +5003,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.19.0.tgz", - "integrity": "sha512-TZs0I0OSbd5Aza4qAMpp1cdCYVnER94IziudE3JU328YUHgWu9gwiwhag+fuLeJ2LkWLXI+F/182TbG+JaBdTg==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.1.tgz", + "integrity": "sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.19.0", - "@typescript-eslint/utils": "8.19.0", + "@typescript-eslint/typescript-estree": "8.32.1", + "@typescript-eslint/utils": "8.32.1", "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4843,13 +5023,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.19.0.tgz", - "integrity": "sha512-8XQ4Ss7G9WX8oaYvD4OOLCjIQYgRQxO+qCiR2V2s2GxI9AUpo7riNwo6jDhKtTcaJjT8PY54j2Yb33kWtSJsmA==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz", + "integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==", "dev": true, "license": "MIT", "engines": { @@ -4861,20 +5041,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.19.0.tgz", - "integrity": "sha512-WW9PpDaLIFW9LCbucMSdYUuGeFUz1OkWYS/5fwZwTA+l2RwlWFdJvReQqMUMBw4yJWJOfqd7An9uwut2Oj8sLw==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz", + "integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.19.0", - "@typescript-eslint/visitor-keys": "8.19.0", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4884,7 +5064,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { @@ -4914,9 +5094,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -4927,16 +5107,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.19.0.tgz", - "integrity": "sha512-PTBG+0oEMPH9jCZlfg07LCB2nYI0I317yyvXGfxnvGvw4SHIOuRnQ3kadyyXY6tGdChusIHIbM5zfIbp4M6tCg==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz", + "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.19.0", - "@typescript-eslint/types": "8.19.0", - "@typescript-eslint/typescript-estree": "8.19.0" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/typescript-estree": "8.32.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4947,17 +5127,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.19.0.tgz", - "integrity": "sha512-mCFtBbFBJDCNCWUl5y6sZSCHXw1DEFEk3c/M3nRK2a4XUB8StGFtmcEMizdjKuBzB6e/smJAAWYug3VrdLMr1w==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz", + "integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.19.0", + "@typescript-eslint/types": "8.32.1", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -5276,7 +5456,6 @@ "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "deprecated": "Use your platform's native atob() and btoa() methods instead", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/accepts": { @@ -5319,7 +5498,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.1.0", @@ -5340,7 +5518,6 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -5439,7 +5616,6 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, "license": "MIT", "dependencies": { "type-fest": "^0.21.3" @@ -5455,7 +5631,6 @@ "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -5494,7 +5669,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5504,7 +5678,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5723,7 +5896,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/available-typed-arrays": { @@ -6181,7 +6353,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6322,7 +6493,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -6339,7 +6509,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -6469,7 +6638,6 @@ "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, "funding": [ { "type": "github", @@ -6501,11 +6669,19 @@ "node": ">= 10.0" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -6562,7 +6738,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6575,7 +6750,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colord": { @@ -6596,7 +6770,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -7316,14 +7489,12 @@ "version": "0.5.0", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true, "license": "MIT" }, "node_modules/cssstyle": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "dev": true, "license": "MIT", "dependencies": { "cssom": "~0.3.6" @@ -7336,7 +7507,6 @@ "version": "0.3.8", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true, "license": "MIT" }, "node_modules/csstype": { @@ -7349,7 +7519,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", - "dev": true, "license": "MIT", "dependencies": { "abab": "^2.0.6", @@ -7364,7 +7533,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.1.1" @@ -7377,7 +7545,6 @@ "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" @@ -7387,7 +7554,6 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "dev": true, "license": "MIT", "dependencies": { "tr46": "^3.0.0", @@ -7482,7 +7648,6 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", - "dev": true, "license": "MIT" }, "node_modules/decode-named-character-reference": { @@ -7615,7 +7780,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -7784,7 +7948,6 @@ "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", "deprecated": "Use your platform's native DOMException instead", - "dev": true, "license": "MIT", "dependencies": { "webidl-conversions": "^7.0.0" @@ -7797,7 +7960,6 @@ "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" @@ -7894,7 +8056,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -7951,7 +8112,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/emojis-list": { @@ -8112,7 +8272,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8122,7 +8281,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8166,7 +8324,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -8179,7 +8336,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8784,7 +8940,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "esprima": "^4.0.1", @@ -8806,7 +8961,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -8872,6 +9026,22 @@ } } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", + "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.3", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.3.tgz", @@ -9104,7 +9274,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -9191,7 +9360,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -9360,9 +9528,9 @@ "license": "MIT" }, "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", "dependencies": { @@ -9370,7 +9538,7 @@ "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.4" + "micromatch": "^4.0.8" }, "engines": { "node": ">=8.6.0" @@ -9413,9 +9581,9 @@ } }, "node_modules/fastq": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", - "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "dev": true, "license": "ISC", "dependencies": { @@ -9708,7 +9876,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -9786,7 +9953,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9836,7 +10002,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -9846,7 +10011,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -9881,7 +10045,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -10009,7 +10172,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10031,6 +10193,15 @@ "dev": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", + "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -10093,7 +10264,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10106,7 +10276,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -10122,7 +10291,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -10310,6 +10478,14 @@ "he": "bin/he" } }, + + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "license": "MIT" + }, + "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -10369,7 +10545,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", - "dev": true, "license": "MIT", "dependencies": { "whatwg-encoding": "^2.0.0" @@ -10557,7 +10732,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dev": true, "license": "MIT", "dependencies": { "@tootallnate/once": "2", @@ -11038,7 +11212,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11140,6 +11313,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -11196,7 +11375,6 @@ "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-regex": { @@ -11950,7 +12128,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", - "dev": true, "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", @@ -12003,6 +12180,18 @@ "promise-polyfill": "^8.1.3" } }, + "node_modules/jest-fixed-jsdom": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/jest-fixed-jsdom/-/jest-fixed-jsdom-0.0.9.tgz", + "integrity": "sha512-KPfqh2+sn5q2B+7LZktwDcwhCpOpUSue8a1I+BcixWLOQoEVyAjAGfH+IYZGoxZsziNojoHGRTC8xRbB1wDD4g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "jest-environment-jsdom": ">=28.0.0" + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -12159,7 +12348,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.12.13", @@ -12180,7 +12368,6 @@ "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" @@ -12193,7 +12380,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -12208,14 +12394,12 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, "license": "MIT" }, "node_modules/jest-mock": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -12500,7 +12684,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -12647,7 +12830,6 @@ "version": "20.0.3", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "dev": true, "license": "MIT", "dependencies": { "abab": "^2.0.6", @@ -12693,7 +12875,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.1.1" @@ -12706,7 +12887,6 @@ "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" @@ -12716,7 +12896,6 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "dev": true, "license": "MIT", "dependencies": { "tr46": "^3.0.0", @@ -13078,7 +13257,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -14016,7 +14194,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -14218,6 +14395,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msw": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.8.2.tgz", + "integrity": "sha512-ugu8RBgUj6//RD0utqDDPdS+QIs36BKYkDAM6u59hcMVtFM4PM0vW4l3G1R+1uCWP2EWFUG8reT/gPXVEtx7/w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@bundled-es-modules/cookie": "^2.0.1", + "@bundled-es-modules/statuses": "^1.0.1", + "@bundled-es-modules/tough-cookie": "^0.1.6", + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.37.0", + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/until": "^2.1.0", + "@types/cookie": "^0.6.0", + "@types/statuses": "^2.0.4", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "strict-event-emitter": "^0.5.1", + "type-fest": "^4.26.1", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, + "node_modules/msw/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/multicast-dns": { "version": "7.2.5", "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", @@ -14232,6 +14471,15 @@ "multicast-dns": "cli.js" } }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/nanoid": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", @@ -14373,7 +14621,6 @@ "version": "2.2.20", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", - "dev": true, "license": "MIT" }, "node_modules/object-assign": { @@ -14583,6 +14830,12 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -15758,7 +16011,6 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -15813,7 +16065,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, "license": "MIT" }, "node_modules/queue-microtask": { @@ -16309,7 +16560,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16328,7 +16578,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, "license": "MIT" }, "node_modules/resolve": { @@ -16406,9 +16655,9 @@ } }, "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { @@ -16597,14 +16846,12 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": 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" @@ -17041,7 +17288,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -17147,7 +17393,6 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" @@ -17160,7 +17405,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -17177,7 +17421,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -17210,6 +17453,12 @@ } } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -17238,7 +17487,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -17365,7 +17613,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -17706,7 +17953,6 @@ "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/tabbable": { @@ -17969,7 +18215,6 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "psl": "^1.1.33", @@ -17985,7 +18230,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4.0.0" @@ -18035,16 +18279,16 @@ } }, "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/ts-dedent": { @@ -18205,7 +18449,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -18320,7 +18563,7 @@ "version": "5.7.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -18330,29 +18573,6 @@ "node": ">=14.17" } }, - "node_modules/typescript-eslint": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.19.0.tgz", - "integrity": "sha512-Ni8sUkVWYK4KAcTtPjQ/UTiRk6jcsuDhPpxULapUDi8A/l8TSBk+t1GtJA1RsCzIJg0q6+J7bf35AwQigENWRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.19.0", - "@typescript-eslint/parser": "8.19.0", - "@typescript-eslint/utils": "8.19.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -18568,7 +18788,6 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, "license": "MIT", "dependencies": { "querystringify": "^2.1.1", @@ -18701,7 +18920,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", - "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^4.0.0" @@ -19131,7 +19349,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "dev": true, "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" @@ -19144,7 +19361,6 @@ "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" @@ -19157,7 +19373,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -19304,7 +19519,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -19343,7 +19557,6 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -19365,7 +19578,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=12" @@ -19375,14 +19587,12 @@ "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/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -19408,7 +19618,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -19427,7 +19636,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -19445,6 +19653,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index d4acc9d9d..f3187e7a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,7 @@ "build-storybook": "storybook build", "chromatic": "chromatic --project-token=$CHROMATIC_PROJECT_TOKEN", "test": "jest", - "coverage": "jest --coverage" + "coverage": "jest --coverage --config jest.config.js" }, "keywords": [], "author": "", @@ -26,7 +26,9 @@ "@tanstack/react-query": "^5.66.0", "date-fns": "^4.1.0", "dotenv-webpack": "^8.1.0", + "jest-fixed-jsdom": "^0.0.9", "mixpanel-browser": "^2.60.0", + "msw": "^2.8.2", "react": "^19.0.0", "react-datepicker": "^8.1.0", "react-dom": "^19.0.0", @@ -61,6 +63,8 @@ "@types/react-refresh": "^0.14.6", "@types/webpack": "^5.28.5", "@types/webpack-dev-server": "^4.7.1", + "@typescript-eslint/eslint-plugin": "^8.32.1", + "@typescript-eslint/parser": "^8.32.1", "chromatic": "^11.25.0", "copy-webpack-plugin": "^13.0.0", "css-loader": "^7.1.2", @@ -68,6 +72,7 @@ "detect-port": "^2.1.0", "esbuild-loader": "^4.3.0", "eslint": "^9.17.0", + "eslint-config-prettier": "^10.1.5", "eslint-plugin-react": "^7.37.3", "eslint-plugin-storybook": "^0.11.2", "fork-ts-checker-webpack-plugin": "^9.0.2", @@ -85,7 +90,6 @@ "ts-jest": "^29.3.0", "tsx": "^4.19.2", "typescript": "^5.7.2", - "typescript-eslint": "^8.19.0", "webpack": "^5.97.1", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.0", diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js new file mode 100644 index 000000000..58eb75a35 --- /dev/null +++ b/frontend/public/mockServiceWorker.js @@ -0,0 +1,307 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.7.6' +const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body], + ) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer() + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)), + ) + }) +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d1dcb0967..e709ef5ba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { Suspense } from 'react'; +import { Suspense } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SearchProvider } from '@/context/SearchContext'; @@ -14,11 +14,12 @@ import AccountEditTab from '@/pages/AdminPage/tabs/AccountEditTab/AccountEditTab import LoginTab from '@/pages/AdminPage/auth/LoginTab/LoginTab'; import PrivateRoute from '@/pages/AdminPage/auth/PrivateRoute/PrivateRoute'; import PhotoEditTab from '@/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab'; +// TODO: 지원서 개발 완료 후 활성화 +// import AnswerApplicationForm from '@/pages/AdminPage/application/answer/AnswerApplicationForm'; +// import CreateApplicationForm from '@/pages/AdminPage/application/CreateApplicationForm'; const queryClient = new QueryClient(); -// [x]TODO: fallback component 추가 - const App = () => { return ( @@ -65,12 +66,24 @@ const App = () => { path='account-edit' element={} /> + {/*🔒 메인 브랜치에서는 접근 차단 (배포용 차단 목적)*/} + {/*develop-fe 브랜치에서는 접근 가능하도록 풀고 개발 예정*/} + {/*}*/} + {/*/>*/} } /> + {/*🔒 사용자용 지원서 작성 페이지도 메인에서는 비활성화 처리 */} + {/*🛠 develop-fe에서는 다시 노출 예정*/} + {/*}*/} + {/*/>*/} } /> diff --git a/frontend/src/apis/application/createApplication.ts b/frontend/src/apis/application/createApplication.ts new file mode 100644 index 000000000..a2ad1fadc --- /dev/null +++ b/frontend/src/apis/application/createApplication.ts @@ -0,0 +1,33 @@ +import API_BASE_URL from '@/constants/api'; +import { secureFetch } from '@/apis/auth/secureFetch'; +import { ApplicationFormData } from '@/types/application'; + +export const createApplication = async ( + data: ApplicationFormData, + clubId: string, +) => { + try { + const response = await secureFetch( + `${API_BASE_URL}/api/club/${clubId}/application`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }, + ); + + if (!response.ok) { + throw new Error('지원서 제출에 실패했습니다.'); + } + + const result = await response.json(); + return result.data; + } catch (error) { + console.error('지원서 제출 중 오류 발생:', error); + throw error; + } +}; + +export default createApplication; diff --git a/frontend/src/apis/application/getApplication.ts b/frontend/src/apis/application/getApplication.ts new file mode 100644 index 000000000..a9ec0e0bc --- /dev/null +++ b/frontend/src/apis/application/getApplication.ts @@ -0,0 +1,20 @@ +import API_BASE_URL from '@/constants/api'; + +const getApplication = async (clubId: string) => { + try { + const response = await fetch(`${API_BASE_URL}/api/club/${clubId}/apply`); + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.statusText}`); + } + + const result = await response.json(); + return result.data; + } catch (error) { + // [x] FIXME: + // {"statuscode":"800-1","message":"지원서가 존재하지 않습니다.","data":null} + console.error('Error fetching club details', error); + throw error; + } +}; + +export default getApplication; diff --git a/frontend/src/apis/application/updateApplication.ts b/frontend/src/apis/application/updateApplication.ts new file mode 100644 index 000000000..6111af261 --- /dev/null +++ b/frontend/src/apis/application/updateApplication.ts @@ -0,0 +1,33 @@ +import API_BASE_URL from '@/constants/api'; +import { secureFetch } from '@/apis/auth/secureFetch'; +import { ApplicationFormData } from '@/types/application'; + +export const updateApplication = async ( + data: ApplicationFormData, + clubId: string, +) => { + try { + const response = await secureFetch( + `${API_BASE_URL}/api/club/${clubId}/application`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }, + ); + + if (!response.ok) { + throw new Error('지원서 수정에 실패했습니다.'); + } + + const result = await response.json(); + return result.data; + } catch (error) { + console.error('지원서 수정 중 오류 발생:', error); + throw error; + } +}; + +export default updateApplication; diff --git a/frontend/src/assets/images/icons/drop_button_icon.svg b/frontend/src/assets/images/icons/drop_button_icon.svg new file mode 100644 index 000000000..24399c538 --- /dev/null +++ b/frontend/src/assets/images/icons/drop_button_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/ClubLogo/ClubLogo.tsx b/frontend/src/components/ClubLogo/ClubLogo.tsx index a0d812eb3..f34da1c7b 100644 --- a/frontend/src/components/ClubLogo/ClubLogo.tsx +++ b/frontend/src/components/ClubLogo/ClubLogo.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import styled, { css } from 'styled-components'; type LogoVariant = 'main' | 'detail'; diff --git a/frontend/src/components/ClubStateBox/ClubStateBox.tsx b/frontend/src/components/ClubStateBox/ClubStateBox.tsx index 8f5e4fa45..792f81007 100644 --- a/frontend/src/components/ClubStateBox/ClubStateBox.tsx +++ b/frontend/src/components/ClubStateBox/ClubStateBox.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import styled from 'styled-components'; const stateStyles: Record< diff --git a/frontend/src/components/ClubTag/ClubTag.tsx b/frontend/src/components/ClubTag/ClubTag.tsx index b0ebe4c34..4711a5a54 100644 --- a/frontend/src/components/ClubTag/ClubTag.tsx +++ b/frontend/src/components/ClubTag/ClubTag.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import styled from 'styled-components'; const TagColors: Record = { diff --git a/frontend/src/components/common/Button/Button.tsx b/frontend/src/components/common/Button/Button.tsx index e1e4c3d8c..cecfaaa74 100644 --- a/frontend/src/components/common/Button/Button.tsx +++ b/frontend/src/components/common/Button/Button.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import styled, { keyframes, css } from 'styled-components'; export interface ButtonProps { diff --git a/frontend/src/components/common/CustomDropDown/CustomDropDown.styles.ts b/frontend/src/components/common/CustomDropDown/CustomDropDown.styles.ts new file mode 100644 index 000000000..4a54f5049 --- /dev/null +++ b/frontend/src/components/common/CustomDropDown/CustomDropDown.styles.ts @@ -0,0 +1,60 @@ +import styled from 'styled-components'; + +export const DropDownWrapper = styled.div` + position: relative; + width: 100%; +`; + +export const Selected = styled.div<{ open: boolean }>` + padding: 12px 16px; + border-radius: 0.375rem; + background: ${({ open }) => (open ? '#fff' : '#f5f5f5')}; + color: #787878; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + border: 1px solid ${({ open }) => (open ? '#c5c5c5' : 'transparent')}; + transition: + border-color 0.2s ease, + background-color 0.2s ease; + + user-select: none; +`; + +export const OptionList = styled.ul` + position: absolute; + top: 100%; + left: 0; + width: 100%; + background: #fff; + border-radius: 6px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05); + z-index: 10; + list-style: none; +`; + +export const OptionItem = styled.li<{ isSelected: boolean }>` + text-align: center; + padding: 10px; + margin: 4px; + font-weight: 600; + border-radius: 6px; + color: #787878; + background-color: ${({ isSelected }) => (isSelected ? '#DCDCDC' : '#fff')}; + cursor: pointer; + + &:hover { + background-color: #dcdcdc; + } + + transition: background-color 0.2s ease; + user-select: none; +`; + +export const Icon = styled.img` + position: absolute; + top: 50%; + right: 19px; + transform: translateY(-50%); + pointer-events: none; +`; diff --git a/frontend/src/components/common/CustomDropDown/CustomDropDown.tsx b/frontend/src/components/common/CustomDropDown/CustomDropDown.tsx new file mode 100644 index 000000000..e5b7a437a --- /dev/null +++ b/frontend/src/components/common/CustomDropDown/CustomDropDown.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import * as Styled from './CustomDropDown.styles'; +import dropdown_icon from '@/assets/images/icons/drop_button_icon.svg'; + +interface DropdownOption { + label: string; + value: string; +} + +interface DropdownProps { + options: DropdownOption[]; + selected: string; + onSelect: (value: string) => void; +} + +const CustomDropdown = ({ options, selected, onSelect }: DropdownProps) => { + const [open, setOpen] = useState(false); + + const handleSelect = (value: string) => { + onSelect(value); + setOpen(false); + }; + + const selectedLabel = + options.find((option) => option.value === selected)?.label || '선택하세요'; + + return ( + + setOpen((prev) => !prev)} open={open}> + {selectedLabel} + + + {open && ( + + {options.map(({ label, value }) => ( + handleSelect(value)} + > + {label} + + ))} + + )} + + ); +}; + +export default CustomDropdown; diff --git a/frontend/src/components/common/CustomTextArea/CustomTextArea.styles.ts b/frontend/src/components/common/CustomTextArea/CustomTextArea.styles.ts new file mode 100644 index 000000000..70ddbeb39 --- /dev/null +++ b/frontend/src/components/common/CustomTextArea/CustomTextArea.styles.ts @@ -0,0 +1,71 @@ +import styled from 'styled-components'; + +//Todo : InputField 컴포넌트와 중복되는 부분이 많아 추후 리팩토링 검토 + +export const TextAreaContainer = styled.div<{ width: string }>` + width: ${(props) => props.width}; + min-width: 385px; + display: flex; + flex-direction: column; +`; + +export const Label = styled.label` + font-size: 1.125rem; + margin-bottom: 12px; + font-weight: 600; +`; + +export const TextAreaWrapper = styled.div` + position: relative; + display: flex; + align-items: center; +`; + +export const TextArea = styled.textarea<{ hasError?: boolean }>` + flex: 1; + height: 45px; + padding: 12px 18px; + border: 1px solid ${({ hasError }) => (hasError ? 'red' : '#c5c5c5')}; + border-radius: 6px; + outline: none; + font-size: 1.125rem; + letter-spacing: 0; + color: rgba(0, 0, 0, 0.8); + overflow: hidden; + resize: none; + + &:focus { + border-color: ${({ hasError }) => (hasError ? 'red' : '#007bff')}; + box-shadow: 0 0 3px + ${({ hasError }) => + hasError ? 'rgba(255, 0, 0, 0.5)' : 'rgba(0, 123, 255, 0.5)'}; + } + + &:disabled { + background-color: rgba(0, 0, 0, 0.05); + } + &::placeholder { + color: rgba(0, 0, 0, 0.3); + } +`; + +export const CharCount = styled.span` + position: absolute; + color: #c5c5c5; + right: 0; + top: 100%; + font-size: 16px; + letter-spacing: -0.96px; +`; + +export const HelperText = styled.div` + position: absolute; + left: 0; + top: 100%; + font-size: 0.75rem; + color: red; + margin-top: 4px; + pointer-events: none; + white-space: nowrap; + z-index: 1; +`; diff --git a/frontend/src/components/common/CustomTextArea/CustomTextArea.tsx b/frontend/src/components/common/CustomTextArea/CustomTextArea.tsx new file mode 100644 index 000000000..7884b6b08 --- /dev/null +++ b/frontend/src/components/common/CustomTextArea/CustomTextArea.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef } from 'react'; +import * as Styled from './CustomTextArea.styles'; + +//Todo : InputField 컴포넌트와 중복되는 부분이 많아 추후 리팩토링 검토 + +interface CustomTextAreaProps { + placeholder?: string; + width?: string; + maxLength?: number; + label?: string; + showMaxChar?: boolean; + disabled?: boolean; + value?: string; + onChange?: (e: React.ChangeEvent) => void; + isError?: boolean; + helperText?: string; +} + +const CustomTextArea = ({ + placeholder = '입력하세요', + width = '100%', + maxLength, + label, + showMaxChar = false, + disabled = false, + value = '', + onChange, + isError, + helperText, +}: CustomTextAreaProps) => { + const textAreaRef = useRef(null); + + useEffect(() => { + if (disabled) { + return; + } + const el = textAreaRef.current; + if (el) { + el.style.height = 'auto'; // 초기화 + el.style.height = `${el.scrollHeight}px`; + } + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; + if (maxLength !== undefined && inputValue.length > maxLength) { + return; + } + onChange?.(e); + }; + + return ( + + {label && {label}} + + + {showMaxChar && maxLength !== undefined && ( + + {value.length}/{maxLength} + + )} + + {isError && helperText && ( + {helperText} + )} + + ); +}; + +export default CustomTextArea; diff --git a/frontend/src/components/common/Footer/Footer.tsx b/frontend/src/components/common/Footer/Footer.tsx index b7a70475d..ee3da62de 100644 --- a/frontend/src/components/common/Footer/Footer.tsx +++ b/frontend/src/components/common/Footer/Footer.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import * as Styled from './Footer.styles'; const Footer = () => { diff --git a/frontend/src/components/common/Header/Header.tsx b/frontend/src/components/common/Header/Header.tsx index 428c6a918..ef23c91ee 100644 --- a/frontend/src/components/common/Header/Header.tsx +++ b/frontend/src/components/common/Header/Header.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { useLocation } from 'react-router-dom'; import * as Styled from './Header.styles'; import SearchBox from '@/components/common/SearchBox/SearchBox'; diff --git a/frontend/src/components/common/InputField/InputField.styles.ts b/frontend/src/components/common/InputField/InputField.styles.ts index 42dc0655c..4638140a6 100644 --- a/frontend/src/components/common/InputField/InputField.styles.ts +++ b/frontend/src/components/common/InputField/InputField.styles.ts @@ -1,10 +1,15 @@ import styled from 'styled-components'; -export const InputContainer = styled.div<{ width: string }>` +export const InputContainer = styled.div<{ width: string; readOnly?: boolean }>` width: ${(props) => props.width}; min-width: 385px; display: flex; flex-direction: column; + + @media (max-width: 768px) { + min-width: 0; + width: 100%; + } `; export const Label = styled.label` @@ -19,7 +24,7 @@ export const InputWrapper = styled.div` align-items: center; `; -export const Input = styled.input<{ hasError?: boolean }>` +export const Input = styled.input<{ hasError?: boolean; readOnly?: boolean }>` flex: 1; height: 45px; padding: 12px 80px 12px 18px; @@ -29,19 +34,25 @@ export const Input = styled.input<{ hasError?: boolean }>` font-size: 1.125rem; letter-spacing: 0; color: rgba(0, 0, 0, 0.8); + ${({ readOnly }) => readOnly && 'cursor: pointer;'} + transition: background 0.2s; + + :hover { + ${({ readOnly }) => readOnly && 'cursor: pointer;'} + } - &:focus { - border-color: ${({ hasError }) => (hasError ? 'red' : '#007bff')}; - box-shadow: 0 0 3px - ${({ hasError }) => - hasError ? 'rgba(255, 0, 0, 0.5)' : 'rgba(0, 123, 255, 0.5)'}; + :focus { + outline: none; + box-shadow: 0 0 3px; + border-color: ${({ hasError, readOnly }) => + readOnly ? '#c5c5c5' : hasError ? 'red' : '#007bff'}; + ${({ readOnly }) => readOnly && 'cursor: pointer;'} + } + + &:disabled { + background-color: rgba(0, 0, 0, 0.05); } - ${({ disabled }) => - disabled && - ` - background-color: rgba(0, 0, 0, 0.05); - `} &::placeholder { color: rgba(0, 0, 0, 0.3); } @@ -84,10 +95,9 @@ export const ToggleButton = styled.button` export const CharCount = styled.span` position: absolute; color: #c5c5c5; - transform: translateY(-50%); - top: 50%; - right: 44px; - font-size: 12px; + top: 110%; + right: 0; + font-size: 14px; letter-spacing: -0.96px; `; diff --git a/frontend/src/components/common/InputField/InputField.tsx b/frontend/src/components/common/InputField/InputField.tsx index 16e507cde..2bba35996 100644 --- a/frontend/src/components/common/InputField/InputField.tsx +++ b/frontend/src/components/common/InputField/InputField.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import * as Styled from './InputField.styles'; import clearIcon from '@/assets/images/icons/delete_button_icon.svg'; @@ -16,6 +16,10 @@ interface CustomInputProps { onClear?: () => void; isError?: boolean; helperText?: string; + readOnly?: boolean; + bgColor?: string; + textColor?: string; + borderColor?: string; } const InputField = ({ @@ -32,6 +36,10 @@ const InputField = ({ onClear, isError, helperText, + readOnly = false, + bgColor, + textColor, + borderColor, }: CustomInputProps) => { const [isPasswordVisible, setIsPasswordVisible] = useState(false); @@ -69,6 +77,12 @@ const InputField = ({ maxLength={maxLength} disabled={disabled} hasError={isError} + readOnly={readOnly} + style={{ + background: bgColor || '#FFF', + color: textColor, + borderColor: borderColor, + }} /> {showClearButton && !disabled && ( diff --git a/frontend/src/components/common/LazyImage/LazyImage.test.tsx b/frontend/src/components/common/LazyImage/LazyImage.test.tsx index dc2d3c0b6..259faffde 100644 --- a/frontend/src/components/common/LazyImage/LazyImage.test.tsx +++ b/frontend/src/components/common/LazyImage/LazyImage.test.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { render, screen, act, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import LazyImage from './LazyImage'; diff --git a/frontend/src/components/common/LazyImage/LazyImage.tsx b/frontend/src/components/common/LazyImage/LazyImage.tsx index 37071ce10..21290be0a 100644 --- a/frontend/src/components/common/LazyImage/LazyImage.tsx +++ b/frontend/src/components/common/LazyImage/LazyImage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; interface LazyImageProps { src: string; diff --git a/frontend/src/components/common/SearchBox/SearchBox.tsx b/frontend/src/components/common/SearchBox/SearchBox.tsx index e7f8bf5cb..5d98a9091 100644 --- a/frontend/src/components/common/SearchBox/SearchBox.tsx +++ b/frontend/src/components/common/SearchBox/SearchBox.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { useSearch } from '@/context/SearchContext'; import useMixpanelTrack from '@/hooks/useMixpanelTrack'; import * as Styled from './SearchBox.styles'; diff --git a/frontend/src/constants/APPLICATION_FORM.ts b/frontend/src/constants/APPLICATION_FORM.ts new file mode 100644 index 000000000..af483410e --- /dev/null +++ b/frontend/src/constants/APPLICATION_FORM.ts @@ -0,0 +1,39 @@ +export const APPLICATION_FORM = { + QUESTION_TITLE: { + placeholder: '질문 제목을 입력해주세요(최대 20자)', + maxLength: 20, + }, + QUESTION_DESCRIPTION: { + placeholder: '질문 설명을 입력해주세요(최대 300자)', + maxLength: 300, + }, + SHORT_TEXT: { + placeholder: '답변입력란(최대 20자)', + maxLength: 20, + }, + LONG_TEXT: { + placeholder: '답변입력란(최대 500자)', + maxLength: 500, + }, + CHOICE: { + placeholder: '항목(최대 20자)', + maxLength: 20, + }, +} as const; + +export const QUESTION_LABEL_MAP = { + SHORT_TEXT: '단답형', + LONG_TEXT: '장문형', + CHOICE: '객관식', + MULTI_CHOICE: '객관식', + EMAIL: '이메일', + PHONE_NUMBER: '전화번호', + NAME: '이름', +} as const; + +const DROPDOWN_QUESTION_TYPES = ['LONG_TEXT', 'SHORT_TEXT', 'CHOICE'] as const; + +export const DROPDOWN_OPTIONS = DROPDOWN_QUESTION_TYPES.map((type) => ({ + value: type, + label: QUESTION_LABEL_MAP[type], +})); diff --git a/frontend/src/constants/INITIAL_FORM_DATA.ts b/frontend/src/constants/INITIAL_FORM_DATA.ts new file mode 100644 index 000000000..e97cf76f1 --- /dev/null +++ b/frontend/src/constants/INITIAL_FORM_DATA.ts @@ -0,0 +1,25 @@ +import { ApplicationFormData } from '@/types/application'; + +const INITIAL_FORM_DATA: ApplicationFormData = { + title: '', + questions: [ + { + id: 1, + title: '', + description: '', + type: 'SHORT_TEXT', + options: { required: true }, + items: [], + }, + { + id: 2, + title: '', + description: '', + type: 'CHOICE', + options: { required: true }, + items: [{ value: '' }, { value: '' }], + }, + ], +}; + +export default INITIAL_FORM_DATA; diff --git a/frontend/src/context/AdminClubContext.tsx b/frontend/src/context/AdminClubContext.tsx index ccbff4da3..23c526eb6 100644 --- a/frontend/src/context/AdminClubContext.tsx +++ b/frontend/src/context/AdminClubContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState } from 'react'; +import { createContext, useContext, useState } from 'react'; interface AdminClubContextType { clubId: string | null; diff --git a/frontend/src/context/SearchContext.tsx b/frontend/src/context/SearchContext.tsx index e8431a2f9..d20af1f39 100644 --- a/frontend/src/context/SearchContext.tsx +++ b/frontend/src/context/SearchContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useState, useContext, ReactNode } from 'react'; +import { createContext, useState, useContext, ReactNode } from 'react'; interface SearchContextType { keyword: string; diff --git a/frontend/src/hooks/queries/application/useGetApplication.ts b/frontend/src/hooks/queries/application/useGetApplication.ts new file mode 100644 index 000000000..28d8865c2 --- /dev/null +++ b/frontend/src/hooks/queries/application/useGetApplication.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; +import getApplication from '@/apis/application/getApplication'; + +export const useGetApplication = (clubId: string) => { + return useQuery({ + queryKey: ['applicationForm', clubId], + queryFn: () => getApplication(clubId), + retry: false, + }); +}; diff --git a/frontend/src/hooks/queries/club/useGetCardList.ts b/frontend/src/hooks/queries/club/useGetCardList.ts index 007403984..fc243fa03 100644 --- a/frontend/src/hooks/queries/club/useGetCardList.ts +++ b/frontend/src/hooks/queries/club/useGetCardList.ts @@ -1,5 +1,7 @@ import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { getClubList } from '@/apis/getClubList'; +import { Club } from '@/types/club'; +import convertToDriveUrl from '@/utils/convertGoogleDriveUrl'; export const useGetCardList = ( keyword: string, @@ -7,9 +9,14 @@ export const useGetCardList = ( category: string, division: string, ) => { - return useQuery({ + return useQuery({ queryKey: ['clubs', keyword, recruitmentStatus, division, category], queryFn: () => getClubList(keyword, recruitmentStatus, division, category), placeholderData: keepPreviousData, + select: (data) => + data.map((club) => ({ + ...club, + logo: convertToDriveUrl(club.logo), + })), }); }; diff --git a/frontend/src/hooks/queries/club/useGetClubDetail.ts b/frontend/src/hooks/queries/club/useGetClubDetail.ts index 9749cca58..4719cbb74 100644 --- a/frontend/src/hooks/queries/club/useGetClubDetail.ts +++ b/frontend/src/hooks/queries/club/useGetClubDetail.ts @@ -1,11 +1,20 @@ import { getClubDetail } from '@/apis/getClubDetail'; import { useQuery } from '@tanstack/react-query'; import { ClubDetail } from '@/types/club'; +import convertGoogleDriveUrl from '@/utils/convertGoogleDriveUrl'; export const useGetClubDetail = (clubId: string) => { return useQuery({ queryKey: ['clubDetail', clubId], queryFn: () => getClubDetail(clubId as string), enabled: !!clubId, + select: (data) => + ({ + ...data, + logo: data.logo ? convertGoogleDriveUrl(data.logo) : undefined, + feeds: Array.isArray(data.feeds) + ? data.feeds.map(convertGoogleDriveUrl) + : [], + }) as ClubDetail, }); }; diff --git a/frontend/src/hooks/useAnswers.ts b/frontend/src/hooks/useAnswers.ts new file mode 100644 index 000000000..6cf5dfead --- /dev/null +++ b/frontend/src/hooks/useAnswers.ts @@ -0,0 +1,33 @@ +import { useState } from 'react'; +import { AnswerItem } from '@/types/application'; + +export const useAnswers = () => { + const [answers, setAnswers] = useState([]); + + const updateSingleAnswer = (id: number, value: string) => { + setAnswers((prev) => [ + ...prev.filter((a) => a.id !== id), + { id, answer: value }, + ]); + }; + + const updateMultiAnswer = (id: number, values: string[]) => { + setAnswers((prev) => [ + ...prev.filter((a) => a.id !== id), + ...values.map((v) => ({ id, answer: v })), + ]); + }; + + const onAnswerChange = (id: number, value: string | string[]) => { + if (Array.isArray(value)) { + updateMultiAnswer(id, value); + } else { + updateSingleAnswer(id, value); + } + }; + + const getAnswersById = (id: number) => + answers.filter((a) => a.id === id).map((a) => a.answer); + + return { onAnswerChange, getAnswersById }; +}; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 20342c892..d8d018ce5 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,37 +1,28 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; -import mixpanel from 'mixpanel-browser'; -import * as ChannelService from '@channel.io/channel-web-sdk-loader'; -import * as Sentry from '@sentry/react'; +import { + initializeMixpanel, + initializeChannelService, + initializeSentry, +} from './utils/initSDK'; -if (process.env.REACT_APP_MIXPANEL_TOKEN) { - mixpanel.init(process.env.REACT_APP_MIXPANEL_TOKEN, { - ip: false, - debug: false, - }); -} +initializeMixpanel(); +initializeChannelService(); +initializeSentry(); -if (window.location.hostname === 'localhost') { - mixpanel.disable(); -} +async function startApp() { + if (process.env.NODE_ENV === 'development') { + const { worker } = await import('./mocks/mswDevSetup'); + await worker.start({ + onUnhandledRequest: 'bypass', + }); + } -ChannelService.loadScript(); -if (process.env.CHANNEL_PLUGIN_KEY) { - ChannelService.boot({ - pluginKey: process.env.CHANNEL_PLUGIN_KEY, - }); + const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement, + ); + root.render(); } -Sentry.init({ - dsn: process.env.SENTRY_DSN, - sendDefaultPii: false, - release: process.env.SENTRY_RELEASE, - tracesSampleRate: 0.1, -}); - -const root = ReactDOM.createRoot( - document.getElementById('root') as HTMLElement, -); - -root.render(); +startApp(); diff --git a/frontend/src/mocks/api/apply.ts b/frontend/src/mocks/api/apply.ts new file mode 100644 index 000000000..56f6a4eee --- /dev/null +++ b/frontend/src/mocks/api/apply.ts @@ -0,0 +1,72 @@ +import { http, HttpResponse } from 'msw'; +import { mockData } from '../data/mockData'; +import { API_BASE } from '../constants/clubApi'; +import { validateClubId } from '../utils/validateClubId'; +import { ERROR_MESSAGE } from '../constants/error'; + +export const applyHandlers = [ + http.get(`${API_BASE}/apply`, () => { + return HttpResponse.json( + { message: ERROR_MESSAGE.INVALID_CLUB_ID }, + { status: 400 }, + ); + }), + + http.get(`${API_BASE}/:clubId/apply`, ({ params }) => { + const clubId = String(params.clubId); + + if (!validateClubId(clubId)) { + return HttpResponse.json( + { message: ERROR_MESSAGE.INVALID_CLUB_ID }, + { status: 400 }, + ); + } + + return HttpResponse.json( + { + clubId, + form_title: mockData.title, + questions: mockData.questions, + }, + { status: 200 }, + ); + }), + + http.post(`${API_BASE}/:clubId/apply`, ({ params }) => { + const clubId = String(params.clubId); + + if (!validateClubId(clubId)) { + return HttpResponse.json( + { message: ERROR_MESSAGE.INVALID_CLUB_ID }, + { status: 400 }, + ); + } + + return HttpResponse.json( + { + clubId, + message: ERROR_MESSAGE.POST_APPLICATION_SUCCESS, + }, + { status: 200 }, + ); + }), + + http.put(`${API_BASE}/:clubId/apply`, async ({ params }) => { + const clubId = String(params.clubId); + + if (!validateClubId(clubId)) { + return HttpResponse.json( + { message: ERROR_MESSAGE.INVALID_CLUB_ID }, + { status: 400 }, + ); + } + + return HttpResponse.json( + { + clubId, + message: ERROR_MESSAGE.PUT_APPLICATION_SUCCESS, + }, + { status: 200 }, + ); + }), +]; diff --git a/frontend/src/mocks/api/applyHandlers.test.ts b/frontend/src/mocks/api/applyHandlers.test.ts new file mode 100644 index 000000000..fd6bb80cf --- /dev/null +++ b/frontend/src/mocks/api/applyHandlers.test.ts @@ -0,0 +1,164 @@ +import { applyHandlers } from './apply'; +import { setupServer } from 'msw/node'; +import { Question } from '../data/mockData'; +import { createApiUrl } from '../utils/createApiUrl'; +import { API_BASE, CLUB_ID } from '../constants/clubApi'; +import { ERROR_MESSAGE } from '../constants/error'; +import { submitApplication, updateApplication } from './utils/request'; + +interface ClubApplyResponse { + clubId: string; + form_title: string; + questions: Question[]; +} + +interface ApiErrorResponse { + message: string; +} + +interface SubmissionResponse { + clubId: string; + message: string; +} + +const server = setupServer(...applyHandlers); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('동아리 지원서 API 테스트', () => { + describe('지원서 GET 테스트', () => { + let response: Response; + let data: ClubApplyResponse | ApiErrorResponse; + + beforeEach(async () => { + response = await fetch(createApiUrl(CLUB_ID)); + data = await response.json(); + }); + + it('클럽 지원서를 정상적으로 불러온다.', () => { + expect(response.status).toBe(200); + expect((data as ClubApplyResponse).form_title).toBeDefined(); + expect((data as ClubApplyResponse).questions.length).toBeGreaterThan(0); + }); + + it('지원서 제목은 20자 이하이다.', () => { + expect((data as ClubApplyResponse).form_title.length).toBeLessThanOrEqual( + 20, + ); + }); + + it('필수 질문의 항목이 비어있지 않아야 한다.', () => { + (data as ClubApplyResponse).questions.forEach((question: Question) => { + if (question.options?.required) { + expect(question.items).toBeDefined(); + expect(question.items?.length).toBeGreaterThan(0); + } + }); + }); + }); + + describe('지원서 GET 에러 케이스', () => { + it('잘못된 형식의 클럽 ID로 요청 시 400 에러를 반환한다.', async () => { + const response = await fetch(`${API_BASE}/invalid-id/apply`); + const data: ApiErrorResponse = await response.json(); + + expect(response.status).toBe(400); + expect(data.message).toContain(ERROR_MESSAGE.INVALID_CLUB_ID); + }); + }); + + describe('클럽 지원서 POST 테스트', () => { + it('지원서 제작 성공', async () => { + const answers = { + '1': ['답변1', '답변2'], + '2': ['답변3'], + }; + + const response = await submitApplication(CLUB_ID, answers); + const data: SubmissionResponse = await response.json(); + + expect(response.status).toBe(200); + expect(data.message).toBe(ERROR_MESSAGE.POST_APPLICATION_SUCCESS); + }); + + it('객관식 질문 답변 제출 성공', async () => { + const answers = { + 1: ['선택 1번입니다'], + 99: ['선택 1번입니다', '선택 2번입니다'], + }; + + const response = await submitApplication(CLUB_ID, answers); + const data: SubmissionResponse = await response.json(); + + expect(response.status).toBe(200); + expect(data.message).toBe(ERROR_MESSAGE.POST_APPLICATION_SUCCESS); + }); + + it('주관식 질문 답변 제출 성공', async () => { + const answers = { + 101: ['주관식 단답형 답변입니다'], + 103: ['주관식 서술형 답변입니다. 자세한 내용을 작성합니다.'], + 104: ['test@example.com'], + 105: ['010-1234-5678'], + 106: ['홍길동'], + }; + + const response = await submitApplication(CLUB_ID, answers); + const data: SubmissionResponse = await response.json(); + + expect(response.status).toBe(200); + expect(data.message).toBe(ERROR_MESSAGE.POST_APPLICATION_SUCCESS); + }); + + it('잘못된 클럽 ID로 요청 시 400 에러', async () => { + const response = await fetch(`${API_BASE}/invalid-id/apply`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + '1': ['답변1'], + '2': ['답변2'], + }), + }); + + const data: ApiErrorResponse = await response.json(); + expect(response.status).toBe(400); + expect(data.message).toContain(ERROR_MESSAGE.INVALID_CLUB_ID); + }); + }); + + describe('클럽 지원서 PUT 테스트', () => { + it('지원서 수정 성공', async () => { + const answers = { + '1': ['수정된 답변1', '수정된 답변2'], + '2': ['수정된 답변3'], + }; + + const response = await updateApplication(CLUB_ID, answers); + const data: SubmissionResponse = await response.json(); + + expect(response.status).toBe(200); + expect(data.message).toBe(ERROR_MESSAGE.PUT_APPLICATION_SUCCESS); + }); + + it('잘못된 클럽 ID로 수정 요청 시 400 에러', async () => { + const response = await fetch(`${API_BASE}/invalid-id/apply`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + '1': ['수정된 답변1'], + '2': ['수정된 답변2'], + }), + }); + + const data: ApiErrorResponse = await response.json(); + expect(response.status).toBe(400); + expect(data.message).toContain(ERROR_MESSAGE.INVALID_CLUB_ID); + }); + }); +}); diff --git a/frontend/src/mocks/api/index.ts b/frontend/src/mocks/api/index.ts new file mode 100644 index 000000000..4bdff89ec --- /dev/null +++ b/frontend/src/mocks/api/index.ts @@ -0,0 +1,3 @@ +import { applyHandlers } from './apply'; + +export const handlers = [...applyHandlers]; diff --git a/frontend/src/mocks/api/utils/request.ts b/frontend/src/mocks/api/utils/request.ts new file mode 100644 index 000000000..3e1420d4d --- /dev/null +++ b/frontend/src/mocks/api/utils/request.ts @@ -0,0 +1,26 @@ +import { createApiUrl } from '@/mocks/utils/createApiUrl'; + +export const sendApiRequest = async ( + clubId: string, + answers: Record, + method: 'POST' | 'PUT', +) => { + const response = await fetch(createApiUrl(clubId), { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(answers), + }); + return response; +}; + +export const submitApplication = ( + clubId: string, + answers: Record, +) => sendApiRequest(clubId, answers, 'POST'); + +export const updateApplication = ( + clubId: string, + answers: Record, +) => sendApiRequest(clubId, answers, 'PUT'); diff --git a/frontend/src/mocks/constants/clubApi.ts b/frontend/src/mocks/constants/clubApi.ts new file mode 100644 index 000000000..99a936f02 --- /dev/null +++ b/frontend/src/mocks/constants/clubApi.ts @@ -0,0 +1,3 @@ +export const API_BASE = 'http://localhost/api/club'; + +export const CLUB_ID = '67e54ae51cfd27718dd40be6'; diff --git a/frontend/src/mocks/constants/error.ts b/frontend/src/mocks/constants/error.ts new file mode 100644 index 000000000..60da51ae6 --- /dev/null +++ b/frontend/src/mocks/constants/error.ts @@ -0,0 +1,5 @@ +export const ERROR_MESSAGE = { + INVALID_CLUB_ID: '유효하지 않은 클럽 ID입니다.', + POST_APPLICATION_SUCCESS: '지원서가 성공적으로 제작되었습니다.', + PUT_APPLICATION_SUCCESS: '지원서가 성공적으로 수정되었습니다.', +} as const; diff --git a/frontend/src/mocks/data/mockData.ts b/frontend/src/mocks/data/mockData.ts new file mode 100644 index 000000000..45c93df72 --- /dev/null +++ b/frontend/src/mocks/data/mockData.ts @@ -0,0 +1,122 @@ +import { ApplicationFormData } from '@/types/application'; + +type QuestionType = + | 'CHOICE' + | 'MULTI_CHOICE' + | 'SHORT_TEXT' + | 'LONG_TEXT' + | 'PHONE_NUMBER' + | 'EMAIL' + | 'NAME'; + +interface QuestionOptions { + required: boolean; +} + +export interface Question { + id: number; + title: string; + description: string; + type: QuestionType; + options: QuestionOptions; + items?: { value: string }[]; +} + +export const mockData: ApplicationFormData = { + title: '2025_2_지원서', + questions: [ + { + id: 1, + title: '개인정보 제 3자 제공 동의', + description: '동의를 거부하실 수 있으나 설문 참여가 불가능합니다.', + type: 'CHOICE', + options: { + required: true, + }, + items: [ + { value: '선택 1번입니다' }, + { value: '선택 2번입니다' }, + { value: '선택 3번입니다' }, + ], + }, + { + id: 2, + title: '객관식 다중 선택', + description: '질문지 밑 설명입니다~~.', + type: 'MULTI_CHOICE', + options: { + required: true, + }, + items: [ + { value: '선택 1번입니다' }, + { value: '선택 2번입니다' }, + { value: '선택 3번입니다' }, + ], + }, + { + id: 3, + title: '주관식 단답형', + description: '주관식 단답형 질문입니다.', + type: 'SHORT_TEXT', + options: { + required: true, + }, + items: [{ value: '' }], + }, + { + id: 4, + title: '주관식 단답형', + description: '주관식 단답형 질문입니다.', + type: 'SHORT_TEXT', + options: { + required: true, + }, + items: [{ value: '주관식은 500자 이하여야 합니다.' }], + }, + { + id: 5, + title: '주관식 서술형', + description: '자유롭게 서술해주세요.', + type: 'LONG_TEXT', + options: { + required: false, + }, + items: [ + { + value: + '주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.주관식 서술형은 500자 이하여야 합니다.', + }, + ], + }, + { + id: 6, + title: '이메일 주소', + description: '이메일을 입력해주세요.', + type: 'EMAIL', + options: { + required: true, + }, + items: [{ value: '' }], + }, + { + id: 105, + title: '전화번호 입력', + description: '휴대전화 번호를 입력해주세요.', + type: 'PHONE_NUMBER', + options: { + required: false, + }, + items: [{ value: '' }], + }, + { + id: 106, + title: '이름 입력', + description: '이름을 입력해주세요.', + type: 'NAME', + options: { + required: true, + }, + items: [{ value: '' }], + }, + ], +}; diff --git a/frontend/src/mocks/mswDevSetup.ts b/frontend/src/mocks/mswDevSetup.ts new file mode 100644 index 000000000..e056f9688 --- /dev/null +++ b/frontend/src/mocks/mswDevSetup.ts @@ -0,0 +1,5 @@ +import { setupWorker } from 'msw/browser'; +import { handlers } from './api'; +import { RequestHandler } from 'msw/lib/core/handlers/RequestHandler.mjs'; + +export const worker = setupWorker(...(handlers as unknown as RequestHandler[])); diff --git a/frontend/src/mocks/utils/createApiUrl.test.ts b/frontend/src/mocks/utils/createApiUrl.test.ts new file mode 100644 index 000000000..482a17ff8 --- /dev/null +++ b/frontend/src/mocks/utils/createApiUrl.test.ts @@ -0,0 +1,18 @@ +import { createApiUrl } from './createApiUrl'; +import { API_BASE, CLUB_ID } from '../constants/clubApi'; + +describe('createApiUrl 함수 테스트', () => { + it('올바른 클럽 ID로 URL이 정상 생성된다', () => { + expect(createApiUrl(CLUB_ID)).toBe(`${API_BASE}/${CLUB_ID}/apply`); + }); + + it('clubId가 빈 문자열이면 에러를 반환해야 한다.', () => { + expect(() => createApiUrl('')).toThrow('유효하지 않은 클럽 ID입니다.'); + expect(() => createApiUrl(' ')).toThrow('유효하지 않은 클럽 ID입니다.'); + }); + + it('잘못된 형식의 클럽 ID로 URL 생성 시 에러가 발생한다', () => { + expect(() => createApiUrl('123')).toThrow('유효하지 않은 클럽 ID입니다.'); + expect(() => createApiUrl('abc')).toThrow('유효하지 않은 클럽 ID입니다.'); + }); +}); diff --git a/frontend/src/mocks/utils/createApiUrl.ts b/frontend/src/mocks/utils/createApiUrl.ts new file mode 100644 index 000000000..042e24630 --- /dev/null +++ b/frontend/src/mocks/utils/createApiUrl.ts @@ -0,0 +1,10 @@ +import { API_BASE } from '../constants/clubApi'; +import { validateClubId } from './validateClubId'; + +export const createApiUrl = (clubId: string, action: string = 'apply') => { + if (!validateClubId(clubId)) { + throw new Error('유효하지 않은 클럽 ID입니다.'); + } + + return `${API_BASE}/${clubId}/${action}`; +}; diff --git a/frontend/src/mocks/utils/validateClubId.ts b/frontend/src/mocks/utils/validateClubId.ts new file mode 100644 index 000000000..bfa0765d7 --- /dev/null +++ b/frontend/src/mocks/utils/validateClubId.ts @@ -0,0 +1,4 @@ +export const validateClubId = (clubId: string) => { + if (!clubId) return false; + return /^[0-9a-fA-F]{24}$/.test(clubId); +}; diff --git a/frontend/src/pages/AdminPage/AdminPage.tsx b/frontend/src/pages/AdminPage/AdminPage.tsx index a1a081ac9..d504d8825 100644 --- a/frontend/src/pages/AdminPage/AdminPage.tsx +++ b/frontend/src/pages/AdminPage/AdminPage.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import Header from '@/components/common/Header/Header'; import { PageContainer } from '@/styles/PageContainer.styles'; import SideBar from '@/pages/AdminPage/components/SideBar/SideBar'; diff --git a/frontend/src/pages/AdminPage/application/CreateApplicationForm.styles.ts b/frontend/src/pages/AdminPage/application/CreateApplicationForm.styles.ts new file mode 100644 index 000000000..cc9650e56 --- /dev/null +++ b/frontend/src/pages/AdminPage/application/CreateApplicationForm.styles.ts @@ -0,0 +1,70 @@ +import styled from 'styled-components'; + +export const FormTitle = styled.input` + font-size: 2.5rem; + font-weight: 700; + border: none; + outline: none; + margin: 76px 0; + + &::placeholder { + color: #c5c5c5; + transition: opacity 0.15s; + } + + &:focus::placeholder { + opacity: 0; + } +`; + +export const QuestionContainer = styled.div` + display: flex; + flex-direction: column; + gap: 83px; +`; + +export const AddQuestionButton = styled.button` + padding: 8px 12px; + border-radius: 6px; + border: 1px solid #ccc; + font-size: 0.875rem; + font-weight: 500; + background: white; + color: #555; + margin-bottom: 60px; + cursor: pointer; +`; + +export const QuestionDivider = styled.hr` + margin-top: 40px; + margin-bottom: 40px; + border: none; + border-top: 1px solid #ddd; +`; + +export const ButtonWrapper = styled.div` + display: flex; + justify-content: flex-end; +`; + +export const submitButton = styled.button` + padding: 10px 56px; + background-color: #ff5414; + border-radius: 10px; + border: none; + color: #fff; + font-size: 1.25rem; + font-weight: 600; + letter-spacing: -0.4px; + transition: background-color 0.2s; + margin: 50px 0; + + &:hover { + background-color: #ffad8e; + animation: pulse 0.4s ease-in-out; + } + + &:active { + transform: scale(0.95); + } +`; diff --git a/frontend/src/pages/AdminPage/application/CreateApplicationForm.tsx b/frontend/src/pages/AdminPage/application/CreateApplicationForm.tsx new file mode 100644 index 000000000..bc3be5bcc --- /dev/null +++ b/frontend/src/pages/AdminPage/application/CreateApplicationForm.tsx @@ -0,0 +1,184 @@ +import { useState, useEffect } from 'react'; +import QuestionBuilder from '@/pages/AdminPage/application/components/QuestionBuilder/QuestionBuilder'; +import { QuestionType } from '@/types/application'; +import { Question } from '@/types/application'; +import { ApplicationFormData } from '@/types/application'; +import { PageContainer } from '@/styles/PageContainer.styles'; +import * as Styled from './CreateApplicationForm.styles'; +import INITIAL_FORM_DATA from '@/constants/INITIAL_FORM_DATA'; +import { QuestionDivider } from './CreateApplicationForm.styles'; +import { useAdminClubContext } from '@/context/AdminClubContext'; +import { useGetApplication } from '@/hooks/queries/application/useGetApplication'; +import createApplication from '@/apis/application/createApplication'; +import updateApplication from '@/apis/application/updateApplication'; + +const CreateApplicationForm = () => { + const { clubId } = useAdminClubContext(); + if (!clubId) return null; + + const { data, isLoading, isError } = useGetApplication(clubId); + + const [formData, setFormData] = + useState(INITIAL_FORM_DATA); + + useEffect(() => { + if (data) { + setFormData(data); + } + }, [data]); + + const [nextId, setNextId] = useState(() => { + const questions = data?.questions ?? INITIAL_FORM_DATA.questions; + if (questions.length === 0) return 1; + const maxId = Math.max(...questions.map((q: Question) => q.id)); + return maxId + 1; + }); + + const addQuestion = () => { + const newQuestion: Question = { + id: nextId, + title: '', + description: '', + type: 'SHORT_TEXT', + items: [], + options: { required: false }, + }; + setFormData((prev) => ({ + ...prev, + questions: [...prev.questions, newQuestion], + })); + setNextId((currentId) => currentId + 1); + }; + + const removeQuestion = (id: number) => { + setFormData((prev) => ({ + ...prev, + questions: prev.questions.filter((q) => q.id !== id), + })); + }; + + const updateQuestionField = ( + id: number, + key: K, + value: Question[K], + ) => { + setFormData((prev) => ({ + ...prev, + questions: prev.questions.map((q) => + q.id === id ? { ...q, [key]: value } : q, + ), + })); + }; + + const handleFormTitleChange = (value: string) => { + setFormData((prev) => ({ + ...prev, + title: value, + })); + }; + + const handleTitleChange = (id: number) => (value: string) => + updateQuestionField(id, 'title', value); + + const handleDescriptionChange = (id: number) => (value: string) => + updateQuestionField(id, 'description', value); + + const handleItemsChange = (id: number) => (items: { value: string }[]) => + updateQuestionField(id, 'items', items); + + const handleTypeChange = (id: number) => (newType: QuestionType) => { + setFormData((prev) => ({ + ...prev, + questions: prev.questions.map((q) => { + if (q.id !== id) return q; + const isChoice = newType === 'CHOICE' || newType === 'MULTI_CHOICE'; + return { + ...q, + type: newType, + items: isChoice + ? q.items && q.items.length >= 2 + ? q.items + : [{ value: '' }, { value: '' }] + : [], + }; + }), + })); + }; + + const handleRequiredChange = (id: number) => (value: boolean) => { + setFormData((prev) => ({ + ...prev, + questions: prev.questions.map((q) => + q.id === id ? { ...q, options: { ...q.options, required: value } } : q, + ), + })); + }; + + const handleSubmit = async () => { + if (!clubId) return; + const reorderedQuestions = formData.questions.map((q, idx) => ({ + ...q, + id: idx + 1, + })); + + const payload: ApplicationFormData = { + ...formData, + questions: reorderedQuestions, + }; + try { + if (data) { + await updateApplication(payload, clubId); + alert('지원서가 성공적으로 수정되었습니다.'); + } else { + await createApplication(payload, clubId); + alert('지원서가 성공적으로 생성되었습니다.'); + } + } catch (error) { + alert('지원서 저장에 실패했습니다.'); + console.error(error); + } + }; + + return ( + <> + + handleFormTitleChange(e.target.value)} + placeholder='지원서 제목을 입력하세요' + > + + {formData.questions.map((question, index) => ( + removeQuestion(question.id)} + /> + ))} + + + + 질문 추가 + + + + + 저장하기 + + + + + ); +}; + +export default CreateApplicationForm; diff --git a/frontend/src/pages/AdminPage/application/answer/AnswerApplicationForm.styles.ts b/frontend/src/pages/AdminPage/application/answer/AnswerApplicationForm.styles.ts new file mode 100644 index 000000000..c731731af --- /dev/null +++ b/frontend/src/pages/AdminPage/application/answer/AnswerApplicationForm.styles.ts @@ -0,0 +1,43 @@ +import styled from 'styled-components'; + +export const FormTitle = styled.h1` + font-size: 2.5rem; + font-weight: 700; + border: none; + outline: none; + margin-top: 20px; + margin-bottom: 46px; +`; + +export const QuestionsWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 50px; +`; + +export const ButtonWrapper = styled.div` + display: flex; + justify-content: flex-end; +`; + +export const submitButton = styled.button` + padding: 10px 56px; + background-color: #ff5414; + border-radius: 10px; + border: none; + color: #fff; + font-size: 1.25rem; + font-weight: 600; + letter-spacing: -0.4px; + transition: background-color 0.2s; + margin: 50px 0; + + &:hover { + background-color: #ffad8e; + animation: pulse 0.4s ease-in-out; + } + + &:active { + transform: scale(0.95); + } +`; diff --git a/frontend/src/pages/AdminPage/application/answer/AnswerApplicationForm.tsx b/frontend/src/pages/AdminPage/application/answer/AnswerApplicationForm.tsx new file mode 100644 index 000000000..dd9fe6f9d --- /dev/null +++ b/frontend/src/pages/AdminPage/application/answer/AnswerApplicationForm.tsx @@ -0,0 +1,67 @@ +import { PageContainer } from '@/styles/PageContainer.styles'; +import * as Styled from './AnswerApplicationForm.styles'; +import Header from '@/components/common/Header/Header'; +import { useParams } from 'react-router-dom'; +import { useGetClubDetail } from '@/hooks/queries/club/useGetClubDetail'; +import ClubProfile from '@/pages/ClubDetailPage/components/ClubProfile/ClubProfile'; +import { useAnswers } from '@/hooks/useAnswers'; +import QuestionAnswerer from '@/pages/AdminPage/application/components/QuestionAnswerer/QuestionAnswerer'; +import { useGetApplication } from '@/hooks/queries/application/useGetApplication'; +import { Question } from '@/types/application'; +import Spinner from '@/components/common/Spinner/Spinner'; + +const AnswerApplicationForm = () => { + const { clubId } = useParams<{ clubId: string }>(); + if (!clubId) return null; + + const { data: clubDetail, error } = useGetClubDetail(clubId); + const { data: formData, isLoading, isError } = useGetApplication(clubId); + + const { onAnswerChange, getAnswersById } = useAnswers(); + + if (isLoading) return ; + + if (error || isError) { + return
문제가 발생했어요. 잠시 후 다시 시도해 주세요.
; + } + + if (!formData || !clubDetail) { + return ( +
+ 지원서 정보를 불러오지 못했어요. 새로고침하거나 잠시 후 다시 시도해 + 주세요. +
+ ); + } + + return ( + <> +
+ + + {formData.title} + + {formData.questions.map((q: Question) => ( + + ))} + + + 제출하기 + + + + ); +}; + +export default AnswerApplicationForm; diff --git a/frontend/src/pages/AdminPage/application/components/QuestionAnswerer/QuestionAnswerer.tsx b/frontend/src/pages/AdminPage/application/components/QuestionAnswerer/QuestionAnswerer.tsx new file mode 100644 index 000000000..b80465c50 --- /dev/null +++ b/frontend/src/pages/AdminPage/application/components/QuestionAnswerer/QuestionAnswerer.tsx @@ -0,0 +1,64 @@ +import { Question } from '@/types/application'; +import ShortText from '@/pages/AdminPage/application/fields/ShortText'; +import LongText from '@/pages/AdminPage/application/fields/LongText'; +import Choice from '@/pages/AdminPage/application/fields/Choice'; + +interface QuestionAnswererProps { + question: Question; + selectedAnswers: string[]; + onChange: (id: number, value: string | string[]) => void; +} + +const QuestionAnswerer = ({ + question, + selectedAnswers, + onChange, +}: QuestionAnswererProps) => { + const baseProps = { + id: question.id, + title: question.title, + description: question.description, + required: question.options.required, + mode: 'answer' as const, + }; + + switch (question.type) { + case 'NAME': + case 'EMAIL': + case 'PHONE_NUMBER': + case 'SHORT_TEXT': + return ( + onChange(question.id, value)} + /> + ); + + case 'LONG_TEXT': + return ( + onChange(question.id, value)} + /> + ); + + case 'CHOICE': + case 'MULTI_CHOICE': + return ( + onChange(question.id, value)} + /> + ); + + default: + return
지원하지 않는 질문 유형입니다: {question.type}
; + } +}; + +export default QuestionAnswerer; diff --git a/frontend/src/pages/AdminPage/application/components/QuestionBuilder/QuestionBuilder.styles.ts b/frontend/src/pages/AdminPage/application/components/QuestionBuilder/QuestionBuilder.styles.ts new file mode 100644 index 000000000..8da426be7 --- /dev/null +++ b/frontend/src/pages/AdminPage/application/components/QuestionBuilder/QuestionBuilder.styles.ts @@ -0,0 +1,81 @@ +import styled from 'styled-components'; + +export const QuestionMenu = styled.div` + display: flex; + max-width: 140px; + width: 100%; + flex-direction: column; + gap: 4px; +`; + +export const QuestionFieldContainer = styled.div` + width: 100%; +`; + +export const RequiredToggleButton = styled.div` + display: flex; + border: none; + padding: 12px 16px; + justify-content: space-between; + align-items: center; + border-radius: 0.375rem; + background: #f5f5f5; + cursor: pointer; + margin: 0; + color: #787878; + font-size: 0.875rem; + font-weight: 600; + user-select: none; +`; + +export const RequiredToggleCircle = styled.span<{ active?: boolean }>` + position: relative; + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid ${({ active }) => (active ? '#ff5000' : '#ccc')}; + background-color: #fff; + transition: background-color 0.2s ease; + + &::after { + content: ''; + width: 10px; + height: 10px; + background-color: #ff5000; + border-radius: 50%; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: ${({ active }) => (active ? 1 : 0)}; + transition: opacity 0.1s ease; + } +`; + +export const SelectionToggleWrapper = styled.div` + display: flex; + background-color: #f7f7f7; + border-radius: 0.375rem; + padding: 2px; +`; + +export const SelectionToggleButton = styled.button<{ active: boolean }>` + border: none; + background-color: ${(props) => (props.active ? '#ddd' : 'transparent')}; + color: #787878; + font-size: 0.875rem; + border-radius: 0.375rem; + padding: 10px; + font-weight: 600; + cursor: pointer; + letter-spacing: -0.42px; + white-space: nowrap; + transition: + background-color 0.2s ease, + color 0.2s ease; +`; + +export const QuestionWrapper = styled.div` + display: flex; + gap: 36px; +`; diff --git a/frontend/src/pages/AdminPage/application/components/QuestionBuilder/QuestionBuilder.tsx b/frontend/src/pages/AdminPage/application/components/QuestionBuilder/QuestionBuilder.tsx new file mode 100644 index 000000000..c84c57d03 --- /dev/null +++ b/frontend/src/pages/AdminPage/application/components/QuestionBuilder/QuestionBuilder.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react'; +import ShortText from '@/pages/AdminPage/application/fields/ShortText'; +import Choice from '@/pages/AdminPage/application/fields/Choice'; +import LongText from '@/pages/AdminPage/application/fields/LongText'; +import { QuestionType } from '@/types/application'; +import { QuestionBuilderProps } from '@/types/application'; +import { QUESTION_LABEL_MAP } from '@/constants/APPLICATION_FORM'; +import { DROPDOWN_OPTIONS } from '@/constants/APPLICATION_FORM'; +import * as Styled from './QuestionBuilder.styles'; +import CustomDropdown from '@/components/common/CustomDropDown/CustomDropDown'; + +const QuestionBuilder = ({ + id, + title, + description, + options, + items, + type, + onTitleChange, + onItemsChange, + onDescriptionChange, + onTypeChange, + onRequiredChange, + onRemoveQuestion, +}: QuestionBuilderProps) => { + if (!(type in QUESTION_LABEL_MAP)) { + return null; + } + + const [selectionType, setSelectionType] = useState<'single' | 'multi'>( + type === 'MULTI_CHOICE' ? 'multi' : 'single', + ); + + useEffect(() => { + if (type === 'MULTI_CHOICE') { + setSelectionType('multi'); + } else if (type === 'CHOICE') { + setSelectionType('single'); + } + }, [type]); + + const renderFieldByQuestionType = () => { + switch (type) { + case 'SHORT_TEXT': + case 'NAME': + case 'EMAIL': + case 'PHONE_NUMBER': + return ( + + ); + case 'LONG_TEXT': + return ( + + ); + case 'CHOICE': + case 'MULTI_CHOICE': + return ( + + ); + default: + return null; + } + }; + + const renderSelectionToggle = () => { + if (type !== 'CHOICE' && type !== 'MULTI_CHOICE') return null; + + return ( + + { + setSelectionType('single'); + onTypeChange?.('CHOICE'); + }} + > + 단일선택 + + { + setSelectionType('multi'); + onTypeChange?.('MULTI_CHOICE'); + }} + > + 다중선택 + + + ); + }; + + return ( + + + onRequiredChange?.(!options?.required)} + > + 답변 필수 + + + { + onTypeChange?.(value as QuestionType); + }} + /> + {renderSelectionToggle()} + + + + {renderFieldByQuestionType()} + + + ); +}; + +export default QuestionBuilder; diff --git a/frontend/src/pages/AdminPage/application/components/QuestionDescription/QuestionDescription.tsx b/frontend/src/pages/AdminPage/application/components/QuestionDescription/QuestionDescription.tsx new file mode 100644 index 000000000..fb12c5fa5 --- /dev/null +++ b/frontend/src/pages/AdminPage/application/components/QuestionDescription/QuestionDescription.tsx @@ -0,0 +1,71 @@ +import styled from 'styled-components'; +import { APPLICATION_FORM } from '@/constants/APPLICATION_FORM'; +import { useEffect, useRef } from 'react'; + +interface QuestionDescriptionProps { + description: string; + mode: 'builder' | 'answer'; + onDescriptionChange?: (value: string) => void; +} + +const QuestionDescriptionText = styled.textarea` + border: none; + outline: none; + color: #c5c5c5; + font-size: 0.8125rem; + font-weight: 400; + line-height: normal; + letter-spacing: -0.26px; + width: 100%; + overflow: hidden; + resize: none; + + &::placeholder { + color: #c5c5c5; + transition: opacity 0.15s; + } + + &:focus::placeholder { + opacity: 0; + } +`; + +const QuestionDescription = ({ + description, + mode, + onDescriptionChange, +}: QuestionDescriptionProps) => { + const textAreaRef = useRef(null); + + useEffect(() => { + if (mode === 'answer') { + return; + } + const el = textAreaRef.current; + if (el) { + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + } + }, [description]); + + return ( + <> + { + const value = e.target.value; + if (value.length <= APPLICATION_FORM.QUESTION_DESCRIPTION.maxLength) { + onDescriptionChange?.(value); + } + }} + /> + + ); +}; + +export default QuestionDescription; diff --git a/frontend/src/pages/AdminPage/application/components/QuestionTitle/QuestionTitle.styles.ts b/frontend/src/pages/AdminPage/application/components/QuestionTitle/QuestionTitle.styles.ts new file mode 100644 index 000000000..53c4c2333 --- /dev/null +++ b/frontend/src/pages/AdminPage/application/components/QuestionTitle/QuestionTitle.styles.ts @@ -0,0 +1,41 @@ +import styled from 'styled-components'; + +export const QuestionTitleContainer = styled.div` + display: flex; + align-items: center; + gap: 4px; +`; + +export const QuestionTitleId = styled.p` + color: #ff5414; + font-size: 1.25rem; + font-weight: 700; + line-height: normal; +`; + +export const QuestionTitleText = styled.input` + border: none; + outline: none; + color: #111; + font-size: 1.25rem; + font-weight: 700; + line-height: normal; + field-sizing: content; + + &::placeholder { + color: #c5c5c5; + transition: opacity 0.15s; + } + + &:focus::placeholder { + opacity: 0; + } +`; + +export const QuestionRequired = styled.div` + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #ff5414; + margin-left: 14px; +`; diff --git a/frontend/src/pages/AdminPage/application/components/QuestionTitle/QuestionTitle.tsx b/frontend/src/pages/AdminPage/application/components/QuestionTitle/QuestionTitle.tsx new file mode 100644 index 000000000..2d9c89996 --- /dev/null +++ b/frontend/src/pages/AdminPage/application/components/QuestionTitle/QuestionTitle.tsx @@ -0,0 +1,41 @@ +import * as Styled from './QuestionTitle.styles'; +import { APPLICATION_FORM } from '@/constants/APPLICATION_FORM'; + +interface QuestionTitleProps { + id: number; + title: string; + required?: boolean; + mode: 'builder' | 'answer'; + onTitleChange?: (value: string) => void; +} + +const QuestionTitle = ({ + id, + title, + required, + mode, + onTitleChange, +}: QuestionTitleProps) => { + return ( + + {id && {id}.} + { + const value = e.target.value; + if (value.length <= APPLICATION_FORM.QUESTION_TITLE.maxLength) { + onTitleChange?.(value); + } + }} + /> + {mode === 'answer' && required && } + + ); +}; + +export default QuestionTitle; diff --git a/frontend/src/pages/AdminPage/application/fields/Choice.styles.ts b/frontend/src/pages/AdminPage/application/fields/Choice.styles.ts new file mode 100644 index 000000000..66b10b1e5 --- /dev/null +++ b/frontend/src/pages/AdminPage/application/fields/Choice.styles.ts @@ -0,0 +1,30 @@ +import styled from 'styled-components'; + +export const AddItemButton = styled.button` + padding: 8px 12px; + border-radius: 6px; + border: 1px solid #ccc; + font-size: 0.875rem; + font-weight: 500; + background: white; + color: #555; + margin-top: 8px; + cursor: pointer; +`; + +export const ItemWrapper = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +`; + +export const DeleteButton = styled.button` + font-size: 0.75rem; + padding: 4px 8px; + border-radius: 4px; + background-color: #ffecec; + color: #e33; + border: 1px solid #f99; + cursor: pointer; +`; diff --git a/frontend/src/pages/AdminPage/application/fields/Choice.tsx b/frontend/src/pages/AdminPage/application/fields/Choice.tsx new file mode 100644 index 000000000..70431d8ca --- /dev/null +++ b/frontend/src/pages/AdminPage/application/fields/Choice.tsx @@ -0,0 +1,131 @@ +import * as Styled from './Choice.styles'; +import QuestionTitle from '@/pages/AdminPage/application/components/QuestionTitle/QuestionTitle'; +import QuestionDescription from '@/pages/AdminPage/application/components/QuestionDescription/QuestionDescription'; +import InputField from '@/components/common/InputField/InputField'; +import { APPLICATION_FORM } from '@/constants/APPLICATION_FORM'; +import { ChoiceProps } from '@/types/application'; + +const MIN_ITEMS = 2; +const MAX_ITEMS = 6; + +const Choice = ({ + id, + title, + description, + required, + mode, + onTitleChange, + onDescriptionChange, + items = [], + isMulti, + onItemsChange, + answer = [], + onAnswerChange, +}: ChoiceProps) => { + // — 아이템 텍스트 변경(빌더 모드 전용) + const handleItemChange = (index: number, newValue: string) => { + const updated = items.map((item, i) => + i === index ? { ...item, value: newValue } : item, + ); + onItemsChange?.(updated); + }; + + // — 아이템 추가(빌더 모드 전용) + const handleAddItem = () => { + if (items.length >= MAX_ITEMS) return; + onItemsChange?.([...items, { value: '' }]); + }; + + // — 아이템 삭제(빌더 모드 전용) + const handleDeleteItem = (index: number) => { + if (items.length <= MIN_ITEMS) return; + const updated = items.filter((_, i) => i !== index); + onItemsChange?.(updated); + }; + + const handleSelect = (idx: number) => { + if (mode !== 'answer') return; + const value = items[idx].value; + if (!value) return; + + if (isMulti) { + if (Array.isArray(answer)) { + if (answer.includes(value)) { + onAnswerChange?.(answer.filter((v) => v !== value)); + } else { + onAnswerChange?.([...answer, value]); + } + } + // 다중 선택: 이미 포함되어 있으면 제거, 아니면 추가 + } else { + // 단일 선택: 클릭된 값만 넘김 + onAnswerChange?.(value); + } + }; + + return ( +
+ + + + {items.map((item, index) => { + // ▶ selected 대신, answer.includes(item.value)로 판별 + const isSelected = mode === 'answer' && answer.includes(item.value); + + return ( + handleSelect(index)} + data-selected={isSelected ? 'true' : undefined} + > + handleItemChange(index, e.target.value)} + placeholder={APPLICATION_FORM.CHOICE.placeholder} + readOnly={mode === 'answer'} + showClearButton={false} + bgColor={isSelected ? '#FFE4DA' : '#F5F5F5'} + textColor={ + mode === 'answer' + ? isSelected + ? 'rgba(0,0,0,0.8)' + : 'rgba(0,0,0,0.3)' + : undefined + } + borderColor={isSelected ? '#FF5414' : undefined} + /> + + {mode === 'builder' && items.length > MIN_ITEMS && ( + { + e.stopPropagation(); + handleDeleteItem(index); + }} + > + 삭제 + + )} + + ); + })} + + {mode === 'builder' && items.length < MAX_ITEMS && ( + + + 추가항목 + + )} +
+ ); +}; + +export default Choice; diff --git a/frontend/src/pages/AdminPage/application/fields/LongText.tsx b/frontend/src/pages/AdminPage/application/fields/LongText.tsx new file mode 100644 index 000000000..a33977252 --- /dev/null +++ b/frontend/src/pages/AdminPage/application/fields/LongText.tsx @@ -0,0 +1,44 @@ +import QuestionTitle from '@/pages/AdminPage/application/components/QuestionTitle/QuestionTitle'; +import QuestionDescription from '@/pages/AdminPage/application/components/QuestionDescription/QuestionDescription'; +import { APPLICATION_FORM } from '@/constants/APPLICATION_FORM'; +import { TextProps } from '@/types/application'; +import CustomTextArea from '@/components/common/CustomTextArea/CustomTextArea'; + +const LongText = ({ + id, + title, + description, + required, + answer, + mode, + onAnswerChange, + onTitleChange, + onDescriptionChange, +}: TextProps) => { + return ( +
+ + + onAnswerChange?.(e.target.value)} + placeholder={APPLICATION_FORM.LONG_TEXT.placeholder} + disabled={mode === 'builder'} + showMaxChar={mode === 'answer'} + maxLength={APPLICATION_FORM.LONG_TEXT.maxLength} + /> +
+ ); +}; + +export default LongText; diff --git a/frontend/src/pages/AdminPage/application/fields/ShortText.tsx b/frontend/src/pages/AdminPage/application/fields/ShortText.tsx new file mode 100644 index 000000000..58c14e392 --- /dev/null +++ b/frontend/src/pages/AdminPage/application/fields/ShortText.tsx @@ -0,0 +1,46 @@ +import QuestionTitle from '@/pages/AdminPage/application/components/QuestionTitle/QuestionTitle'; +import QuestionDescription from '@/pages/AdminPage/application/components/QuestionDescription/QuestionDescription'; +import InputField from '@/components/common/InputField/InputField'; +import { APPLICATION_FORM } from '@/constants/APPLICATION_FORM'; +import { TextProps } from '@/types/application'; + +const ShortText = ({ + id, + title, + description, + required, + answer, + mode, + onAnswerChange, + onTitleChange, + onDescriptionChange, +}: TextProps) => { + return ( +
+ + + onAnswerChange?.(e.target.value)} + placeholder={APPLICATION_FORM.SHORT_TEXT.placeholder} + disabled={mode === 'builder'} + showMaxChar={mode === 'answer'} + maxLength={APPLICATION_FORM.SHORT_TEXT.maxLength} + showClearButton={false} + width={'60%'} + /> +
+ ); +}; + +export default ShortText; diff --git a/frontend/src/pages/AdminPage/auth/LoginTab/LoginTab.tsx b/frontend/src/pages/AdminPage/auth/LoginTab/LoginTab.tsx index bfb31901a..c760f9725 100644 --- a/frontend/src/pages/AdminPage/auth/LoginTab/LoginTab.tsx +++ b/frontend/src/pages/AdminPage/auth/LoginTab/LoginTab.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import * as Styled from './LoginTab.styles'; import InputField from '@/components/common/InputField/InputField'; diff --git a/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx b/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx index 2e44574bb..d6b3b305d 100644 --- a/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx +++ b/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx @@ -14,6 +14,7 @@ const tabs = [ { label: '기본 정보 수정', path: '/admin/club-info' }, { label: '모집 정보 수정', path: '/admin/recruit-edit' }, { label: '활동 사진 수정', path: '/admin/photo-edit' }, + { label: '지원 관리', path: '/admin/application-edit' }, { label: '계정 관리', path: '/admin/account-edit' }, ]; @@ -28,7 +29,10 @@ const SideBar = ({ clubLogo, clubName }: SideBarProps) => { const handleTabClick = (tab: (typeof tabs)[number]) => { if (tab.label === '계정 관리') { - alert('계정 관리 탭은 준비 중입니다☺️'); + alert('계정 관리 기능은 아직 준비 중이에요. ☺️'); + return; + } else if (tab.label === '지원 관리') { + alert('동아리 지원 관리 기능은 곧 오픈돼요!\n조금만 기다려주세요 🚀'); return; } navigate(tab.path); @@ -43,7 +47,6 @@ const SideBar = ({ clubLogo, clubName }: SideBarProps) => { localStorage.removeItem('accessToken'); navigate('/admin/login', { replace: true }); } catch (error) { - console.error(error); alert('로그아웃에 실패했습니다.'); } }; @@ -62,7 +65,8 @@ const SideBar = ({ clubLogo, clubName }: SideBarProps) => { handleTabClick(tab)}> + onClick={() => handleTabClick(tab)} + > {tab.label} ))} diff --git a/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/ClubInfoEditTab.tsx b/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/ClubInfoEditTab.tsx index 35ca5c03c..175970aac 100644 --- a/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/ClubInfoEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/ClubInfoEditTab.tsx @@ -183,31 +183,29 @@ const ClubInfoEditTab = () => { - {/*동아리 SNS 링크*/} - {/*

현재 준비 중인 기능입니다. 조금만 기다려 주세요!

*/} - {/**/} - {/* {Object.entries(SNS_CONFIG).map(([rawKey, { label, placeholder }]) => {*/} - {/* const key = rawKey as SNSPlatform;*/} - - {/* return (*/} - {/* */} - {/* {label}*/} - {/* handleSocialLinkChange(key, e.target.value)}*/} - {/* onClear={() => {*/} - {/* setSocialLinks((prev) => ({ ...prev, [key]: '' }));*/} - {/* setSnsErrors((prev) => ({ ...prev, [key]: '' }));*/} - {/* }}*/} - {/* isError={snsErrors[key] !== ''}*/} - {/* helperText={snsErrors[key]}*/} - {/* disabled={true}*/} - {/* />*/} - {/* */} - {/* );*/} - {/* })}*/} - {/**/} + 동아리 SNS 링크 + + {Object.entries(SNS_CONFIG).map(([rawKey, { label, placeholder }]) => { + const key = rawKey as SNSPlatform; + + return ( + + {label} + handleSocialLinkChange(key, e.target.value)} + onClear={() => { + setSocialLinks((prev) => ({ ...prev, [key]: '' })); + setSnsErrors((prev) => ({ ...prev, [key]: '' })); + }} + isError={snsErrors[key] !== ''} + helperText={snsErrors[key]} + /> + + ); + })} + ); }; diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx index 84938ef74..fb0878ef7 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import * as Styled from '@/styles/PageContainer.styles'; import Header from '@/components/common/Header/Header'; diff --git a/frontend/src/pages/ClubDetailPage/components/BackNavigationBar/BackNavigationBar.tsx b/frontend/src/pages/ClubDetailPage/components/BackNavigationBar/BackNavigationBar.tsx index bd7fee058..ed1e5a5bf 100644 --- a/frontend/src/pages/ClubDetailPage/components/BackNavigationBar/BackNavigationBar.tsx +++ b/frontend/src/pages/ClubDetailPage/components/BackNavigationBar/BackNavigationBar.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import useMixpanelTrack from '@/hooks/useMixpanelTrack'; import { useNavigate } from 'react-router-dom'; import * as Styled from './BackNavigationBar.styles'; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx index a65dbbb03..bb0d43a94 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx @@ -1,6 +1,7 @@ -import React from 'react'; import useMixpanelTrack from '@/hooks/useMixpanelTrack'; import styled from 'styled-components'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useGetClubDetail } from '@/hooks/queries/club/useGetClubDetail'; interface ButtonProps { recruitmentForm?: string; @@ -40,11 +41,16 @@ const ClubApplyButton = ({ recruitmentForm, presidentPhoneNumber, }: ButtonProps) => { + const { clubId } = useParams<{ clubId: string }>(); const trackEvent = useMixpanelTrack(); + const navigate = useNavigate(); const handleClick = () => { trackEvent('Club Apply Button Clicked'); + //TODO: 지원서를 작성한 동아리의 경우에만 리다이렉트 + //navigate(`/application/${clubId}`); + // [x] FIXME: recruitmentForm 있을 때는 리다이렉트 if (presidentPhoneNumber) { alert(`${presidentPhoneNumber} 으로 연락하여 지원해 주세요.`); diff --git a/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.tsx b/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.tsx index 085108547..3aca35fae 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import * as Styled from './ClubDetailFooter.styles'; import DeadlineBadge from '@/pages/ClubDetailPage/components/DeadlineBadge/DeadlineBadge'; import ClubApplyButton from '@/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton'; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubDetailHeader/ClubDetailHeader.tsx b/frontend/src/pages/ClubDetailPage/components/ClubDetailHeader/ClubDetailHeader.tsx index ba1ecb7da..028c5744f 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubDetailHeader/ClubDetailHeader.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubDetailHeader/ClubDetailHeader.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import * as Styled from './ClubDetailHeader.styles'; import ClubProfile from '@/pages/ClubDetailPage/components/ClubProfile/ClubProfile'; import ClubApplyButton from '@/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton'; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubProfile/ClubProfile.tsx b/frontend/src/pages/ClubDetailPage/components/ClubProfile/ClubProfile.tsx index 1608a26bf..9c9c7a0a2 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubProfile/ClubProfile.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubProfile/ClubProfile.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import ClubLogo from '@/components/ClubLogo/ClubLogo'; import ClubTag from '@/components/ClubTag/ClubTag'; import * as Styled from './ClubProfile.styles'; diff --git a/frontend/src/pages/ClubDetailPage/components/DeadlineBadge/DeadlineBadge.tsx b/frontend/src/pages/ClubDetailPage/components/DeadlineBadge/DeadlineBadge.tsx index 4a8bd9fff..5dd55f90c 100644 --- a/frontend/src/pages/ClubDetailPage/components/DeadlineBadge/DeadlineBadge.tsx +++ b/frontend/src/pages/ClubDetailPage/components/DeadlineBadge/DeadlineBadge.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import * as Styled from './DeadlineBadge.styles'; interface DeadlineBadgeProps { diff --git a/frontend/src/pages/ClubDetailPage/components/InfoBox/InfoBox.styles.ts b/frontend/src/pages/ClubDetailPage/components/InfoBox/InfoBox.styles.ts index b7200d506..18f6024f3 100644 --- a/frontend/src/pages/ClubDetailPage/components/InfoBox/InfoBox.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/InfoBox/InfoBox.styles.ts @@ -16,7 +16,7 @@ export const InfoBoxWrapper = styled.div` export const InfoBox = styled.div` width: 573px; - height: 164px; //todo 추후 197로 수정 필요 + height: 197px; border-radius: 18px; border: 1px solid #dcdcdc; padding: 30px; @@ -51,8 +51,9 @@ export const DescriptionWrapper = styled.div` justify-content: space-between; align-items: center; gap: 50px; - font-size: 14px; + font-size: 16px; @media (max-width: 500px) { + font-size: 14px; gap: 40px; } `; diff --git a/frontend/src/pages/ClubDetailPage/components/InfoBox/InfoBox.tsx b/frontend/src/pages/ClubDetailPage/components/InfoBox/InfoBox.tsx index b5defaae2..a922e1194 100644 --- a/frontend/src/pages/ClubDetailPage/components/InfoBox/InfoBox.tsx +++ b/frontend/src/pages/ClubDetailPage/components/InfoBox/InfoBox.tsx @@ -1,8 +1,7 @@ -import React from 'react'; import * as Styled from './InfoBox.styles'; import { ClubDetail } from '@/types/club'; import { INFOTABS_SCROLL_INDEX } from '@/constants/scrollSections'; -//import SnsLinkIcons from '@/pages/ClubDetailPage/components/SnsLinkIcons/SnsLinkIcons'; +import SnsLinkIcons from '@/pages/ClubDetailPage/components/SnsLinkIcons/SnsLinkIcons'; interface ClubInfoItem { label: string; @@ -39,10 +38,10 @@ const InfoBox = ({ sectionRefs, clubDetail }: InfoBoxProps) => { descriptions: [ { label: '회장이름', value: clubDetail.presidentName }, { label: '전화번호', value: clubDetail.presidentPhoneNumber }, - // { - // label: 'SNS', - // render: , - // }, + { + label: 'SNS', + render: , + }, ], refIndex: INFOTABS_SCROLL_INDEX.CLUB_INFO_TAB, }, diff --git a/frontend/src/pages/ClubDetailPage/components/InfoTabs/InfoTabs.tsx b/frontend/src/pages/ClubDetailPage/components/InfoTabs/InfoTabs.tsx index 4ce41b2cf..2446bd1f8 100644 --- a/frontend/src/pages/ClubDetailPage/components/InfoTabs/InfoTabs.tsx +++ b/frontend/src/pages/ClubDetailPage/components/InfoTabs/InfoTabs.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import * as Styled from './InfoTabs.styles'; import useMixpanelTrack from '@/hooks/useMixpanelTrack'; diff --git a/frontend/src/pages/ClubDetailPage/components/IntroduceBox/IntroduceBox.styles.ts b/frontend/src/pages/ClubDetailPage/components/IntroduceBox/IntroduceBox.styles.ts index 993334e3a..b9757f46a 100644 --- a/frontend/src/pages/ClubDetailPage/components/IntroduceBox/IntroduceBox.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/IntroduceBox/IntroduceBox.styles.ts @@ -18,6 +18,7 @@ export const IntroduceBoxWrapper = styled.div` border-radius: 0; border-bottom: 1px solid #dcdcdc; padding: 20px; + font-size: 14px; } `; diff --git a/frontend/src/pages/ClubDetailPage/components/IntroduceBox/IntroduceBox.tsx b/frontend/src/pages/ClubDetailPage/components/IntroduceBox/IntroduceBox.tsx index 87bb5df5e..e23da0501 100644 --- a/frontend/src/pages/ClubDetailPage/components/IntroduceBox/IntroduceBox.tsx +++ b/frontend/src/pages/ClubDetailPage/components/IntroduceBox/IntroduceBox.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import * as Styled from './IntroduceBox.styles'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; // 링크 및 마크다운 확장 지원 diff --git a/frontend/src/pages/ClubDetailPage/components/PhotoList/PhotoList.tsx b/frontend/src/pages/ClubDetailPage/components/PhotoList/PhotoList.tsx index 90d96b193..eba73888b 100644 --- a/frontend/src/pages/ClubDetailPage/components/PhotoList/PhotoList.tsx +++ b/frontend/src/pages/ClubDetailPage/components/PhotoList/PhotoList.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useMemo, useCallback } from 'react'; +import { useState, useRef, useMemo, useCallback } from 'react'; import * as Styled from './PhotoList.styles'; import convertGoogleDriveUrl from '@/utils/convertGoogleDriveUrl'; import { usePhotoNavigation } from '@/hooks/PhotoList/usePhotoNavigation'; diff --git a/frontend/src/pages/MainPage/MainPage.styles.ts b/frontend/src/pages/MainPage/MainPage.styles.ts index bb2ff7b32..dfd25d32d 100644 --- a/frontend/src/pages/MainPage/MainPage.styles.ts +++ b/frontend/src/pages/MainPage/MainPage.styles.ts @@ -48,3 +48,16 @@ export const FilterWrapper = styled.div` justify-content: right; margin: 20px 0; `; + +export const EmptyResult = styled.div` + padding: 80px 20px; + text-align: center; + color: #555; + font-size: 1.125rem; + line-height: 1.6; + white-space: pre-line; + + @media (max-width: 500px) { + font-size: 0.95rem; + } +`; diff --git a/frontend/src/pages/MainPage/MainPage.tsx b/frontend/src/pages/MainPage/MainPage.tsx index df1f0ebfb..248203484 100644 --- a/frontend/src/pages/MainPage/MainPage.tsx +++ b/frontend/src/pages/MainPage/MainPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import { useState, useMemo } from 'react'; import { useSearch } from '@/context/SearchContext'; import useTrackPageView from '@/hooks/useTrackPageView'; import { useGetCardList } from '@/hooks/queries/club/useGetCardList'; @@ -11,6 +11,7 @@ import Banner from '@/pages/MainPage/components/Banner/Banner'; import { DesktopBannerImageList } from '@/constants/banners'; import { MobileBannerImageList } from '@/constants/banners'; import { Club } from '@/types/club'; +import Spinner from '@/components/common/Spinner/Spinner'; import * as Styled from './MainPage.styles'; const MainPage = () => { @@ -23,13 +24,12 @@ const MainPage = () => { const recruitmentStatus = isFilterActive ? 'OPEN' : 'all'; const division = 'all'; - const { data: clubs, error } = useGetCardList( - keyword, - recruitmentStatus, - division, - selectedCategory, - ); - + const { + data: clubs, + error, + isLoading, + } = useGetCardList(keyword, recruitmentStatus, division, selectedCategory); + const isEmpty = !isLoading && (!clubs || clubs.length === 0); const hasData = clubs && clubs.length > 0; const clubList = useMemo(() => { @@ -54,7 +54,17 @@ const MainPage = () => { - {hasData && clubList} + {isLoading ? ( + + ) : isEmpty ? ( + + 앗, 조건에 맞는 동아리가 없어요. +
+ 다른 키워드나 조건으로 다시 시도해보세요! +
+ ) : ( + {clubList} + )}