diff --git a/.github/hooks/prepare-commit-msg.sample b/.github/hooks/prepare-commit-msg.sample new file mode 100644 index 00000000..f0d1381d --- /dev/null +++ b/.github/hooks/prepare-commit-msg.sample @@ -0,0 +1,19 @@ +#!/bin/sh + +# 커밋 메시지 파일 경로 +commit_msg_file=$1 + +# 현재 브랜치 이름 가져오기 +branch_name=$(git symbolic-ref --short HEAD) + +# 브랜치 이름에서 이슈 번호 추출하기 +issue_number=$(echo $branch_name | grep -oE '[0-9]+') + +# 이슈 번호가 추출되었는지 확인 +if [ -n "$issue_number" ]; then + # 주석을 제외한 기존 커밋 메시지 가져오기 + message_body=$(sed '/^#/d' "$commit_msg_file") + + # 새로운 메시지 작성 + echo "$message_body\n\n#$issue_number" > "$commit_msg_file" +fi diff --git a/FE/.Rhistory b/FE/.Rhistory deleted file mode 100644 index e69de29b..00000000 diff --git a/FE/.gitignore b/FE/.gitignore index ae6767f1..678a9c03 100644 --- a/FE/.gitignore +++ b/FE/.gitignore @@ -85,5 +85,7 @@ bower_components psd thumb sketch +todo.md +.prettierignore # End of https://www.toptal.com/developers/gitignore/api/nextjs,react,macos \ No newline at end of file diff --git a/FE/jest.config.ts b/FE/jest.config.ts new file mode 100644 index 00000000..26b49112 --- /dev/null +++ b/FE/jest.config.ts @@ -0,0 +1,207 @@ +/** + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ + +import type { Config } from "jest"; +import nextJest from "next/jest.js"; + +const createJestConfig = nextJest({ + // Provide the path to your Next.js app to load next.config.js and .env files in your test environment + dir: "./", +}); + +const config: Config = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/private/var/folders/_l/kg6_wrp51z92kpypfzt3jf580000gn/T/jest_dx", + + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // The default configuration for fake timers + // fakeTimers: { + // "enableGlobally": false + // }, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "mjs", + // "cjs", + // "jsx", + // "ts", + // "tsx", + // "json", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + moduleNameMapper: { + "^@/(.*)$": "/src/$1", + }, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: undefined, + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state before every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state and implementation before every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + // testEnvironment: "jest-environment-node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/", + // "\\.pnp\\.[^\\/]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + verbose: true, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; + +export default createJestConfig(config); diff --git a/FE/jest.setup.js b/FE/jest.setup.js new file mode 100644 index 00000000..6ea50da3 --- /dev/null +++ b/FE/jest.setup.js @@ -0,0 +1 @@ +setNextMock(); diff --git a/FE/package.json b/FE/package.json index 27d55a3f..3cf090e9 100644 --- a/FE/package.json +++ b/FE/package.json @@ -6,11 +6,14 @@ "dev": "next dev", "build": "next build", "start": "next build && next start", - "lint": "next lint --fix" + "lint": "next lint --fix", + "test": "jest --watch" }, "dependencies": { + "@next/third-parties": "^14.2.8", "@tanstack/react-query": "^4.35.7", "@tanstack/react-query-devtools": "^5.28.14", + "@toss/react": "^1.7.0", "@types/qs": "^6.9.10", "@types/react-syntax-highlighter": "^15.5.11", "@vercel/analytics": "^1.2.2", @@ -25,6 +28,7 @@ "react-day-picker": "^8.9.1", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.12", + "react-hook-form": "^7.52.2", "react-markdown": "^9.0.1", "react-syntax-highlighter": "^15.5.0", "react-toastify": "^9.1.3", @@ -32,9 +36,15 @@ "tailwind-scrollbar-hide": "^1.1.7" }, "devDependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.4.8", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.2", + "@types/jest": "^29.5.12", "@types/node": "latest", - "@types/react": "latest", - "@types/react-dom": "latest", + "@types/react": "^18.2.58", + "@types/react-dom": "^18.2.18", + "@types/react-test-renderer": "^18.3.0", "@typescript-eslint/eslint-plugin": "^6.7.5", "@typescript-eslint/parser": "^6.7.5", "autoprefixer": "latest", @@ -45,10 +55,14 @@ "eslint-plugin-import": "^2.28.1", "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-react": "^7.33.2", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "postcss": "latest", "prettier": "^3.0.3", "prettier-plugin-tailwindcss": "^0.5.6", + "react-test-renderer": "^18.3.1", "tailwindcss": "latest", + "ts-node": "^10.9.2", "typescript": "latest" } } diff --git a/FE/pnpm-lock.yaml b/FE/pnpm-lock.yaml index 3ddd5f8b..46cafbbb 100644 --- a/FE/pnpm-lock.yaml +++ b/FE/pnpm-lock.yaml @@ -5,12 +5,18 @@ settings: excludeLinksFromLockfile: false dependencies: + '@next/third-parties': + specifier: ^14.2.8 + version: 14.2.8(next@14.0.4)(react@18.2.0) '@tanstack/react-query': specifier: ^4.35.7 version: 4.35.7(react-dom@18.2.0)(react@18.2.0) '@tanstack/react-query-devtools': specifier: ^5.28.14 version: 5.28.14(@tanstack/react-query@4.35.7)(react@18.2.0) + '@toss/react': + specifier: ^1.7.0 + version: 1.7.0(react@18.2.0) '@types/qs': specifier: ^6.9.10 version: 6.9.10 @@ -37,7 +43,7 @@ dependencies: version: 0.5.2(jotai@2.6.0) next: specifier: 14.0.4 - version: 14.0.4(react-dom@18.2.0)(react@18.2.0) + version: 14.0.4(@babel/core@7.25.2)(react-dom@18.2.0)(react@18.2.0) qs: specifier: ^6.11.2 version: 6.11.2 @@ -53,6 +59,9 @@ dependencies: react-error-boundary: specifier: ^4.0.12 version: 4.0.12(react@18.2.0) + react-hook-form: + specifier: ^7.52.2 + version: 7.52.2(react@18.2.0) react-markdown: specifier: ^9.0.1 version: 9.0.1(@types/react@18.2.58)(react@18.2.0) @@ -70,60 +79,90 @@ dependencies: version: 1.1.7 devDependencies: + '@testing-library/dom': + specifier: ^10.4.0 + version: 10.4.0 + '@testing-library/jest-dom': + specifier: ^6.4.8 + version: 6.4.8 + '@testing-library/react': + specifier: ^16.0.0 + version: 16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.2.18)(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.5.2(@testing-library/dom@10.4.0) + '@types/jest': + specifier: ^29.5.12 + version: 29.5.12 '@types/node': specifier: latest - version: 20.11.5 + version: 22.5.4 '@types/react': - specifier: latest + specifier: ^18.2.58 version: 18.2.58 '@types/react-dom': - specifier: latest + specifier: ^18.2.18 version: 18.2.18 + '@types/react-test-renderer': + specifier: ^18.3.0 + version: 18.3.0 '@typescript-eslint/eslint-plugin': specifier: ^6.7.5 - version: 6.7.5(@typescript-eslint/parser@6.7.5)(eslint@8.57.0)(typescript@5.3.3) + version: 6.7.5(@typescript-eslint/parser@6.7.5)(eslint@9.9.1)(typescript@5.5.4) '@typescript-eslint/parser': specifier: ^6.7.5 - version: 6.7.5(eslint@8.57.0)(typescript@5.3.3) + version: 6.7.5(eslint@9.9.1)(typescript@5.5.4) autoprefixer: specifier: latest - version: 10.4.17(postcss@8.4.35) + version: 10.4.20(postcss@8.4.45) eslint: specifier: latest - version: 8.57.0 + version: 9.9.1 eslint-config-next: specifier: latest - version: 14.1.0(eslint@8.57.0)(typescript@5.3.3) + version: 14.2.8(eslint@9.9.1)(typescript@5.5.4) eslint-config-prettier: specifier: ^9.0.0 - version: 9.0.0(eslint@8.57.0) + version: 9.0.0(eslint@9.9.1) eslint-import-resolver-typescript: specifier: ^3.6.1 - version: 3.6.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@8.57.0) + version: 3.6.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@9.9.1) eslint-plugin-import: specifier: ^2.28.1 - version: 2.28.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + version: 2.28.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@9.9.1) eslint-plugin-prettier: specifier: ^5.0.1 - version: 5.0.1(eslint-config-prettier@9.0.0)(eslint@8.57.0)(prettier@3.0.3) + version: 5.0.1(eslint-config-prettier@9.0.0)(eslint@9.9.1)(prettier@3.0.3) eslint-plugin-react: specifier: ^7.33.2 - version: 7.33.2(eslint@8.57.0) + version: 7.33.2(eslint@9.9.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.5.4)(ts-node@10.9.2) + jest-environment-jsdom: + specifier: ^29.7.0 + version: 29.7.0 postcss: specifier: latest - version: 8.4.35 + version: 8.4.45 prettier: specifier: ^3.0.3 version: 3.0.3 prettier-plugin-tailwindcss: specifier: ^0.5.6 version: 0.5.6(prettier@3.0.3) + react-test-renderer: + specifier: ^18.3.1 + version: 18.3.1(react@18.2.0) tailwindcss: specifier: latest - version: 3.4.1 + version: 3.4.10(ts-node@10.9.2) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.5.4)(typescript@5.5.4) typescript: specifier: latest - version: 5.3.3 + version: 5.5.4 packages: @@ -132,24 +171,329 @@ packages: engines: {node: '>=0.10.0'} dev: true + /@adobe/css-tools@4.4.0: + resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==} + dev: true + /@alloc/quick-lru@5.2.0: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} dev: true + /@ampproject/remapping@2.3.0: + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + + /@babel/code-frame@7.24.7: + resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.24.7 + picocolors: 1.0.0 + + /@babel/compat-data@7.25.2: + resolution: {integrity: sha512-bYcppcpKBvX4znYaPEeFau03bp89ShqNMLs+rmdptMw+heSZh9+z84d2YG+K7cYLbWwzdjtDoW/uqZmPjulClQ==} + engines: {node: '>=6.9.0'} + + /@babel/core@7.25.2: + resolution: {integrity: sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.24.7 + '@babel/generator': 7.25.0 + '@babel/helper-compilation-targets': 7.25.2 + '@babel/helper-module-transforms': 7.25.2(@babel/core@7.25.2) + '@babel/helpers': 7.25.0 + '@babel/parser': 7.25.3 + '@babel/template': 7.25.0 + '@babel/traverse': 7.25.3 + '@babel/types': 7.25.2 + convert-source-map: 2.0.0 + debug: 4.3.4 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + /@babel/generator@7.25.0: + resolution: {integrity: sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.25.2 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 2.5.2 + + /@babel/helper-compilation-targets@7.25.2: + resolution: {integrity: sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/compat-data': 7.25.2 + '@babel/helper-validator-option': 7.24.8 + browserslist: 4.23.3 + lru-cache: 5.1.1 + semver: 6.3.1 + + /@babel/helper-module-imports@7.24.7: + resolution: {integrity: sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/traverse': 7.25.3 + '@babel/types': 7.25.2 + transitivePeerDependencies: + - supports-color + + /@babel/helper-module-transforms@7.25.2(@babel/core@7.25.2): + resolution: {integrity: sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-module-imports': 7.24.7 + '@babel/helper-simple-access': 7.24.7 + '@babel/helper-validator-identifier': 7.24.7 + '@babel/traverse': 7.25.3 + transitivePeerDependencies: + - supports-color + + /@babel/helper-plugin-utils@7.24.8: + resolution: {integrity: sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-simple-access@7.24.7: + resolution: {integrity: sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/traverse': 7.25.3 + '@babel/types': 7.25.2 + transitivePeerDependencies: + - supports-color + + /@babel/helper-string-parser@7.24.8: + resolution: {integrity: sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==} + engines: {node: '>=6.9.0'} + + /@babel/helper-validator-identifier@7.24.7: + resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} + engines: {node: '>=6.9.0'} + + /@babel/helper-validator-option@7.24.8: + resolution: {integrity: sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==} + engines: {node: '>=6.9.0'} + + /@babel/helpers@7.25.0: + resolution: {integrity: sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.25.0 + '@babel/types': 7.25.2 + + /@babel/highlight@7.24.7: + resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.24.7 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.0.0 + + /@babel/parser@7.25.3: + resolution: {integrity: sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.25.2 + + /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.25.2): + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + dev: true + + /@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.25.2): + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + dev: true + + /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.25.2): + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + dev: true + + /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.25.2): + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + dev: true + + /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.25.2): + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + dev: true + + /@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.25.2): + resolution: {integrity: sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + dev: true + + /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.25.2): + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + dev: true + + /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.25.2): + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + dev: true + + /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.25.2): + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + dev: true + + /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.25.2): + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + dev: true + + /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.25.2): + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + dev: true + + /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.25.2): + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + dev: true + + /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.25.2): + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + dev: true + + /@babel/plugin-syntax-typescript@7.24.7(@babel/core@7.25.2): + resolution: {integrity: sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + dev: true + /@babel/runtime@7.23.9: resolution: {integrity: sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==} engines: {node: '>=6.9.0'} dependencies: regenerator-runtime: 0.14.1 - /@eslint-community/eslint-utils@4.4.0(eslint@8.57.0): + /@babel/template@7.25.0: + resolution: {integrity: sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/parser': 7.25.3 + '@babel/types': 7.25.2 + + /@babel/traverse@7.25.3: + resolution: {integrity: sha512-HefgyP1x754oGCsKmV5reSmtV7IXj/kpaE1XYY+D9G5PvKKoFfSbiS4M77MdjuwlZKDIKFCffq9rPU+H/s3ZdQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/generator': 7.25.0 + '@babel/parser': 7.25.3 + '@babel/template': 7.25.0 + '@babel/types': 7.25.2 + debug: 4.3.4 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + /@babel/types@7.25.2: + resolution: {integrity: sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.24.8 + '@babel/helper-validator-identifier': 7.24.7 + to-fast-properties: 2.0.0 + + /@bcoe/v8-coverage@0.2.3: + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + dev: true + + /@cspotcode/source-map-support@0.8.1: + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + dev: true + + /@eslint-community/eslint-utils@4.4.0(eslint@9.9.1): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 dependencies: - eslint: 8.57.0 + eslint: 9.9.1 eslint-visitor-keys: 3.4.3 dev: true @@ -158,14 +502,30 @@ packages: engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} dev: true - /@eslint/eslintrc@2.1.4: - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@eslint-community/regexpp@4.11.0: + resolution: {integrity: sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + dev: true + + /@eslint/config-array@0.18.0: + resolution: {integrity: sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + dependencies: + '@eslint/object-schema': 2.1.4 + debug: 4.3.4 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@eslint/eslintrc@3.1.0: + resolution: {integrity: sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dependencies: ajv: 6.12.6 debug: 4.3.4 - espree: 9.6.1 - globals: 13.24.0 + espree: 10.1.0 + globals: 14.0.0 ignore: 5.3.1 import-fresh: 3.3.0 js-yaml: 4.1.0 @@ -175,20 +535,14 @@ packages: - supports-color dev: true - /@eslint/js@8.57.0: - resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@eslint/js@9.9.1: + resolution: {integrity: sha512-xIDQRsfg5hNBqHz04H1R3scSVwmI+KUbqjsQKHKQ1DAUSaUjYPReZZmS/5PNiKu1fUvzDd6H7DEDKACSEhu+TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dev: true - /@humanwhocodes/config-array@0.11.14: - resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} - engines: {node: '>=10.10.0'} - dependencies: - '@humanwhocodes/object-schema': 2.0.2 - debug: 4.3.4 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color + /@eslint/object-schema@2.1.4: + resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dev: true /@humanwhocodes/module-importer@1.0.1: @@ -196,8 +550,9 @@ packages: engines: {node: '>=12.22'} dev: true - /@humanwhocodes/object-schema@2.0.2: - resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + /@humanwhocodes/retry@0.3.0: + resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} + engines: {node: '>=18.18'} dev: true /@isaacs/cliui@8.0.2: @@ -212,88 +567,327 @@ packages: wrap-ansi-cjs: /wrap-ansi@7.0.0 dev: true - /@jridgewell/gen-mapping@0.3.4: - resolution: {integrity: sha512-Oud2QPM5dHviZNn4y/WhhYKSXksv+1xLEIsNrAbGcFzUN3ubqWRFT5gwPchNc5NuzILOU4tPBDTZ4VwhL8Y7cw==} - engines: {node: '>=6.0.0'} + /@istanbuljs/load-nyc-config@1.1.0: + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} dependencies: - '@jridgewell/set-array': 1.1.2 - '@jridgewell/sourcemap-codec': 1.4.15 - '@jridgewell/trace-mapping': 0.3.23 + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 dev: true - /@jridgewell/resolve-uri@3.1.2: - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} + /@istanbuljs/schema@0.1.3: + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} dev: true - /@jridgewell/set-array@1.1.2: - resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} - engines: {node: '>=6.0.0'} + /@jest/console@29.7.0: + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.5.4 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 dev: true - /@jridgewell/sourcemap-codec@1.4.15: - resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + /@jest/core@29.7.0(ts-node@10.9.2): + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.5.4 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@22.5.4)(ts-node@10.9.2) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.5 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node dev: true - /@jridgewell/trace-mapping@0.3.23: - resolution: {integrity: sha512-9/4foRoUKp8s96tSkh8DlAAc5A0Ty8vLXld+l9gjKKY6ckwI8G15f0hskGmuLZu78ZlGa1vtsfOa+lnB4vG6Jg==} + /@jest/environment@29.7.0: + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.4.15 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.5.4 + jest-mock: 29.7.0 dev: true - /@next/env@14.0.4: - resolution: {integrity: sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==} - dev: false + /@jest/expect-utils@29.7.0: + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + jest-get-type: 29.6.3 + dev: true - /@next/eslint-plugin-next@14.1.0: - resolution: {integrity: sha512-x4FavbNEeXx/baD/zC/SdrvkjSby8nBn8KcCREqk6UuwvwoAPZmaV8TFCAuo/cpovBRTIY67mHhe86MQQm/68Q==} + /@jest/expect@29.7.0: + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - glob: 10.3.10 + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color dev: true - /@next/swc-darwin-arm64@14.0.4: - resolution: {integrity: sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: false - optional: true + /@jest/fake-timers@29.7.0: + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 22.5.4 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + dev: true - /@next/swc-darwin-x64@14.0.4: - resolution: {integrity: sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: false - optional: true + /@jest/globals@29.7.0: + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + dev: true - /@next/swc-linux-arm64-gnu@14.0.4: - resolution: {integrity: sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: false - optional: true + /@jest/reporters@29.7.0: + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.23 + '@types/node': 22.5.4 + chalk: 4.1.2 + collect-v8-coverage: 1.0.2 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.7 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + dev: true - /@next/swc-linux-arm64-musl@14.0.4: - resolution: {integrity: sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: false - optional: true + /@jest/schemas@29.6.3: + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@sinclair/typebox': 0.27.8 + dev: true - /@next/swc-linux-x64-gnu@14.0.4: - resolution: {integrity: sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - requiresBuild: true + /@jest/source-map@29.6.3: + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jridgewell/trace-mapping': 0.3.23 + callsites: 3.1.0 + graceful-fs: 4.2.11 + dev: true + + /@jest/test-result@29.7.0: + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.2 + dev: true + + /@jest/test-sequencer@29.7.0: + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + dev: true + + /@jest/transform@29.7.0: + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/core': 7.25.2 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.23 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.5 + pirates: 4.0.6 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@jest/types@29.6.3: + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.5.4 + '@types/yargs': 17.0.33 + chalk: 4.1.2 + dev: true + + /@jridgewell/gen-mapping@0.3.5: + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.25 + + /@jridgewell/resolve-uri@3.1.2: + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + /@jridgewell/set-array@1.2.1: + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + /@jridgewell/sourcemap-codec@1.4.15: + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + + /@jridgewell/trace-mapping@0.3.23: + resolution: {integrity: sha512-9/4foRoUKp8s96tSkh8DlAAc5A0Ty8vLXld+l9gjKKY6ckwI8G15f0hskGmuLZu78ZlGa1vtsfOa+lnB4vG6Jg==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /@jridgewell/trace-mapping@0.3.25: + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + + /@jridgewell/trace-mapping@0.3.9: + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /@next/env@14.0.4: + resolution: {integrity: sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==} + dev: false + + /@next/eslint-plugin-next@14.2.8: + resolution: {integrity: sha512-ue5vcq9Fjk3asACRDrzYjcGMEN7pMMDQ5zUD+FenkqvlPCVUD1x7PxBNOLfPYDZOrk/Vnl4GHmjj2mZDqPW8TQ==} + dependencies: + glob: 10.3.10 + dev: true + + /@next/swc-darwin-arm64@14.0.4: + resolution: {integrity: sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@next/swc-darwin-x64@14.0.4: + resolution: {integrity: sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@next/swc-linux-arm64-gnu@14.0.4: + resolution: {integrity: sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@next/swc-linux-arm64-musl@14.0.4: + resolution: {integrity: sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@next/swc-linux-x64-gnu@14.0.4: + resolution: {integrity: sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true dev: false optional: true @@ -333,6 +927,17 @@ packages: dev: false optional: true + /@next/third-parties@14.2.8(next@14.0.4)(react@18.2.0): + resolution: {integrity: sha512-Vus4MYsb+7B2X4Mks9aCztZwgnzTxU9sHEm1Y35z7Vw1seh43ummniD9CBk7A8dVJZf8NGpx8re6YSZLkaSQiQ==} + peerDependencies: + next: ^13.0.0 || ^14.0.0 + react: ^18.2.0 + dependencies: + next: 14.0.4(@babel/core@7.25.2)(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + third-party-capital: 1.0.20 + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -370,6 +975,22 @@ packages: resolution: {integrity: sha512-RbhOOTCNoCrbfkRyoXODZp75MlpiHMgbE5MEBZAnnnLyQNgrigEj4p0lzsMDyc1zVsJDLrivB58tgg3emX0eEA==} dev: true + /@sinclair/typebox@0.27.8: + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + dev: true + + /@sinonjs/commons@3.0.1: + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + dependencies: + type-detect: 4.0.8 + dev: true + + /@sinonjs/fake-timers@10.3.0: + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + dependencies: + '@sinonjs/commons': 3.0.1 + dev: true + /@swc/helpers@0.5.2: resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} dependencies: @@ -413,6 +1034,147 @@ packages: use-sync-external-store: 1.2.0(react@18.2.0) dev: false + /@testing-library/dom@10.4.0: + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/runtime': 7.23.9 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + dev: true + + /@testing-library/jest-dom@6.4.8: + resolution: {integrity: sha512-JD0G+Zc38f5MBHA4NgxQMR5XtO5Jx9g86jqturNTt2WUfRmLDIY7iKkWHDCCTiDuFMre6nxAD5wHw9W5kI4rGw==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + dependencies: + '@adobe/css-tools': 4.4.0 + '@babel/runtime': 7.23.9 + aria-query: 5.3.0 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + dev: true + + /@testing-library/react@16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.2.18)(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-guuxUKRWQ+FgNX0h0NS0FIq3Q3uLtWVpBzcLOggmfMoUpgBnzBzvLLd4fbm6yS8ydJd94cIfY4yP9qUQjM2KwQ==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 + '@types/react-dom': ^18.0.0 + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.9 + '@testing-library/dom': 10.4.0 + '@types/react': 18.2.58 + '@types/react-dom': 18.2.18 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: true + + /@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0): + resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + dependencies: + '@testing-library/dom': 10.4.0 + dev: true + + /@tootallnate/once@2.0.0: + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + dev: true + + /@toss/react@1.7.0(react@18.2.0): + resolution: {integrity: sha512-aD6pIPUdCyxj4QK2HmLCC6HESgZeqIi5Slzc84Sw/r/PJaRKRG+ukV/Vft7c2NXzqG7TXdFvTzgc4dtfjT3ieA==} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18 + dependencies: + '@babel/runtime': 7.23.9 + '@toss/storage': 1.4.0 + '@toss/utils': 1.5.0 + classnames: 2.3.2 + lodash.debounce: 4.0.8 + lodash.throttle: 4.1.1 + react: 18.2.0 + dev: false + + /@toss/storage@1.4.0: + resolution: {integrity: sha512-rT0AKWS41hX/JmPQ2JZZ7GdicJZM1DPO5VFeiZwDbNFhfgHMGuEAc6w5qDN8+a8R+sQi5jG1oMFd3QNM/EcpRA==} + dependencies: + '@babel/runtime': 7.23.9 + dev: false + + /@toss/utils@1.5.0: + resolution: {integrity: sha512-zIZoth29c0IGXenFo34dB97CSaGurCtuQ2GRPxcqvj1CfBB/cxEu0GVGFLv/Dg74fNxrkBnv7sbLkrZ6egpzwA==} + dependencies: + '@babel/runtime': 7.23.9 + date-fns: 2.30.0 + dev: false + + /@tsconfig/node10@1.0.11: + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + dev: true + + /@tsconfig/node12@1.0.11: + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + dev: true + + /@tsconfig/node14@1.0.3: + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + dev: true + + /@tsconfig/node16@1.0.4: + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + dev: true + + /@types/aria-query@5.0.4: + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + dev: true + + /@types/babel__core@7.20.5: + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + dependencies: + '@babel/parser': 7.25.3 + '@babel/types': 7.25.2 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.6 + dev: true + + /@types/babel__generator@7.6.8: + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + dependencies: + '@babel/types': 7.25.2 + dev: true + + /@types/babel__template@7.4.4: + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + dependencies: + '@babel/parser': 7.25.3 + '@babel/types': 7.25.2 + dev: true + + /@types/babel__traverse@7.20.6: + resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + dependencies: + '@babel/types': 7.25.2 + dev: true + /@types/debug@4.1.12: resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} dependencies: @@ -429,6 +1191,12 @@ packages: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: false + /@types/graceful-fs@4.1.9: + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + dependencies: + '@types/node': 22.5.4 + dev: true + /@types/hast@2.3.10: resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} dependencies: @@ -441,6 +1209,37 @@ packages: '@types/unist': 3.0.2 dev: false + /@types/istanbul-lib-coverage@2.0.6: + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + dev: true + + /@types/istanbul-lib-report@3.0.3: + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + dev: true + + /@types/istanbul-reports@3.0.4: + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + dependencies: + '@types/istanbul-lib-report': 3.0.3 + dev: true + + /@types/jest@29.5.12: + resolution: {integrity: sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==} + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + dev: true + + /@types/jsdom@20.0.1: + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + dependencies: + '@types/node': 22.5.4 + '@types/tough-cookie': 4.0.5 + parse5: 7.1.2 + dev: true + /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true @@ -459,10 +1258,10 @@ packages: resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} dev: false - /@types/node@20.11.5: - resolution: {integrity: sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==} + /@types/node@22.5.4: + resolution: {integrity: sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==} dependencies: - undici-types: 5.26.5 + undici-types: 6.19.8 dev: true /@types/prop-types@15.7.11: @@ -484,6 +1283,12 @@ packages: '@types/react': 18.2.58 dev: false + /@types/react-test-renderer@18.3.0: + resolution: {integrity: sha512-HW4MuEYxfDbOHQsVlY/XtOvNHftCVEPhJF2pQXXwcUiUF+Oyb0usgp48HSgpK5rt8m9KZb22yqOeZm+rrVG8gw==} + dependencies: + '@types/react': 18.2.58 + dev: true + /@types/react@18.2.58: resolution: {integrity: sha512-TaGvMNhxvG2Q0K0aYxiKfNDS5m5ZsoIBBbtfUorxdH4NGSXIlYvZxLJI+9Dd3KjeB3780bciLyAb7ylO8pLhPw==} dependencies: @@ -498,6 +1303,14 @@ packages: resolution: {integrity: sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==} dev: true + /@types/stack-utils@2.0.3: + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + dev: true + + /@types/tough-cookie@4.0.5: + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + dev: true + /@types/unist@2.0.10: resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} dev: false @@ -506,7 +1319,17 @@ packages: resolution: {integrity: sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==} dev: false - /@typescript-eslint/eslint-plugin@6.7.5(@typescript-eslint/parser@6.7.5)(eslint@8.57.0)(typescript@5.3.3): + /@types/yargs-parser@21.0.3: + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + dev: true + + /@types/yargs@17.0.33: + resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + dependencies: + '@types/yargs-parser': 21.0.3 + dev: true + + /@typescript-eslint/eslint-plugin@6.7.5(@typescript-eslint/parser@6.7.5)(eslint@9.9.1)(typescript@5.5.4): resolution: {integrity: sha512-JhtAwTRhOUcP96D0Y6KYnwig/MRQbOoLGXTON2+LlyB/N35SP9j1boai2zzwXb7ypKELXMx3DVk9UTaEq1vHEw==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -518,24 +1341,24 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 6.7.5(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.7.5(eslint@9.9.1)(typescript@5.5.4) '@typescript-eslint/scope-manager': 6.7.5 - '@typescript-eslint/type-utils': 6.7.5(eslint@8.57.0)(typescript@5.3.3) - '@typescript-eslint/utils': 6.7.5(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/type-utils': 6.7.5(eslint@9.9.1)(typescript@5.5.4) + '@typescript-eslint/utils': 6.7.5(eslint@9.9.1)(typescript@5.5.4) '@typescript-eslint/visitor-keys': 6.7.5 debug: 4.3.4 - eslint: 8.57.0 + eslint: 9.9.1 graphemer: 1.4.0 ignore: 5.3.1 natural-compare: 1.4.0 semver: 7.6.0 - ts-api-utils: 1.2.1(typescript@5.3.3) - typescript: 5.3.3 + ts-api-utils: 1.2.1(typescript@5.5.4) + typescript: 5.5.4 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/parser@6.7.5(eslint@8.57.0)(typescript@5.3.3): + /@typescript-eslint/parser@6.7.5(eslint@9.9.1)(typescript@5.5.4): resolution: {integrity: sha512-bIZVSGx2UME/lmhLcjdVc7ePBwn7CLqKarUBL4me1C5feOd663liTGjMBGVcGr+BhnSLeP4SgwdvNnnkbIdkCw==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -547,11 +1370,11 @@ packages: dependencies: '@typescript-eslint/scope-manager': 6.7.5 '@typescript-eslint/types': 6.7.5 - '@typescript-eslint/typescript-estree': 6.7.5(typescript@5.3.3) + '@typescript-eslint/typescript-estree': 6.7.5(typescript@5.5.4) '@typescript-eslint/visitor-keys': 6.7.5 debug: 4.3.4 - eslint: 8.57.0 - typescript: 5.3.3 + eslint: 9.9.1 + typescript: 5.5.4 transitivePeerDependencies: - supports-color dev: true @@ -564,7 +1387,7 @@ packages: '@typescript-eslint/visitor-keys': 6.7.5 dev: true - /@typescript-eslint/type-utils@6.7.5(eslint@8.57.0)(typescript@5.3.3): + /@typescript-eslint/type-utils@6.7.5(eslint@9.9.1)(typescript@5.5.4): resolution: {integrity: sha512-Gs0qos5wqxnQrvpYv+pf3XfcRXW6jiAn9zE/K+DlmYf6FcpxeNYN0AIETaPR7rHO4K2UY+D0CIbDP9Ut0U4m1g==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -574,12 +1397,12 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 6.7.5(typescript@5.3.3) - '@typescript-eslint/utils': 6.7.5(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/typescript-estree': 6.7.5(typescript@5.5.4) + '@typescript-eslint/utils': 6.7.5(eslint@9.9.1)(typescript@5.5.4) debug: 4.3.4 - eslint: 8.57.0 - ts-api-utils: 1.2.1(typescript@5.3.3) - typescript: 5.3.3 + eslint: 9.9.1 + ts-api-utils: 1.2.1(typescript@5.5.4) + typescript: 5.5.4 transitivePeerDependencies: - supports-color dev: true @@ -589,7 +1412,7 @@ packages: engines: {node: ^16.0.0 || >=18.0.0} dev: true - /@typescript-eslint/typescript-estree@6.7.5(typescript@5.3.3): + /@typescript-eslint/typescript-estree@6.7.5(typescript@5.5.4): resolution: {integrity: sha512-NhJiJ4KdtwBIxrKl0BqG1Ur+uw7FiOnOThcYx9DpOGJ/Abc9z2xNzLeirCG02Ig3vkvrc2qFLmYSSsaITbKjlg==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -604,25 +1427,25 @@ packages: globby: 11.1.0 is-glob: 4.0.3 semver: 7.6.0 - ts-api-utils: 1.2.1(typescript@5.3.3) - typescript: 5.3.3 + ts-api-utils: 1.2.1(typescript@5.5.4) + typescript: 5.5.4 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/utils@6.7.5(eslint@8.57.0)(typescript@5.3.3): + /@typescript-eslint/utils@6.7.5(eslint@9.9.1)(typescript@5.5.4): resolution: {integrity: sha512-pfRRrH20thJbzPPlPc4j0UNGvH1PjPlhlCMq4Yx7EGjV7lvEeGX0U6MJYe8+SyFutWgSHsdbJ3BXzZccYggezA==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1) '@types/json-schema': 7.0.15 '@types/semver': 7.5.7 '@typescript-eslint/scope-manager': 6.7.5 '@typescript-eslint/types': 6.7.5 - '@typescript-eslint/typescript-estree': 6.7.5(typescript@5.3.3) - eslint: 8.57.0 + '@typescript-eslint/typescript-estree': 6.7.5(typescript@5.5.4) + eslint: 9.9.1 semver: 7.6.0 transitivePeerDependencies: - supports-color @@ -639,6 +1462,7 @@ packages: /@ungap/structured-clone@1.2.0: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + dev: false /@vercel/analytics@1.2.2(next@14.0.4)(react@18.2.0): resolution: {integrity: sha512-X0rctVWkQV1e5Y300ehVNqpOfSOufo7ieA5PIdna8yX/U7Vjz0GFsGf4qvAhxV02uQ2CVt7GYcrFfddXXK2Y4A==} @@ -651,7 +1475,7 @@ packages: react: optional: true dependencies: - next: 14.0.4(react-dom@18.2.0)(react@18.2.0) + next: 14.0.4(@babel/core@7.25.2)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 server-only: 0.0.1 dev: false @@ -680,14 +1504,33 @@ packages: vue-router: optional: true dependencies: - next: 14.0.4(react-dom@18.2.0)(react@18.2.0) + next: 14.0.4(@babel/core@7.25.2)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 dev: false - /acorn-jsx@5.3.2(acorn@8.11.3): + /abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + dev: true + + /acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + dependencies: + acorn: 8.11.3 + acorn-walk: 8.3.3 + dev: true + + /acorn-jsx@5.3.2(acorn@8.12.1): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 8.12.1 + dev: true + + /acorn-walk@8.3.3: + resolution: {integrity: sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==} + engines: {node: '>=0.4.0'} dependencies: acorn: 8.11.3 dev: true @@ -698,6 +1541,21 @@ packages: hasBin: true dev: true + /acorn@8.12.1: + resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + dependencies: + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + /ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: @@ -707,6 +1565,13 @@ packages: uri-js: 4.4.1 dev: true + /ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.21.3 + dev: true + /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -717,6 +1582,12 @@ packages: engines: {node: '>=12'} dev: true + /ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + dependencies: + color-convert: 1.9.3 + /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -724,6 +1595,11 @@ packages: color-convert: 2.0.1 dev: true + /ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + dev: true + /ansi-styles@6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} @@ -741,12 +1617,22 @@ packages: picomatch: 2.3.1 dev: true + /arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + dev: true + /arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} dev: true - /argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + /argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + dependencies: + sprintf-js: 1.0.3 + dev: true + + /argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true /aria-query@5.3.0: @@ -857,21 +1743,20 @@ packages: /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - dev: false - /autoprefixer@10.4.17(postcss@8.4.35): - resolution: {integrity: sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==} + /autoprefixer@10.4.20(postcss@8.4.45): + resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: postcss: ^8.1.0 dependencies: - browserslist: 4.23.0 - caniuse-lite: 1.0.30001589 + browserslist: 4.23.3 + caniuse-lite: 1.0.30001651 fraction.js: 4.3.7 normalize-range: 0.1.2 - picocolors: 1.0.0 - postcss: 8.4.35 + picocolors: 1.0.1 + postcss: 8.4.45 postcss-value-parser: 4.2.0 dev: true @@ -903,6 +1788,78 @@ packages: dequal: 2.0.3 dev: true + /babel-jest@29.7.0(@babel/core@7.25.2): + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + dependencies: + '@babel/core': 7.25.2 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.25.2) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + dependencies: + '@babel/helper-plugin-utils': 7.24.8 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/template': 7.25.0 + '@babel/types': 7.25.2 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.20.6 + dev: true + + /babel-preset-current-node-syntax@1.0.1(@babel/core@7.25.2): + resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.25.2 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.25.2) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.25.2) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.25.2) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.25.2) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.25.2) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.25.2) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.25.2) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.25.2) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.25.2) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.25.2) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.25.2) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.25.2) + dev: true + + /babel-preset-jest@29.6.3(@babel/core@7.25.2): + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.25.2 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.25.2) + dev: true + /bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} dev: false @@ -936,15 +1893,24 @@ packages: fill-range: 7.0.1 dev: true - /browserslist@4.23.0: - resolution: {integrity: sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==} + /browserslist@4.23.3: + resolution: {integrity: sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001589 - electron-to-chromium: 1.4.681 - node-releases: 2.0.14 - update-browserslist-db: 1.0.13(browserslist@4.23.0) + caniuse-lite: 1.0.30001651 + electron-to-chromium: 1.5.6 + node-releases: 2.0.18 + update-browserslist-db: 1.1.0(browserslist@4.23.3) + + /bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + dependencies: + node-int64: 0.4.0 + dev: true + + /buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: true /busboy@1.6.0: @@ -974,13 +1940,43 @@ packages: engines: {node: '>= 6'} dev: true + /camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + dev: true + + /camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + dev: true + /caniuse-lite@1.0.30001589: resolution: {integrity: sha512-vNQWS6kI+q6sBlHbh71IIeC+sRwK2N3EDySc/updIGhIee2x5z00J4c1242/5/d6EpEMdOnk/m+6tuk4/tcsqg==} + dev: false + + /caniuse-lite@1.0.30001651: + resolution: {integrity: sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==} /ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} dev: false + /chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + /chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + /chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -989,6 +1985,11 @@ packages: supports-color: 7.2.0 dev: true + /char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + dev: true + /character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} dev: false @@ -1032,6 +2033,15 @@ packages: fsevents: 2.3.3 dev: true + /ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + dev: true + + /cjs-module-lexer@1.3.1: + resolution: {integrity: sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==} + dev: true + /classnames@2.3.2: resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==} dev: false @@ -1040,11 +2050,34 @@ packages: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} dev: false + /cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + dev: true + /clsx@1.2.1: resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} engines: {node: '>=6'} dev: false + /co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + dev: true + + /collect-v8-coverage@1.0.2: + resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} + dev: true + + /color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + dependencies: + color-name: 1.1.3 + /color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1052,6 +2085,9 @@ packages: color-name: 1.1.4 dev: true + /color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} dev: true @@ -1061,7 +2097,6 @@ packages: engines: {node: '>= 0.8'} dependencies: delayed-stream: 1.0.0 - dev: false /comma-separated-tokens@1.0.8: resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==} @@ -1080,6 +2115,32 @@ packages: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true + /convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + /create-jest@29.7.0(@types/node@22.5.4)(ts-node@10.9.2): + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@22.5.4)(ts-node@10.9.2) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + dev: true + + /create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + dev: true + /cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -1089,12 +2150,31 @@ packages: which: 2.0.2 dev: true + /css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + dev: true + /cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true dev: true + /cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + dev: true + + /cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + dev: true + + /cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + dependencies: + cssom: 0.3.8 + dev: true + /csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -1102,6 +2182,15 @@ packages: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} dev: true + /data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + dev: true + /date-fns@2.30.0: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} @@ -1131,16 +2220,34 @@ packages: dependencies: ms: 2.1.2 + /decimal.js@10.4.3: + resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + dev: true + /decode-named-character-reference@1.0.2: resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} dependencies: character-entities: 2.0.2 dev: false + /dedent@1.5.3: + resolution: {integrity: sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + dev: true + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + dev: true + /define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -1161,12 +2268,16 @@ packages: /delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - dev: false /dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + /detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + dev: true + /devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} dependencies: @@ -1177,6 +2288,16 @@ packages: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} dev: true + /diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dev: true + + /diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + dev: true + /dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1195,19 +2316,32 @@ packages: esutils: 2.0.3 dev: true - /doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} + /dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dev: true + + /dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dev: true + + /domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead dependencies: - esutils: 2.0.3 + webidl-conversions: 7.0.0 dev: true /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true - /electron-to-chromium@1.4.681: - resolution: {integrity: sha512-1PpuqJUFWoXZ1E54m8bsLPVYwIVCRzvaL+n5cjigGga4z854abDnFRc+cTa2th4S79kyGqya/1xoR7h+Y5G5lg==} + /electron-to-chromium@1.5.6: + resolution: {integrity: sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==} + + /emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} dev: true /emoji-regex@8.0.0: @@ -1226,6 +2360,17 @@ packages: tapable: 2.2.1 dev: true + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: true + + /error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + dependencies: + is-arrayish: 0.2.1 + dev: true + /es-abstract@1.22.4: resolution: {integrity: sha512-vZYJlk2u6qHYxBOTjAeg7qUxHdNfih64Uu2J8QqWgXZ2cri0ZpJAkzDUK/q593+mvKwlxyaxr6F1Q+3LKoQRgg==} engines: {node: '>= 0.4'} @@ -1335,6 +2480,14 @@ packages: /escalade@3.1.2: resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} engines: {node: '>=6'} + + /escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + /escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} dev: true /escape-string-regexp@4.0.0: @@ -1347,8 +2500,20 @@ packages: engines: {node: '>=12'} dev: false - /eslint-config-next@14.1.0(eslint@8.57.0)(typescript@5.3.3): - resolution: {integrity: sha512-SBX2ed7DoRFXC6CQSLc/SbLY9Ut6HxNB2wPTcoIWjUMd7aF7O/SIE7111L8FdZ9TXsNV4pulUDnfthpyPtbFUg==} + /escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + dev: true + + /eslint-config-next@14.2.8(eslint@9.9.1)(typescript@5.5.4): + resolution: {integrity: sha512-gRqxHkSuCrQro6xqXnmXphcq8rdiw7FI+nLXpWmIlp/AfUzHCgXNQE7mOK+oco+SRaJbhqCg/68uRln1qjkF+Q==} peerDependencies: eslint: ^7.23.0 || ^8.0.0 typescript: '>=3.3.1' @@ -1356,29 +2521,30 @@ packages: typescript: optional: true dependencies: - '@next/eslint-plugin-next': 14.1.0 + '@next/eslint-plugin-next': 14.2.8 '@rushstack/eslint-patch': 1.7.2 - '@typescript-eslint/parser': 6.7.5(eslint@8.57.0)(typescript@5.3.3) - eslint: 8.57.0 + '@typescript-eslint/eslint-plugin': 6.7.5(@typescript-eslint/parser@6.7.5)(eslint@9.9.1)(typescript@5.5.4) + '@typescript-eslint/parser': 6.7.5(eslint@9.9.1)(typescript@5.5.4) + eslint: 9.9.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@8.57.0) - eslint-plugin-import: 2.28.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) - eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0) - eslint-plugin-react: 7.33.2(eslint@8.57.0) - eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0) - typescript: 5.3.3 + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@9.9.1) + eslint-plugin-import: 2.28.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@9.9.1) + eslint-plugin-jsx-a11y: 6.8.0(eslint@9.9.1) + eslint-plugin-react: 7.33.2(eslint@9.9.1) + eslint-plugin-react-hooks: 4.6.0(eslint@9.9.1) + typescript: 5.5.4 transitivePeerDependencies: - eslint-import-resolver-webpack - supports-color dev: true - /eslint-config-prettier@9.0.0(eslint@8.57.0): + /eslint-config-prettier@9.0.0(eslint@9.9.1): resolution: {integrity: sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==} hasBin: true peerDependencies: eslint: '>=7.0.0' dependencies: - eslint: 8.57.0 + eslint: 9.9.1 dev: true /eslint-import-resolver-node@0.3.9: @@ -1391,7 +2557,7 @@ packages: - supports-color dev: true - /eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@8.57.0): + /eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@9.9.1): resolution: {integrity: sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: @@ -1400,9 +2566,9 @@ packages: dependencies: debug: 4.3.4 enhanced-resolve: 5.15.0 - eslint: 8.57.0 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) - eslint-plugin-import: 2.28.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint: 9.9.1 + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.9.1) + eslint-plugin-import: 2.28.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@9.9.1) fast-glob: 3.3.2 get-tsconfig: 4.7.2 is-core-module: 2.13.1 @@ -1414,7 +2580,7 @@ packages: - supports-color dev: true - /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): + /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.9.1): resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} engines: {node: '>=4'} peerDependencies: @@ -1435,16 +2601,16 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 6.7.5(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.7.5(eslint@9.9.1)(typescript@5.5.4) debug: 3.2.7 - eslint: 8.57.0 + eslint: 9.9.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@9.9.1) transitivePeerDependencies: - supports-color dev: true - /eslint-plugin-import@2.28.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): + /eslint-plugin-import@2.28.1(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@9.9.1): resolution: {integrity: sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==} engines: {node: '>=4'} peerDependencies: @@ -1454,16 +2620,16 @@ packages: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 6.7.5(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.7.5(eslint@9.9.1)(typescript@5.5.4) array-includes: 3.1.7 array.prototype.findlastindex: 1.2.4 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 debug: 3.2.7 doctrine: 2.1.0 - eslint: 8.57.0 + eslint: 9.9.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.9.1) has: 1.0.4 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -1479,7 +2645,7 @@ packages: - supports-color dev: true - /eslint-plugin-jsx-a11y@6.8.0(eslint@8.57.0): + /eslint-plugin-jsx-a11y@6.8.0(eslint@9.9.1): resolution: {integrity: sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==} engines: {node: '>=4.0'} peerDependencies: @@ -1495,7 +2661,7 @@ packages: damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 es-iterator-helpers: 1.0.17 - eslint: 8.57.0 + eslint: 9.9.1 hasown: 2.0.1 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -1504,7 +2670,7 @@ packages: object.fromentries: 2.0.7 dev: true - /eslint-plugin-prettier@5.0.1(eslint-config-prettier@9.0.0)(eslint@8.57.0)(prettier@3.0.3): + /eslint-plugin-prettier@5.0.1(eslint-config-prettier@9.0.0)(eslint@9.9.1)(prettier@3.0.3): resolution: {integrity: sha512-m3u5RnR56asrwV/lDC4GHorlW75DsFfmUcjfCYylTUs85dBRnB7VM6xG8eCMJdeDRnppzmxZVf1GEPJvl1JmNg==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: @@ -1518,23 +2684,23 @@ packages: eslint-config-prettier: optional: true dependencies: - eslint: 8.57.0 - eslint-config-prettier: 9.0.0(eslint@8.57.0) + eslint: 9.9.1 + eslint-config-prettier: 9.0.0(eslint@9.9.1) prettier: 3.0.3 prettier-linter-helpers: 1.0.0 synckit: 0.8.8 dev: true - /eslint-plugin-react-hooks@4.6.0(eslint@8.57.0): + /eslint-plugin-react-hooks@4.6.0(eslint@9.9.1): resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} engines: {node: '>=10'} peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 dependencies: - eslint: 8.57.0 + eslint: 9.9.1 dev: true - /eslint-plugin-react@7.33.2(eslint@8.57.0): + /eslint-plugin-react@7.33.2(eslint@9.9.1): resolution: {integrity: sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==} engines: {node: '>=4'} peerDependencies: @@ -1545,7 +2711,7 @@ packages: array.prototype.tosorted: 1.1.3 doctrine: 2.1.0 es-iterator-helpers: 1.0.17 - eslint: 8.57.0 + eslint: 9.9.1 estraverse: 5.3.0 jsx-ast-utils: 3.3.5 minimatch: 3.1.2 @@ -1559,9 +2725,9 @@ packages: string.prototype.matchall: 4.0.10 dev: true - /eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /eslint-scope@8.0.2: + resolution: {integrity: sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 @@ -1572,41 +2738,47 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /eslint@8.57.0: - resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /eslint-visitor-keys@4.0.0: + resolution: {integrity: sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + dev: true + + /eslint@9.9.1: + resolution: {integrity: sha512-dHvhrbfr4xFQ9/dq+jcVneZMyRYLjggWjk6RVsIiHsP8Rz6yZ8LvZ//iU4TrZF+SXWG+JkNF2OyiZRvzgRDqMg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@eslint-community/regexpp': 4.10.0 - '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.0 - '@humanwhocodes/config-array': 0.11.14 + '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1) + '@eslint-community/regexpp': 4.11.0 + '@eslint/config-array': 0.18.0 + '@eslint/eslintrc': 3.1.0 + '@eslint/js': 9.9.1 '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.3.0 '@nodelib/fs.walk': 1.2.8 - '@ungap/structured-clone': 1.2.0 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 debug: 4.3.4 - doctrine: 3.0.0 escape-string-regexp: 4.0.0 - eslint-scope: 7.2.2 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 + eslint-scope: 8.0.2 + eslint-visitor-keys: 4.0.0 + espree: 10.1.0 esquery: 1.5.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 + file-entry-cache: 8.0.0 find-up: 5.0.0 glob-parent: 6.0.2 - globals: 13.24.0 - graphemer: 1.4.0 ignore: 5.3.1 imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 - js-yaml: 4.1.0 json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 @@ -1619,13 +2791,19 @@ packages: - supports-color dev: true - /espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /espree@10.1.0: + resolution: {integrity: sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dependencies: - acorn: 8.11.3 - acorn-jsx: 5.3.2(acorn@8.11.3) - eslint-visitor-keys: 3.4.3 + acorn: 8.12.1 + acorn-jsx: 5.3.2(acorn@8.12.1) + eslint-visitor-keys: 4.0.0 + dev: true + + /esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true dev: true /esquery@1.5.0: @@ -1656,6 +2834,37 @@ packages: engines: {node: '>=0.10.0'} dev: true + /execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + dev: true + + /exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + dev: true + + /expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + dev: true + /extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} dev: false @@ -1699,11 +2908,17 @@ packages: format: 0.2.2 dev: false - /file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} + /fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + dependencies: + bser: 2.1.1 + dev: true + + /file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} dependencies: - flat-cache: 3.2.0 + flat-cache: 4.0.1 dev: true /fill-range@7.0.1: @@ -1713,6 +2928,14 @@ packages: to-regex-range: 5.0.1 dev: true + /find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + dev: true + /find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1721,13 +2944,12 @@ packages: path-exists: 4.0.0 dev: true - /flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} + /flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} dependencies: flatted: 3.3.1 keyv: 4.5.4 - rimraf: 3.0.2 dev: true /flatted@3.3.1: @@ -1765,7 +2987,6 @@ packages: asynckit: 0.4.0 combined-stream: 1.0.8 mime-types: 2.1.35 - dev: false /format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} @@ -1805,6 +3026,15 @@ packages: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} dev: true + /gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + /get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + dev: true + /get-intrinsic@1.2.4: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} @@ -1815,6 +3045,16 @@ packages: has-symbols: 1.0.3 hasown: 2.0.1 + /get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + dev: true + + /get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + dev: true + /get-symbol-description@1.0.2: resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} engines: {node: '>= 0.4'} @@ -1871,11 +3111,13 @@ packages: path-is-absolute: 1.0.1 dev: true - /globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} - dependencies: - type-fest: 0.20.2 + /globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + /globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} dev: true /globalthis@1.0.3: @@ -1913,6 +3155,10 @@ packages: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} dev: true + /has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1995,10 +3241,54 @@ packages: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} dev: false + /html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + dependencies: + whatwg-encoding: 2.0.0 + dev: true + + /html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + dev: true + /html-url-attributes@3.0.0: resolution: {integrity: sha512-/sXbVCWayk6GDVg3ctOX6nxaVj7So40FcFAnWlWGNAB1LpYKcV5Cd10APjPjW80O7zYW2MsjBV4zZ7IZO5fVow==} dev: false + /http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + + /https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + + /human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + dev: true + + /iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: true + /ignore@5.3.1: resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} engines: {node: '>= 4'} @@ -2012,11 +3302,25 @@ packages: resolve-from: 4.0.0 dev: true + /import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + dev: true + /imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} dev: true + /indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + dev: true + /inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} dependencies: @@ -2071,6 +3375,10 @@ packages: get-intrinsic: 1.2.4 dev: true + /is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + dev: true + /is-async-function@2.0.0: resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} engines: {node: '>= 0.4'} @@ -2099,183 +3407,690 @@ packages: has-tostringtag: 1.0.2 dev: true - /is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} + /is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + dev: true + + /is-core-module@2.13.1: + resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} + dependencies: + hasown: 2.0.1 + dev: true + + /is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.2 + dev: true + + /is-decimal@1.0.4: + resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==} + dev: false + + /is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + dev: false + + /is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-finalizationregistry@1.0.2: + resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} + dependencies: + call-bind: 1.0.7 + dev: true + + /is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + dev: true + + /is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + dev: true + + /is-generator-function@1.0.10: + resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.2 + dev: true + + /is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-hexadecimal@1.0.4: + resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==} + dev: false + + /is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + dev: false + + /is-map@2.0.2: + resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} + dev: true + + /is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + dev: true + + /is-number-object@1.0.7: + resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.2 + dev: true + + /is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + dev: true + + /is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + dev: false + + /is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + dev: true + + /is-regex@1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + dev: true + + /is-set@2.0.2: + resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==} + dev: true + + /is-shared-array-buffer@1.0.3: + resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + dev: true + + /is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + dev: true + + /is-string@1.0.7: + resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.2 + dev: true + + /is-symbol@1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.0.3 + dev: true + + /is-typed-array@1.1.13: + resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} + engines: {node: '>= 0.4'} + dependencies: + which-typed-array: 1.1.14 + dev: true + + /is-weakmap@2.0.1: + resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==} + dev: true + + /is-weakref@1.0.2: + resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + dependencies: + call-bind: 1.0.7 + dev: true + + /is-weakset@2.0.2: + resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==} + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + dev: true + + /isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + dev: true + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: true + + /istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + dev: true + + /istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + dependencies: + '@babel/core': 7.25.2 + '@babel/parser': 7.25.3 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + dependencies: + '@babel/core': 7.25.2 + '@babel/parser': 7.25.3 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.6.0 + transitivePeerDependencies: + - supports-color + dev: true + + /istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + dev: true + + /istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + dependencies: + debug: 4.3.4 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + dev: true + + /istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + dev: true + + /iterator.prototype@1.1.2: + resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} + dependencies: + define-properties: 1.2.1 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + reflect.getprototypeof: 1.0.5 + set-function-name: 2.0.2 + dev: true + + /jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + dev: true + + /jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + dev: true + + /jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.5.4 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.5.3 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + dev: true + + /jest-cli@29.7.0(@types/node@22.5.4)(ts-node@10.9.2): + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@22.5.4)(ts-node@10.9.2) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@22.5.4)(ts-node@10.9.2) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + dev: true + + /jest-config@29.7.0(@types/node@22.5.4)(ts-node@10.9.2): + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + dependencies: + '@babel/core': 7.25.2 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.5.4 + babel-jest: 29.7.0(@babel/core@7.25.2) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.5 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + ts-node: 10.9.2(@types/node@22.5.4)(typescript@5.5.4) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + dev: true + + /jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 dev: true - /is-core-module@2.13.1: - resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} + /jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - hasown: 2.0.1 + detect-newline: 3.1.0 dev: true - /is-date-object@1.0.5: - resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} - engines: {node: '>= 0.4'} + /jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - has-tostringtag: 1.0.2 + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 dev: true - /is-decimal@1.0.4: - resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==} - dev: false - - /is-decimal@2.0.1: - resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - dev: false - - /is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} + /jest-environment-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 22.5.4 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate dev: true - /is-finalizationregistry@1.0.2: - resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} + /jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - call-bind: 1.0.7 + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.5.4 + jest-mock: 29.7.0 + jest-util: 29.7.0 dev: true - /is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} + /jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dev: true - /is-generator-function@1.0.10: - resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} - engines: {node: '>= 0.4'} + /jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - has-tostringtag: 1.0.2 + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 22.5.4 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.5 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 dev: true - /is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} + /jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - is-extglob: 2.1.1 - dev: true - - /is-hexadecimal@1.0.4: - resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==} - dev: false - - /is-hexadecimal@2.0.1: - resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - dev: false - - /is-map@2.0.2: - resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} - dev: true - - /is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} + jest-get-type: 29.6.3 + pretty-format: 29.7.0 dev: true - /is-number-object@1.0.7: - resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} - engines: {node: '>= 0.4'} + /jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - has-tostringtag: 1.0.2 + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 dev: true - /is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} + /jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/code-frame': 7.24.7 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.5 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 dev: true - /is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} + /jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.5.4 + jest-util: 29.7.0 dev: true - /is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - dev: false - - /is-regex@1.1.4: - resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} - engines: {node: '>= 0.4'} + /jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true dependencies: - call-bind: 1.0.7 - has-tostringtag: 1.0.2 + jest-resolve: 29.7.0 dev: true - /is-set@2.0.2: - resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==} + /jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dev: true - /is-shared-array-buffer@1.0.3: - resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} - engines: {node: '>= 0.4'} + /jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - call-bind: 1.0.7 + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color dev: true - /is-string@1.0.7: - resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} - engines: {node: '>= 0.4'} + /jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - has-tostringtag: 1.0.2 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.8 + resolve.exports: 2.0.2 + slash: 3.0.0 dev: true - /is-symbol@1.0.4: - resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} - engines: {node: '>= 0.4'} + /jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - has-symbols: 1.0.3 + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.5.4 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color dev: true - /is-typed-array@1.1.13: - resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} - engines: {node: '>= 0.4'} + /jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - which-typed-array: 1.1.14 - dev: true - - /is-weakmap@2.0.1: - resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==} + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.5.4 + chalk: 4.1.2 + cjs-module-lexer: 1.3.1 + collect-v8-coverage: 1.0.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color dev: true - /is-weakref@1.0.2: - resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + /jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - call-bind: 1.0.7 + '@babel/core': 7.25.2 + '@babel/generator': 7.25.0 + '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.25.2) + '@babel/plugin-syntax-typescript': 7.24.7(@babel/core@7.25.2) + '@babel/types': 7.25.2 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.25.2) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.6.0 + transitivePeerDependencies: + - supports-color dev: true - /is-weakset@2.0.2: - resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==} + /jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - call-bind: 1.0.7 - get-intrinsic: 1.2.4 + '@jest/types': 29.6.3 + '@types/node': 22.5.4 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 dev: true - /isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + /jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 dev: true - /isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + /jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.5.4 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 dev: true - /iterator.prototype@1.1.2: - resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} + /jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - define-properties: 1.2.1 - get-intrinsic: 1.2.4 - has-symbols: 1.0.3 - reflect.getprototypeof: 1.0.5 - set-function-name: 2.0.2 + '@types/node': 22.5.4 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 dev: true - /jackspeak@2.3.6: - resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} - engines: {node: '>=14'} + /jest@29.7.0(@types/node@22.5.4)(ts-node@10.9.2): + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 + '@jest/core': 29.7.0(ts-node@10.9.2) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.5.4)(ts-node@10.9.2) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node dev: true /jiti@1.21.0: @@ -2310,6 +4125,14 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + /js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + dev: true + /js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -2317,10 +4140,60 @@ packages: argparse: 2.0.1 dev: true + /jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + abab: 2.0.6 + acorn: 8.11.3 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.4.3 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.0 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.12 + parse5: 7.1.2 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.18.0 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + + /jsesc@2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} + hasBin: true + /json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} dev: true + /json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + dev: true + /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true @@ -2336,6 +4209,11 @@ packages: minimist: 1.2.8 dev: true + /json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + /jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -2352,6 +4230,11 @@ packages: json-buffer: 3.0.1 dev: true + /kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + dev: true + /language-subtag-registry@0.3.22: resolution: {integrity: sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==} dev: true @@ -2363,6 +4246,11 @@ packages: language-subtag-registry: 0.3.22 dev: true + /leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + dev: true + /levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2385,6 +4273,13 @@ packages: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} dev: true + /locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + dependencies: + p-locate: 4.1.0 + dev: true + /locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2392,10 +4287,22 @@ packages: p-locate: 5.0.0 dev: true + /lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + dev: false + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true + /lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + dev: false + + /lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + dev: true + /longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} dev: false @@ -2418,6 +4325,11 @@ packages: engines: {node: 14 || >=16.14} dev: true + /lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + dependencies: + yallist: 3.1.1 + /lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -2425,6 +4337,28 @@ packages: yallist: 4.0.0 dev: true + /lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + dev: true + + /make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + dependencies: + semver: 7.6.0 + dev: true + + /make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + dev: true + + /makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + dependencies: + tmpl: 1.0.5 + dev: true + /markdown-table@3.0.3: resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} dev: false @@ -2612,6 +4546,10 @@ packages: '@types/mdast': 4.0.3 dev: false + /merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + dev: true + /merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2875,14 +4813,22 @@ packages: /mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} - dev: false /mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} dependencies: mime-db: 1.52.0 - dev: false + + /mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + dev: true + + /min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2930,7 +4876,7 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true - /next@14.0.4(react-dom@18.2.0)(react@18.2.0): + /next@14.0.4(@babel/core@7.25.2)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA==} engines: {node: '>=18.17.0'} hasBin: true @@ -2953,7 +4899,7 @@ packages: postcss: 8.4.31 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - styled-jsx: 5.1.1(react@18.2.0) + styled-jsx: 5.1.1(@babel/core@7.25.2)(react@18.2.0) watchpack: 2.4.0 optionalDependencies: '@next/swc-darwin-arm64': 14.0.4 @@ -2970,18 +4916,32 @@ packages: - babel-plugin-macros dev: false - /node-releases@2.0.14: - resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + /node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} dev: true + /node-releases@2.0.18: + resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + /normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} dev: true - /normalize-range@0.1.2: - resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} - engines: {node: '>=0.10.0'} + /normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + dev: true + + /npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + dependencies: + path-key: 3.1.1 + dev: true + + /nwsapi@2.2.12: + resolution: {integrity: sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==} dev: true /object-assign@4.1.1: @@ -3062,6 +5022,13 @@ packages: wrappy: 1.0.2 dev: true + /onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + dependencies: + mimic-fn: 2.1.0 + dev: true + /optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -3074,6 +5041,13 @@ packages: type-check: 0.4.0 dev: true + /p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + dependencies: + p-try: 2.2.0 + dev: true + /p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -3081,6 +5055,13 @@ packages: yocto-queue: 0.1.0 dev: true + /p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + dependencies: + p-limit: 2.3.0 + dev: true + /p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -3088,6 +5069,11 @@ packages: p-limit: 3.1.0 dev: true + /p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + dev: true + /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3119,6 +5105,22 @@ packages: is-hexadecimal: 2.0.1 dev: false + /parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + dependencies: + '@babel/code-frame': 7.24.7 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + dev: true + + /parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + dependencies: + entities: 4.5.0 + dev: true + /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3154,6 +5156,9 @@ packages: /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + /picocolors@1.0.1: + resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} + /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -3169,34 +5174,41 @@ packages: engines: {node: '>= 6'} dev: true + /pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + dependencies: + find-up: 4.1.0 + dev: true + /possible-typed-array-names@1.0.0: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} dev: true - /postcss-import@15.1.0(postcss@8.4.35): + /postcss-import@15.1.0(postcss@8.4.45): resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} peerDependencies: postcss: ^8.0.0 dependencies: - postcss: 8.4.35 + postcss: 8.4.45 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.8 dev: true - /postcss-js@4.0.1(postcss@8.4.35): + /postcss-js@4.0.1(postcss@8.4.45): resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} engines: {node: ^12 || ^14 || >= 16} peerDependencies: postcss: ^8.4.21 dependencies: camelcase-css: 2.0.1 - postcss: 8.4.35 + postcss: 8.4.45 dev: true - /postcss-load-config@4.0.2(postcss@8.4.35): + /postcss-load-config@4.0.2(postcss@8.4.45)(ts-node@10.9.2): resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} engines: {node: '>= 14'} peerDependencies: @@ -3209,17 +5221,18 @@ packages: optional: true dependencies: lilconfig: 3.1.1 - postcss: 8.4.35 + postcss: 8.4.45 + ts-node: 10.9.2(@types/node@22.5.4)(typescript@5.5.4) yaml: 2.3.4 dev: true - /postcss-nested@6.0.1(postcss@8.4.35): + /postcss-nested@6.0.1(postcss@8.4.45): resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} engines: {node: '>=12.0'} peerDependencies: postcss: ^8.2.14 dependencies: - postcss: 8.4.35 + postcss: 8.4.45 postcss-selector-parser: 6.0.15 dev: true @@ -3240,17 +5253,17 @@ packages: engines: {node: ^10 || ^12 || >=14} dependencies: nanoid: 3.3.7 - picocolors: 1.0.0 + picocolors: 1.0.1 source-map-js: 1.0.2 dev: false - /postcss@8.4.35: - resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==} + /postcss@8.4.45: + resolution: {integrity: sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==} engines: {node: ^10 || ^12 || >=14} dependencies: nanoid: 3.3.7 - picocolors: 1.0.0 - source-map-js: 1.0.2 + picocolors: 1.0.1 + source-map-js: 1.2.0 dev: true /prelude-ls@1.2.1: @@ -3326,6 +5339,24 @@ packages: hasBin: true dev: true + /pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + dev: true + + /pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + dev: true + /prismjs@1.27.0: resolution: {integrity: sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==} engines: {node: '>=6'} @@ -3336,6 +5367,14 @@ packages: engines: {node: '>=6'} dev: false + /prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + dev: true + /prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} dependencies: @@ -3358,11 +5397,19 @@ packages: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} dev: false + /psl@1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + dev: true + /punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} dev: true + /pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + dev: true + /qs@6.11.2: resolution: {integrity: sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==} engines: {node: '>=0.6'} @@ -3370,6 +5417,10 @@ packages: side-channel: 1.0.5 dev: false + /querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + dev: true + /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true @@ -3392,7 +5443,6 @@ packages: loose-envify: 1.4.0 react: 18.2.0 scheduler: 0.23.0 - dev: false /react-error-boundary@4.0.12(react@18.2.0): resolution: {integrity: sha512-kJdxdEYlb7CPC1A0SeUY38cHpjuu6UkvzKiAmqmOFL21VRfMhOcWxTCBgLVCO0VEMh9JhFNcVaXlV4/BTpiwOA==} @@ -3403,10 +5453,27 @@ packages: react: 18.2.0 dev: false + /react-hook-form@7.52.2(react@18.2.0): + resolution: {integrity: sha512-pqfPEbERnxxiNMPd0bzmt1tuaPcVccywFDpyk2uV5xCIBphHV5T8SVnX9/o3kplPE1zzKt77+YIoq+EMwJp56A==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + dependencies: + react: 18.2.0 + dev: false + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: true + /react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + dev: true + + /react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + dev: true + /react-markdown@9.0.1(@types/react@18.2.58)(react@18.2.0): resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==} peerDependencies: @@ -3429,6 +5496,16 @@ packages: - supports-color dev: false + /react-shallow-renderer@16.15.0(react@18.2.0): + resolution: {integrity: sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + object-assign: 4.1.1 + react: 18.2.0 + react-is: 18.3.1 + dev: true + /react-syntax-highlighter@15.5.0(react@18.2.0): resolution: {integrity: sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg==} peerDependencies: @@ -3442,6 +5519,17 @@ packages: refractor: 3.6.0 dev: false + /react-test-renderer@18.3.1(react@18.2.0): + resolution: {integrity: sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==} + peerDependencies: + react: ^18.3.1 + dependencies: + react: 18.2.0 + react-is: 18.3.1 + react-shallow-renderer: 16.15.0(react@18.2.0) + scheduler: 0.23.2 + dev: true + /react-toastify@9.1.3(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==} peerDependencies: @@ -3458,7 +5546,6 @@ packages: engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 - dev: false /read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} @@ -3473,6 +5560,14 @@ packages: picomatch: 2.3.1 dev: true + /redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + dev: true + /reflect.getprototypeof@1.0.5: resolution: {integrity: sha512-62wgfC8dJWrmxv44CA36pLDnP6KKl3Vhxb7PL+8+qrrFMMoJij4vgiMP8zV4O8+CBMXY1mHxI5fITGHXFHVmQQ==} engines: {node: '>= 0.4'} @@ -3549,15 +5644,41 @@ packages: unified: 11.0.4 dev: false + /require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + dev: true + + /requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + dev: true + + /resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + dependencies: + resolve-from: 5.0.0 + dev: true + /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} dev: true + /resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + dev: true + /resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} dev: true + /resolve.exports@2.0.2: + resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==} + engines: {node: '>=10'} + dev: true + /resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true @@ -3581,13 +5702,6 @@ packages: engines: {iojs: '>=1.0.0', node: '>=0.10.0'} dev: true - /rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - hasBin: true - dependencies: - glob: 7.2.3 - dev: true - /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -3613,16 +5727,31 @@ packages: is-regex: 1.1.4 dev: true + /safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + dev: true + + /saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + dependencies: + xmlchars: 2.2.0 + dev: true + /scheduler@0.23.0: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} dependencies: loose-envify: 1.4.0 - dev: false + + /scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + dependencies: + loose-envify: 1.4.0 + dev: true /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - dev: true /semver@7.6.0: resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} @@ -3678,11 +5807,19 @@ packages: get-intrinsic: 1.2.4 object-inspect: 1.13.1 + /signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + dev: true + /signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} dev: true + /sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + dev: true + /slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -3691,6 +5828,24 @@ packages: /source-map-js@1.0.2: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'} + dev: false + + /source-map-js@1.2.0: + resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} + engines: {node: '>=0.10.0'} + dev: true + + /source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + dev: true + + /source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + dev: true /space-separated-tokens@1.1.5: resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==} @@ -3700,11 +5855,30 @@ packages: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} dev: false + /sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + dev: true + + /stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + dependencies: + escape-string-regexp: 2.0.0 + dev: true + /streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} dev: false + /string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + dev: true + /string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3788,6 +5962,23 @@ packages: engines: {node: '>=4'} dev: true + /strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + dev: true + + /strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + dev: true + + /strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + /strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3799,7 +5990,7 @@ packages: inline-style-parser: 0.2.2 dev: false - /styled-jsx@5.1.1(react@18.2.0): + /styled-jsx@5.1.1(@babel/core@7.25.2)(react@18.2.0): resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} engines: {node: '>= 12.0.0'} peerDependencies: @@ -3812,6 +6003,7 @@ packages: babel-plugin-macros: optional: true dependencies: + '@babel/core': 7.25.2 client-only: 0.0.1 react: 18.2.0 dev: false @@ -3821,7 +6013,7 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true dependencies: - '@jridgewell/gen-mapping': 0.3.4 + '@jridgewell/gen-mapping': 0.3.5 commander: 4.1.1 glob: 10.3.10 lines-and-columns: 1.2.4 @@ -3830,6 +6022,12 @@ packages: ts-interface-checker: 0.1.13 dev: true + /supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + dependencies: + has-flag: 3.0.0 + /supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3837,11 +6035,22 @@ packages: has-flag: 4.0.0 dev: true + /supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + dependencies: + has-flag: 4.0.0 + dev: true + /supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} dev: true + /symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + dev: true + /synckit@0.8.8: resolution: {integrity: sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3854,8 +6063,8 @@ packages: resolution: {integrity: sha512-X324n9OtpTmOMqEgDUEA/RgLrNfBF/jwJdctaPZDzB3mppxJk7TLIDmOreEDm1Bq4R9LSPu4Epf8VSdovNU+iA==} dev: false - /tailwindcss@3.4.1: - resolution: {integrity: sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==} + /tailwindcss@3.4.10(ts-node@10.9.2): + resolution: {integrity: sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==} engines: {node: '>=14.0.0'} hasBin: true dependencies: @@ -3872,12 +6081,12 @@ packages: micromatch: 4.0.5 normalize-path: 3.0.0 object-hash: 3.0.0 - picocolors: 1.0.0 - postcss: 8.4.35 - postcss-import: 15.1.0(postcss@8.4.35) - postcss-js: 4.0.1(postcss@8.4.35) - postcss-load-config: 4.0.2(postcss@8.4.35) - postcss-nested: 6.0.1(postcss@8.4.35) + picocolors: 1.0.1 + postcss: 8.4.45 + postcss-import: 15.1.0(postcss@8.4.45) + postcss-js: 4.0.1(postcss@8.4.45) + postcss-load-config: 4.0.2(postcss@8.4.45)(ts-node@10.9.2) + postcss-nested: 6.0.1(postcss@8.4.45) postcss-selector-parser: 6.0.15 resolve: 1.22.8 sucrase: 3.35.0 @@ -3890,6 +6099,15 @@ packages: engines: {node: '>=6'} dev: true + /test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + dev: true + /text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true @@ -3907,6 +6125,18 @@ packages: any-promise: 1.3.0 dev: true + /third-party-capital@1.0.20: + resolution: {integrity: sha512-oB7yIimd8SuGptespDAZnNkzIz+NWaJCu2RMsbs4Wmp9zSDUM8Nhi3s2OOcqYuv3mN4hitXc8DVx+LyUmbUDiA==} + dev: false + + /tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + dev: true + + /to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + /to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3914,6 +6144,23 @@ packages: is-number: 7.0.0 dev: true + /tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + dependencies: + psl: 1.9.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + dev: true + + /tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + dependencies: + punycode: 2.3.1 + dev: true + /trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} dev: false @@ -3922,19 +6169,50 @@ packages: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} dev: false - /ts-api-utils@1.2.1(typescript@5.3.3): + /ts-api-utils@1.2.1(typescript@5.5.4): resolution: {integrity: sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==} engines: {node: '>=16'} peerDependencies: typescript: '>=4.2.0' dependencies: - typescript: 5.3.3 + typescript: 5.5.4 dev: true /ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} dev: true + /ts-node@10.9.2(@types/node@22.5.4)(typescript@5.5.4): + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.5.4 + acorn: 8.11.3 + acorn-walk: 8.3.3 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.5.4 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + dev: true + /tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} dependencies: @@ -3954,8 +6232,13 @@ packages: prelude-ls: 1.2.1 dev: true - /type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + /type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + dev: true + + /type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} dev: true @@ -4003,8 +6286,8 @@ packages: possible-typed-array-names: 1.0.0 dev: true - /typescript@5.3.3: - resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + /typescript@5.5.4: + resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} engines: {node: '>=14.17'} hasBin: true dev: true @@ -4018,8 +6301,8 @@ packages: which-boxed-primitive: 1.0.2 dev: true - /undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + /undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} dev: true /unified@11.0.4: @@ -4074,16 +6357,20 @@ packages: unist-util-visit-parents: 6.0.1 dev: false - /update-browserslist-db@1.0.13(browserslist@4.23.0): - resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} + /universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + dev: true + + /update-browserslist-db@1.1.0(browserslist@4.23.3): + resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' dependencies: - browserslist: 4.23.0 + browserslist: 4.23.3 escalade: 3.1.2 - picocolors: 1.0.0 - dev: true + picocolors: 1.0.1 /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4091,6 +6378,13 @@ packages: punycode: 2.3.1 dev: true + /url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + dev: true + /use-sync-external-store@1.2.0(react@18.2.0): resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} peerDependencies: @@ -4103,6 +6397,19 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true + /v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + dev: true + + /v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + dependencies: + '@jridgewell/trace-mapping': 0.3.23 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + dev: true + /vfile-message@4.0.2: resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} dependencies: @@ -4118,6 +6425,19 @@ packages: vfile-message: 4.0.2 dev: false + /w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + dependencies: + xml-name-validator: 4.0.0 + dev: true + + /walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + dependencies: + makeerror: 1.0.12 + dev: true + /watchpack@2.4.0: resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} engines: {node: '>=10.13.0'} @@ -4126,6 +6446,31 @@ packages: graceful-fs: 4.2.11 dev: false + /webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + dev: true + + /whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + dependencies: + iconv-lite: 0.6.3 + dev: true + + /whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + dev: true + + /whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + dev: true + /which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} dependencies: @@ -4204,11 +6549,49 @@ packages: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} dev: true + /write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + dev: true + + /ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + + /xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + dev: true + + /xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + dev: true + /xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} dev: false + /y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + dev: true + + /yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} dev: true @@ -4218,6 +6601,29 @@ packages: engines: {node: '>= 14'} dev: true + /yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + dev: true + + /yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + dependencies: + cliui: 8.0.1 + escalade: 3.1.2 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + dev: true + + /yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + dev: true + /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} diff --git a/FE/public/icons/arrowRight.svg b/FE/public/icons/arrowRight.svg new file mode 100644 index 00000000..ecb3dbb8 --- /dev/null +++ b/FE/public/icons/arrowRight.svg @@ -0,0 +1,4 @@ + + + + diff --git a/FE/public/icons/eeosAdminLogo.svg b/FE/public/icons/eeosAdminLogo.svg new file mode 100644 index 00000000..3550c8eb --- /dev/null +++ b/FE/public/icons/eeosAdminLogo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/FE/public/icons/folder.svg b/FE/public/icons/folder.svg new file mode 100644 index 00000000..bd26e391 --- /dev/null +++ b/FE/public/icons/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/FE/public/icons/non_select.svg b/FE/public/icons/non_select.svg new file mode 100644 index 00000000..9fa39c8c --- /dev/null +++ b/FE/public/icons/non_select.svg @@ -0,0 +1,4 @@ + + + + diff --git a/FE/public/icons/select.svg b/FE/public/icons/select.svg new file mode 100644 index 00000000..1fe1b005 --- /dev/null +++ b/FE/public/icons/select.svg @@ -0,0 +1,3 @@ + + + diff --git a/FE/src/__test__/__mock__/Link.tsx b/FE/src/__test__/__mock__/Link.tsx new file mode 100644 index 00000000..1032cc40 --- /dev/null +++ b/FE/src/__test__/__mock__/Link.tsx @@ -0,0 +1,5 @@ +export const Link = jest.fn(({ children, href }) => ( + e.preventDefault()}> + {children} + +)); diff --git a/FE/src/__test__/__stub__/response/auth.mock.ts b/FE/src/__test__/__stub__/response/auth.mock.ts new file mode 100644 index 00000000..c9e1bf16 --- /dev/null +++ b/FE/src/__test__/__stub__/response/auth.mock.ts @@ -0,0 +1,12 @@ +import { slackAuth } from "./mockData/auth"; +import { createResponseStub } from "./utils/responseStubWrapper"; + +const authResponse = { + "/auth/login/slack": { + POST: createResponseStub({ + data: slackAuth, + }), + }, +}; + +export default authResponse; diff --git a/FE/src/__test__/__stub__/response/index.ts b/FE/src/__test__/__stub__/response/index.ts new file mode 100644 index 00000000..fa88c852 --- /dev/null +++ b/FE/src/__test__/__stub__/response/index.ts @@ -0,0 +1,22 @@ +import authResponse from "./auth.mock"; +import memberResponse from "./member.mock"; +import programResponse from "./program.mock"; +import questionResponse from "./question.mock"; +import userResponse from "./user.mock"; + +interface GetResponseParams { + url: string; + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; +} +const getResponse = ({ url, method }: GetResponseParams) => { + const responseData = { + ...programResponse, + ...memberResponse, + ...questionResponse, + ...userResponse, + ...authResponse, + }; + return responseData[url][method]; +}; + +export default getResponse; diff --git a/FE/src/__test__/__stub__/response/member.mock.ts b/FE/src/__test__/__stub__/response/member.mock.ts new file mode 100644 index 00000000..9b97cf48 --- /dev/null +++ b/FE/src/__test__/__stub__/response/member.mock.ts @@ -0,0 +1,25 @@ +import { + members, + myActiveStatus, + updateMemberActiveStatus, + updateMyActiveStatus, +} from "./mockData/member"; +import { createResponseStub } from "./utils/responseStubWrapper"; + +const memberResponse = { + "/members": { + GET: createResponseStub({ data: members }), + }, + "/members/:memberId": { + DELETE: createResponseStub({ message: "삭제 성공", data: null }), + }, + "/members/activeStatus": { + GET: createResponseStub({ data: myActiveStatus }), + PUT: createResponseStub({ data: updateMyActiveStatus }), + }, + "/members/activeStatus/:memberId": { + PUT: createResponseStub({ data: updateMemberActiveStatus }), + }, +}; + +export default memberResponse; diff --git a/FE/src/__test__/__stub__/response/mockData/auth.ts b/FE/src/__test__/__stub__/response/mockData/auth.ts new file mode 100644 index 00000000..e6f01e7b --- /dev/null +++ b/FE/src/__test__/__stub__/response/mockData/auth.ts @@ -0,0 +1,10 @@ +export const slackAuth = { + data: { + token: "slackToken", + user: { + id: "slackId", + name: "slackName", + email: "slackEmail", + }, + }, +}; diff --git a/FE/src/__test__/__stub__/response/mockData/member.ts b/FE/src/__test__/__stub__/response/mockData/member.ts new file mode 100644 index 00000000..cb0d9661 --- /dev/null +++ b/FE/src/__test__/__stub__/response/mockData/member.ts @@ -0,0 +1,59 @@ +/** + * @url /members + * @method GET + * @description 사용자 리스트 불러오기 + */ +export const members = { + members: [ + { + memberId: 0, + name: "홍길동", + activeStatus: "am", + }, + { + memberId: 1, + name: "홍길동", + activeStatus: "rm", + }, + { + memberId: 2, + name: "홍길동", + activeStatus: "cm", + }, + { + memberId: 3, + name: "홍길동", + activeStatus: "ob", + }, + ], +}; + +/** + * @url /members/activeStatus + * @method GET + * @description 본인의 활동상태 정보 가져오기 + */ +export const myActiveStatus = { + name: "26기 홍길동", + activeStatus: "am", +}; + +/** + * @url /members/activeStatus + * @method PUT + * @description 본인의 활동상태 변경 + */ +export const updateMyActiveStatus = { + name: "26기 박건규", + activeStatus: "am", +}; + +/** + * @url /members/activestatus/:memberId + * @method PUT + * @description 특정 멤버의 활동상태 변경 + */ +export const updateMemberActiveStatus = { + name: "22기 홍길동", + activeStatus: "am", +}; diff --git a/FE/src/__test__/__stub__/response/mockData/program.ts b/FE/src/__test__/__stub__/response/mockData/program.ts new file mode 100644 index 00000000..d2196eaf --- /dev/null +++ b/FE/src/__test__/__stub__/response/mockData/program.ts @@ -0,0 +1,636 @@ +/** + * @url /programs + * @method GET + */ +export const programs = { + size: 10, + page: 1, + totalPage: 5, + programs: [ + { + programId: 0, + title: "행사이름 0", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "demand", + attendMode: "attend", + }, + { + programId: 1, + title: "행사이름 1", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "notification", + attendMode: "attend", + }, + { + programId: 2, + title: "행사이름 2", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "demand", + attendMode: "attend", + }, + { + programId: 3, + title: "행사이름 3", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "notification", + attendMode: "attend", + }, + { + programId: 4, + title: "행사이름 4", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "demand", + attendMode: "attend", + }, + { + programId: 5, + title: "행사이름 5", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "notification", + attendMode: "attend", + }, + { + programId: 6, + title: "행사이름 6", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "demand", + attendMode: "attend", + }, + { + programId: 7, + title: "행사이름 7", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "notification", + attendMode: "attend", + }, + { + programId: 8, + title: "행사이름 8", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "demand", + attendMode: "attend", + }, + { + programId: 9, + title: "행사이름 9", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "notification", + attendMode: "attend", + }, + ], +}; + +/** + * @url /programs + * @method POST + */ +export const postProgram = { + programId: 1, +}; + +/** + * @url programs/:programId/members + * @method GET + * @description 프로그램에 참여한 멤버 리스트 + */ +export const programMembers = { + members: [ + { + memberId: 0, + name: "25기 김이박", + attendStatus: "attend", + activeStatus: "am", + }, + { + memberId: 1, + name: "25기 홍길동", + attendStatus: "late", + activeStatus: "rm", + }, + { + memberId: 2, + name: "25기 김똥개", + attendStatus: "absent", + activeStatus: "cm", + }, + { + memberId: 3, + name: "25기 아무개", + attendStatus: "nonResponse", + activeStatus: "ob", + }, + { + memberId: 4, + name: "25기 홍길동", + attendStatus: "attend", + activeStatus: "am", + }, + { + memberId: 5, + name: "25기 홍길동", + attendStatus: "late", + activeStatus: "rm", + }, + { + memberId: 6, + name: "25기 아무런", + attendStatus: "nonRelated", + activeStatus: "cm", + }, + { + memberId: 7, + name: "25기 이이름", + attendStatus: "attend", + activeStatus: "ob", + }, + { + memberId: 8, + name: "25기 홍길동", + attendStatus: "absent", + activeStatus: "am", + }, + { + memberId: 9, + name: "25기 김이박", + attendStatus: "nonResponse", + activeStatus: "rm", + }, + { + memberId: 10, + name: "25기 김이박", + attendStatus: "attend", + activeStatus: "cm", + }, + { + memberId: 11, + name: "25기 홍길동", + attendStatus: "late", + activeStatus: "ob", + }, + { + memberId: 12, + name: "25기 김똥개", + attendStatus: "absent", + activeStatus: "am", + }, + { + memberId: 13, + name: "25기 아무개", + attendStatus: "nonRelated", + activeStatus: "rm", + }, + { + memberId: 14, + name: "25기 홍길동", + attendStatus: "attend", + activeStatus: "cm", + }, + { + memberId: 15, + name: "25기 홍길동", + attendStatus: "late", + activeStatus: "ob", + }, + { + memberId: 16, + name: "25기 아무런", + attendStatus: "nonResponse", + activeStatus: "am", + }, + { + memberId: 17, + name: "25기 이이름", + attendStatus: "attend", + activeStatus: "rm", + }, + { + memberId: 18, + name: "25기 홍길동", + attendStatus: "absent", + activeStatus: "cm", + }, + { + memberId: 19, + name: "25기 김이박", + attendStatus: "nonRelated", + activeStatus: "ob", + }, + { + memberId: 20, + name: "25기 김이박", + attendStatus: "attend", + activeStatus: "am", + }, + { + memberId: 21, + name: "25기 홍길동", + attendStatus: "late", + activeStatus: "rm", + }, + { + memberId: 22, + name: "25기 김똥개", + attendStatus: "absent", + activeStatus: "cm", + }, + { + memberId: 23, + name: "25기 아무개", + attendStatus: "nonResponse", + activeStatus: "ob", + }, + { + memberId: 24, + name: "25기 홍길동", + attendStatus: "attend", + activeStatus: "am", + }, + { + memberId: 25, + name: "25기 홍길동", + attendStatus: "late", + activeStatus: "rm", + }, + { + memberId: 26, + name: "25기 아무런", + attendStatus: "nonRelated", + activeStatus: "cm", + }, + { + memberId: 27, + name: "25기 이이름", + attendStatus: "attend", + activeStatus: "ob", + }, + { + memberId: 28, + name: "25기 홍길동", + attendStatus: "absent", + activeStatus: "am", + }, + { + memberId: 29, + name: "25기 김이박", + attendStatus: "nonResponse", + activeStatus: "rm", + }, + { + memberId: 30, + name: "25기 김이박", + attendStatus: "attend", + activeStatus: "cm", + }, + ], +}; + +/** + * @url programs/:programId + * @method GET + * @description 프로그램 (상세정보) 조회 + */ +export const program = { + programId: 1, + title: "주간 발표 B팀", + deadLine: "1795691732000", + content: + "[주간발표 공지]\n금일 B팀의 주간발표가 있습니다.\n\n - 일시: 10월 13일 (금) 17시~\n - 장소: 정보화본부 109호\n - 발표팀\n - 발표자료 업로드\n - 16:00까지 깃허브에 각 팀 폴더 생성 후 발표자료 업로드\n - 발표자료 업로드 가이드 (막힌다면 언제든지 DM 주세요!)\n - 발표 순서는 추후 공지합니다.[주간발표 공지]\n금일 B팀의 주간발표가 있습니다.\n\n - 일시: 10월 13일 (금) 17시~\n - 장소: 정보화본부 109호\n - 발표팀\n \n- 발표자료 업로드\n - 16:00까지 깃허브에 각 팀 폴더 생성 후 발표자료 업로드\n - 발표자료 업로드 가이드 (막힌다면 언제든지 DM 주세요!)\n - 발표 순서는 추후 공지합니다.", + category: "weekly", + programStatus: "active", + type: "demand", + accessRight: "edit", + programGithubUrl: + "https://github.com/JNU-econovation/weekly_presentation/tree/2024-1/2024-1/A_team/1st", + eventStatus: "active", + attendMode: "attend", +}; + +/** + * @url programs/:programId + * @method POST + * @description 프로그램 출석 상태 변경 + */ +export const changeProgramAttendMode = { + programId: 1, +}; + +/** + * @url guest/programs/:programId + * @method GET + * @description [guest] 프로그램 조회 + */ +export const guestProgram = { + programId: 1, + title: "주간 발표 B팀", + deadLine: "1795691732000", + content: + "[주간발표 공지]\n금일 B팀의 주간발표가 있습니다.\n\n - 일시: 10월 13일 (금) 17시~\n - 장소: 정보화본부 109호\n - 발표팀\n - 발표자료 업로드\n - 16:00까지 깃허브에 각 팀 폴더 생성 후 발표자료 업로드\n - 발표자료 업로드 가이드 (막힌다면 언제든지 DM 주세요!)\n - 발표 순서는 추후 공지합니다.[주간발표 공지]\n금일 B팀의 주간발표가 있습니다.\n\n - 일시: 10월 13일 (금) 17시~\n - 장소: 정보화본부 109호\n - 발표팀\n \n- 발표자료 업로드\n - 16:00까지 깃허브에 각 팀 폴더 생성 후 발표자료 업로드\n - 발표자료 업로드 가이드 (막힌다면 언제든지 DM 주세요!)\n - 발표 순서는 추후 공지합니다.", + category: "weekly", + programStatus: "active", + type: "demand", + accessRight: "edit", + programGithubUrl: + "https://github.com/JNU-econovation/weekly_presentation/tree/2024-1/2024-1/A_team/1st", + eventStatus: "active", + attendMode: "attend", +}; + +/** + * @url guest/programs + * @method GET + * @description [guest] 행사 리스트 조회 + */ +export const guestPrograms = { + size: 10, + page: 1, + totalPage: 5, + programs: [ + { + programId: 0, + title: "행사이름 0", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "demand", + attendMode: "attend", + }, + { + programId: 1, + title: "행사이름 1", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "notification", + attendMode: "attend", + }, + { + programId: 2, + title: "행사이름 2", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "demand", + attendMode: "attend", + }, + { + programId: 3, + title: "행사이름 3", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "notification", + attendMode: "attend", + }, + { + programId: 4, + title: "행사이름 4", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "demand", + attendMode: "attend", + }, + { + programId: 5, + title: "행사이름 5", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "notification", + attendMode: "attend", + }, + { + programId: 6, + title: "행사이름 6", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "demand", + attendMode: "attend", + }, + { + programId: 7, + title: "행사이름 7", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "notification", + attendMode: "attend", + }, + { + programId: 8, + title: "행사이름 8", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "demand", + attendMode: "attend", + }, + { + programId: 9, + title: "행사이름 9", + deadLine: "1695691732000", + category: "exampleCategory", + programStatus: "exampleStatus", + type: "notification", + attendMode: "attend", + }, + ], +}; + +/** + * @url attend/programs/:programId + * @method GET + * @description 해당 행사의 본인의 상태정보 가져오기 + */ +export const programAttend = { + status: 200, + message: "응답 성공", + data: { + name: "26기 박건규", + attendStatus: "attend", // "attend" | "late" | "absent" | "nonResponse" + }, +}; + +/** + * @url programs/:programId/accessRight + * @method GET + * @description 행사 수정 및 삭제 권한 확인 + */ +export const nonAbleProgramAccess = { + accessRight: "", +}; + +/** + * @url programs/:programId/accessRight + * @method GET + * @description 행사 수정 및 삭제 권한 확인 + */ +export const ableProgramAccess = { + accessRight: "edit", +}; + +/** + * @url attend/programs/:programId/members + * @method GET + * @description 행사에 참여하는 사용자 불러오기 + */ +export const attendMembers = { + members: [ + { + memberId: 0, + name: "25기 홍길동", + attendStatus: "attend", + }, + { + memberId: 1, + name: "25기 홍길동", + attendStatus: "absent", + }, + { + memberId: 2, + name: "25기 홍길동", + attendStatus: "late", + }, + { + memberId: 3, + name: "25기 홍길동", + attendStatus: "nonResponse", + }, + { + memberId: 4, + name: "25기 홍길동", + attendStatus: "nonRelated", + }, + { + memberId: 5, + name: "25기 홍길동", + attendStatus: "attend", + }, + { + memberId: 6, + name: "25기 홍길동", + attendStatus: "absent", + }, + { + memberId: 7, + name: "25기 홍길동", + attendStatus: "late", + }, + { + memberId: 8, + name: "25기 홍길동", + attendStatus: "nonResponse", + }, + { + memberId: 9, + name: "25기 홍길동", + attendStatus: "nonRelated", + }, + { + memberId: 10, + name: "25기 홍길동", + attendStatus: "attend", + }, + { + memberId: 11, + name: "25기 홍길동", + attendStatus: "absent", + }, + { + memberId: 12, + name: "25기 홍길동", + attendStatus: "late", + }, + { + memberId: 13, + name: "25기 홍길동", + attendStatus: "nonResponse", + }, + { + memberId: 14, + name: "25기 홍길동", + attendStatus: "nonRelated", + }, + { + memberId: 15, + name: "25기 홍길동", + attendStatus: "attend", + }, + { + memberId: 16, + name: "25기 홍길동", + attendStatus: "absent", + }, + { + memberId: 17, + name: "25기 홍길동", + attendStatus: "late", + }, + { + memberId: 18, + name: "25기 홍길동", + attendStatus: "nonResponse", + }, + { + memberId: 19, + name: "25기 홍길동", + attendStatus: "nonRelated", + }, + { + memberId: 20, + name: "25기 홍길동", + attendStatus: "attend", + }, + { + memberId: 21, + name: "25기 홍길동", + attendStatus: "absent", + }, + ], +}; + +/** + * @url programs/:programId + * @method DELETE + * @description 프로그램 삭제 + */ +export const deleteProgram = null; + +/** + * @url programs/:programId/slack/notification + * @method POST + * @description slack 메시지 전송 + */ +export const sendSlackMessage = { + programId: 1, +}; + +/** + * @url programs/:programId + * @method PATCH + * @description 프로그램 수정 + */ +export const patchProgram = { + programId: 1, +}; diff --git a/FE/src/__test__/__stub__/response/mockData/question.ts b/FE/src/__test__/__stub__/response/mockData/question.ts new file mode 100644 index 00000000..72732319 --- /dev/null +++ b/FE/src/__test__/__stub__/response/mockData/question.ts @@ -0,0 +1,96 @@ +/** + * @url /comments + * @method GET + * @description 질문에 대한 답변 리스트를 반환한다. + */ +export const questionList = { + comments: [ + { + commentId: 2, + teamId: 1, + writer: "26기 박건규", + accessRight: "edit", + time: "2024-07-07 16:57:39.0", + content: + "사실 fetch api로도 모든 서버 요청을 할 수 있는데요..! 혹시 기술 스택으로 꼭 axios를 사용한 이유가 있을까요?", + answers: [ + { + commentId: 3, + writer: "27기 홍길동", + accessRight: "read_only", + time: "2024-07-07 16:57:51.0", + content: + "fetch의 경우 요즘 최신 브라우저는 모두 잘 지원하지만, 간혹! 지원하지 않는 경우가 존재합니다. 추가적으로 json데이터를 자동으로 변환해준다는점과 인터셉터를 지원해주어서 반복되는 로직을 줄일 수 있다는 강력한 장점이 있기 때문입니다!", + }, + ], + }, + { + commentId: 2, + teamId: 1, + writer: "26기 박건규", + accessRight: "edit", + time: "2024-07-07 16:57:39.0", + content: + "발표 잘 들었습니다! 제가 아직 프론트앤드를 잘 몰라서 그런데, 컴포넌트가 무엇인지 설명해주실 수 있나요?", + answers: [ + { + commentId: 3, + writer: "27기 홍길동", + accessRight: "read_only", + time: "2024-07-07 16:57:51.0", + content: + "fetch의 경우 요즘 최신 브라우저는 모두 잘 지원하지만, 간혹! 지원하지 않는 경우가 존재합니다. 추가적으로 json데이터를 자동으로 변환해준다는점과 인터셉터를 지원해주어서 반복되는 로직을 줄일 수 있다는 강력한 장점이 있기 때문입니다!", + }, + ], + }, + { + commentId: 2, + teamId: 1, + writer: "26기 박건규", + accessRight: "edit", + time: "2024-07-07 16:57:39.0", + content: + "바쁘신 와중에도 매번 리뷰를 해주시는 멘토님께 감사의 말씀 드립니다.\n## 해결과정\n- 우선 지금까지 커스텀훅으로 서버요청을 하던 로직을 react-query 로 교체하였습니다. \n- 이때, product를 가져올 때는 무한스크롤로 구현해야 했으며, useInfinityQuery와 useInView를 사용하였습니다. 구체적인 동작은 아래와 같습니다.\n - useInView를 통해서 ref 하는 div를 하단에 위치하도록 합니다.\n - div가 보여지면 inView 가 true가 되며, useEffect의 의존성배열에 추가되어있기에 useEffect 로직이 실행됩니다.\n - 로직 안에는 useInfinityQuery가 반환하는 fetchNextPage 를 호출하여 다음 데이터를 받아오게 됩니다. \n\n리뷰해주신 부분을 적용해보았습니다. \n- [리뷰](https://github.com/kakao-tech-campus-2nd-step2/react-gift-goods-list/pull/70#discussion_r1675446992) List의 더보기 가능 상태를 useReducer로 변경하였습니다.\n- [리뷰](https://github.com/kakao-tech-campus-2nd-step2/react-gift-goods-list/pull/70#discussion_r1675445121) 에러를 타입가드로서, 만약 기존에 정의된 에러의 경우 새롭게 메시지를 만들어(만들어 둔 메시지로) 던지고, 아닌 경우 그대로 던지도록 수정하였습니다.\n- [리뷰](https://github.com/kakao-tech-campus-2nd-step2/react-gift-goods-list/pull/70#discussion_r1675447856) '응답값이 없는 경우 체크'를 옵셔널 체이닝으로 변경하였습니다. \n\n## 리뷰어에게\n- 처음 무한스크롤을 구현하다보니 위와 같이 구현하는게 맞을지 궁금합니다..! \n- 리뷰 내용이 잘 반영되었는지 확인 부탁드립니다..!\n\n이번주차도 리뷰해주심에 정말 감사드립니다!", + answers: [ + { + commentId: 3, + writer: "27기 홍길동", + accessRight: "read_only", + time: "2024-07-07 16:57:51.0", + content: + "error 타입이 항상 axios 에서 던져주는 에러라고 가정 되어 있는데요..! 에러의 종류는 많기 때문에 타입가드로 변경 해주셔야합니다.", + }, + { + commentId: 4, + writer: "26", + accessRight: "read_only", + time: "2024-07-07 16:57:57.0", + content: + "error 타입이 항상 axios 에서 던져주는 에러라고 가정 되어 있는데요..! 에러의 종류는 많기 때문에 타입가드로 변경 해주셔야합니다.", + }, + { + commentId: 5, + writer: "27기 홍길동", + accessRight: "edit", + time: "2024-07-07 16:58:00.0", + content: "요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```", + }, + { + commentId: 6, + writer: "27기 홍길동", + accessRight: "read_only", + time: "2024-07-07 16:58:00.0", + content: + "요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n요렇게 작성해도 되겠네요\n\n ```\nconsole.log('hi');\n```\n", + }, + ], + }, + ], +}; + +/** + * @url /comments + * @method POST + * @description 질문 혹은 답변을 등록한다. + */ +export const postQuestionList = { programId: 0 }; diff --git a/FE/src/__test__/__stub__/response/mockData/user.ts b/FE/src/__test__/__stub__/response/mockData/user.ts new file mode 100644 index 00000000..3e153728 --- /dev/null +++ b/FE/src/__test__/__stub__/response/mockData/user.ts @@ -0,0 +1,19 @@ +/** + * @url /attend/programs/:programId + * @method GET + * @description 본인의 출석 상태를 반환한다 + */ +export const myAttendStatus = { + name: "26기 홍길동", + attendStatus: "attend", // attend, absent, late, nonResponse, nonRelated +}; + +/** + * @url /attend/programs/:programId + * @method PUT + * @description 본인의 출석 상태 변경 + */ +export const updateMyAttendStatus = { + name: "26기 홍길동", + attendStatus: "attend", +}; diff --git a/FE/src/__test__/__stub__/response/program.mock.ts b/FE/src/__test__/__stub__/response/program.mock.ts new file mode 100644 index 00000000..d9cd9bd6 --- /dev/null +++ b/FE/src/__test__/__stub__/response/program.mock.ts @@ -0,0 +1,56 @@ +import { + // ableProgramAccess, + attendMembers, + deleteProgram, + guestProgram, + guestPrograms, + nonAbleProgramAccess, + program, + programAttend, + programMembers, + programs, + sendSlackMessage, + postProgram, + patchProgram, + changeProgramAttendMode, +} from "./mockData/program"; +import { createResponseStub } from "./utils/responseStubWrapper"; + +const programResponse = { + "/programs": { + GET: createResponseStub({ data: programs }), + POST: createResponseStub({ data: postProgram }), + }, + "/programs/:programId": { + GET: createResponseStub({ data: program }), + DELETE: createResponseStub({ data: deleteProgram }), + PATCH: createResponseStub({ data: patchProgram }), + POST: createResponseStub({ data: changeProgramAttendMode }), + }, + "/guest/programs/:programId": { + GET: createResponseStub({ data: guestProgram }), + }, + "/guest/programs": { + GET: createResponseStub({ data: guestPrograms }), + }, + "/programs/:programId/members": { + GET: createResponseStub({ data: programMembers }), + }, + "/attend/programs/:programId": { + GET: createResponseStub({ data: programAttend }), + }, + "/programs/:programId/accessRight": { + GET: createResponseStub({ data: nonAbleProgramAccess }), + }, + // "/programs/:programId/accessRight": { + // GET: createResponseStub({ data: ableProgramAccess }), + // }, + "/attend/programs/:programId/members": { + GET: createResponseStub({ data: attendMembers }), + }, + "/programs/:programId/slack/notification": { + POST: createResponseStub({ data: sendSlackMessage }), + }, +} as const; + +export default programResponse; diff --git a/FE/src/__test__/__stub__/response/question.mock.ts b/FE/src/__test__/__stub__/response/question.mock.ts new file mode 100644 index 00000000..b643a492 --- /dev/null +++ b/FE/src/__test__/__stub__/response/question.mock.ts @@ -0,0 +1,21 @@ +import { postQuestionList, questionList } from "./mockData/question"; +import { createResponseStub } from "./utils/responseStubWrapper"; + +const questionResponse = { + "/comments": { + GET: createResponseStub({ data: questionList }), + POST: createResponseStub({ data: postQuestionList }), + }, + "/comments/:commentId": { + PUT: createResponseStub({ + data: {}, + message: "질문 수정 성공", + }), + DELETE: createResponseStub({ + data: {}, + message: "질문 삭제 성공", + }), + }, +}; + +export default questionResponse; diff --git a/FE/src/__test__/__stub__/response/user.mock.ts b/FE/src/__test__/__stub__/response/user.mock.ts new file mode 100644 index 00000000..4a7e93fa --- /dev/null +++ b/FE/src/__test__/__stub__/response/user.mock.ts @@ -0,0 +1,18 @@ +import { myAttendStatus, updateMyAttendStatus } from "./mockData/user"; +import { createResponseStub } from "./utils/responseStubWrapper"; + +const userResponse = { + "/attend/programs/:programId": { + GET: createResponseStub({ data: myAttendStatus }), + PUT: createResponseStub({ + data: updateMyAttendStatus, + message: "수정 성공", + }), + POST: createResponseStub({ + data: updateMyAttendStatus, + message: "수정 성공", + }), + }, +}; + +export default userResponse; diff --git a/FE/src/__test__/__stub__/response/utils/responseStubWrapper.ts b/FE/src/__test__/__stub__/response/utils/responseStubWrapper.ts new file mode 100644 index 00000000..21cdbde3 --- /dev/null +++ b/FE/src/__test__/__stub__/response/utils/responseStubWrapper.ts @@ -0,0 +1,18 @@ +interface CreateResponseStubParams { + status?: number; + message?: string; + data: DataType; +} +export const createResponseStub = ({ + status = 200, + message = "성공", + data, +}: CreateResponseStubParams) => { + return { + data: { + status, + message, + data, + }, + }; +}; diff --git a/FE/src/__test__/utils/withReactQuery.tsx b/FE/src/__test__/utils/withReactQuery.tsx new file mode 100644 index 00000000..57e7a940 --- /dev/null +++ b/FE/src/__test__/utils/withReactQuery.tsx @@ -0,0 +1,17 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const withReactQuery = (Component: React.ReactNode) => { + return () => ( + {Component} + ); +}; + +export default withReactQuery; diff --git a/FE/src/apis/__test__/auth.test.ts b/FE/src/apis/__test__/auth.test.ts new file mode 100644 index 00000000..d08142d1 --- /dev/null +++ b/FE/src/apis/__test__/auth.test.ts @@ -0,0 +1,105 @@ +//TODO: 서버 반환값 모킹하기 +import { describe } from "node:test"; +import { postAdminLogin, postSlackLogin, postTokenReissue } from "../auth"; +import { LoginDto } from "../dtos/auth.dto"; +import { https } from "../instance"; + +jest.mock("../instance"); + +describe("postSlackLogin", () => { + // 슬랙 리다이렉트 후 토큰 정보를 요청하는 api + + const code = "code"; + const redirect_uri = "redirect_uri"; + + const mockReturnData = { + data: { + accessToken: "abcdedf", + accessExpiredTime: 170444965500000, + }, + message: "생성 성공", + code: "201", + }; + + it("/auth/login/slack 위치로 post 요청을 code, redirect_uri와 함께 보낸다", async () => { + https.mockReturnValue(mockReturnData); + + // when + await postSlackLogin(code, redirect_uri); + + // then + expect(https).toHaveBeenCalledWith({ + url: "/auth/login/slack", + method: "POST", + params: { code, redirect_uri }, + }); + }); + + it("토큰 정보를 반환한다", async () => { + https.mockReturnValue(mockReturnData); + + // when + const result = await postSlackLogin(code, redirect_uri); + + // then + expect(result).toBeInstanceOf(LoginDto); + }); +}); + +describe("postTokenReissue", () => { + // 토큰 재발급 요청 api + + const mockReturnData = { + data: { + accessToken: "abcdedf", + }, + }; + + it("/auth/token/reissue 위치로 post 요청을 보낸다", async () => { + await postTokenReissue(); + + expect(https).toHaveBeenCalledWith({ + url: "/auth/reissue", + method: "POST", + }); + }); + + it("토큰 정보(LoginDto)를 반환한다", async () => { + https.mockReturnValue(mockReturnData); + + const result = await postTokenReissue(); + + expect(result).toBeInstanceOf(LoginDto); + }); +}); + +describe("postAdminLogin", () => { + // 관리자 로그인 api + + it("/auth/login 위치로 post 요청을 id, password 와 함께 보낸다 ", async () => { + const id = "id"; + const password = "password"; + + await postAdminLogin(id, password); + + expect(https).toHaveBeenCalledWith({ + url: "/auth/login", + method: "POST", + data: { id, password }, + }); + }); + + it("토큰 정보(LoginDto)를 반환한다", async () => { + const mockReturnData = { + data: { + accessToken: "abcdedf", + }, + }; + + https.mockReturnValue(mockReturnData); + + const result = await postAdminLogin("id", "password"); + + expect(result).toBeInstanceOf(LoginDto); + }); +}); diff --git a/FE/src/apis/__test__/member.test.ts b/FE/src/apis/__test__/member.test.ts new file mode 100644 index 00000000..a299094f --- /dev/null +++ b/FE/src/apis/__test__/member.test.ts @@ -0,0 +1,210 @@ +import { + MemberActiveStatusInfoDto, + MemberAttendStatusInfoDto, + MemberInfoDto, +} from "../dtos/member.dto"; +import { https } from "../instance"; +import { + deleteMember, + getMembersByActiveStatus, + getProgramMembersByActiveStatus, + getProgramMembersByAttendStatus, + updateMemberActiveStatus, +} from "../member"; +import getResponse from "@/__test__/__stub__/response"; + +jest.mock("../instance"); +const mockHttps = https as jest.MockedFunction; + +// 활동 상태별 회원 정보 조회 api +describe("getMembersByActiveStatus", () => { + const mockReturnData = getResponse({ + url: "/members", + method: "GET", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("request", () => { + it("/members 위치로 get 요청을 보낸다. 파라미터로 activeStatus 를 넣는다", async () => { + const activeStatus = "all"; + + await getMembersByActiveStatus(activeStatus); + + expect(https).toHaveBeenCalledWith({ + url: "/members", + method: "GET", + params: { activeStatus }, + }); + }); + }); + + describe("response", () => { + it("회원 정보 리스트를 반환한다", async () => { + const result = await getMembersByActiveStatus("all"); + + expect(result[0]).toBeInstanceOf(MemberActiveStatusInfoDto); + }); + }); +}); + +// 해당 프로그램의 활동 상태별 회원 정보 조회 api +describe("getProgramMembersByActiveStatus", () => { + describe("request", () => { + it("/programs/{programId}/members 위치로 get 요청을 보낸다. 파라미터로 activeStatus 를 넣는다", async () => { + const activeStatus = "all"; + + await getProgramMembersByActiveStatus(1, activeStatus); + + expect(https).toHaveBeenCalledWith({ + url: `/programs/1/members`, + method: "GET", + params: { activeStatus }, + }); + }); + }); + + describe("response", () => { + const mockReturnData = getResponse({ + url: "/programs/:programId/members", + method: "GET", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("회원 정보 리스트를 반환한다", async () => { + const result = await getProgramMembersByActiveStatus(1, "all"); + + expect(result[0]).toBeInstanceOf(MemberInfoDto); + }); + }); +}); + +// 해당 프로그램의 출석 상태별 회원 정보 조회 api +describe("getProgramMembersByAttendStatus", () => { + describe("request", () => { + it("/programs/{programId}/members/attend-status 위치로 get 요청을 보낸다. 파라미터로 attendStatus 를 넣는다", async () => { + const attendStatus = "attend"; + + await getProgramMembersByAttendStatus(1, attendStatus); + + expect(https).toHaveBeenCalledWith({ + url: `/attend/programs/1/members`, + method: "GET", + params: { attendStatus }, + }); + }); + }); + + describe("response", () => { + const mockReturnData = getResponse({ + url: "/attend/programs/:programId/members", + method: "GET", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("회원 정보 리스트를 반환한다", async () => { + const result = await getProgramMembersByAttendStatus(1, "attend"); + + expect(result[0]).toBeInstanceOf(MemberAttendStatusInfoDto); + }); + }); +}); + +// 회원 활동 상태 변경 api +describe("updateMemberActiveStatus", () => { + describe("request", () => { + it("/members/{memberId}/active-status 위치로 put 요청을 보낸다", async () => { + const memberId = 1; + const activeStatus = "am"; + + await updateMemberActiveStatus(memberId, activeStatus); + + expect(https).toHaveBeenCalledWith({ + url: `/members/activeStatus/1`, + method: "PUT", + data: activeStatus, + }); + }); + }); + + describe("response", () => { + const mockReturnData = getResponse({ + url: "/members/activeStatus/:memberId", + method: "PUT", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("정보 수정 성공 메시지를 반환한다", async () => { + const result = await updateMemberActiveStatus(1, "am"); + + expect(result).toEqual({ + name: "22기 홍길동", + activeStatus: "am", + }); + }); + }); +}); + +// 회원 삭제 api +describe("deleteMember", () => { + describe("request", () => { + it("/members/{memberId} 위치로 delete 요청을 보낸다", async () => { + const memberId = 1; + + await deleteMember(memberId); + + expect(https).toHaveBeenCalledWith({ + url: `/members/${memberId}`, + method: "DELETE", + }); + }); + }); + + describe("response", () => { + const mockReturnData = getResponse({ + url: "/members/:memberId", + method: "DELETE", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("유저를 삭제한다", async () => { + const result = await deleteMember(1); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/FE/src/apis/__test__/program.test.ts b/FE/src/apis/__test__/program.test.ts new file mode 100644 index 00000000..d7b54aee --- /dev/null +++ b/FE/src/apis/__test__/program.test.ts @@ -0,0 +1,455 @@ +// import { toast } from "react-toastify"; +import { ProgramInfoDto, ProgramListDto } from "../dtos/program.dto"; +import { https } from "../instance"; +import { + deleteProgram, + getProgramAccessRight, + getProgramById, + getProgramList, + patchProgram, + PatchProgramBody, + postProgram, + PostProgramRequest, + sendSlackMessage, + updateProgramAttendMode, +} from "../program"; +import getResponse from "@/__test__/__stub__/response"; +import { ProgramAttendStatus } from "@/types/program"; + +jest.mock("../instance"); +const mockHttps = https as jest.MockedFunction; + +// 프로그램 정보 조회 api +describe("getProgramById", () => { + const mockReturnData = getResponse({ + url: "/programs/:programId", + method: "GET", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("isAbleToEdit이 false인 경우 게스트 프로그램 정보를 조회한다", async () => { + const programId = 1; + const isAbleToEdit = false; + + const result = await getProgramById(programId, isAbleToEdit); + + expect(result).toBeInstanceOf(ProgramInfoDto); + expect(https).toHaveBeenCalledWith({ + url: "/guest/programs/1", + method: "GET", + }); + expect(result).toEqual({ + accessRight: "edit", + attendMode: "attend", + category: "weekly", + content: + "[주간발표 공지]\n금일 B팀의 주간발표가 있습니다.\n\n - 일시: 10월 13일 (금) 17시~\n - 장소: 정보화본부 109호\n - 발표팀\n - 발표자료 업로드\n - 16:00까지 깃허브에 각 팀 폴더 생성 후 발표자료 업로드\n - 발표자료 업로드 가이드 (막힌다면 언제든지 DM 주세요!)\n - 발표 순서는 추후 공지합니다.[주간발표 공지]\n금일 B팀의 주간발표가 있습니다.\n\n - 일시: 10월 13일 (금) 17시~\n - 장소: 정보화본부 109호\n - 발표팀\n \n- 발표자료 업로드\n - 16:00까지 깃허브에 각 팀 폴더 생성 후 발표자료 업로드\n - 발표자료 업로드 가이드 (막힌다면 언제든지 DM 주세요!)\n - 발표 순서는 추후 공지합니다.", + deadLine: "1795691732000", + programGithubUrl: + "https://github.com/JNU-econovation/weekly_presentation/tree/2024-1/2024-1/A_team/1st", + programId: 1, + programStatus: "active", + title: "주간 발표 B팀", + type: "demand", + }); + }); + + it("isAbleToEdit이 true인 경우 프로그램 정보를 조회한다", async () => { + const programId = 1; + const isAbleToEdit = true; + + const result = await getProgramById(programId, isAbleToEdit); + + expect(result).toBeInstanceOf(ProgramInfoDto); + expect(https).toHaveBeenCalledWith({ + url: "/programs/1", + method: "GET", + }); + expect(result).toEqual({ + accessRight: "edit", + attendMode: "attend", + category: "weekly", + content: + "[주간발표 공지]\n금일 B팀의 주간발표가 있습니다.\n\n - 일시: 10월 13일 (금) 17시~\n - 장소: 정보화본부 109호\n - 발표팀\n - 발표자료 업로드\n - 16:00까지 깃허브에 각 팀 폴더 생성 후 발표자료 업로드\n - 발표자료 업로드 가이드 (막힌다면 언제든지 DM 주세요!)\n - 발표 순서는 추후 공지합니다.[주간발표 공지]\n금일 B팀의 주간발표가 있습니다.\n\n - 일시: 10월 13일 (금) 17시~\n - 장소: 정보화본부 109호\n - 발표팀\n \n- 발표자료 업로드\n - 16:00까지 깃허브에 각 팀 폴더 생성 후 발표자료 업로드\n - 발표자료 업로드 가이드 (막힌다면 언제든지 DM 주세요!)\n - 발표 순서는 추후 공지합니다.", + deadLine: "1795691732000", + programGithubUrl: + "https://github.com/JNU-econovation/weekly_presentation/tree/2024-1/2024-1/A_team/1st", + programId: 1, + programStatus: "active", + title: "주간 발표 B팀", + type: "demand", + }); + }); +}); + +describe("getProgramList", () => { + const mockReturnData = getResponse({ + url: "/guest/programs", + method: "GET", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("어드민 계정인 경우 프로그램 리스트를 조회한다", async () => { + const category = "weekly"; + const programStatus = "active"; + const size = 10; + const page = 1; + const isAdmin = true; + + await getProgramList({ + category, + programStatus, + size, + page, + isAdmin, + }); + + expect(https).toHaveBeenCalledWith({ + method: "GET", + params: { + category: "weekly", + page: 1, + programStatus: "active", + size: 10, + }, + url: "/programs", + }); + }); + it("게스트 계정인 경우 `/guest/programs`로 게스트 프로그램 리스트를 조회한다", async () => { + const category = "weekly"; + const programStatus = "active"; + const size = 10; + const page = 1; + const isAdmin = false; + + const guestResult = await getProgramList({ + category, + programStatus, + size, + page, + isAdmin, + }); + + expect(guestResult).toBeInstanceOf(ProgramListDto); + expect(https).toHaveBeenCalledWith({ + method: "GET", + params: { + category: "weekly", + page: 1, + programStatus: "active", + size: 10, + }, + url: "/guest/programs", + }); + expect(guestResult).toEqual({ + page: 1, + programs: [ + { + attendMode: "attend", + category: "exampleCategory", + deadLine: "1695691732000", + programId: 0, + programStatus: "exampleStatus", + title: "행사이름 0", + type: "demand", + }, + { + attendMode: "attend", + category: "exampleCategory", + deadLine: "1695691732000", + programId: 1, + programStatus: "exampleStatus", + title: "행사이름 1", + type: "notification", + }, + { + attendMode: "attend", + category: "exampleCategory", + deadLine: "1695691732000", + programId: 2, + programStatus: "exampleStatus", + title: "행사이름 2", + type: "demand", + }, + { + attendMode: "attend", + category: "exampleCategory", + deadLine: "1695691732000", + programId: 3, + programStatus: "exampleStatus", + title: "행사이름 3", + type: "notification", + }, + { + attendMode: "attend", + category: "exampleCategory", + deadLine: "1695691732000", + programId: 4, + programStatus: "exampleStatus", + title: "행사이름 4", + type: "demand", + }, + { + attendMode: "attend", + category: "exampleCategory", + deadLine: "1695691732000", + programId: 5, + programStatus: "exampleStatus", + title: "행사이름 5", + type: "notification", + }, + { + attendMode: "attend", + category: "exampleCategory", + deadLine: "1695691732000", + programId: 6, + programStatus: "exampleStatus", + title: "행사이름 6", + type: "demand", + }, + { + attendMode: "attend", + category: "exampleCategory", + deadLine: "1695691732000", + programId: 7, + programStatus: "exampleStatus", + title: "행사이름 7", + type: "notification", + }, + { + attendMode: "attend", + category: "exampleCategory", + deadLine: "1695691732000", + programId: 8, + programStatus: "exampleStatus", + title: "행사이름 8", + type: "demand", + }, + { + attendMode: "attend", + category: "exampleCategory", + deadLine: "1695691732000", + programId: 9, + programStatus: "exampleStatus", + title: "행사이름 9", + type: "notification", + }, + ], + size: 10, + totalPage: 5, + }); + }); +}); + +// 프로그램 삭제 api +describe("deleteProgram", () => { + const mockReturnData = getResponse({ + url: "/programs/:programId", + method: "DELETE", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("프로그램을 삭제한다", async () => { + await deleteProgram(1); + + expect(https).toHaveBeenCalledWith({ + url: "/programs/1", + method: "DELETE", + }); + }); +}); + +// slack 메시지 전송 api +describe("sendSlackMessage", () => { + const mockReturnData = getResponse({ + url: "/programs/:programId/slack/notification", + method: "POST", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("요청 성공시 programId 를 반환받는다", async () => { + const programId = 1; + const result = await sendSlackMessage(programId); + + // 내부 구현사항 + expect(https).toHaveBeenCalledWith({ + data: { programUrl: "https://econo.eeos.store/detail/1" }, + method: "POST", + url: "/programs/1/slack/notification", + }); + + expect(result).toEqual({ programId: 1 }); + }); + + it("요청 실패시 실패 메시지를 보여준다", async () => {}); +}); + +// 행사 생성 api +describe("postProgram", () => { + const mockReturnData = getResponse({ + url: "/programs", + method: "POST", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("행사를 생성한다", async () => { + const body: PostProgramRequest = { + category: "weekly", + content: "content", + deadLine: "2022-12-12", + programGithubUrl: "https://github.com", + title: "title", + type: "demand", + members: [{ memberId: 1 }], + teams: [{ teamId: 1 }], + }; + + const result = await postProgram(body); + + expect(https).toHaveBeenCalledWith({ + data: body, + method: "POST", + url: "/programs", + }); + + expect(result).toEqual({ programId: 1 }); + }); +}); + +// 프로그램 수정 api +describe("patchProgram", () => { + const mockReturnData = getResponse({ + url: "/programs/:programId", + method: "PATCH", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("프로그램을 수정한다", async () => { + const body: PatchProgramBody = { + title: "title", + content: "content", + deadLine: "2022-12-12", + category: "weekly", + type: "demand", + members: [ + { + memberId: 1, + beforeAttendStatus: "attend", + afterAttendStatus: "absent", + }, + ], + teams: [{ teamId: 1 }], + }; + + const programId = 1; + + await patchProgram({ programId, body }); + + expect(https).toHaveBeenCalledWith({ + data: body, + method: "PATCH", + url: "/programs/1", + }); + }); +}); + +// 프로그램 수정/삭제 권한 확인 +describe("getProgramAccessRight", () => { + const mockReturnData = getResponse({ + url: "/programs/:programId/accessRight", + method: "GET", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("프로그램 수정/삭제 권한을 확인한다", async () => { + const result = await getProgramAccessRight(1); + + expect(https).toHaveBeenCalledWith({ + method: "GET", + url: "/programs/1/accessRight", + }); + + expect(result).toEqual({ accessRight: "" }); + // expect(result).toEqual({ accessRight: "edit" }); // 만약 관리자라면, accessRight: "edit" 이 반환된다. + }); +}); + +// 프로그램 출석 상태 수정 api +describe("updateProgramAttendMode", () => { + const mockReturnData = getResponse({ + url: "/programs/:programId", + method: "POST", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("프로그램 출석 상태를 수정한다", async () => { + const programId = 1; + const attendMode: ProgramAttendStatus = "attend"; + const result = await updateProgramAttendMode(programId, attendMode); + + expect(https).toHaveBeenCalledWith({ + method: "POST", + url: "/programs/1", + params: { + mode: "attend", + }, + }); + expect(result).toEqual({ programId: 1 }); + }); +}); diff --git a/FE/src/apis/__test__/question.test.ts b/FE/src/apis/__test__/question.test.ts new file mode 100644 index 00000000..b03be2c5 --- /dev/null +++ b/FE/src/apis/__test__/question.test.ts @@ -0,0 +1,169 @@ +import { https } from "../instance"; +import { getQuestionsByTeam, postQuestion, updateQuestion } from "../question"; +import getResponse from "@/__test__/__stub__/response"; + +jest.mock("../instance"); +const mockHttps = https as jest.MockedFunction; + +describe("getQuestionsByTeam", () => { + const mockReturnData = getResponse({ + url: "/comments", + method: "GET", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("팀의 질문 리스트를 반환한다", async () => { + // arrange + const programId = 1; + const teamId = 1; + + // act + await getQuestionsByTeam(programId, teamId); + + // assert + expect(mockHttps).toHaveBeenCalledWith({ + url: "comments", + method: "GET", + params: { programId, teamId }, + }); + }); +}); + +// 질문 및 답변 등록 api +describe("postQuestion", () => { + const mockReturnData = getResponse({ + url: "/comments", + method: "POST", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("질문을 등록한다", async () => { + // arrange + const programId = 1; + const teamId = 1; + const questionContent = "질문 내용"; + + // act + await postQuestion({ programId, teamId, questionContent }); + + // assert + expect(mockHttps).toHaveBeenCalledWith({ + url: "comments", + method: "POST", + data: { + programId, + teamId, + content: questionContent, + parentsCommentId: -1, + }, + }); + }); + it("답변을 등록한다", async () => { + // arrange + const programId = 1; + const teamId = 1; + const questionContent = "답변 내용"; + const parentsCommentId = 1; + + // act + await postQuestion({ + programId, + teamId, + questionContent, + parentsCommentId, + }); + + // assert + expect(mockHttps).toHaveBeenCalledWith({ + url: "comments", + method: "POST", + data: { + programId, + teamId, + content: questionContent, + parentsCommentId, + }, + }); + }); +}); + +// 질문 수정 api +describe("updateQuestion", () => { + const mockReturnData = getResponse({ + url: "/comments/:commentId", + method: "PUT", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("질문을 수정한다", async () => { + // arrange + const commentId = 1; + const contents = "수정된 질문 내용"; + + // act + await updateQuestion(commentId, contents); + + // assert + expect(mockHttps).toHaveBeenCalledWith({ + url: "comments/1", + method: "PUT", + data: { + contents, + }, + }); + }); +}); + +// 질문 삭제 api +describe("deleteQuestion", () => { + const mockReturnData = getResponse({ + url: "/comments/:commentId", + method: "DELETE", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("질문을 삭제한다", async () => { + // arrange + const commentId = 1; + + // act + await updateQuestion(commentId, "삭제된 질문 내용"); + + // assert + expect(mockHttps).toHaveBeenCalledWith({ + url: "comments/1", + method: "PUT", + data: { + contents: "삭제된 질문 내용", + }, + }); + }); +}); diff --git a/FE/src/apis/__test__/user.test.ts b/FE/src/apis/__test__/user.test.ts new file mode 100644 index 00000000..3ed5a016 --- /dev/null +++ b/FE/src/apis/__test__/user.test.ts @@ -0,0 +1,109 @@ +import { https } from "../instance"; +import { + getMyActiveStatus, + getMyAttendStatus, + postMyAttendance, +} from "../user"; +import getResponse from "@/__test__/__stub__/response"; + +jest.mock("../instance"); +const mockHttps = https as jest.MockedFunction; + +// 본인의 출석 상태 조회 api +describe("getMyAttendStatus", () => { + const mockReturnData = getResponse({ + url: "/attend/programs/:programId", + method: "GET", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("본인의 출석 상태를 반환한다", async () => { + // arrange + const programId = 1; + + // act + const response = await getMyAttendStatus(programId); + + // assert + expect(mockHttps).toHaveBeenCalledWith({ + url: "/attend/programs/1", + method: "GET", + }); + expect(response).toEqual({ + name: "26기 홍길동", + attendStatus: "attend", + }); + }); +}); + +// 출석 체크 하기 api +describe("postMyAttendance", () => { + const mockReturnData = getResponse({ + url: "/attend/programs/:programId", + method: "POST", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("출석 체크한다", async () => { + // arrange + const programId = 1; + + // act + const response = await postMyAttendance(programId); + + // assert + expect(mockHttps).toHaveBeenCalledWith({ + url: "/attend/programs/1", + method: "POST", + }); + expect(response).toEqual({ + name: "26기 홍길동", + attendStatus: "attend", + }); + }); +}); + +// 본인의 활동상태 조회 api +describe("getMyActiveStatus", () => { + const mockReturnData = getResponse({ + url: "/members/activeStatus", + method: "GET", + }); + + beforeEach(() => { + mockHttps.mockReturnValue(mockReturnData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("본인의 회원 상태를 반환한다", async () => { + // act + const response = await getMyActiveStatus(); + + // assert + expect(mockHttps).toHaveBeenCalledWith({ + url: "/members/activeStatus", + method: "GET", + }); + expect(response).toEqual({ + name: "26기 홍길동", + activeStatus: "am", + }); + }); +}); diff --git a/FE/src/apis/auth.ts b/FE/src/apis/auth.ts index 9d58c379..c1b5e28b 100644 --- a/FE/src/apis/auth.ts +++ b/FE/src/apis/auth.ts @@ -27,3 +27,16 @@ export const postTokenReissue = async (): Promise => { }); return new LoginDto(data?.data); }; + +export const postAdminLogin = async ( + id: string, + password: string, +): Promise => { + const { data } = await https({ + url: API.AUTH.ADMIN_LOGIN, + method: "POST", + data: { id, password }, + }); + + return new LoginDto(data?.data); +}; diff --git a/FE/src/apis/dtos/member.dto.ts b/FE/src/apis/dtos/member.dto.ts index 67d977d5..43983ed4 100644 --- a/FE/src/apis/dtos/member.dto.ts +++ b/FE/src/apis/dtos/member.dto.ts @@ -43,11 +43,3 @@ export class MemberActiveStatusInfoDto { this.activeStatus = data?.activeStatus; } } - -export class MemberListDto { - public readonly members: MemberInfoDto[]; - - constructor(data: { members: MemberInfo[] }) { - this.members = data?.members.map((member) => new MemberInfoDto(member)); - } -} diff --git a/FE/src/apis/dtos/program.dto.ts b/FE/src/apis/dtos/program.dto.ts index 96de9bf3..9ec50a68 100644 --- a/FE/src/apis/dtos/program.dto.ts +++ b/FE/src/apis/dtos/program.dto.ts @@ -5,6 +5,7 @@ import { ProgramStatus, ProgramCategory, AccessRight, + ProgramAttendStatus, } from "@/types/program"; export class ProgramIdDto { @@ -24,6 +25,8 @@ export class ProgramInfoDto { public readonly programStatus: ProgramStatus; public readonly type: ProgramType; public readonly accessRight: AccessRight; + public readonly attendMode: ProgramAttendStatus; + public readonly programGithubUrl: string; constructor(data: ProgramInfo) { this.programId = data?.programId; @@ -34,6 +37,8 @@ export class ProgramInfoDto { this.programStatus = data?.programStatus; this.type = data?.type; this.accessRight = data?.accessRight; + this.attendMode = data?.attendMode; + this.programGithubUrl = data?.programGithubUrl; } } @@ -44,6 +49,7 @@ export class ProgramSimpleInfoDto { public readonly category: ProgramCategory; public readonly programStatus: ProgramStatus; public readonly type: ProgramType; + public readonly attendMode: ProgramAttendStatus; constructor(data: ProgramSimpleInfo) { this.programId = data?.programId; @@ -52,6 +58,7 @@ export class ProgramSimpleInfoDto { this.category = data?.category; this.programStatus = data?.programStatus; this.type = data?.type; + this.attendMode = data?.attendMode; } } diff --git a/FE/src/apis/dtos/question.dto.ts b/FE/src/apis/dtos/question.dto.ts new file mode 100644 index 00000000..5e57cc54 --- /dev/null +++ b/FE/src/apis/dtos/question.dto.ts @@ -0,0 +1,24 @@ +export interface Comment { + commentId: number; + teamId: number; + writer: string; + accessRight: "edit" | "read_only"; + time: string; + content: string; + answers: Answer[]; +} + +export interface Answer { + commentId: number; + writer: string; + accessRight: "edit" | "read_only"; + time: string; + content: string; +} + +export class QuestionListDto { + public comments: Comment[]; + constructor(data) { + this.comments = data.comments; + } +} diff --git a/FE/src/apis/dtos/team.dto.ts b/FE/src/apis/dtos/team.dto.ts new file mode 100644 index 00000000..e8175d88 --- /dev/null +++ b/FE/src/apis/dtos/team.dto.ts @@ -0,0 +1,8 @@ +import { TeamInfo } from "@/types/team"; + +export class TeamListDto { + public readonly teams: TeamInfo[]; + constructor(data: { teams: TeamInfo[] }) { + this.teams = data?.teams || []; + } +} diff --git a/FE/src/apis/instance.ts b/FE/src/apis/instance.ts index 70a8b913..730c996e 100644 --- a/FE/src/apis/instance.ts +++ b/FE/src/apis/instance.ts @@ -5,6 +5,8 @@ import ERROR_CODE from "@/constants/ERROR_CODE"; import ERROR_MESSAGE from "@/constants/ERROR_MESSAGE"; import { deleteTokenInfo, + getAccessToken, + getTokenExpiration, setAccessToken, setTokenExpiration, } from "@/utils/authWithStorage"; @@ -20,25 +22,25 @@ const https = axios.create({ https.interceptors.request.use( async (config) => { if (typeof window === "undefined") return config; - const accessToken = localStorage.getItem("accessToken")?.replace(/"/g, ""); - const tokenExpiration = localStorage.getItem("tokenExpiration"); + const accessToken = getAccessToken(); + const tokenExpiration = getTokenExpiration(); - if (accessToken && tokenExpiration) { - const currentTime = new Date().getTime(); - const timeToExpiration = Number(tokenExpiration) - currentTime; - const TOKEN_REISSUE_THRESHOLD = Number( - process.env.NEXT_PUBLIC_TOKEN_REISSUE_THRESHOLD, - ); + if (!accessToken || !tokenExpiration) return config; - if (timeToExpiration < TOKEN_REISSUE_THRESHOLD) { - const { accessToken, accessExpiredTime } = await postTokenReissue(); - setAccessToken(accessToken); - setTokenExpiration(accessExpiredTime); - } + const currentTime = new Date().getTime(); + const timeToExpiration = Number(tokenExpiration) - currentTime; + const TOKEN_REISSUE_THRESHOLD = Number( + process.env.NEXT_PUBLIC_TOKEN_REISSUE_THRESHOLD, + ); - config.headers["Authorization"] = `Bearer ${accessToken}`; + if (timeToExpiration < TOKEN_REISSUE_THRESHOLD) { + const { accessToken, accessExpiredTime } = await postTokenReissue(); + setAccessToken(accessToken); + setTokenExpiration(accessExpiredTime); } + config.headers["Authorization"] = `Bearer ${accessToken}`; + return config; }, (error) => Promise.reject(error), @@ -77,6 +79,7 @@ https.interceptors.response.use( }); deleteTokenInfo(); setTimeout(() => { + if (errorCode === ERROR_CODE.AUTH.INCORRECT_LOGIN_INFO) return; window.location.href = "/login"; }, 3000); } diff --git a/FE/src/apis/member.ts b/FE/src/apis/member.ts index b8519178..515c69f0 100644 --- a/FE/src/apis/member.ts +++ b/FE/src/apis/member.ts @@ -1,5 +1,6 @@ import API from "../constants/API"; import { + ActiveStatus, ActiveStatusWithAll, AttendStatus, MemberActiveStatusInfo, @@ -68,3 +69,30 @@ export const getProgramMembersByAttendStatus = async ( (member: MemberAttendStatusInfo) => new MemberAttendStatusInfoDto(member), ); }; + +/** + * 회원 활동 상태 변경 + */ +export const updateMemberActiveStatus = async ( + memberId: number, + activeStatus: ActiveStatus, +) => { + const { data } = await https({ + url: API.MEMBER.UPDATE(memberId), + method: "PUT", + data: activeStatus, + }); + + return data?.data; +}; + +/** + * 회원 삭제 + */ +export const deleteMember = async (memberId: number) => { + const { data } = await https({ + url: API.MEMBER.DELETE(memberId), + method: "DELETE", + }); + return data?.data; +}; diff --git a/FE/src/apis/program.ts b/FE/src/apis/program.ts index 88df4037..b9e8f259 100644 --- a/FE/src/apis/program.ts +++ b/FE/src/apis/program.ts @@ -1,7 +1,7 @@ -import { toast } from "react-toastify"; import API from "../constants/API"; import { AttendStatus } from "../types/member"; import { + ProgramAttendStatus, ProgramCategoryWithAll, ProgramInfo, ProgramStatus, @@ -13,7 +13,7 @@ import { ProgramListDto, } from "./dtos/program.dto"; import { https } from "./instance"; -import MESSAGE from "@/constants/MESSAGE"; +import { TeamInputInfo } from "@/types/team"; /** * 프로그램 정보 조회 @@ -21,14 +21,16 @@ import MESSAGE from "@/constants/MESSAGE"; export const getProgramById = async ( programId: number, - isLoggedIn: boolean, + isAbletoEdit: boolean, ): Promise => { - const url = isLoggedIn - ? API.PROGRAM.DETAIL(programId) - : API.PROGRAM.GUEST_DETAIL(programId); + const url = isAbletoEdit + ? API.PROGRAM.Edit_DETAIL(programId) + : API.PROGRAM.DETAIL(programId); const { data } = await https({ url, + method: "GET", }); + return new ProgramInfoDto(data?.data); }; @@ -41,7 +43,7 @@ export interface GetProgramListRequest { programStatus: ProgramStatus; size: number; page: number; - isLoggedIn: boolean; + isAdmin?: boolean; } export const getProgramList = async ({ @@ -49,9 +51,9 @@ export const getProgramList = async ({ programStatus, size, page, - isLoggedIn, + isAdmin, }: GetProgramListRequest): Promise => { - const url = isLoggedIn ? API.PROGRAM.LIST : API.PROGRAM.GUEST_LIST; + const url = isAdmin ? API.PROGRAM.LIST : API.PROGRAM.GUEST_LIST; const { data } = await https({ url, method: "GET", @@ -70,17 +72,10 @@ export const getProgramList = async ({ */ export const deleteProgram = async (programId: number) => { - const { data } = await toast.promise( - https({ - url: API.PROGRAM.DELETE(programId), - method: "DELETE", - }), - { - pending: MESSAGE.DELETE.PENDING, - success: MESSAGE.DELETE.SUCCESS, - error: MESSAGE.DELETE.FAILED, - }, - ); + const { data } = await https({ + url: API.PROGRAM.DELETE(programId), + method: "DELETE", + }); return data?.data; }; @@ -89,51 +84,40 @@ export const deleteProgram = async (programId: number) => { */ export interface PostProgramRequest - extends Omit { + extends Omit< + ProgramInfo, + | "programId" + | "programStatus" + | "accessRight" + | "attendMode" + | "eventStatus" + | "teams" + > { members: { memberId: number }[]; + teams: TeamInputInfo[]; } -export const sendSlackMessage = async ( - programId: number, - isRetry: boolean = false, -) => { - if (!window) return; - - if (!isRetry) { - const isConfirmed = confirm(MESSAGE.SLACK_MESSAGE.CONFIRM); - if (!isConfirmed) return; - } - - return await https({ +export const sendSlackMessage = async (programId: number) => { + const { data } = await https({ url: API.PROGRAM.SEND_MESSAGE(programId), method: "POST", data: { programUrl: process.env.NEXT_PUBLIC_SLACK_MESSAGE_REQUEST_URL_PREFIX + programId, }, - }) - .then(() => alert(MESSAGE.SLACK_MESSAGE.SUCCESS)) - .catch(() => { - const retry = confirm(MESSAGE.SLACK_MESSAGE.FAIL); - if (retry) sendSlackMessage(programId); - }); + }); + return data?.data; }; export const postProgram = async ( body: PostProgramRequest, ): Promise => { - const { data } = await toast.promise( - https({ - url: API.PROGRAM.CREATE, - method: "POST", - data: body, - }), - { - pending: MESSAGE.CREATE.PENDING, - success: MESSAGE.CREATE.SUCCESS, - error: MESSAGE.CREATE.FAILED, - }, - ); + const { data } = await https({ + url: API.PROGRAM.CREATE, + method: "POST", + data: body, + }); + return new ProgramIdDto(data?.data); }; @@ -148,31 +132,32 @@ export interface PatchProgramMember { } export interface PatchProgramBody - extends Omit { + extends Omit< + ProgramInfo, + | "programId" + | "programStatus" + | "accessRight" + | "attendMode" + | "eventStatus" + | "teams" + > { members: PatchProgramMember[]; + teams: TeamInputInfo[]; } export interface PatchProgramRequest { programId: number; body: PatchProgramBody; } - export const patchProgram = async ({ programId, body, }: PatchProgramRequest): Promise => { - const { data } = await toast.promise( - https({ - url: API.PROGRAM.UPDATE(programId), - method: "PATCH", - data: body, - }), - { - pending: MESSAGE.EDIT.PENDING, - success: MESSAGE.EDIT.SUCCESS, - error: MESSAGE.EDIT.FAILED, - }, - ); + const { data } = await https({ + url: API.PROGRAM.UPDATE(programId), + method: "PATCH", + data: body, + }); return new ProgramIdDto(data?.data); }; @@ -189,3 +174,18 @@ export const getProgramAccessRight = async ( }); return data?.data; }; + +export const updateProgramAttendMode = async ( + programId: number, + attendMode: ProgramAttendStatus, +) => { + const { data } = await https({ + url: API.PROGRAM.UPDATE_ATTEND_MODE(programId), + method: "POST", + params: { + mode: attendMode, + }, + }); + + return data?.data; +}; diff --git a/FE/src/apis/proxy/github.ts b/FE/src/apis/proxy/github.ts new file mode 100644 index 00000000..e645edf2 --- /dev/null +++ b/FE/src/apis/proxy/github.ts @@ -0,0 +1,31 @@ +import proxy from "./instance"; +import { convertGitHubUrl } from "@/utils/convert"; + +type Presentation = { + name: string; + download_url: string; +}; + +export const getPresentations = async (githubUrl: string) => { + const { branch, owner, path, repo } = convertGitHubUrl(githubUrl); + + const data = await proxy + .get("/github", { + params: { + owner, + repo, + path, + branch, + }, + }) + .then((res) => { + console.log(res.data.data); + return res.data.data as Presentation[]; + }) + .catch((err) => { + console.log(err); + return []; + }); + + return data; +}; diff --git a/FE/src/apis/proxy/instance.ts b/FE/src/apis/proxy/instance.ts new file mode 100644 index 00000000..4675dfa7 --- /dev/null +++ b/FE/src/apis/proxy/instance.ts @@ -0,0 +1,7 @@ +import axios from "axios"; + +const proxy = axios.create({ + baseURL: "/api", +}); + +export default proxy; diff --git a/FE/src/apis/question.ts b/FE/src/apis/question.ts new file mode 100644 index 00000000..af74d3dd --- /dev/null +++ b/FE/src/apis/question.ts @@ -0,0 +1,48 @@ +import { QuestionListDto } from "./dtos/question.dto"; +import { https } from "./instance"; +import API from "@/constants/API"; + +export const getQuestionsByTeam = async (programId: number, teamId: number) => { + const { data } = await https({ + url: API.QUESTION.LIST, + method: "GET", + params: { programId, teamId }, + }); + return new QuestionListDto(data?.data); +}; + +export interface PostQuestionParams { + programId: number; + teamId: number; + questionContent: string; + parentsCommentId?: number; +} +export const postQuestion = async ({ + programId, + teamId, + questionContent, + parentsCommentId = -1, +}: PostQuestionParams) => { + return await https({ + url: API.QUESTION.CREATE, + method: "POST", + data: { programId, teamId, content: questionContent, parentsCommentId }, + }); +}; + +export const updateQuestion = async (commentId: number, contents: string) => { + return await https({ + url: API.QUESTION.UPDATE(commentId), + method: "PUT", + data: { + contents, + }, + }); +}; + +export const deleteQuestion = async (commentId: number) => { + return await https({ + url: API.QUESTION.DELETE(commentId), + method: "DELETE", + }); +}; diff --git a/FE/src/apis/team.ts b/FE/src/apis/team.ts new file mode 100644 index 00000000..b39bd452 --- /dev/null +++ b/FE/src/apis/team.ts @@ -0,0 +1,38 @@ +import { TeamListDto } from "./dtos/team.dto"; +import { https } from "./instance"; +import API from "@/constants/API"; + +/** + * 팀 리스트 가져오기 + * 인자가 number인 경우에는 programId를 받아서 해당 프로그램의 팀 리스트를 가져옵니다. + * 인자가 없는 경우에는 모든 팀 리스트를 가져옵니다. + */ +export const getTeamList = async (programId?: number | "none") => { + programId = programId || "none"; + const { data } = await https({ + url: API.TEAM.LIST, + method: "GET", + params: { + programId, + }, + }); + return new TeamListDto(data?.data); +}; + +export const createTeam = async (teamName: string) => { + if (!teamName) throw new Error("팀 이름을 입력해주세요."); + const { data } = await https({ + url: API.TEAM.CREATE, + method: "POST", + data: { teamName }, + }); + return data?.data; +}; + +export const deleteTeam = async (teamId: number) => { + const { data } = await https({ + url: API.TEAM.DELETE(teamId), + method: "DELETE", + }); + return data?.data; +}; diff --git a/FE/src/apis/user.ts b/FE/src/apis/user.ts index a4bf2385..cb576395 100644 --- a/FE/src/apis/user.ts +++ b/FE/src/apis/user.ts @@ -24,13 +24,13 @@ export const getMyAttendStatus = async ( /** * 본인의 출석 상태 변경 + * 현재는 사용하지 않음 */ export interface PutMyAttendStatusRequest { beforeAttendStatus: AttendStatus; afterAttendStatus: AttendStatus; } - export const putMyAttendStatus = async ( programId: number, body: PutMyAttendStatusRequest, @@ -50,6 +50,17 @@ export const putMyAttendStatus = async ( return new UserAttendStatusInfoDto(data?.data); }; +export const postMyAttendance = async ( + programId: number, +): Promise => { + const { data } = await https({ + url: API.USER.ATTEND_STATUS(programId), + method: "POST", + }); + + return new UserAttendStatusInfoDto(data?.data); +}; + /** * 본인의 회원 상태 조회 */ @@ -64,6 +75,7 @@ export const getMyActiveStatus = async (): Promise => { /** * 본인의 회원 상태 변경 + * 현재는 사용하지 않음 */ interface PutMyActiveStatusRequest { diff --git a/FE/src/app/(private)/(program)/create/loading.tsx b/FE/src/app/(admin)/admin/create/loading.tsx similarity index 100% rename from FE/src/app/(private)/(program)/create/loading.tsx rename to FE/src/app/(admin)/admin/create/loading.tsx diff --git a/FE/src/app/(private)/(program)/create/page.tsx b/FE/src/app/(admin)/admin/create/page.tsx similarity index 52% rename from FE/src/app/(private)/(program)/create/page.tsx rename to FE/src/app/(admin)/admin/create/page.tsx index 9375fcdc..e7f2424c 100644 --- a/FE/src/app/(private)/(program)/create/page.tsx +++ b/FE/src/app/(admin)/admin/create/page.tsx @@ -1,11 +1,11 @@ -import Title from "@/components/common/Title"; -import ProgramCreateForm from "@/components/programCreate/ProgramCreateForm"; +import CreateForm from "@/components/common/form/program/CreateForm"; +import Title from "@/components/common/Title/Title"; const ProgramCreatePage = () => { return (
- <ProgramCreateForm /> + <CreateForm /> </div> ); }; diff --git a/FE/src/app/(admin)/admin/detail/[programId]/page.tsx b/FE/src/app/(admin)/admin/detail/[programId]/page.tsx new file mode 100644 index 00000000..4eb29799 --- /dev/null +++ b/FE/src/app/(admin)/admin/detail/[programId]/page.tsx @@ -0,0 +1,20 @@ +import AttendeeInfoContainer from "@/components/programDetail/attendee/AttendeeInfo.container"; +import ProgramInfo from "@/components/programDetail/program/ProgramInfo"; + +interface ProgramDetailPageProps { + params: { + programId: string; + }; +} + +const ProgramDetailPage = ({ params }: ProgramDetailPageProps) => { + const { programId } = params; + + return ( + <div className="mb-16 space-y-16"> + <ProgramInfo programId={+programId} accessType="admin" /> + <AttendeeInfoContainer programId={+programId} isLoggedIn /> + </div> + ); +}; +export default ProgramDetailPage; diff --git a/FE/src/app/(admin)/admin/detail/error.tsx b/FE/src/app/(admin)/admin/detail/error.tsx new file mode 100644 index 00000000..4fd856a9 --- /dev/null +++ b/FE/src/app/(admin)/admin/detail/error.tsx @@ -0,0 +1,19 @@ +"use client"; + +import ErrorFallback from "@/components/common/error/ErrorFallback"; + +const DetailPageError = () => { + const error = { + message: "행사 정보를 불러오는 중에 오류가 발생했습니다.", + }; + + return ( + <ErrorFallback + error={error} + resetErrorBoundary={() => { + window.location.reload(); + }} + /> + ); +}; +export default DetailPageError; diff --git a/FE/src/app/(admin)/admin/detail/loading.tsx b/FE/src/app/(admin)/admin/detail/loading.tsx new file mode 100644 index 00000000..4a5cb70e --- /dev/null +++ b/FE/src/app/(admin)/admin/detail/loading.tsx @@ -0,0 +1,4 @@ +import LoadingSpinner from "@/components/common/LoadingSpinner"; + +const DetailLoading = () => <LoadingSpinner />; +export default DetailLoading; diff --git a/FE/src/app/(private)/(program)/edit/[programId]/page.tsx b/FE/src/app/(admin)/admin/edit/[programId]/page.tsx similarity index 51% rename from FE/src/app/(private)/(program)/edit/[programId]/page.tsx rename to FE/src/app/(admin)/admin/edit/[programId]/page.tsx index 4aa6aaec..f6363ad1 100644 --- a/FE/src/app/(private)/(program)/edit/[programId]/page.tsx +++ b/FE/src/app/(admin)/admin/edit/[programId]/page.tsx @@ -1,10 +1,9 @@ +//TODO: 서버 컴포넌트로 변경 "use client"; -import LoadingSpinner from "@/components/common/LoadingSpinner"; -import Title from "@/components/common/Title"; +import Title from "@/components/common/Title/Title"; import AccessRightValidate from "@/components/common/validate/AccessRight"; -import ProgramEditForm from "@/components/programEdit/ProgramEditForm"; -import { useGetProgramById } from "@/hooks/query/useProgramQuery"; +import EditForm from "@/components/programEdit/EditForm"; interface ProgramEditPageProps { params: { @@ -14,16 +13,13 @@ interface ProgramEditPageProps { const ProgramEditPage = ({ params }: ProgramEditPageProps) => { const { programId } = params; - const { data: programInfo, isLoading } = useGetProgramById(+programId, true); - - if (isLoading) return <LoadingSpinner />; return ( <> <AccessRightValidate programId={programId} /> <div className="space-y-12"> <Title text="행사 수정" /> - <ProgramEditForm programId={programId} programInfo={programInfo} /> + <EditForm programId={+programId} /> </div> </> ); diff --git a/FE/src/app/(private)/(program)/edit/error.tsx b/FE/src/app/(admin)/admin/edit/error.tsx similarity index 82% rename from FE/src/app/(private)/(program)/edit/error.tsx rename to FE/src/app/(admin)/admin/edit/error.tsx index b0ec7a55..1abd9c5f 100644 --- a/FE/src/app/(private)/(program)/edit/error.tsx +++ b/FE/src/app/(admin)/admin/edit/error.tsx @@ -1,6 +1,6 @@ "use client"; -import ErrorFallback from "@/components/common/ErrorFallback"; +import ErrorFallback from "@/components/common/error/ErrorFallback"; const EditPageError = () => { const error = { diff --git a/FE/src/app/(private)/(program)/edit/loading.tsx b/FE/src/app/(admin)/admin/edit/loading.tsx similarity index 100% rename from FE/src/app/(private)/(program)/edit/loading.tsx rename to FE/src/app/(admin)/admin/edit/loading.tsx diff --git a/FE/src/app/(admin)/admin/main/page.tsx b/FE/src/app/(admin)/admin/main/page.tsx new file mode 100644 index 00000000..17bf1dc2 --- /dev/null +++ b/FE/src/app/(admin)/admin/main/page.tsx @@ -0,0 +1,11 @@ +import Program from "@/components/main/Program"; + +const AdminMainPage = () => { + return ( + <div className="relative space-y-8"> + <Program AccessType="admin" /> + </div> + ); +}; + +export default AdminMainPage; diff --git a/FE/src/app/(admin)/admin/manage/page.tsx b/FE/src/app/(admin)/admin/manage/page.tsx new file mode 100644 index 00000000..bd09ff42 --- /dev/null +++ b/FE/src/app/(admin)/admin/manage/page.tsx @@ -0,0 +1,19 @@ +import CancleBtn from "@/components/manage/CancleBtn"; +import MemberManageSection from "@/components/manage/member/MemberManageSection"; +import TeamManageSection from "@/components/manage/team/TeamManageSection"; + +const AdminManagePage = () => { + return ( + <div> + <TeamManageSection /> + <div className="mt-20"> + <MemberManageSection /> + </div> + <div className="mt-10 flex w-full justify-end"> + <CancleBtn /> + </div> + </div> + ); +}; + +export default AdminManagePage; diff --git a/FE/src/app/(admin)/layout.tsx b/FE/src/app/(admin)/layout.tsx new file mode 100644 index 00000000..1507de6d --- /dev/null +++ b/FE/src/app/(admin)/layout.tsx @@ -0,0 +1,16 @@ +import { PropsWithChildren } from "react"; +import Header from "@/components/common/header/Header"; +import AuthValidate from "@/components/common/validate/Auth"; + +const PrivateLayout = ({ children }: PropsWithChildren) => { + return ( + <> + <AuthValidate isHaveToLoggedInRoute /> + <Header isAdmin /> + <main className="my-16 w-full px-3 sm:max-w-[800px] lg:max-w-[1112px]"> + {children} + </main> + </> + ); +}; +export default PrivateLayout; diff --git a/FE/src/app/(auth)/login/name-error/page.tsx b/FE/src/app/(auth)/login/name-error/page.tsx index 752ee9f9..0d5e1ee2 100644 --- a/FE/src/app/(auth)/login/name-error/page.tsx +++ b/FE/src/app/(auth)/login/name-error/page.tsx @@ -1,8 +1,8 @@ "use client"; import { useRouter } from "next/navigation"; -import Button from "@/components/common/Button"; -import ErrorFallback from "@/components/common/ErrorFallback"; +import Button from "@/components/common/Button/Button"; +import ErrorFallback from "@/components/common/error/ErrorFallback"; import ERROR_CODE from "@/constants/ERROR_CODE"; import ERROR_MESSAGE from "@/constants/ERROR_MESSAGE"; import ROUTES from "@/constants/ROUTES"; diff --git a/FE/src/app/(auth)/login/page.tsx b/FE/src/app/(auth)/login/page.tsx index a70e0120..59e0bd0a 100644 --- a/FE/src/app/(auth)/login/page.tsx +++ b/FE/src/app/(auth)/login/page.tsx @@ -1,12 +1,19 @@ -import LoginLeftSection from "@/components/login/LeftSection"; -import LoginRightSection from "@/components/login/RightSection"; +import LoginPageFooter from "@/components/feature/login/LoginPageFooter"; +import LoginSection from "@/components/feature/login/LoginSection"; +import { IntroLogo, Saly } from "@/components/icons"; const LoginPage = () => { return ( - <div className="grid h-[80vh] sm:h-[44rem] sm:grid-cols-[25rem_1fr] sm:shadow-lg"> - <LoginLeftSection /> - <LoginRightSection /> - </div> + <> + <div className="grid h-[80vh] sm:h-[44rem] sm:grid-cols-[25rem_1fr] sm:shadow-lg"> + <div className="hidden flex-col gap-28 bg-secondary-10 p-8 sm:flex"> + <IntroLogo /> + <Saly /> + </div> + <LoginSection /> + </div> + <LoginPageFooter /> + </> ); }; export default LoginPage; diff --git a/FE/src/app/(guest)/guest/detail/[programId]/page.tsx b/FE/src/app/(guest)/guest/detail/[programId]/page.tsx index 72bb0405..0701336d 100644 --- a/FE/src/app/(guest)/guest/detail/[programId]/page.tsx +++ b/FE/src/app/(guest)/guest/detail/[programId]/page.tsx @@ -13,7 +13,7 @@ const ProgramDetailPage = ({ params }: ProgramDetailPageProps) => { return ( <div className="mb-16 space-y-16"> - <ProgramInfo programId={+programId} isLoggedIn={false} /> + <ProgramInfo programId={+programId} accessType="public" /> <AttendeeInfoContainer programId={+programId} isLoggedIn={false} /> <UserAttendModalContainer programId={+programId} isLoggedIn={false} /> </div> diff --git a/FE/src/app/(guest)/guest/detail/error.tsx b/FE/src/app/(guest)/guest/detail/error.tsx index adf565a8..4fd856a9 100644 --- a/FE/src/app/(guest)/guest/detail/error.tsx +++ b/FE/src/app/(guest)/guest/detail/error.tsx @@ -1,6 +1,6 @@ "use client"; -import ErrorFallback from "@/components/common/ErrorFallback"; +import ErrorFallback from "@/components/common/error/ErrorFallback"; const DetailPageError = () => { const error = { diff --git a/FE/src/app/(guest)/guest/main/page.tsx b/FE/src/app/(guest)/guest/main/page.tsx index 8af24436..2523d8a4 100644 --- a/FE/src/app/(guest)/guest/main/page.tsx +++ b/FE/src/app/(guest)/guest/main/page.tsx @@ -1,98 +1,11 @@ -// TODO: 서버 컴포넌트로 변경하기 -"use client"; +import Program from "@/components/main/Program"; -import { useSearchParams } from "next/navigation"; -import { Suspense, useEffect, useState } from "react"; -import { ErrorBoundary } from "react-error-boundary"; -import ErrorFallback from "@/components/common/ErrorFallback"; -import Tab from "@/components/common/tabs/Tab"; -import TextTab from "@/components/common/tabs/TextTab"; -import ProgramList from "@/components/main/ProgramList"; -import ProgramListLoader from "@/components/main/ProgramList.loader"; -import TeamBuildingDropup from "@/components/main/TeamBuildingDropup"; -import MAIN from "@/constants/MAIN"; -import PROGRAM from "@/constants/PROGRAM"; -import { ProgramCategoryWithAll, ProgramStatus } from "@/types/program"; - -const MainPage = () => { - const searchParams = useSearchParams(); - - // TODO: Hook으로 변경하기 - const [queryValue, setQueryValue] = useState(MAIN.DEFAULT_QUERY); - - // TODO: useEffect를 Hook으로 변경하기 - useEffect(() => { - setQueryValue({ - ...MAIN.DEFAULT_QUERY, - category: - (searchParams.get("category") as ProgramCategoryWithAll) ?? "all", - status: (searchParams.get("status") as ProgramStatus) ?? "active", - page: searchParams.get("page") ?? "1", - }); - }, [searchParams]); - - useEffect(() => { - window.history.replaceState( - {}, - "", - `?category=${queryValue.category}&status=${queryValue.status}&page=${queryValue.page}`, - ); - }, [queryValue]); - - const handleSetCategory = (category: ProgramCategoryWithAll) => { - setQueryValue({ - ...queryValue, - category, - page: "1", - }); - }; - - const handleSetStatus = (status: ProgramStatus) => { - setQueryValue({ - ...queryValue, - status, - page: "1", - }); - }; - - const handleSetPage = (page: number) => { - setQueryValue({ - ...queryValue, - page: page.toString(), - }); - }; - - // TODO: 합성 컴포넌트! +const GuestMainPage = () => { return ( <div className="relative space-y-8"> - <Tab<ProgramCategoryWithAll> - options={Object.values(PROGRAM.CATEGORY_TAB_WITH_ALL)} - selected={queryValue.category} - onItemClick={(v) => handleSetCategory(v)} - size="lg" - baseColor="white" - pointColor="navy" - align="line" - /> - <TextTab<ProgramStatus> - options={Object.values(PROGRAM.STATUS_TAB)} - selected={queryValue.status} - onClick={(v) => handleSetStatus(v)} - /> - <ErrorBoundary FallbackComponent={ErrorFallback}> - <Suspense fallback={<ProgramListLoader />}> - <ProgramList - category={queryValue.category} - programStatus={queryValue.status} - page={+queryValue.page} - setPage={handleSetPage} - isLoggedIn={false} - /> - </Suspense> - </ErrorBoundary> - <TeamBuildingDropup /> + <Program AccessType="public" /> </div> ); }; -export default MainPage; +export default GuestMainPage; diff --git a/FE/src/app/(private)/(program)/detail/[programId]/page.tsx b/FE/src/app/(private)/(program)/detail/[programId]/page.tsx index 9315cc77..08621ea5 100644 --- a/FE/src/app/(private)/(program)/detail/[programId]/page.tsx +++ b/FE/src/app/(private)/(program)/detail/[programId]/page.tsx @@ -13,7 +13,7 @@ const ProgramDetailPage = ({ params }: ProgramDetailPageProps) => { return ( <div className="mb-16 space-y-16"> - <ProgramInfo programId={+programId} isLoggedIn /> + <ProgramInfo programId={+programId} accessType="private" /> <AttendeeInfoContainer programId={+programId} isLoggedIn /> <UserAttendModalContainer programId={+programId} isLoggedIn /> </div> diff --git a/FE/src/app/(private)/(program)/detail/error.tsx b/FE/src/app/(private)/(program)/detail/error.tsx index adf565a8..4fd856a9 100644 --- a/FE/src/app/(private)/(program)/detail/error.tsx +++ b/FE/src/app/(private)/(program)/detail/error.tsx @@ -1,6 +1,6 @@ "use client"; -import ErrorFallback from "@/components/common/ErrorFallback"; +import ErrorFallback from "@/components/common/error/ErrorFallback"; const DetailPageError = () => { const error = { diff --git a/FE/src/app/(private)/(program)/main/page.tsx b/FE/src/app/(private)/(program)/main/page.tsx index 78b3e60d..b369722c 100644 --- a/FE/src/app/(private)/(program)/main/page.tsx +++ b/FE/src/app/(private)/(program)/main/page.tsx @@ -1,96 +1,9 @@ -// TODO: 서버 컴포넌트로 변경하기 -"use client"; - -import { useSearchParams } from "next/navigation"; -import { Suspense, useEffect, useState } from "react"; -import { ErrorBoundary } from "react-error-boundary"; -import ErrorFallback from "@/components/common/ErrorFallback"; -import Tab from "@/components/common/tabs/Tab"; -import TextTab from "@/components/common/tabs/TextTab"; -import ProgramList from "@/components/main/ProgramList"; -import ProgramListLoader from "@/components/main/ProgramList.loader"; -import TeamBuildingDropup from "@/components/main/TeamBuildingDropup"; -import MAIN from "@/constants/MAIN"; -import PROGRAM from "@/constants/PROGRAM"; -import { ProgramCategoryWithAll, ProgramStatus } from "@/types/program"; +import Program from "@/components/main/Program"; const MainPage = () => { - const searchParams = useSearchParams(); - - // TODO: Hook으로 변경하기 - const [queryValue, setQueryValue] = useState(MAIN.DEFAULT_QUERY); - - // TODO: useEffect를 Hook으로 변경하기 - useEffect(() => { - setQueryValue({ - ...MAIN.DEFAULT_QUERY, - category: - (searchParams.get("category") as ProgramCategoryWithAll) ?? "all", - status: (searchParams.get("status") as ProgramStatus) ?? "active", - page: searchParams.get("page") ?? "1", - }); - }, [searchParams]); - - useEffect(() => { - window.history.replaceState( - {}, - "", - `?category=${queryValue.category}&status=${queryValue.status}&page=${queryValue.page}`, - ); - }, [queryValue]); - - const handleSetCategory = (category: ProgramCategoryWithAll) => { - setQueryValue({ - ...queryValue, - category, - page: "1", - }); - }; - - const handleSetStatus = (status: ProgramStatus) => { - setQueryValue({ - ...queryValue, - status, - page: "1", - }); - }; - - const handleSetPage = (page: number) => { - setQueryValue({ - ...queryValue, - page: page.toString(), - }); - }; - - // TODO: 합성 컴포넌트! return ( <div className="relative space-y-8"> - <Tab<ProgramCategoryWithAll> - options={Object.values(PROGRAM.CATEGORY_TAB_WITH_ALL)} - selected={queryValue.category} - onItemClick={(v) => handleSetCategory(v)} - size="lg" - baseColor="white" - pointColor="navy" - align="line" - /> - <TextTab<ProgramStatus> - options={Object.values(PROGRAM.STATUS_TAB)} - selected={queryValue.status} - onClick={(v) => handleSetStatus(v)} - /> - <ErrorBoundary FallbackComponent={ErrorFallback}> - <Suspense fallback={<ProgramListLoader />}> - <ProgramList - category={queryValue.category} - programStatus={queryValue.status} - page={+queryValue.page} - setPage={handleSetPage} - isLoggedIn - /> - </Suspense> - </ErrorBoundary> - <TeamBuildingDropup /> + <Program AccessType="private" /> </div> ); }; diff --git a/FE/src/app/(private)/team-building/create/page.tsx b/FE/src/app/(private)/team-building/create/page.tsx index 2ed7910b..d8a9e4cf 100644 --- a/FE/src/app/(private)/team-building/create/page.tsx +++ b/FE/src/app/(private)/team-building/create/page.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/navigation"; import { toast } from "react-toastify"; import LoadingSpinner from "@/components/common/LoadingSpinner"; -import Title from "@/components/common/Title"; +import Title from "@/components/common/Title/Title"; import TeamBuildingCreateForm from "@/components/teamBuildingCreate/TeamBuildingCreateForm"; import ERROR_CODE from "@/constants/ERROR_CODE"; import ERROR_MESSAGE from "@/constants/ERROR_MESSAGE"; diff --git a/FE/src/app/(private)/team-building/result/page.tsx b/FE/src/app/(private)/team-building/result/page.tsx index 81e571d1..b8767b64 100644 --- a/FE/src/app/(private)/team-building/result/page.tsx +++ b/FE/src/app/(private)/team-building/result/page.tsx @@ -1,7 +1,7 @@ "use client"; import LoadingSpinner from "@/components/common/LoadingSpinner"; -import Title from "@/components/common/Title"; +import Title from "@/components/common/Title/Title"; import TeamBuildingCloseBtn from "@/components/teamBuildingResult/TeamBuildingCloseBtn"; import TeamResultInfoContainer from "@/components/teamBuildingResult/TeamResultInfo.container"; import { useGetTeamBuildingResultQuery } from "@/hooks/query/useTeamBuildingQuery"; diff --git a/FE/src/app/api/github/route.ts b/FE/src/app/api/github/route.ts new file mode 100644 index 00000000..66808f91 --- /dev/null +++ b/FE/src/app/api/github/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(req: NextRequest) { + const searchParams = req.nextUrl.searchParams; + const owner = searchParams.get("owner"); + const repo = searchParams.get("repo"); + const path = searchParams.get("path"); + const branch = searchParams.get("branch"); + + const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`; + + try { + const response = await fetch(url, { + headers: { + Accept: "application/vnd.github.v3+json", + Authorization: `token ${process.env.NEXT_PUBLIC_GITHUB_API_TOKEN}`, + }, + }); + + if (!response.ok) { + throw new Error(`GitHub API responded with status ${response.status}`); + } + + const data = await response.json(); + const responseDatas = data + .filter((item) => item.name !== "README.md") + .map((item) => ({ + name: item.name.split(".")[0], + download_url: item.html_url, + })); + + return NextResponse.json( + { + data: responseDatas, + }, + { + status: 200, + }, + ); + } catch (error) { + console.error(error); + // return NextResponse.json( + // { error: "깃허브 요청 중 문제가 발생했습니다. 다시 시도해주세요." }, + // { status: 500 }, + // ); + return NextResponse.json( + { + data: [], + }, + { + status: 200, + }, + ); + } +} diff --git a/FE/src/app/layout.tsx b/FE/src/app/layout.tsx index be0cc916..bb8c40f6 100644 --- a/FE/src/app/layout.tsx +++ b/FE/src/app/layout.tsx @@ -4,6 +4,7 @@ import type { Metadata } from "next"; import "./globals.css"; import { PropsWithChildren } from "react"; import Provider from "@/utils/provider"; +import { GoogleAnalytics } from "@next/third-parties/google"; export const metadata: Metadata = { title: "EEOS", @@ -28,6 +29,7 @@ export default function RootLayout({ children }: PropsWithChildren) { <Provider>{children}</Provider> <Analytics /> <SpeedInsights /> + <GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS} /> </body> </html> ); diff --git a/FE/src/components/common/Button.tsx b/FE/src/components/common/Button/Button.tsx similarity index 98% rename from FE/src/components/common/Button.tsx rename to FE/src/components/common/Button/Button.tsx index a14a3f92..4c91cc96 100644 --- a/FE/src/components/common/Button.tsx +++ b/FE/src/components/common/Button/Button.tsx @@ -1,3 +1,4 @@ +"use client"; import classNames from "classnames"; const colors = { diff --git a/FE/src/components/login/ui/StyledLoginButton.tsx b/FE/src/components/common/Button/StyledLinkButton.tsx similarity index 73% rename from FE/src/components/login/ui/StyledLoginButton.tsx rename to FE/src/components/common/Button/StyledLinkButton.tsx index 21a5be66..9e5460d3 100644 --- a/FE/src/components/login/ui/StyledLoginButton.tsx +++ b/FE/src/components/common/Button/StyledLinkButton.tsx @@ -9,23 +9,24 @@ const colors = { guest: "bg-primary text-black text-paragraph", }; -interface StyledLoginButtonProps { +interface StyledLinkButtonProps extends React.HTMLProps<HTMLAnchorElement> { linkUrl: string; buttonText: string; imageUrl: string; color: keyof typeof colors; } -export default function StyledLoginButton({ +export default function StyledLinkButton({ linkUrl, buttonText, imageUrl, color, -}: StyledLoginButtonProps) { + alt = "", +}: Readonly<StyledLinkButtonProps>) { const buttonStyle = classNames(defaultStyle, colors[color]); return ( <Link className={buttonStyle} href={linkUrl}> - <Image src={imageUrl} alt="슬랙 로고" width={24} height={24} /> + <Image src={imageUrl} alt={alt} width={24} height={24} /> <p className="text-center font-semibold">{buttonText}</p> </Link> ); diff --git a/FE/src/components/common/CheckBox.tsx b/FE/src/components/common/CheckBox/CheckBox.tsx similarity index 100% rename from FE/src/components/common/CheckBox.tsx rename to FE/src/components/common/CheckBox/CheckBox.tsx diff --git a/FE/src/components/common/Dropup.tsx b/FE/src/components/common/Dropup.tsx index 515eb844..0a130165 100644 --- a/FE/src/components/common/Dropup.tsx +++ b/FE/src/components/common/Dropup.tsx @@ -1,3 +1,4 @@ +//TODO: 사용하지 않는 컴포넌트 import { PropsWithChildren, useState } from "react"; import useOutsideRef from "@/hooks/useOutsideRef"; diff --git a/FE/src/components/common/ProgressDisplay.tsx b/FE/src/components/common/ProgressDisplay.tsx new file mode 100644 index 00000000..a0c69191 --- /dev/null +++ b/FE/src/components/common/ProgressDisplay.tsx @@ -0,0 +1,38 @@ +import classNames from "classnames"; + +const colorCandidate = { + success: "success-30", + action: "action-20", +}; + +interface ProgressDisplayProps { + progressText: string; + color?: keyof typeof colorCandidate; +} + +const ProgressDisplay = ({ progressText, color }: ProgressDisplayProps) => { + const bgColor = "bg-" + colorCandidate[color]; + const textColor = "text-" + colorCandidate[color]; + return ( + <div className="flex shrink-0 items-center gap-3"> + <span className="relative flex h-3 w-3"> + <div + className={classNames( + "absolute inline-flex h-full w-full animate-ping rounded-full opacity-40", + bgColor, + )} + /> + <div + className={classNames( + "relative inline-flex h-3 w-3 rounded-full", + bgColor, + )} + /> + </span> + <p className={classNames("text text-lg font-bold", textColor)}> + {progressText} + </p> + </div> + ); +}; +export default ProgressDisplay; diff --git a/FE/src/components/common/attendStatusToggle/StatusToggleItem.tsx b/FE/src/components/common/StatusToggleItem.tsx similarity index 66% rename from FE/src/components/common/attendStatusToggle/StatusToggleItem.tsx rename to FE/src/components/common/StatusToggleItem.tsx index 46a40042..48038e83 100644 --- a/FE/src/components/common/attendStatusToggle/StatusToggleItem.tsx +++ b/FE/src/components/common/StatusToggleItem.tsx @@ -1,18 +1,23 @@ import classNames from "classnames"; +export type StatusToggleItemColor = keyof typeof badgeColors; interface StatusToggleItemProps { text: string; - color: string; + color: keyof typeof badgeColors; } -const badgeColors = { +export const badgeColors = { green: "bg-success-10 text-success-30 border-success-30", yellow: "bg-warning-10 text-warning-30 border-warning-30", red: "bg-action-10 text-action-20 border-action-20", gray: "bg-gray-10 text-gray-30 border-gray-10", teal: "bg-secondary-20 text-tertiary-20 border-tertiary-20", }; - +/** + * 해당하는 색상 타입은 전부 badgeColors에 정의되어 있어야 합니다. + * 해당 버튼에 사용되는 모든 색상에 대한 책임은 badgeColors에 있습니다. + * 색상 변경 혹은 추가시 badgeColors에 새로운 색상을 추가해주세요. + */ const StatusToggleItem = ({ text, color = "gray" }: StatusToggleItemProps) => { const badgeStyle = classNames( "flex h-fit w-fit transform cursor-pointer items-center justify-center rounded-3xl border-2 px-8 py-2 font-bold duration-200", diff --git a/FE/src/components/programEdit/EditMemberTableItem.tsx b/FE/src/components/common/Table/TableCompounds/EditMemberList.tsx similarity index 81% rename from FE/src/components/programEdit/EditMemberTableItem.tsx rename to FE/src/components/common/Table/TableCompounds/EditMemberList.tsx index 9a97cc16..42a5b9ed 100644 --- a/FE/src/components/programEdit/EditMemberTableItem.tsx +++ b/FE/src/components/common/Table/TableCompounds/EditMemberList.tsx @@ -1,33 +1,29 @@ +//TODO: 내부/외부 데이터 및 로직을 상위로 올릴 필요 있음 import classNames from "classnames"; import { useState } from "react"; import { toast } from "react-toastify"; -import AttendStatusToggle from "../common/attendStatusToggle/AttendStatusToggle"; -import CheckBox from "../common/CheckBox"; +import CheckBox from "../../CheckBox/CheckBox"; +import AttendStatusToggle from "../../toggle/AttendStatusToggle"; import ACTIVE_STATUS from "@/constants/ACTIVE_STATUS"; import MESSAGE from "@/constants/MESSAGE"; -import { ActiveStatus, AttendStatus } from "@/types/member"; +import { AttendStatus } from "@/types/member"; -interface EditMemberTableItemProps { +interface EditMemberListProps { memberId: number; name: string; - activeStatus: ActiveStatus; + activeStatus: string; initAttendStatus: AttendStatus; - setMembers: ( - memberId: number, - before: AttendStatus, - after: AttendStatus, - ) => void; + setMembers: (memberId: number, before: string, after: string) => void; isEditable?: boolean; } - -const EditMemberTableItem = ({ +const EditMemberList = ({ memberId, name, activeStatus, initAttendStatus, setMembers, isEditable = true, -}: EditMemberTableItemProps) => { +}: EditMemberListProps) => { const [selectedAttend, setSelectedAttend] = useState<AttendStatus>(initAttendStatus); const isRelated = selectedAttend !== "nonRelated"; @@ -84,4 +80,5 @@ const EditMemberTableItem = ({ </div> ); }; -export default EditMemberTableItem; + +export default EditMemberList; diff --git a/FE/src/components/common/Table/TableCompounds/Header.tsx b/FE/src/components/common/Table/TableCompounds/Header.tsx new file mode 100644 index 00000000..d27db8a3 --- /dev/null +++ b/FE/src/components/common/Table/TableCompounds/Header.tsx @@ -0,0 +1,54 @@ +import classNames from "classnames"; +import { useEffect } from "react"; +import CheckBox from "../../CheckBox/CheckBox"; +import { useTableContext } from "../TableWrapper"; + +interface HeaderProps { + handleSetCheckBox?: () => void; + handleResetCheckBox?: () => void; + isChecked?: boolean; +} +const Header = ({ + isChecked, + handleSetCheckBox, + handleResetCheckBox, +}: HeaderProps) => { + const { + headerItems, + columnWidths, + checkboxState: { hasCheckBox, isCheckedAll, setIsCheckedAll }, + } = useTableContext(); + + const headerGridStyle = `grid-cols-[${columnWidths}]`; + const headerStyle = classNames( + "grid w-fit justify-items-center gap-4 border-y-2 border-stroke-10 bg-gray-10 px-10 py-4 font-bold sm:w-full", + headerGridStyle, + ); + + useEffect(() => { + setIsCheckedAll(isChecked); + }, [isChecked]); + + const handleClickCheckBox = () => { + if (isCheckedAll) handleResetCheckBox(); + else handleSetCheckBox(); + + setIsCheckedAll(!isCheckedAll); + }; + + return ( + <div className={headerStyle}> + {hasCheckBox && ( + <CheckBox + checked={isCheckedAll || isChecked} + onClick={handleClickCheckBox} + /> + )} + {headerItems.map((text: string, index: number) => ( + <span key={`${index}-${text}`}>{text}</span> + ))} + </div> + ); +}; + +export default Header; diff --git a/FE/src/components/common/Table/TableCompounds/MemberManageList.tsx b/FE/src/components/common/Table/TableCompounds/MemberManageList.tsx new file mode 100644 index 00000000..4e89f8dc --- /dev/null +++ b/FE/src/components/common/Table/TableCompounds/MemberManageList.tsx @@ -0,0 +1,59 @@ +import classNames from "classnames"; +import Image from "next/image"; +import ActiveStatusToggle from "../../toggle/ActiveStatusToggle"; +import { useTableContext } from "../TableWrapper"; +import { MemberActiveStatusInfoDto } from "@/apis/dtos/member.dto"; +import ACTIVE_STATUS from "@/constants/ACTIVE_STATUS"; +import { useDeleteMember } from "@/hooks/query/useMemberQuery"; + +interface MemberManageListProps { + memberList: MemberActiveStatusInfoDto[]; +} +const MemberManageList = ({ memberList }: MemberManageListProps) => { + const { columnWidths } = useTableContext(); + const { mutate: deleteMember } = useDeleteMember(); + + const handleDeleteMember = (memberId: number) => { + const ok = confirm("정말로 삭제하시겠습니까?"); + ok && deleteMember({ memberId }); + }; + + const listGridStyle = `grid-cols-[${columnWidths}]`; + const listColumnStyle = classNames( + "grid h-20 w-fit items-center justify-items-center gap-4 border-b-2 border-stroke-10 bg-background px-10 sm:w-full", + listGridStyle, + ); + + //TODO: queryClient로직은 훅에서 처리하도록 변경 + // queryClient.setQueryData( + // ["memberIdList"], + // memberList.map((v) => v.memberId), + // ); + + return ( + <> + {memberList.map(({ activeStatus, memberId, name }) => ( + <div className={listColumnStyle} key={memberId}> + <span>{ACTIVE_STATUS.TAB[activeStatus]?.text ?? "."}</span> + <span className="font-bold">{name}</span> + <div className="flex w-full items-center justify-end"> + <ActiveStatusToggle + memberId={memberId} + selectedValue={activeStatus} + /> + </div> + <button onClick={() => handleDeleteMember(memberId)}> + <Image + src="/icons/trash.svg" + width={22} + height={22} + alt="Delete Btn" + /> + </button> + </div> + ))} + </> + ); +}; + +export default MemberManageList; diff --git a/FE/src/components/common/Table/TableCompounds/SelectedMemberList.tsx b/FE/src/components/common/Table/TableCompounds/SelectedMemberList.tsx new file mode 100644 index 00000000..d7e30394 --- /dev/null +++ b/FE/src/components/common/Table/TableCompounds/SelectedMemberList.tsx @@ -0,0 +1,30 @@ +import CheckBox from "../../CheckBox/CheckBox"; +import { ActiveStatus } from "@/types/member"; + +interface SelectMemberListProps { + memberId: number; + isChecked: boolean; + activeStatus: ActiveStatus; + name: string; + handleCheck: (memberId: number) => void; +} +const SelectMemberList = ({ + memberId, + isChecked, + activeStatus, + name, + handleCheck, +}: SelectMemberListProps) => { + return ( + <div + className="grid h-20 w-fit grid-cols-[4.75rem_7rem_7.25rem_1fr_20.5rem] items-center justify-items-center gap-4 border-b-2 border-stroke-10 bg-background px-10 sm:w-full" + key={memberId} + > + <CheckBox checked={isChecked} onClick={() => handleCheck(memberId)} /> + <span>{activeStatus}</span> + <span className="font-bold">{name}</span> + </div> + ); +}; + +export default SelectMemberList; diff --git a/FE/src/components/common/Table/TableWrapper.tsx b/FE/src/components/common/Table/TableWrapper.tsx new file mode 100644 index 00000000..e386973b --- /dev/null +++ b/FE/src/components/common/Table/TableWrapper.tsx @@ -0,0 +1,73 @@ +import { + createContext, + PropsWithChildren, + useContext, + useMemo, + useState, +} from "react"; +import EditMemberList from "./TableCompounds/EditMemberList"; +import Header from "./TableCompounds/Header"; +import MemberManageList from "./TableCompounds/MemberManageList"; +import SelectMemberList from "./TableCompounds/SelectedMemberList"; + +interface TableContextType { + checkboxState: { + hasCheckBox: boolean; + isCheckedAll: boolean; + setIsCheckedAll: (checked: boolean) => void; + }; + columnWidths: string; + headerItems: string[]; +} + +const TableContext = createContext<TableContextType>(null); + +export const useTableContext = () => { + const context = useContext(TableContext); + if (!context) { + throw new Error("TableContext는 TableWrapper 내부에서 사용되어야 합니다."); + } + return context; +}; + +interface TabWrapperProps extends PropsWithChildren { + hasCheckBox?: boolean; + columnWidths: string[]; + headerItems: string[]; +} +const TableWrapper = ({ + hasCheckBox = false, + columnWidths, + headerItems, + children, +}: TabWrapperProps) => { + const [isCheckedAll, setIsCheckedAll] = useState(false); + + const checkboxState = { + hasCheckBox, + isCheckedAll, + setIsCheckedAll, + }; + + const providerValue: TableContextType = useMemo( + () => ({ + checkboxState, + columnWidths: columnWidths.join("_"), + headerItems, + }), + [checkboxState, columnWidths, headerItems], + ); + + return ( + <TableContext.Provider value={providerValue}> + {children} + </TableContext.Provider> + ); +}; + +TableWrapper.Header = Header; +TableWrapper.MemberManageList = MemberManageList; +TableWrapper.SelectMemberList = SelectMemberList; +TableWrapper.EditMemberList = EditMemberList; + +export default TableWrapper; diff --git a/FE/src/components/common/Title.tsx b/FE/src/components/common/Title/Title.tsx similarity index 100% rename from FE/src/components/common/Title.tsx rename to FE/src/components/common/Title/Title.tsx diff --git a/FE/src/components/common/ErrorFallback.tsx b/FE/src/components/common/error/ErrorFallback.tsx similarity index 94% rename from FE/src/components/common/ErrorFallback.tsx rename to FE/src/components/common/error/ErrorFallback.tsx index b86f189f..1de28e70 100644 --- a/FE/src/components/common/ErrorFallback.tsx +++ b/FE/src/components/common/error/ErrorFallback.tsx @@ -1,7 +1,7 @@ import { useQueryErrorResetBoundary } from "@tanstack/react-query"; import Image from "next/image"; -import Button from "./Button"; -import Title from "./Title"; +import Button from "../Button/Button"; +import Title from "../Title/Title"; const ERROR_TITLE = "ERROR"; const RETRY_BUTTON_TEXT = "Try again"; diff --git a/FE/src/components/common/ErrorFallbackNoIcon.tsx b/FE/src/components/common/error/ErrorFallbackNoIcon.tsx similarity index 93% rename from FE/src/components/common/ErrorFallbackNoIcon.tsx rename to FE/src/components/common/error/ErrorFallbackNoIcon.tsx index b9e9fad3..29901fb8 100644 --- a/FE/src/components/common/ErrorFallbackNoIcon.tsx +++ b/FE/src/components/common/error/ErrorFallbackNoIcon.tsx @@ -1,6 +1,6 @@ import { useQueryErrorResetBoundary } from "@tanstack/react-query"; -import Button from "./Button"; -import Title from "./Title"; +import Button from "../Button/Button"; +import Title from "../Title/Title"; const ERROR_TITLE = "ERROR"; const RETRY_BUTTON_TEXT = "Try again"; diff --git a/FE/src/components/common/form/FormBtn.tsx b/FE/src/components/common/form/FormBtn.tsx index bfb3d9ef..8ace7d02 100644 --- a/FE/src/components/common/form/FormBtn.tsx +++ b/FE/src/components/common/form/FormBtn.tsx @@ -1,4 +1,4 @@ -import Button from "../Button"; +import Button from "../Button/Button"; interface FormBtnProps { submitText: string; diff --git a/FE/src/components/common/form/LabeledInput.tsx b/FE/src/components/common/form/LabeledInput.tsx index 8d0dcf19..da908b8f 100644 --- a/FE/src/components/common/form/LabeledInput.tsx +++ b/FE/src/components/common/form/LabeledInput.tsx @@ -1,3 +1,12 @@ +/** + * 이 컴포넌트는 하위 호환성을 위하여 남겨둔 레거시 코드입니다. 추후 삭제될 예정입니다. +이 컴포넌트는 "LabeldInputFiled" 컴포넌트로 완전히 대체죌 수 있습니다. 해당 컴포넌트를 사용하고자 한다면, "LabeldInputFiled" 컴포넌트를 사용해주세요. + */ + +"use client"; + +import Label from "./input/Label"; + interface InputProps extends React.HTMLProps<HTMLInputElement> { onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void; prefix?: string; @@ -14,9 +23,7 @@ const LabeledInput = ({ }: InputProps) => { return ( <div className="flex flex-col gap-2"> - <label htmlFor={id} className="truncate text-sm"> - {label} - </label> + <Label id={id} label={label} /> <div className="flex w-full gap-1 rounded-md border-[1.5px] border-gray-300 px-3 py-2 focus:border-tertiary-10"> {prefix && <span className="whitespace-nowrap">{prefix}</span>} <input diff --git a/FE/src/components/common/form/input/Label.tsx b/FE/src/components/common/form/input/Label.tsx new file mode 100644 index 00000000..7f161585 --- /dev/null +++ b/FE/src/components/common/form/input/Label.tsx @@ -0,0 +1,14 @@ +interface LabelProps { + id?: string; + label: string; +} + +const Label = ({ id, label }: LabelProps) => { + return ( + <label htmlFor={id} className="select-none truncate font-semibold"> + {label} + </label> + ); +}; + +export default Label; diff --git a/FE/src/components/common/form/input/LabeldInputFiled.tsx b/FE/src/components/common/form/input/LabeldInputFiled.tsx new file mode 100644 index 00000000..250a0528 --- /dev/null +++ b/FE/src/components/common/form/input/LabeldInputFiled.tsx @@ -0,0 +1,39 @@ +import { UseFormRegister } from "react-hook-form"; +import Label from "./Label"; + +interface LabeldInputFiledProps<RegitserType> { + id: string; + label: string; + register: UseFormRegister<RegitserType>; + placeholder: string; + type: string; + prefix?: string; +} +// TODO: 변경에 열려있는 컴포넌트로 만들기 +const LabeldInputFiled = <RegitserType,>({ + id, + label, + register, + placeholder, + type, + prefix, +}: LabeldInputFiledProps<RegitserType>) => { + return ( + <div className="flex flex-col gap-2"> + <Label id={id} label={label} /> + <div className="flex w-full gap-1 rounded-md border-[1.5px] border-gray-300 px-3 py-2 focus:border-tertiary-10"> + {prefix && <span className="whitespace-nowrap">{prefix}</span>} + <input + {...register(id as never)} + id={id} + placeholder={placeholder} + type={type} + autoComplete="off" + className="w-full bg-transparent focus:outline-none" + /> + </div> + </div> + ); +}; + +export default LabeldInputFiled; diff --git a/FE/src/components/common/form/program/CreateCategory.tsx b/FE/src/components/common/form/program/CreateCategory.tsx new file mode 100644 index 00000000..2da9dfd3 --- /dev/null +++ b/FE/src/components/common/form/program/CreateCategory.tsx @@ -0,0 +1,35 @@ +import { ProgramCategory } from "../../../../types/program"; +import Label from "../input/Label"; +import Tab from "@/components/common/tabs/tab/Tab"; +import PROGRAM from "@/constants/PROGRAM"; +import { notAllowDecorator } from "@/utils/demo"; + +interface CreateCategoryProps { + selectedCategory: ProgramCategory; + setCategory: (v: ProgramCategory) => void; +} +const CreateCategory = ({ + selectedCategory, + setCategory, +}: CreateCategoryProps) => { + const handleCategory = notAllowDecorator((v: ProgramCategory) => { + setCategory(v); + }); + + return ( + <div className="flex w-full flex-col gap-2 sm:w-fit"> + <Label label="카테고리" /> + <Tab<ProgramCategory> + options={Object.values(PROGRAM.CATEGORY_TAB)} + selected={selectedCategory} + onItemClick={handleCategory} + size="lg" + baseColor="gray" + pointColor="yellow" + align="line" + /> + </div> + ); +}; + +export default CreateCategory; diff --git a/FE/src/components/common/form/program/CreateForm.tsx b/FE/src/components/common/form/program/CreateForm.tsx new file mode 100644 index 00000000..59e0eae8 --- /dev/null +++ b/FE/src/components/common/form/program/CreateForm.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { toast } from "react-toastify"; +import Participant from "../../../programCreate/Participant"; +import CreateCategory from "./CreateCategory"; +import ProgramTitle from "./ProgramTitle"; +import FormBtn from "@/components/common/form/FormBtn"; +import ProgramDate from "@/components/common/form/program/ProgramDate"; +import MarkdownEditor from "@/components/common/markdown/MarkdownEditor"; +import ProgramGithubLinkInput from "@/components/programCreate/ProgramGithubLinkInput"; +import ProgramTeamList from "@/components/programCreate/ProgramTeamList"; +import FORM_INFO from "@/constants/FORM_INFO"; +import MESSAGE from "@/constants/MESSAGE"; +import ROUTES from "@/constants/ROUTES"; +import { + useCreateProgram, + useSendSlackMessage, +} from "@/hooks/query/useProgramQuery"; +import { useMemberSet } from "@/hooks/useMemberForm"; +import { ProgramCategory } from "@/types/program"; +import { TeamInputInfo } from "@/types/team"; +import { checkIsValidateGithubUrl } from "@/utils/github"; + +export interface ProgramFormDataState { + title: string; + deadLine: string; + isDemand: boolean; + category: ProgramCategory; + content: string; + programGithubUrl: string; + teamList: TeamInputInfo[]; +} + +const initialState: ProgramFormDataState = { + title: "", + deadLine: new Date().getTime().toString(), + isDemand: false, + category: "weekly", + content: "", + programGithubUrl: "", + teamList: [], +}; + +const CreateForm = () => { + const router = useRouter(); + + const { register, handleSubmit, getValues, reset, watch, setValue } = + useForm<ProgramFormDataState>({ + defaultValues: initialState, + }); + + const { members, clearMembers, setAllMembers, updateMembers } = + useMemberSet(); + + const { mutate: createProgramMutate } = useCreateProgram(); + const { mutate: sendSlackMessage } = useSendSlackMessage(); + + const isDemand = watch("isDemand"); + + const onSubmit: SubmitHandler<ProgramFormDataState> = (data) => { + const { + title, + content, + deadLine, + category, + isDemand, + programGithubUrl, + teamList, + } = data; + + if (!title || !content || !deadLine || !category || !programGithubUrl) { + toast.error("모든 항목을 입력해주세요."); + return; + } + + const isValidGithubUrl = checkIsValidateGithubUrl(programGithubUrl); + + if (!isValidGithubUrl) { + toast.error("올바른 Github URL을 입력해주세요."); + return; + } + const toastId = toast.loading(MESSAGE.CREATE.PENDING); + + createProgramMutate( + { + deadLine, + content, + category, + type: isDemand ? "demand" : "notification", + programGithubUrl: programGithubUrl, + teams: teamList, + members: Array.from(members, (memberId) => ({ memberId })), + title: isDemand ? `${FORM_INFO.DEMAND_PREFIX} ${title}` : title, + }, + { + onSuccess: ({ programId }) => { + const confirm = window.confirm(MESSAGE.SLACK_MESSAGE.CONFIRM); + const sendMessage = () => { + if (!confirm) return; + sendSlackMessage(programId, { + onSuccess: () => { + alert(MESSAGE.SLACK_MESSAGE.SUCCESS); + }, + onError: () => { + const retry = window.confirm(MESSAGE.SLACK_MESSAGE.FAIL); + if (retry) sendMessage(); + }, + }); + }; + + sendMessage(); + reset(); + router.replace(ROUTES.ADMIN_DETAIL(programId)); + toast.update(toastId, { + render: MESSAGE.CREATE.SUCCESS, + type: "success", + isLoading: false, + closeOnClick: true, + autoClose: 3000, + }); + }, + onError: () => { + toast.error(MESSAGE.CREATE.FAILED); + toast.update(toastId, { + render: MESSAGE.CREATE.FAILED, + type: "error", + isLoading: false, + closeOnClick: true, + autoClose: 3000, + }); + }, + }, + ); + }; + + const handleReset = () => { + reset(); + router.back(); + }; + + return ( + <form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}> + <ProgramTitle + register={register} + prefix={isDemand && FORM_INFO.DEMAND_PREFIX} + formType="create" + isDemand={isDemand} + /> + <div className="flex flex-col items-end gap-8 sm:flex-row"> + <ProgramDate setValue={setValue} getValues={getValues} /> + <CreateCategory + selectedCategory={watch("category")} + setCategory={(category: ProgramCategory) => + setValue("category", category) + } + /> + </div> + <MarkdownEditor + id={FORM_INFO.PROGRAM.CONTENT.id} + label={FORM_INFO.PROGRAM.CONTENT.label} + placeholder={FORM_INFO.PROGRAM.CONTENT.placeholder} + value={watch("content")} + onChange={(v) => setValue("content", v)} + /> + <div className="my-4 flex flex-col gap-4"> + <ProgramGithubLinkInput register={register} /> + <ProgramTeamList + selectedTeamList={watch("teamList")} + handleTeamListChange={(teamList: TeamInputInfo[]) => + setValue("teamList", teamList) + } + /> + </div> + <Participant + members={members} + setMembers={updateMembers} + clearMembers={clearMembers} + setAllMembers={setAllMembers} + /> + <FormBtn + submitText={FORM_INFO.SUBMIT_TEXT["create"]} + formReset={handleReset} + /> + </form> + ); +}; + +export default CreateForm; diff --git a/FE/src/components/common/form/program/ProgramDate.tsx b/FE/src/components/common/form/program/ProgramDate.tsx index 95bc8d1a..306fa0da 100644 --- a/FE/src/components/common/form/program/ProgramDate.tsx +++ b/FE/src/components/common/form/program/ProgramDate.tsx @@ -1,27 +1,33 @@ "use client"; -import { Dispatch, SetStateAction, useState } from "react"; +// import dynamic from "next/dynamic"; +import { useState } from "react"; +import { UseFormGetValues, UseFormSetValue } from "react-hook-form"; import Calendar from "../../calendar/Calendar"; import LabeledInput from "../LabeledInput"; +import { ProgramFormDataState } from "./CreateForm"; import FORM_INFO from "@/constants/FORM_INFO"; import useOutsideRef from "@/hooks/useOutsideRef"; -import { convertDate } from "@/utils/convert"; +import { formatTimestamp } from "@/utils/convert"; + +// const Calendar = dynamic(() => import("@/components/common/Calendar/Calendar")); interface ProgramDateProps { - programDate: string; - setProgramDate: Dispatch<SetStateAction<string>>; + getValues: UseFormGetValues<ProgramFormDataState>; + setValue: UseFormSetValue<ProgramFormDataState>; } -const ProgramDate = ({ programDate, setProgramDate }: ProgramDateProps) => { +const ProgramDate = ({ getValues, setValue }: ProgramDateProps) => { const [openCalender, setOpenCalender] = useState<boolean>(false); const calenderRef = useOutsideRef(() => setOpenCalender(false)); const [date, setDate] = useState<Date | undefined>( - new Date(parseInt(programDate)) || new Date(), + new Date(parseInt(getValues("deadLine"))) || new Date(), ); const handleDateChange = (date: Date | undefined) => { setDate(date); - setProgramDate( + setValue( + "deadLine", date?.getTime().toString() || new Date().getTime().toString(), ); }; @@ -41,10 +47,13 @@ const ProgramDate = ({ programDate, setProgramDate }: ProgramDateProps) => { type={FORM_INFO.PROGRAM.DATE.type} label={FORM_INFO.PROGRAM.DATE.label} placeholder={FORM_INFO.PROGRAM.DATE.placeholder} - value={convertDate(programDate)} + value={formatTimestamp(getValues("deadLine"))} /> {openCalender && ( - <Calendar date={date} handleDateChange={handleDateChange} /> + <Calendar + date={date} + handleDateChange={(date: Date) => handleDateChange(date)} + /> )} </div> ); diff --git a/FE/src/components/common/form/program/ProgramDemandCheckBox.tsx b/FE/src/components/common/form/program/ProgramDemandCheckBox.tsx deleted file mode 100644 index 138d86ac..00000000 --- a/FE/src/components/common/form/program/ProgramDemandCheckBox.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -interface ProgramDemandCheckBoxProps { - disabled: boolean; - isDemand: boolean; - onClick: () => void; -} - -const ProgramDemandCheckBox = ({ - disabled, - isDemand, - onClick, -}: ProgramDemandCheckBoxProps) => { - return ( - <> - {!disabled && ( - <div className="absolute right-0 top-0 flex gap-2"> - <label className="text-sm font-bold">수요조사 등록하기</label> - {/* TODO: Checkbox 컴포넌트로 변경 */} - <input - type="checkbox" - className="accent-primary" - checked={isDemand} - onChange={onClick} - /> - </div> - )} - </> - ); -}; -export default ProgramDemandCheckBox; diff --git a/FE/src/components/common/form/program/ProgramForm.tsx b/FE/src/components/common/form/program/ProgramForm.tsx deleted file mode 100644 index fd0d9d71..00000000 --- a/FE/src/components/common/form/program/ProgramForm.tsx +++ /dev/null @@ -1,95 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { PropsWithChildren } from "react"; -import MarkdownEditor from "../../markdown/MarkdownEditor"; -import Tab from "../../tabs/Tab"; -import FormBtn from "../FormBtn"; -import ProgramDate from "./ProgramDate"; -import ProgramDemandCheckBox from "./ProgramDemandCheckBox"; -import ProgramTitle from "./ProgramTitle"; -import FORM_INFO from "@/constants/FORM_INFO"; -import PROGRAM from "@/constants/PROGRAM"; -import { ProgramFormData } from "@/hooks/useProgramFormData"; -import { FormType } from "@/types/form"; -import { ProgramCategory } from "@/types/program"; - -interface ProgramFormProps extends ProgramFormData { - formType: FormType; - onSubmit: (e: React.FormEvent<HTMLFormElement>) => void; -} - -const ProgramForm = ({ - children, - formType, - title, - setTitle, - deadLine, - setDeadLine, - category, - setCategory, - type, - setType, - content, - setContent, - reset, - onSubmit, -}: PropsWithChildren<ProgramFormProps>) => { - const router = useRouter(); - const isDemand = type === "demand"; - const demandCheckBoxDisabled = formType === "edit"; - - const handleChangeType = () => { - setType(isDemand ? "notification" : "demand"); - }; - - const handleReset = () => { - reset(); - router.back(); - }; - - return ( - <form className="space-y-6" onSubmit={onSubmit}> - <ProgramTitle - title={title} - setTitle={setTitle} - prefix={isDemand && FORM_INFO.DEMAND_PREFIX} - > - <ProgramDemandCheckBox - disabled={demandCheckBoxDisabled} - isDemand={isDemand} - onClick={() => handleChangeType()} - /> - </ProgramTitle> - <div className="flex flex-col items-end gap-8 sm:flex-row"> - <ProgramDate programDate={deadLine} setProgramDate={setDeadLine} /> - <div className="flex w-full flex-col gap-2 sm:w-fit"> - <label className="text-sm">행사 카테고리</label> - <Tab<ProgramCategory> - options={Object.values(PROGRAM.CATEGORY_TAB)} - selected={category} - onItemClick={(v) => setCategory(v)} - size="lg" - baseColor="gray" - pointColor="yellow" - align="line" - /> - </div> - </div> - <MarkdownEditor - id={FORM_INFO.PROGRAM.CONTENT.id} - label={FORM_INFO.PROGRAM.CONTENT.label} - placeholder={FORM_INFO.PROGRAM.CONTENT.placeholder} - value={content} - onChange={(v) => setContent(v)} - /> - {children} - <FormBtn - submitText={FORM_INFO.SUBMIT_TEXT[formType]} - formReset={handleReset} - /> - </form> - ); -}; - -export default ProgramForm; diff --git a/FE/src/components/common/form/program/ProgramTitle.tsx b/FE/src/components/common/form/program/ProgramTitle.tsx index bf685011..1f6e6d07 100644 --- a/FE/src/components/common/form/program/ProgramTitle.tsx +++ b/FE/src/components/common/form/program/ProgramTitle.tsx @@ -1,41 +1,56 @@ -import React, { Dispatch, PropsWithChildren, SetStateAction } from "react"; -import LabeledInput from "../LabeledInput"; +import { UseFormRegister } from "react-hook-form"; +import LabeldInputFiled from "../input/LabeldInputFiled"; +import { ProgramFormDataState } from "./CreateForm"; import FORM_INFO from "@/constants/FORM_INFO"; -import { convertText } from "@/utils/convert"; +import { FormType } from "@/types/form"; interface ProgramTitleProps { - title: string; - setTitle: Dispatch<SetStateAction<string>>; + formType: FormType; prefix?: string; + isDemand: boolean; + register: UseFormRegister<ProgramFormDataState>; } const ProgramTitle = ({ - title, - setTitle, + register, prefix, - children, -}: PropsWithChildren<ProgramTitleProps>) => { - const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => { - if (title.includes(FORM_INFO.DEMAND_PREFIX)) { - const newTitle = convertText(title, FORM_INFO.DEMAND_PREFIX); - setTitle(newTitle); - return; - } - setTitle(e.target.value); + isDemand, + formType, +}: ProgramTitleProps) => { + const demandCheckBoxDisabled = formType === "edit"; + + // 현재 정책상 수요조사는 수정이 불가능하므로 체크박스 클릭시 경고창을 띄워준다. + const handleNonAllowDemand = () => { + alert("현재는 사용할 수 없는 기능입니다."); }; + return ( <div className="relative"> - {children} - <LabeledInput + {!demandCheckBoxDisabled && ( + <div className="absolute right-0 top-0 flex gap-2"> + <label className="select-none text-sm font-bold" htmlFor="demand"> + 수요조사 등록하기 + </label> + <input + // {...register("isDemand")} // 현재 정책상 수요조사는 수정이 불가능하므로 체크박스 클릭시 경고창을 띄워준다. + type="checkbox" + className="accent-primary" + checked={isDemand} + onChange={handleNonAllowDemand} + id="demand" + /> + </div> + )} + <LabeldInputFiled<ProgramFormDataState> id={FORM_INFO.PROGRAM.TITLE.id} type={FORM_INFO.PROGRAM.TITLE.type} label={FORM_INFO.PROGRAM.TITLE.label} placeholder={FORM_INFO.PROGRAM.TITLE.placeholder} - value={convertText(title, FORM_INFO.DEMAND_PREFIX)} - onChange={handleTitleChange} prefix={prefix} + register={register} /> </div> ); }; + export default ProgramTitle; diff --git a/FE/src/components/common/header/Header.tsx b/FE/src/components/common/header/Header.tsx index 0ec97a95..2caae859 100644 --- a/FE/src/components/common/header/Header.tsx +++ b/FE/src/components/common/header/Header.tsx @@ -1,36 +1,15 @@ -"use client"; -import { useEffect, useState } from "react"; -import CreateBtn from "./CreateBtn"; -import LoginRedirectBtn from "./LoginRedirectBtn"; +import HeaderNavSection from "./HeaderNavSection"; import Logo from "./Logo"; -import UserBtn from "./UserBtn"; -import { CheckIsLoggedIn } from "@/utils/authWithStorage"; -const Header = () => { - const [isLoggedIn, setIsLoggedIn] = useState(false); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - const isLoggedIn = CheckIsLoggedIn(); - setIsLoggedIn(isLoggedIn); - setIsLoading(false); - }, []); +interface HeaderProps { + isAdmin?: boolean; +} +const Header = ({ isAdmin = false }: HeaderProps) => { return ( <header className="sticky top-0 z-50 flex w-full items-center justify-between rounded-b-xl bg-background px-2 py-4 shadow-sm sm:px-32"> - <Logo isLoggedIn={isLoggedIn} /> - {!isLoading && ( - <section className="flex w-fit items-center gap-4 sm:gap-8"> - {isLoggedIn ? ( - <> - <UserBtn /> - <CreateBtn /> - </> - ) : ( - <LoginRedirectBtn /> - )} - </section> - )} + <Logo isAdmin={isAdmin} /> + <HeaderNavSection isAdmin={isAdmin} /> </header> ); }; diff --git a/FE/src/components/common/header/HeaderNavSection.tsx b/FE/src/components/common/header/HeaderNavSection.tsx new file mode 100644 index 00000000..4980549f --- /dev/null +++ b/FE/src/components/common/header/HeaderNavSection.tsx @@ -0,0 +1,31 @@ +"use client"; + +import CreateBtn from "./CreateBtn"; +import LoginRedirectBtn from "./LoginRedirectBtn"; +import ManageRedirectButton from "./ManageRedirectButton"; +import UserBtn from "./UserBtn"; +import useAuth from "@/hooks/useAuth"; + +interface HeaderNavSectionProps { + isAdmin: boolean; +} + +const HeaderNavSection = ({ isAdmin }: HeaderNavSectionProps) => { + const { isLoading, isLoggedIn } = useAuth(); + + if (isLoading) return null; + if (isAdmin) + return ( + <section className="flex w-fit items-center gap-4 sm:gap-8 "> + <ManageRedirectButton /> + <CreateBtn /> + </section> + ); + return ( + <section className="flex w-fit items-center gap-4 sm:gap-8"> + {isLoggedIn ? <UserBtn /> : <LoginRedirectBtn />} + </section> + ); +}; + +export default HeaderNavSection; diff --git a/FE/src/components/common/header/Logo.tsx b/FE/src/components/common/header/Logo.tsx index f5b26fa2..46d3396b 100644 --- a/FE/src/components/common/header/Logo.tsx +++ b/FE/src/components/common/header/Logo.tsx @@ -1,39 +1,44 @@ +"use client"; + import Image from "next/image"; -import { usePathname, useRouter } from "next/navigation"; +import { useRouter } from "next/navigation"; import ROUTES from "@/constants/ROUTES"; - -const INIT_CATEGORY = "all"; -const INIT_STATUS = "active"; -const INIT_PAGE = "1"; +import useAuth from "@/hooks/useAuth"; interface LogoProps { - isLoggedIn: boolean; + isAdmin?: boolean; } -const Logo = ({ isLoggedIn }: LogoProps) => { +const Logo = ({ isAdmin }: LogoProps) => { + const { isLoggedIn } = useAuth(); const router = useRouter(); - const pathname = usePathname(); - const mainUrl = isLoggedIn ? ROUTES.MAIN : ROUTES.GUEST_MAIN; + const alt = isAdmin ? "eeosAdminLogo" : "eeosLogo"; + const src = isAdmin ? "/icons/eeosAdminLogo.svg" : "/eeos_logo.svg"; + const widdth = isAdmin ? 180 : 80; + const height = isAdmin ? 36 : 36; + const priority = true; + const ImageProps = { + src, + alt, + width: widdth, + height, + priority, + }; const handleClick = () => { - if (pathname === mainUrl) { - window.location.href = `${mainUrl}?category=${INIT_CATEGORY}&status=${INIT_STATUS}&page=${INIT_PAGE}`; - return; - } - router.push(mainUrl); + const redirectUrl = isAdmin + ? ROUTES.ADMIN_MAIN + : isLoggedIn + ? ROUTES.MAIN + : ROUTES.GUEST_MAIN; + + router.push(redirectUrl); }; return ( <button type="button" onClick={handleClick}> - <Image - src="/eeos_logo.svg" - alt="logo" - width={80} - height={36} - className="h-[36px] w-[80px]" - priority - /> + <Image {...ImageProps} /> </button> ); }; diff --git a/FE/src/components/common/header/ManageRedirectButton.tsx b/FE/src/components/common/header/ManageRedirectButton.tsx new file mode 100644 index 00000000..140a5be8 --- /dev/null +++ b/FE/src/components/common/header/ManageRedirectButton.tsx @@ -0,0 +1,22 @@ +import Image from "next/image"; +import Link from "../Link"; +import ROUTES from "@/constants/ROUTES"; + +const BUTTONTEXT = "회원 관리"; + +const ManageRedirectButton = () => { + return ( + <Link href={ROUTES.MANAGE} color="primary" size="md"> + <Image + src="/icons/user.svg" + alt="행사 추가" + width={20} + height={20} + className="hidden sm:block sm:h-[20px] sm:w-[20px]" + /> + {BUTTONTEXT} + </Link> + ); +}; + +export default ManageRedirectButton; diff --git a/FE/src/components/common/header/Modal/ActiveStatusTab.tsx b/FE/src/components/common/header/Modal/ActiveStatusTab.tsx index c1242d88..ac234873 100644 --- a/FE/src/components/common/header/Modal/ActiveStatusTab.tsx +++ b/FE/src/components/common/header/Modal/ActiveStatusTab.tsx @@ -1,5 +1,5 @@ import { useQueryClient } from "@tanstack/react-query"; -import Tab from "../../tabs/Tab"; +import Tab from "../../tabs/tab/Tab"; import ACTIVE_STATUS from "@/constants/ACTIVE_STATUS"; import API from "@/constants/API"; import MESSAGE from "@/constants/MESSAGE"; diff --git a/FE/src/components/common/header/Modal/UserActiveModal.tsx b/FE/src/components/common/header/Modal/UserActiveModal.tsx index aec601f4..1ed01666 100644 --- a/FE/src/components/common/header/Modal/UserActiveModal.tsx +++ b/FE/src/components/common/header/Modal/UserActiveModal.tsx @@ -1,8 +1,8 @@ import { useRouter } from "next/navigation"; import { Suspense } from "react"; import { ErrorBoundary } from "react-error-boundary"; -import Button from "../../Button"; -import ErrorFallback from "../../ErrorFallback"; +import Button from "../../Button/Button"; +import ErrorFallback from "../../error/ErrorFallback"; import UserActiveModalSkeleton from "./UserActiveModal.loader"; import UserInfoSection from "./UserInfoSection"; import ROUTES from "@/constants/ROUTES"; @@ -20,7 +20,7 @@ const UserActiveModal = () => { }; return ( - <section className="absolute -left-44 top-10 flex w-80 min-w-fit flex-col items-center gap-6 rounded-2xl bg-background px-12 py-6 drop-shadow-lg"> + <section className="absolute -left-44 top-10 flex w-80 min-w-fit flex-col items-center gap-6 rounded-2xl bg-background px-10 py-6 drop-shadow-lg"> <ErrorBoundary FallbackComponent={ErrorFallback}> <Suspense fallback={<UserActiveModalSkeleton />}> <UserInfoSection /> diff --git a/FE/src/components/common/header/Modal/UserInfoSection.tsx b/FE/src/components/common/header/Modal/UserInfoSection.tsx index 556f6fb9..4c90c2a7 100644 --- a/FE/src/components/common/header/Modal/UserInfoSection.tsx +++ b/FE/src/components/common/header/Modal/UserInfoSection.tsx @@ -1,7 +1,6 @@ -import ActiveStatusTab from "./ActiveStatusTab"; import { useGetMyActiveStatus } from "@/hooks/query/useUserQuery"; -const MESSAGE = "본인의 회원 상태를 선택해주세요."; +const MESSAGE = "활동 상태 변경은 관리자에게 요청해주세요!"; const UserInfoSection = () => { const { data: myActiveData } = useGetMyActiveStatus(); @@ -11,7 +10,13 @@ const UserInfoSection = () => { <> <p className="text-lg font-bold">{name}</p> <p className="text-sm">{MESSAGE}</p> - <ActiveStatusTab activeStatus={activeStatus} /> + {/* <ActiveStatusTab activeStatus={activeStatus} /> */} + {/* TODO: tabitem을 타 컴포넌트로 변경할 필요 있음 .*/} + {/* TODO: 해당 컴포넌트를 공통 컴포넌트로 뺼 필요성 있음. */} + + <div className="flex h-fit min-h-[4rem] w-fit min-w-[8rem] cursor-pointer items-center justify-center rounded-md border-2 border-tertiary-20 bg-secondary-20 px-4 py-2 text-lg font-semibold text-tertiary-20"> + <p>{activeStatus}</p> + </div> </> ); }; diff --git a/FE/src/components/common/header/UserBtn.tsx b/FE/src/components/common/header/UserBtn.tsx index 921fc870..5c54334e 100644 --- a/FE/src/components/common/header/UserBtn.tsx +++ b/FE/src/components/common/header/UserBtn.tsx @@ -12,11 +12,10 @@ const UserBtn = () => { }; return ( - <div + <button ref={modalRef} className="relative cursor-pointer" onClick={handleClick} - role="button" > <Image src="/icons/user.svg" @@ -26,7 +25,7 @@ const UserBtn = () => { className="h-[28px] w-[28px]" /> {isOpen && <UserActiveModal />} - </div> + </button> ); }; diff --git a/FE/src/components/common/markdown/MarkdownEditor.tsx b/FE/src/components/common/markdown/MarkdownEditor.tsx index 959568ab..24efb103 100644 --- a/FE/src/components/common/markdown/MarkdownEditor.tsx +++ b/FE/src/components/common/markdown/MarkdownEditor.tsx @@ -2,7 +2,8 @@ import "./markdown-editor.styles.css"; import { useState } from "react"; -import Tab from "../tabs/Tab"; +import Label from "../form/input/Label"; +import Tab from "../tabs/tab/Tab"; import MarkdownViewer from "./MarkdownViewer"; import { TabOption } from "@/types/tab"; import { handleKeydown } from "@/utils/handleKeydown"; @@ -41,9 +42,7 @@ const MarkdownEditor = ({ return ( <div className="flex flex-col gap-2"> - <label htmlFor={id} className="text-sm"> - {label} - </label> + <Label id={id} label={label} /> <div className="flex h-fit w-full flex-col gap-2 rounded-md border-2 border-gray-300 bg-gray-10 p-2 transition-transform"> <Tab<MarkdownViewType> options={Object.values(EDITOR_TAB)} diff --git a/FE/src/components/common/markdown/MarkdownViewer.tsx b/FE/src/components/common/markdown/MarkdownViewer.tsx index 34455ddc..c9e91fbb 100644 --- a/FE/src/components/common/markdown/MarkdownViewer.tsx +++ b/FE/src/components/common/markdown/MarkdownViewer.tsx @@ -18,6 +18,7 @@ const MarkdownViewer = ({ value: string; className?: string; height?: "full" | "fix"; + bgColor?: string; }) => { const markdownClass = classNames( "markdown-body overflox-y-scroll w-full overflow-y-auto bg-background p-4 scrollbar-hide", diff --git a/FE/src/components/common/memberTable/MemberTable.tsx b/FE/src/components/common/memberTable/MemberTable.tsx index 2d22915c..94ae8ff8 100644 --- a/FE/src/components/common/memberTable/MemberTable.tsx +++ b/FE/src/components/common/memberTable/MemberTable.tsx @@ -1,16 +1,9 @@ "use client"; - -import { useState } from "react"; -import { ErrorBoundary } from "react-error-boundary"; -import ErrorFallback from "../ErrorFallback"; -import Tab from "../tabs/Tab"; -import MemberTableHeader from "./MemberTableHeader"; -import CreateMemberTableItemContainer from "@/components/common/memberTable/create/CreateMemberTableItemContainer"; -import EditMemberTableItemContainer from "@/components/programEdit/EditMemberTableItemContainer"; -import { Members } from "@/components/programEdit/ProgramEditForm"; -import ACTIVE_STATUS from "@/constants/ACTIVE_STATUS"; +import MemberTableWrapper from "./MemberTableWrapper"; +import { Members } from "@/hooks/useMemberForm"; +//FIXME: 하위 호환성을 위해 만들어진 컴포넌트로, 타 컴포넌트와의 의존성을 줄이는 방식으로 리팩토링이 필요 import { FormType } from "@/types/form"; -import { ActiveStatusWithAll, AttendStatus } from "@/types/member"; +import { AttendStatus } from "@/types/member"; interface MemberTableProps { formType: FormType; @@ -31,49 +24,29 @@ const MemberTable = ({ programId, isEditable = true, }: MemberTableProps) => { - const [selectedActive, setSelectedActive] = - useState<ActiveStatusWithAll>("all"); - return ( - <div className="space-y-6 pt-10"> - <Tab<ActiveStatusWithAll> - options={Object.values(ACTIVE_STATUS.TAB_WITH_ALL)} - selected={selectedActive} - onItemClick={(v) => setSelectedActive(v)} - size="lg" - baseColor="gray" - pointColor="teal" - align="line" - /> + <MemberTableWrapper applyLayout> + <MemberTableWrapper.StatusTab /> <div className="overflow-x-scroll scrollbar-hide"> - <MemberTableHeader + <MemberTableWrapper.Header formType={formType} onClickCheckBox={onClickHeaderCheckBox} /> - <ErrorBoundary FallbackComponent={ErrorFallback}> - {formType === "create" ? ( - <CreateMemberTableItemContainer - members={members as Set<number>} - setMembers={setMembers as (memberId: number) => void} - status={selectedActive} - /> - ) : ( - <EditMemberTableItemContainer - setMembers={ - setMembers as ( - memberId: number, - before: AttendStatus, - after: AttendStatus, - ) => void - } - status={selectedActive} - programId={programId || 0} - isEditable={isEditable} - /> - )} - </ErrorBoundary> + {formType === "create" && ( + <MemberTableWrapper.CreateList + members={members} + setMembers={setMembers as (memberId: number) => void} + /> + )} + {formType === "edit" && ( + <MemberTableWrapper.EditList + programId={programId} + setMembers={setMembers} + isEditable={isEditable} + /> + )} </div> - </div> + </MemberTableWrapper> ); }; diff --git a/FE/src/components/common/memberTable/MemberTableHeader.tsx b/FE/src/components/common/memberTable/MemberTableHeader.tsx deleted file mode 100644 index 98a338d5..00000000 --- a/FE/src/components/common/memberTable/MemberTableHeader.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useAtom } from "jotai"; -import CheckBox from "../CheckBox"; -import { memberTableCheckedAtom } from "@/store/memberTableCheckedAtom"; -import { FormType } from "@/types/form"; - -interface MemberTableHeaderProps { - formType: FormType; - onClickCheckBox: (selected: boolean) => void; -} - -const HEADER_TEXT = { - create: ["활동 상태", "이름"], - edit: ["활동 상태", "이름", "", "출석 상태"], -}; - -const MemberTableHeader = ({ - formType, - onClickCheckBox, -}: MemberTableHeaderProps) => { - const [checked, setChecked] = useAtom(memberTableCheckedAtom); - - const handleClickCheckBox = () => { - onClickCheckBox(!checked); - setChecked((prev) => !prev); - }; - - return ( - <div className="grid w-fit grid-cols-[4.75rem_7rem_7.25rem_1fr_20.5rem] justify-items-center gap-4 border-y-2 border-stroke-10 bg-gray-10 px-10 py-4 font-bold sm:w-full"> - {formType === "create" ? ( - <CheckBox checked={checked} onClick={handleClickCheckBox} /> - ) : ( - <span></span> - )} - {HEADER_TEXT[formType].map((text: string, index: number) => ( - <span key={index}>{text}</span> - ))} - </div> - ); -}; -export default MemberTableHeader; diff --git a/FE/src/components/common/memberTable/MemberTableWrapper.tsx b/FE/src/components/common/memberTable/MemberTableWrapper.tsx new file mode 100644 index 00000000..79ca2b48 --- /dev/null +++ b/FE/src/components/common/memberTable/MemberTableWrapper.tsx @@ -0,0 +1,74 @@ +"use client"; + +//TODO: TableWrapper로 변경하기 + +/** + * 해당 컴포넌트는 회원 테이블을 관리하는 컴포넌트입니다. + * 현재 호환성을 위하여 두고 있으며, 추후 TableWrapper로 변경할 예정입니다. + */ + +import { useSetAtom } from "jotai"; +import { createContext, useState } from "react"; +import Header from "./compounds/Header"; +import ListInCreateType from "./compounds/ListInCreateType"; +import ListInEditType from "./compounds/ListInEditTypeProps"; +import ListInManageType from "./compounds/ListInManageType"; +import StatusTab from "./compounds/StatusTab"; +import { memberTableCheckedAtom } from "@/store/memberTableCheckedAtom"; +import { ActiveStatusWithAll } from "@/types/member"; + +interface MemberContextType { + tab: { + selectedActive: ActiveStatusWithAll; + setSelectedActive: React.Dispatch< + React.SetStateAction<ActiveStatusWithAll> + >; + }; + createData: { + setChecked: (checked: boolean) => void; + }; +} + +export const MemberContext = createContext<MemberContextType>(null); + +interface MemberTableWrapperProps { + applyLayout?: boolean; + children: React.ReactNode; +} + +/** + * 레이아웃 : "space-y-6 pt-10" + */ +const MemberTableWrapper = ({ + applyLayout = false, + children, +}: MemberTableWrapperProps) => { + //tab 상태 + const [selectedActive, setSelectedActive] = + useState<ActiveStatusWithAll>("all"); + + const tab = { + selectedActive, + setSelectedActive, + }; + + //create + const setChecked = useSetAtom(memberTableCheckedAtom); + + const createData = { + setChecked, + }; + + return ( + <MemberContext.Provider value={{ tab, createData }}> + <div className={applyLayout ? "space-y-6 pt-10" : ""}>{children}</div> + </MemberContext.Provider> + ); +}; + +MemberTableWrapper.StatusTab = StatusTab; +MemberTableWrapper.Header = Header; +MemberTableWrapper.CreateList = ListInCreateType; +MemberTableWrapper.EditList = ListInEditType; +MemberTableWrapper.ManageList = ListInManageType; +export default MemberTableWrapper; diff --git a/FE/src/components/common/memberTable/compounds/Header.tsx b/FE/src/components/common/memberTable/compounds/Header.tsx new file mode 100644 index 00000000..e1f7a483 --- /dev/null +++ b/FE/src/components/common/memberTable/compounds/Header.tsx @@ -0,0 +1,50 @@ +import classNames from "classnames"; +import { useAtom } from "jotai"; +import CheckBox from "@/components/common/CheckBox/CheckBox"; +import { memberTableCheckedAtom } from "@/store/memberTableCheckedAtom"; +import { FormType } from "@/types/form"; + +interface HeaderProps { + formType: FormType; + onClickCheckBox?: (selected: boolean) => void; +} +/** + * 멤버 테이블의 헤더 컴포넌트 + * fromType에 따라 다른 ui를 출력함. (create, edit) + * create: ["활동 상태", "이름"] + * edit: ["활동 상태", "이름", "", "출석 상태"] + */ +const Header = ({ formType, onClickCheckBox = () => {} }: HeaderProps) => { + const HEADER_TEXT = { + create: ["활동 상태", "이름"], + edit: ["대상", "활동 상태", "이름", "", "출석 상태"], + manage: ["활동 상태", "이름", "", ""], + }; + const HeaderStyle = classNames( + "grid w-fit justify-items-center gap-4 border-y-2 border-stroke-10 bg-gray-10 px-10 py-4 font-bold sm:w-full", + { + "grid-cols-[7rem_7.25rem_1fr_10rem]": formType === "manage", + "grid-cols-[4.75rem_7rem_7.25rem_1fr_20.5rem]": + formType === "edit" || formType === "create", + }, + ); + + const [checked, setChecked] = useAtom(memberTableCheckedAtom); + + const handleClickCheckBox = () => { + onClickCheckBox(!checked); + setChecked((prev) => !prev); + }; + + return ( + <div className={HeaderStyle}> + {formType === "create" && ( + <CheckBox checked={checked} onClick={handleClickCheckBox} /> + )} + {HEADER_TEXT[formType].map((text: string, index: number) => ( + <span key={`${index}-${text}`}>{text}</span> + ))} + </div> + ); +}; +export default Header; diff --git a/FE/src/components/common/memberTable/compounds/ListInCreateType.tsx b/FE/src/components/common/memberTable/compounds/ListInCreateType.tsx new file mode 100644 index 00000000..925593f9 --- /dev/null +++ b/FE/src/components/common/memberTable/compounds/ListInCreateType.tsx @@ -0,0 +1,58 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useContext } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import ErrorFallback from "../../error/ErrorFallback"; +import MemberTableLoader from "../MemberTable.loader"; +import { MemberContext } from "../MemberTableWrapper"; +import CheckBox from "@/components/common/CheckBox/CheckBox"; +import ACTIVE_STATUS from "@/constants/ACTIVE_STATUS"; +import { useGetMemberByActive } from "@/hooks/query/useMemberQuery"; +import { Members } from "@/hooks/useMemberForm"; + +interface ListInCreateTypeProps { + members: Set<number> | Map<number, Members>; + setMembers: (memberId: number) => void; +} +const ListInCreateType = ({ members, setMembers }: ListInCreateTypeProps) => { + const queryClient = useQueryClient(); + const { + tab: { selectedActive }, + createData: { setChecked }, + } = useContext(MemberContext); + + const { data: memberList, isLoading } = useGetMemberByActive(selectedActive); + if (isLoading) return <MemberTableLoader />; + + queryClient.setQueryData( + ["memberIdList"], + memberList.map((v) => v.memberId), + ); + + const isCheckedAll = memberList + .map((v) => v.memberId) + .every((v) => members.has(v)); + setChecked(isCheckedAll); + + const handleCheck = (memberId) => { + setMembers(memberId); + }; + + return ( + <ErrorBoundary FallbackComponent={ErrorFallback}> + {memberList.map(({ activeStatus, memberId, name }) => ( + <div + className="grid h-20 w-fit grid-cols-[4.75rem_7rem_7.25rem_1fr_20.5rem] items-center justify-items-center gap-4 border-b-2 border-stroke-10 bg-background px-10 sm:w-full" + key={memberId} + > + <CheckBox + checked={members.has(memberId)} + onClick={() => handleCheck(memberId)} + /> + <span>{ACTIVE_STATUS.TAB[activeStatus]?.text ?? "."}</span> + <span className="font-bold">{name}</span> + </div> + ))} + </ErrorBoundary> + ); +}; +export default ListInCreateType; diff --git a/FE/src/components/common/memberTable/compounds/ListInEditTypeProps.tsx b/FE/src/components/common/memberTable/compounds/ListInEditTypeProps.tsx new file mode 100644 index 00000000..f966bc95 --- /dev/null +++ b/FE/src/components/common/memberTable/compounds/ListInEditTypeProps.tsx @@ -0,0 +1,131 @@ +import classNames from "classnames"; +import { useContext, useState } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { toast } from "react-toastify"; +import CheckBox from "../../CheckBox/CheckBox"; +import ErrorFallback from "../../error/ErrorFallback"; +import AttendStatusToggle from "../../toggle/AttendStatusToggle"; +import MemberTableLoader from "../MemberTable.loader"; +import { MemberContext } from "../MemberTableWrapper"; +import ACTIVE_STATUS from "@/constants/ACTIVE_STATUS"; +import MESSAGE from "@/constants/MESSAGE"; +import { useGetProgramMembersByActive } from "@/hooks/query/useMemberQuery"; +import { ActiveStatus, AttendStatus } from "@/types/member"; + +interface ListInEditTypeProps { + programId: number; + setMembers: (memberId: number, before: string, after: string) => void; + isEditable?: boolean; +} +const ListInEditType = ({ + programId, + setMembers, + isEditable, +}: ListInEditTypeProps) => { + const { + tab: { selectedActive }, + } = useContext(MemberContext); + + const { data: editMemberList, isLoading: isEditListLoading } = + useGetProgramMembersByActive({ + programId, + status: selectedActive, + }); + + if (isEditListLoading) return <MemberTableLoader />; + return ( + <ErrorBoundary FallbackComponent={ErrorFallback}> + {editMemberList.map(({ memberId, activeStatus, attendStatus, name }) => ( + <EditMemberTableItem + key={memberId} + memberId={memberId} + name={name} + activeStatus={activeStatus} + initAttendStatus={attendStatus} + setMembers={setMembers} + isEditable={isEditable} + /> + ))} + </ErrorBoundary> + ); +}; + +interface EditMemberTableItemProps { + memberId: number; + name: string; + activeStatus: ActiveStatus; + initAttendStatus: AttendStatus; + setMembers: ( + memberId: number, + before: AttendStatus, + after: AttendStatus, + ) => void; + isEditable?: boolean; +} + +const EditMemberTableItem = ({ + memberId, + name, + activeStatus, + initAttendStatus, + setMembers, + isEditable = true, +}: EditMemberTableItemProps) => { + const [selectedAttend, setSelectedAttend] = + useState<AttendStatus>(initAttendStatus); + const isRelated = selectedAttend !== "nonRelated"; + + const itemStyle = classNames( + "grid h-20 w-fit grid-cols-[4.75rem_7rem_7.25rem_1fr_20.5rem] items-center justify-items-center gap-4 border-b-2 border-stroke-10 bg-background px-10 sm:w-full", + { + "opacity-50": !isEditable, + }, + ); + + const getAfterAttendStatus = ( + initAttend: AttendStatus, + selectedAttend: AttendStatus, + ) => { + if (selectedAttend !== "nonRelated") return "nonRelated"; + if (initAttend === "nonRelated") return "nonResponse"; + return initAttend; + }; + + const handleCheckBoxChange = () => { + if (!isEditable) { + toast.error(MESSAGE.EDIT_DISABLED.PROGRAM_ACTIVE); + return; + } + const afterAttendStatus = getAfterAttendStatus( + initAttendStatus, + selectedAttend, + ); + setSelectedAttend(afterAttendStatus); + setMembers(memberId, initAttendStatus, afterAttendStatus); + }; + + const handleAttendStatusChange = (value: AttendStatus) => { + if (!isEditable) { + toast.error(MESSAGE.EDIT_DISABLED.PROGRAM_ACTIVE); + return; + } + setSelectedAttend(value); + setMembers(memberId, initAttendStatus, value); + }; + + return ( + <div className={itemStyle}> + <CheckBox checked={isRelated} onClick={handleCheckBoxChange} /> + <span>{ACTIVE_STATUS.TAB[activeStatus]?.text ?? "."}</span> + <span className="font-bold">{name}</span> + <span></span> + <AttendStatusToggle + disabled={!isRelated} + selectedValue={selectedAttend} + onSelect={handleAttendStatusChange} + /> + </div> + ); +}; + +export default ListInEditType; diff --git a/FE/src/components/common/memberTable/compounds/ListInManageType.tsx b/FE/src/components/common/memberTable/compounds/ListInManageType.tsx new file mode 100644 index 00000000..ca32c6ab --- /dev/null +++ b/FE/src/components/common/memberTable/compounds/ListInManageType.tsx @@ -0,0 +1,64 @@ +"use client"; +import { useQueryClient } from "@tanstack/react-query"; +import Image from "next/image"; +import { useContext } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import ErrorFallback from "../../error/ErrorFallback"; +import ActiveStatusToggle from "../../toggle/ActiveStatusToggle"; +import MemberTableLoader from "../MemberTable.loader"; +import { MemberContext } from "../MemberTableWrapper"; +import ACTIVE_STATUS from "@/constants/ACTIVE_STATUS"; +import { + useDeleteMember, + useGetMemberByActive, +} from "@/hooks/query/useMemberQuery"; + +const ListInManageType = () => { + const queryClient = useQueryClient(); + const { + tab: { selectedActive }, + } = useContext(MemberContext); + + const { data: memberList, isLoading } = useGetMemberByActive(selectedActive); + const { mutate: deleteMember } = useDeleteMember(); + if (isLoading) return <MemberTableLoader />; + + const handleDeleteMember = (memberId: number) => { + const ok = confirm("정말로 삭제하시겠습니까?"); + ok && deleteMember({ memberId }); + }; + + queryClient.setQueryData( + ["memberIdList"], + memberList.map((v) => v.memberId), + ); + return ( + <ErrorBoundary FallbackComponent={ErrorFallback}> + {memberList.map(({ activeStatus, memberId, name }) => ( + <div + className="grid h-20 w-fit grid-cols-[7rem_7.25rem_1fr_10rem] items-center justify-items-center gap-4 border-b-2 border-stroke-10 bg-background px-10 sm:w-full" + key={memberId} + > + <span>{ACTIVE_STATUS.TAB[activeStatus]?.text ?? "."}</span> + <span className="font-bold">{name}</span> + <div className="flex w-full items-center justify-end"> + <ActiveStatusToggle + memberId={memberId} + selectedValue={activeStatus} + /> + </div> + <button onClick={() => handleDeleteMember(memberId)}> + <Image + src="/icons/trash.svg" + width={22} + height={22} + alt="Delete Btn" + /> + </button> + </div> + ))} + </ErrorBoundary> + ); +}; + +export default ListInManageType; diff --git a/FE/src/components/common/memberTable/compounds/StatusTab.tsx b/FE/src/components/common/memberTable/compounds/StatusTab.tsx new file mode 100644 index 00000000..2b90976c --- /dev/null +++ b/FE/src/components/common/memberTable/compounds/StatusTab.tsx @@ -0,0 +1,27 @@ +import { useContext } from "react"; +import { MemberContext } from "../MemberTableWrapper"; +import Tab from "@/components/common/tabs/tab/Tab"; +import ACTIVE_STATUS from "@/constants/ACTIVE_STATUS"; +import { ActiveStatusWithAll } from "@/types/member"; + +/** + * 멤버 테이블의 상태 탭 컴포넌트 + */ +const StatusTab = () => { + const { + tab: { selectedActive, setSelectedActive }, + } = useContext(MemberContext); + + return ( + <Tab<ActiveStatusWithAll> + options={Object.values(ACTIVE_STATUS.TAB_WITH_ALL)} + selected={selectedActive} + onItemClick={(v) => setSelectedActive(v)} + size="lg" + baseColor="gray" + pointColor="teal" + align="line" + /> + ); +}; +export default StatusTab; diff --git a/FE/src/components/common/memberTable/create/CreateMemberTableItem.tsx b/FE/src/components/common/memberTable/create/CreateMemberTableItem.tsx deleted file mode 100644 index a1478d6a..00000000 --- a/FE/src/components/common/memberTable/create/CreateMemberTableItem.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import CheckBox from "../../CheckBox"; -import ACTIVE_STATUS from "@/constants/ACTIVE_STATUS"; -import { ActiveStatus } from "@/types/member"; - -interface CreateMemberTableItemProps { - memberId: number; - name: string; - activeStatus: ActiveStatus; - members: Set<number>; - setMembers: (memberId: number) => void; -} - -const CreateMemberTableItem = ({ - memberId, - name, - activeStatus, - members, - setMembers, -}: CreateMemberTableItemProps) => { - const isRelated = members.has(memberId); - - const handleCheck = () => { - setMembers(memberId); - }; - - return ( - <div className="grid h-20 w-fit grid-cols-[4.75rem_7rem_7.25rem_1fr_20.5rem] items-center justify-items-center gap-4 border-b-2 border-stroke-10 bg-background px-10 sm:w-full"> - <CheckBox checked={isRelated} onClick={handleCheck} /> - <span>{ACTIVE_STATUS.TAB[activeStatus]?.text ?? "."}</span> - <span className="font-bold">{name}</span> - </div> - ); -}; -export default CreateMemberTableItem; diff --git a/FE/src/components/common/memberTable/create/CreateMemberTableItemContainer.tsx b/FE/src/components/common/memberTable/create/CreateMemberTableItemContainer.tsx deleted file mode 100644 index ea2d1c29..00000000 --- a/FE/src/components/common/memberTable/create/CreateMemberTableItemContainer.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { useSetAtom } from "jotai"; -import MemberTableLoader from "../MemberTable.loader"; -import CreateMemberTableItem from "./CreateMemberTableItem"; -import { useGetMemberByActive } from "@/hooks/query/useMemberQuery"; -import { memberTableCheckedAtom } from "@/store/memberTableCheckedAtom"; -import { ActiveStatusWithAll } from "@/types/member"; - -interface CreateMemberTableItemContainerProps { - members: Set<number>; - setMembers: (memberId: number) => void; - status: ActiveStatusWithAll; -} - -const CreateMemberTableItemContainer = ({ - members, - setMembers, - status, -}: CreateMemberTableItemContainerProps) => { - const queryClient = useQueryClient(); - const setChecked = useSetAtom(memberTableCheckedAtom); - const { data: memberList, isLoading } = useGetMemberByActive(status); - - if (isLoading) return <MemberTableLoader />; - - queryClient.setQueryData( - ["memberIdList"], - memberList.map((v) => v.memberId), - ); - - const isCheckedAll = memberList - .map((v) => v.memberId) - .every((v) => members.has(v)); - setChecked(isCheckedAll); - - return ( - <> - {memberList.map((member) => ( - <CreateMemberTableItem - key={member.memberId} - memberId={member.memberId} - name={member.name} - activeStatus={member.activeStatus} - members={members} - setMembers={setMembers} - /> - ))} - </> - ); -}; - -export default CreateMemberTableItemContainer; diff --git a/FE/src/components/common/tabs/MemberActiveStatusTab.tsx b/FE/src/components/common/tabs/MemberActiveStatusTab.tsx new file mode 100644 index 00000000..f840449a --- /dev/null +++ b/FE/src/components/common/tabs/MemberActiveStatusTab.tsx @@ -0,0 +1,37 @@ +import Tab from "./tab/TabCompound/TabCompound"; +import { ActiveStatusWithAll } from "@/types/member"; + +const memberTabItemList: ActiveStatusWithAll[] = [ + "all", + "am", + "rm", + "cm", + "ob", +]; + +interface MemberActiveStatusTabProps { + children: (selectedItem: ActiveStatusWithAll) => JSX.Element; +} +const MemberActiveStatusTab = ({ children }: MemberActiveStatusTabProps) => { + return ( + <Tab<ActiveStatusWithAll> + align="line" + defaultSelected={memberTabItemList[0]} + nonPickedColor="gray" + pickedColor="teal" + tabItemList={memberTabItemList} + tabSize="lg" + > + <Tab.List> + {memberTabItemList.map((tabItem) => ( + <Tab.Item key={tabItem} text={tabItem} /> + ))} + </Tab.List> + <Tab.Content<ActiveStatusWithAll>> + {({ selectedItem }) => children(selectedItem)} + </Tab.Content> + </Tab> + ); +}; + +export default MemberActiveStatusTab; diff --git a/FE/src/components/common/tabs/Tab.tsx b/FE/src/components/common/tabs/tab/Tab.tsx similarity index 97% rename from FE/src/components/common/tabs/Tab.tsx rename to FE/src/components/common/tabs/tab/Tab.tsx index 7951c66a..0b55eecc 100644 --- a/FE/src/components/common/tabs/Tab.tsx +++ b/FE/src/components/common/tabs/tab/Tab.tsx @@ -48,7 +48,6 @@ const Tab = <T,>({ rounded={rounded} /> ))} - <div className="s"></div> </div> ); }; diff --git a/FE/src/components/common/tabs/tab/TabAsChild/TabAsChild.tsx b/FE/src/components/common/tabs/tab/TabAsChild/TabAsChild.tsx new file mode 100644 index 00000000..29ac9f34 --- /dev/null +++ b/FE/src/components/common/tabs/tab/TabAsChild/TabAsChild.tsx @@ -0,0 +1,85 @@ +//TODO: 스크립트 작성 후 삭제할 파일 +"use client"; +import classNames from "classnames"; +import { useState, ReactNode } from "react"; +import TabItem from "../TabItem"; + +const tabAlign = { + line: "flex gap-4", + square: "grid grid-cols-2 gap-4", +} as const; + +export const tabColors = { + gray: "bg-gray-10 text-gray-30 border-gray-20", + yellow: "bg-warning-10 text-warning-30 border-warning-30", + teal: "bg-secondary-20 text-tertiary-20 border-tertiary-20", + white: "bg-background text-gray-30 border-background", + navy: "bg-paragraph text-background border-paragraph", +}; + +export const tabSizes = { + sm: "min-w-[4.25rem] px-2 py-[0.3rem] text-xs", + md: "min-w-[5rem] px-3 py-2 text-sm", + lg: "min-w-[6rem] px-4 py-2 text-base", +}; + +//TODO: Tab 컴포넌트까지 새롭게 만들어야 함. +interface TabAsChildProps<ListType> { + defaultSelected: ListType; + tabItemList?: ListType[]; + align: keyof typeof tabAlign; + children: ({ selectedItem }: { selectedItem: ListType }) => ReactNode; + rounded?: boolean; + tabSize: keyof typeof tabSizes; + nonPickedColor: keyof typeof tabColors; + pickedColor: keyof typeof tabColors; +} + +/** + * [변경] 탭의 사이즈, 색상, 정렬등의 모든 책임을 해당 컴포넌트에서 가집니다. + * + * @example - children에 ({selectedItem}) => <div>{selectedItem === "Home" && <HomeContent />}</div> 와 같이 사용합니다. + * + */ + +const TabAsChild = <ListType extends string>({ + defaultSelected, + tabItemList, + nonPickedColor, + pickedColor, + rounded, + align, + tabSize, + children, +}: TabAsChildProps<ListType>) => { + const [selectedItem, setSelectedItem] = useState<ListType>(defaultSelected); + + if (!tabItemList) { + throw new Error("optionItemList이 필요합니다."); + } + + const tabStyle = classNames( + tabAlign[align], + "w-full overflow-x-scroll scrollbar-hide", + ); + + return ( + <> + <div className={tabStyle}> + {tabItemList.map((item) => ( + <TabItem + key={item} + color={item === selectedItem ? pickedColor : nonPickedColor} + size={tabSize} + text={item} + onClick={() => setSelectedItem(item)} + rounded={rounded} + /> + ))} + </div> + {children({ selectedItem })} + </> + ); +}; + +export default TabAsChild; diff --git a/FE/src/components/common/tabs/tab/TabCompound/TabCompound.tsx b/FE/src/components/common/tabs/tab/TabCompound/TabCompound.tsx new file mode 100644 index 00000000..bc626c16 --- /dev/null +++ b/FE/src/components/common/tabs/tab/TabCompound/TabCompound.tsx @@ -0,0 +1,152 @@ +"use client"; + +import classNames from "classnames"; +import React, { + createContext, + useContext, + useState, + ReactNode, + PropsWithChildren, +} from "react"; + +const tabAlign = { + line: "flex gap-4", + square: "grid grid-cols-2 gap-4", +} as const; + +export const tabColors = { + gray: "bg-gray-10 text-gray-30 border-gray-20", + yellow: "bg-warning-10 text-warning-30 border-warning-30", + teal: "bg-secondary-20 text-tertiary-20 border-tertiary-20", + white: "bg-background text-gray-30 border-background", + navy: "bg-paragraph text-background border-paragraph", +}; + +export const tabSizes = { + sm: "min-w-[4.25rem] px-2 py-[0.3rem] text-xs", + md: "min-w-[5rem] px-3 py-2 text-sm", + lg: "min-w-[6rem] px-4 py-2 text-base", +}; + +interface TabContextType<T extends string> { + selectedItem: T; + setSelectedItem: (item: T) => void; + align: keyof typeof tabAlign; + tabSize: keyof typeof tabSizes; + nonPickedColor: keyof typeof tabColors; + pickedColor: keyof typeof tabColors; + rounded?: boolean; + tabItemList: T[]; +} + +const TabContext = createContext<TabContextType<string> | null>(null); + +export function useTab<T extends string>(): TabContextType<T> { + const context = useContext(TabContext); + if (!context) + throw new Error("해당 컴포넌트는 Tab 컴포넌트 내부에서 사용되어야 합니다."); + + return context as TabContextType<T>; +} + +interface TabProps<T extends string> extends PropsWithChildren { + defaultSelected: T; + tabItemList: T[]; + align: keyof typeof tabAlign; + tabSize: keyof typeof tabSizes; + nonPickedColor: keyof typeof tabColors; + pickedColor: keyof typeof tabColors; + rounded?: boolean; +} + +function Tab<T extends string>({ + children, + defaultSelected, + tabItemList, + align, + tabSize, + nonPickedColor, + pickedColor, + rounded, +}: TabProps<T>) { + const [selectedItem, setSelectedItem] = useState<T>(defaultSelected); + + const contextValue: TabContextType<T> = { + selectedItem, + setSelectedItem, + align, + tabSize, + nonPickedColor, + pickedColor, + rounded, + tabItemList, + }; + + return ( + <TabContext.Provider value={contextValue}>{children}</TabContext.Provider> + ); +} + +const TabList = ({ children }: PropsWithChildren) => { + const { align } = useTab<string>(); + + const tabStyle = classNames( + tabAlign[align], + "w-full overflow-x-scroll scrollbar-hide", + ); + + return <div className={tabStyle}>{children}</div>; +}; + +interface TabItemProps<T extends string> { + text: T; +} + +function TabItem<T extends string>({ text }: TabItemProps<T>) { + const { + selectedItem, + setSelectedItem, + tabSize, + nonPickedColor, + pickedColor, + rounded, + } = useTab<T>(); + + const color = text === selectedItem ? pickedColor : nonPickedColor; + + const tabItemStyle = classNames( + "flex h-fit w-fit cursor-pointer items-center justify-center border-2 font-semibold", + tabColors[color], + tabSizes[tabSize], + { + "rounded-2xl": rounded, + "rounded-md": !rounded, + }, + ); + + return ( + <button + className={tabItemStyle} + onClick={() => setSelectedItem(text)} + type="button" + > + <p>{text}</p> + </button> + ); +} + +interface TabContentProps<T extends string> { + children: ({ selectedItem }: { selectedItem: T }) => ReactNode; +} + +function TabContent<T extends string>({ children }: TabContentProps<T>) { + const { selectedItem } = useTab<T>(); + + return <>{children({ selectedItem })}</>; +} + +Tab.List = TabList; +Tab.Item = TabItem; +Tab.Content = TabContent; + +export default Tab; diff --git a/FE/src/components/common/tabs/tab/TabCompound/compounds/TabList.tsx b/FE/src/components/common/tabs/tab/TabCompound/compounds/TabList.tsx new file mode 100644 index 00000000..881d0fd6 --- /dev/null +++ b/FE/src/components/common/tabs/tab/TabCompound/compounds/TabList.tsx @@ -0,0 +1,16 @@ +// import classNames from "classnames"; +// import { PropsWithChildren } from "react"; +// import { useTab } from "../TabCompound"; + +// const TabList = ({ children }: PropsWithChildren) => { +// const { align } = useTab<string>(); + +// const tabStyle = classNames( +// tabAlign[align], +// "w-full overflow-x-scroll scrollbar-hide", +// ); + +// return <div className={tabStyle}>{children}</div>; +// }; + +// export default TabList; diff --git a/FE/src/components/common/tabs/TabItem.tsx b/FE/src/components/common/tabs/tab/TabItem.tsx similarity index 100% rename from FE/src/components/common/tabs/TabItem.tsx rename to FE/src/components/common/tabs/tab/TabItem.tsx diff --git a/FE/src/components/common/tabs/TextTab.tsx b/FE/src/components/common/tabs/tab/TextTab.tsx similarity index 100% rename from FE/src/components/common/tabs/TextTab.tsx rename to FE/src/components/common/tabs/tab/TextTab.tsx diff --git a/FE/src/components/common/toggle/ActiveStatusToggle.tsx b/FE/src/components/common/toggle/ActiveStatusToggle.tsx new file mode 100644 index 00000000..086ff8fb --- /dev/null +++ b/FE/src/components/common/toggle/ActiveStatusToggle.tsx @@ -0,0 +1,36 @@ +import StatusToggleItem, { StatusToggleItemColor } from "../StatusToggleItem"; +import ACTIVE_STATUS from "@/constants/ACTIVE_STATUS"; +import { useUpdateMemberActiveStatus } from "@/hooks/query/useMemberQuery"; +import { ActiveStatus } from "@/types/member"; + +interface ActiveStatusToggleProps { + memberId: number; + selectedValue: ActiveStatus; +} +const ActiveStatusToggle = ({ + memberId, + selectedValue, +}: ActiveStatusToggleProps) => { + const options = Object.values(ACTIVE_STATUS.TAB_WITH_COLOR); + const { mutate } = useUpdateMemberActiveStatus({ memberId }); + + const handleClick = (activeStatus: ActiveStatus) => { + if (selectedValue === activeStatus) return; + mutate({ activeStatus }); + }; + + const getItemColor = (type: ActiveStatus, color: StatusToggleItemColor) => + selectedValue === type ? color : "gray"; + + return ( + <div className="flex h-fit w-fit transform rounded-3xl bg-gray-10"> + {options.map(({ type, color, text }) => ( + <button key={text} onClick={() => handleClick(type)}> + <StatusToggleItem text={text} color={getItemColor(type, color)} /> + </button> + ))} + </div> + ); +}; + +export default ActiveStatusToggle; diff --git a/FE/src/components/common/attendStatusToggle/AttendStatusToggle.tsx b/FE/src/components/common/toggle/AttendStatusToggle.tsx similarity index 69% rename from FE/src/components/common/attendStatusToggle/AttendStatusToggle.tsx rename to FE/src/components/common/toggle/AttendStatusToggle.tsx index ff043e3d..a9ec6284 100644 --- a/FE/src/components/common/attendStatusToggle/AttendStatusToggle.tsx +++ b/FE/src/components/common/toggle/AttendStatusToggle.tsx @@ -1,5 +1,8 @@ +"use client"; + import classNames from "classnames"; -import StatusToggleItem from "./StatusToggleItem"; +import { createContext } from "react"; +import StatusToggleItem, { StatusToggleItemColor } from "../StatusToggleItem"; import ATTEND_STATUS, { AttendStatusToggleOption, } from "@/constants/ATTEND_STATUS"; @@ -10,6 +13,18 @@ interface AttendStatusToggleProps { onSelect: (value: AttendStatus) => void; disabled?: boolean; } + +const toggleContext = createContext<AttendStatusToggleProps>(null); + +interface ToggleWrapperProps { + children: React.ReactNode; +} +export const ToggleWrapper = ({ children }: ToggleWrapperProps) => { + return ( + <toggleContext.Provider value={null}>{children}</toggleContext.Provider> + ); +}; + const AttendStatusToggle = ({ selectedValue, onSelect, @@ -40,7 +55,9 @@ const AttendStatusToggle = ({ <div onClick={() => handleClick(option)} key={option.text}> <StatusToggleItem text={option.text} - color={getItemColor(option.type, option.color)} + color={ + getItemColor(option.type, option.color) as StatusToggleItemColor + } /> </div> ))} diff --git a/FE/src/components/common/validate/Auth.tsx b/FE/src/components/common/validate/Auth.tsx index e226edec..b78d1573 100644 --- a/FE/src/components/common/validate/Auth.tsx +++ b/FE/src/components/common/validate/Auth.tsx @@ -1,9 +1,8 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect } from "react"; import ROUTES from "@/constants/ROUTES"; -import { deleteTokenInfo } from "@/utils/authWithStorage"; +import useAuth from "@/hooks/useAuth"; interface AuthValidateProps { isHaveToLoggedInRoute?: boolean; @@ -11,20 +10,11 @@ interface AuthValidateProps { const AuthValidate = ({ isHaveToLoggedInRoute = true }: AuthValidateProps) => { const router = useRouter(); - useEffect(() => { - const accessToken = localStorage.getItem("accessToken"); - const tokenExpiration = localStorage.getItem("tokenExpiration"); + const { isLoggedIn, isLoading } = useAuth(); - const isLoggedIn = accessToken || tokenExpiration; - - if (isHaveToLoggedInRoute && !isLoggedIn) { - deleteTokenInfo(); - router.push(ROUTES.LOGIN); - } - if (!isHaveToLoggedInRoute && isLoggedIn) { - router.push(ROUTES.MAIN); - } - }, []); + if (isLoading) return null; + if (isHaveToLoggedInRoute && !isLoggedIn) router.push(ROUTES.LOGIN); + if (!isHaveToLoggedInRoute && isLoggedIn) router.push(ROUTES.MAIN); return <></>; }; diff --git a/FE/src/components/feature/login/LoginPageFooter.tsx b/FE/src/components/feature/login/LoginPageFooter.tsx new file mode 100644 index 00000000..2c439d94 --- /dev/null +++ b/FE/src/components/feature/login/LoginPageFooter.tsx @@ -0,0 +1,38 @@ +const LoginLinks = [ + { + name: "About EEOS", + href: "https://triangular-attempt-ae3.notion.site/About-EEOS-721eab294aa04a729fa6afcd7cf21d4c?pvs=4", + }, + { + name: "EEOS Manual", + href: "https://triangular-attempt-ae3.notion.site/EEOS-MANUAL-685d0760a36840979875bca08c03abef?pvs=4", + }, + { + name: "개인정보처리방침", + href: "https://triangular-attempt-ae3.notion.site/7bfa5c21cf9c4d7fa24387e64540b573?pvs=4", + }, + { + name: "고객의 소리", + href: "https://padlet.com/jsp8514/eeos-feedback-34nct436veaklw9e", + }, +]; + +const LoginPageFooter = () => { + return ( + <ul className="mt-24 flex items-center justify-center gap-4"> + {LoginLinks.map(({ href, name }, index) => ( + <> + <li key={`${name}-${index}`} className="text-gray-40"> + <a href={href} target="_blank"> + {name} + </a> + </li> + <p key={`${name}-${index}`}>|</p> + </> + ))} + <p className="cursor-default font-semibold">@Black Company</p> + </ul> + ); +}; + +export default LoginPageFooter; diff --git a/FE/src/components/feature/login/LoginSection.tsx b/FE/src/components/feature/login/LoginSection.tsx new file mode 100644 index 00000000..7bcef6c8 --- /dev/null +++ b/FE/src/components/feature/login/LoginSection.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { SwitchCase } from "@toss/react"; +import { useState } from "react"; +import AdminLoginSection from "./admin/AdminLoginSection"; +import DefaultLoginSection from "./default/DefaultLoginSection"; + +export type LoginType = "login" | "admin"; + +const LoginSection = () => { + const [loginType, setLoginType] = useState<LoginType>("login"); + + return ( + <SwitchCase + value={loginType} + caseBy={{ + login: ( + <DefaultLoginSection changeLoginType={() => setLoginType("admin")} /> + ), + admin: ( + <AdminLoginSection changeLoginType={() => setLoginType("login")} /> + ), + }} + defaultComponent={ + <DefaultLoginSection changeLoginType={() => setLoginType("admin")} /> + } + /> + ); +}; + +export default LoginSection; diff --git a/FE/src/components/feature/login/__test__/DefaultLoginSection.test.tsx b/FE/src/components/feature/login/__test__/DefaultLoginSection.test.tsx new file mode 100644 index 00000000..c519cad5 --- /dev/null +++ b/FE/src/components/feature/login/__test__/DefaultLoginSection.test.tsx @@ -0,0 +1,85 @@ +/** + * @jest-environment jsdom + */ + +import { render, screen } from "@testing-library/react"; +import renderer from "react-test-renderer"; +import DefaultLoginSection from "../default/DefaultLoginSection"; +import { Link } from "@/__test__/__mock__/Link"; +import getResponse from "@/__test__/__stub__/response"; +import withReactQuery from "@/__test__/utils/withReactQuery"; +import { https } from "@/apis/instance"; + +//axios mocking +jest.mock("@/apis/instance"); +const mockHttps = https as jest.MockedFunction<typeof https>; + +const mockReturnData = getResponse({ + url: "/auth/login/slack", + method: "POST", +}); + +mockHttps.mockResolvedValue(mockReturnData); + +// next.js mocking +const MockNextRouterComponent = Link; + +jest.mock("next/navigation", () => ({ + useSearchParams: jest.fn(() => ({ + get: jest.fn(() => "code"), + })), + useRouter: jest.fn(() => ({ + back: jest.fn(), + replace: jest.fn(), + })), +})); + +jest.mock("next/link", () => { + return ({ children, href }) => { + return ( + <MockNextRouterComponent href={href}>{children}</MockNextRouterComponent> + ); + }; +}); + +// module mocking +const changeLoginType = jest.fn(); + +describe("DefaultLoginSection", () => { + it("올바르게 렌더링 된다", async () => { + const WrappedComponent = withReactQuery( + <DefaultLoginSection changeLoginType={changeLoginType} />, + ); + const tree = renderer.create(<WrappedComponent />).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it("게스트 모드로 이동할 수 있다", async () => { + const WrappedComponent = withReactQuery( + <DefaultLoginSection changeLoginType={changeLoginType} />, + ); + render(<WrappedComponent />); + + const guestLoginButton = screen.getByRole("link", { + name: "Visit EEOS", + }); + + guestLoginButton.click(); + + expect(MockNextRouterComponent).toHaveBeenCalled(); + }); + + it("관리자 로그인 버튼 클릭시 로그인 타입을 변경한다", () => { + const WrappedComponent = withReactQuery( + <DefaultLoginSection changeLoginType={changeLoginType} />, + ); + render(<WrappedComponent />); + + const adminLoginSpan = screen.getByText("관리자 로그인"); + + adminLoginSpan.click(); + + expect(changeLoginType).toHaveBeenCalledTimes(1); + }); +}); diff --git a/FE/src/components/feature/login/__test__/LoginForm.test.tsx b/FE/src/components/feature/login/__test__/LoginForm.test.tsx new file mode 100644 index 00000000..789c24c5 --- /dev/null +++ b/FE/src/components/feature/login/__test__/LoginForm.test.tsx @@ -0,0 +1,291 @@ +/** + * @jest-environment jsdom + */ + +import { render, screen, waitFor } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import renderer from "react-test-renderer"; +import LoginForm from "../admin/LoginForm"; +// eslint-disable-next-line import/order +import withReactQuery from "@/__test__/utils/withReactQuery"; +import { useAdminLoginMutation } from "@/hooks/query/useAuthQuery"; + +// react-dom useFormStatus mocking +jest.mock("react-dom", () => { + return { + ...jest.requireActual("react-dom"), + useFormStatus: jest.fn(), + }; +}); + +// next.js mocking +jest.mock("next/navigation", () => ({ + useSearchParams: jest.fn(() => ({ + get: jest.fn(() => "code"), + })), + useRouter: jest.fn(() => ({ + back: jest.fn(), + replace: jest.fn(), + })), +})); + +// query mocking +const mutate = jest.fn(); + +jest.mock("@/hooks/query/useAuthQuery", () => ({ + useAdminLoginMutation: jest.fn(() => ({ + mutate, + isError: false, + })), +})); + +describe("LoginForm.tsx", () => { + let mockedAlert: jest.SpyInstance; + + beforeEach(() => { + mockedAlert = jest.spyOn(window, "alert").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("네트워크가 올바른 환경에서", () => { + it("올바르게 렌더링 된다", async () => { + const WrappedComponent = withReactQuery(<LoginForm />); + const tree = renderer.create(<WrappedComponent />).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it("초기상태(아무 값도 입력하지 않은 상태)에서 로그인 시 아이디와 비밀번호를 입력해주세요 alert가 뜬다", async () => { + // arrange + const WrappedComponent = withReactQuery(<LoginForm />); + render(<WrappedComponent />); + + // act + const loginButton = screen.getByRole("button", { + name: "로그인", + }); + + await userEvent.click(loginButton); + + // assert + expect(mockedAlert).toHaveBeenCalledWith( + "아이디와 비밀번호를 입력해주세요.", + ); + }); + + describe("아이디를 입력한 상태에서", () => { + it("로그인 시 아이디와 비밀번호를 입력해주세요 alert가 뜬다", async () => { + // Arrange + const id = "testId"; + + const WrappedComponent = withReactQuery(<LoginForm />); + render(<WrappedComponent />); + + // act + const loginButton = screen.getByRole("button", { + name: "로그인", + }); + const idInput = screen.getByRole("textbox", { + name: "아이디", + }); + + await userEvent.click(loginButton); + await userEvent.type(idInput, id); + + expect(mockedAlert).toHaveBeenCalledTimes(1); + }); + describe("비밀번호를 입력한 상태에서", () => { + it("아이디 혹은 비밀번호가 올바르지 않은 경우 로그인이 진행되지 않는다", async () => { + // Arrange + const id = "notCorrectId"; + const password = "notCorrectPassword"; + + const WrappedComponent = withReactQuery(<LoginForm />); + render(<WrappedComponent />); + + // act + const idInput = screen.getByRole("textbox", { + name: "아이디", + }); + + const passwordInput = screen.getByRole("password", { + name: "비밀번호", + }); + + const loginButton = screen.getByRole("button", { + name: "로그인", + }); + + await userEvent.type(idInput, id); + await userEvent.type(passwordInput, password); + await userEvent.click(loginButton); + + // assert + expect(mutate).toHaveBeenCalled(); + expect(mutate).toHaveBeenCalledWith({ + id, + password, + }); + }); + it("아이디 및 비밀번호가 올바른 경우 로그인이 진행된다", async () => { + // Arrange + const id = "correctId"; + const password = "correctPassword"; + + const WrappedComponent = withReactQuery(<LoginForm />); + render(<WrappedComponent />); + + // act + const loginButton = screen.getByRole("button", { + name: "로그인", + }); + const idInput = screen.getByRole("textbox", { + name: "아이디", + }); + const passwordInput = screen.getByRole("password", { + name: "비밀번호", + }); + + await userEvent.type(idInput, id); + await userEvent.type(passwordInput, password); + await userEvent.click(loginButton); + + expect(mutate).toHaveBeenCalled(); + expect(mutate).toHaveBeenCalledWith({ + id, + password, + }); + }); + }); + }); + describe("비밀번호를 입력한 상태에서", () => { + it("로그인 시 아이디와 비밀번호를 입력해주세요 alert가 뜬다", async () => { + // Arrange + const password = "testPassword"; + + const WrappedComponent = withReactQuery(<LoginForm />); + render(<WrappedComponent />); + + // act + const loginButton = screen.getByRole("button", { + name: "로그인", + }); + + const passwordInput = screen.getByRole("password", { + name: "비밀번호", + }); + + await userEvent.type(passwordInput, password); + await userEvent.click(loginButton); + + // assert + expect(mockedAlert).toHaveBeenCalledWith( + "아이디와 비밀번호를 입력해주세요.", + ); + }); + + describe("아이디를 입력한 상태에서", () => { + it("아이디 혹은 비밀번호가 올바르지 않은 경우 로그인이 진행되지 않는다", async () => { + // Arrange + const id = "uncorrectId"; + const password = "uncorrectPassword"; + + const WrappedComponent = withReactQuery(<LoginForm />); + render(<WrappedComponent />); + + mutate.mockReturnValue({ + isError: true, + }); + + // act + const loginButton = screen.getByRole("button", { + name: "로그인", + }); + const idInput = screen.getByRole("textbox", { + name: "아이디", + }); + const passwordInput = screen.getByRole("password", { + name: "비밀번호", + }); + + await userEvent.type(idInput, id); + await userEvent.type(passwordInput, password); + await userEvent.click(loginButton); + + // assert + expect(mutate).toHaveBeenCalled(); + expect(mutate).toHaveBeenCalledWith({ + id, + password, + }); + }); + + it("아이디 및 비밀번호가 올바른 경우 로그인이 진행된다", async () => { + // Arrange + const id = "testId"; + const password = "testPassword"; + const WrappedComponent = withReactQuery(<LoginForm />); + render(<WrappedComponent />); + // act + const loginButton = screen.getByRole("button", { + name: "로그인", + }); + const idInput = screen.getByRole("textbox", { + name: "아이디", + }); + const passwordInput = screen.getByRole("password", { + name: "비밀번호", + }); + + await userEvent.type(idInput, id); + await userEvent.type(passwordInput, password); + await userEvent.click(loginButton); + + // assert + expect(mutate).toHaveBeenCalled(); + expect(mutate).toHaveBeenCalledWith({ + id, + password, + }); + }); + }); + }); + }); + + describe("네트워크가 올바르지 않은 환경에서", () => { + it("로그인 시 에러가 발생하면 비밀번호를 초기화한다", async () => { + // Arrange + const WrappedComponent = withReactQuery(<LoginForm />); + render(<WrappedComponent />); + const loginButton = screen.getByRole("button", { + name: "로그인", + }); + const idInput = screen.getByRole("textbox", { + name: "아이디", + }); + const passwordInput = screen.getByRole("password", { + name: "비밀번호", + }) as HTMLInputElement; + + useAdminLoginMutation.mockReturnValue({ + mutate, + isError: true, + }); + + // act + await userEvent.type(idInput, "testId"); + await userEvent.type(passwordInput, "testPassword"); + + await userEvent.click(loginButton); + + // assert + expect(mutate).toHaveBeenCalled(); + waitFor(() => { + expect(passwordInput.value).toBe(""); + }); + }); + }); +}); diff --git a/FE/src/components/feature/login/__test__/SlackLoginBtn.test.tsx b/FE/src/components/feature/login/__test__/SlackLoginBtn.test.tsx new file mode 100644 index 00000000..f34dbe90 --- /dev/null +++ b/FE/src/components/feature/login/__test__/SlackLoginBtn.test.tsx @@ -0,0 +1,60 @@ +/** + * @jest-environment jsdom + */ + +import { render, screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import renderer from "react-test-renderer"; +import SlackLoginBtn from "../default/SlackLoginBtn"; +import { Link } from "@/__test__/__mock__/Link"; +import withReactQuery from "@/__test__/utils/withReactQuery"; + +jest.mock("next/navigation", () => ({ + useSearchParams: jest.fn(() => ({ + get: jest.fn(() => "code"), + })), + useRouter: jest.fn(() => ({ + back: jest.fn(), + replace: jest.fn(), + })), +})); + +jest.mock("@/hooks/query/useAuthQuery", () => ({ + useSlackLoginMutation: jest.fn(() => ({ + mutate: jest.fn(), + })), +})); + +const MockNextRouterComponent = Link; + +jest.mock("next/link", () => { + return ({ children, href }) => { + return ( + <MockNextRouterComponent href={href}>{children}</MockNextRouterComponent> + ); + }; +}); + +describe("SlackLoginBtn", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("올바르게 렌더링 된다", async () => { + const WrappedComponent = withReactQuery(<SlackLoginBtn />); + const tree = renderer.create(<WrappedComponent />).toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it("버튼 클릭시 슬랙 로그인 페이지로 이동한다", async () => { + const user = userEvent.setup(); + + render(<SlackLoginBtn />); + + const button = screen.getByRole("link"); + await user.click(button); + + expect(MockNextRouterComponent).toHaveBeenCalled; + }); +}); diff --git a/FE/src/components/feature/login/__test__/__snapshots__/DefaultLoginSection.test.tsx.snap b/FE/src/components/feature/login/__test__/__snapshots__/DefaultLoginSection.test.tsx.snap new file mode 100644 index 00000000..0797afd5 --- /dev/null +++ b/FE/src/components/feature/login/__test__/__snapshots__/DefaultLoginSection.test.tsx.snap @@ -0,0 +1,111 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DefaultLoginSection 올바르게 렌더링 된다 1`] = ` +<div + className="flex flex-col items-center justify-center gap-24" +> + <h1 + className="text-2xl font-bold sm:text-3xl text-black" + > + 로그인 + </h1> + <div + className="flex flex-col gap-6" + > + <div + className="flex flex-col items-center gap-6" + > + <p + className="font-light" + > + 에코노베이션 슬랙으로 로그인 + </p> + <a + href="https://slack.com/oauth/v2/authorize?client_id=5775976606341.6306752698400&team_id=undefined&scope=&user_scope=users.profile:read&redirect_uri=https://localhost:3000/login/logging-in" + onClick={[Function]} + role="link" + > + <img + alt="슬랙 로그인 버튼" + data-nimg="1" + decoding="async" + height={24} + loading="lazy" + onError={[Function]} + onLoad={[Function]} + src="/icons/slack.svg" + style={ + { + "color": "transparent", + } + } + width={24} + /> + <p + className="text-center font-semibold" + > + 슬랙으로 로그인 + </p> + </a> + </div> + <div + className="flex flex-col items-center gap-6" + > + <p + className="font-light" + > + 게스트모드로 EEOS 둘러보기 + </p> + <a + href="/guest/main" + onClick={[Function]} + role="link" + > + <img + alt="" + data-nimg="1" + decoding="async" + height={24} + loading="lazy" + onError={[Function]} + onLoad={[Function]} + src="/icons/blackCompany.svg" + style={ + { + "color": "transparent", + } + } + width={24} + /> + <p + className="text-center font-semibold" + > + Visit EEOS + </p> + </a> + </div> + <p + className="mx-auto flex select-none" + onClick={[Function]} + > + 관리자 로그인 + <img + alt="arrow right to go admin login page" + data-nimg="1" + decoding="async" + height={20} + loading="lazy" + onError={[Function]} + onLoad={[Function]} + src="/icons/arrowRight.svg" + style={ + { + "color": "transparent", + } + } + width={20} + /> + </p> + </div> +</div> +`; diff --git a/FE/src/components/feature/login/__test__/__snapshots__/LoginForm.test.tsx.snap b/FE/src/components/feature/login/__test__/__snapshots__/LoginForm.test.tsx.snap new file mode 100644 index 00000000..d99c0bf1 --- /dev/null +++ b/FE/src/components/feature/login/__test__/__snapshots__/LoginForm.test.tsx.snap @@ -0,0 +1,97 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LoginForm 네트워크가 올바른 환경에서 올바르게 렌더링 된다 1`] = ` +<form + className="mt-12 flex flex-col gap-3" + onSubmit={[Function]} +> + <input + aria-label="아이디" + className="mx-8 p-2" + name="id" + onChange={[Function]} + placeholder="아이디" + type="text" + value="" + /> + <input + aria-label="비밀번호" + className="mx-8 p-2" + name="password" + onChange={[Function]} + placeholder="비밀번호" + role="password" + type="password" + value="" + /> + <button + className="mt-4 w-full rounded-lg bg-primary p-2 font-semibold" + > + 로그인 + </button> +</form> +`; + +exports[`LoginForm 올바르게 렌더링 된다 1`] = ` +<form + className="mt-12 flex flex-col gap-3" + onSubmit={[Function]} +> + <input + aria-label="아이디" + className="mx-8 p-2" + name="id" + onChange={[Function]} + placeholder="아이디" + type="text" + value="" + /> + <input + aria-label="비밀번호" + className="mx-8 p-2" + name="password" + onChange={[Function]} + placeholder="비밀번호" + role="password" + type="password" + value="" + /> + <button + className="mt-4 w-full rounded-lg bg-primary p-2 font-semibold" + > + 로그인 + </button> +</form> +`; + +exports[`LoginForm.tsx 네트워크가 올바른 환경에서 올바르게 렌더링 된다 1`] = ` +<form + className="mt-12 flex flex-col gap-3" + onSubmit={[Function]} +> + <input + aria-label="아이디" + className="mx-8 p-2" + name="id" + onChange={[Function]} + placeholder="아이디" + type="text" + value="" + /> + <input + aria-label="비밀번호" + className="mx-8 p-2" + name="password" + onChange={[Function]} + placeholder="비밀번호" + role="password" + type="password" + value="" + /> + <button + className="mt-4 w-full rounded-lg bg-primary p-2 font-semibold" + > + 로그인 + </button> +</form> +`; diff --git a/FE/src/components/feature/login/__test__/__snapshots__/SlackLoginBtn.test.tsx.snap b/FE/src/components/feature/login/__test__/__snapshots__/SlackLoginBtn.test.tsx.snap new file mode 100644 index 00000000..46ad5666 --- /dev/null +++ b/FE/src/components/feature/login/__test__/__snapshots__/SlackLoginBtn.test.tsx.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SlackLoginBtn 올바르게 렌더링 된다 1`] = ` +<a + href="https://slack.com/oauth/v2/authorize?client_id=5775976606341.6306752698400&team_id=undefined&scope=&user_scope=users.profile:read&redirect_uri=https://localhost:3000/login/logging-in" + onClick={[Function]} + role="link" +> + <img + alt="슬랙 로그인 버튼" + data-nimg="1" + decoding="async" + height={24} + loading="lazy" + onError={[Function]} + onLoad={[Function]} + src="/icons/slack.svg" + style={ + { + "color": "transparent", + } + } + width={24} + /> + <p + className="text-center font-semibold" + > + 슬랙으로 로그인 + </p> +</a> +`; diff --git a/FE/src/components/feature/login/admin/AdminLoginSection.tsx b/FE/src/components/feature/login/admin/AdminLoginSection.tsx new file mode 100644 index 00000000..107e2c92 --- /dev/null +++ b/FE/src/components/feature/login/admin/AdminLoginSection.tsx @@ -0,0 +1,28 @@ +import LoginForm from "./LoginForm"; +import { EeosAdminLogo } from "@/components/icons"; + +interface AdminLoginSectionProps { + changeLoginType: () => void; +} +const AdminLoginSection = ({ changeLoginType }: AdminLoginSectionProps) => { + return ( + <div className="flex flex-col items-center justify-center gap-8"> + <div className="rounded-lg border bg-gray-10 p-8"> + <EeosAdminLogo /> + <LoginForm /> + <button + className="mt-4 w-full rounded-lg bg-gray-20 p-2 font-semibold" + disabled + > + ID / PW 찾기 + </button> + </div> + {/* TODO: 명세서 수정 필요 */} + <p className="mx-auto flex select-none" onClick={changeLoginType}> + 이전으로 돌아가기 + </p> + </div> + ); +}; + +export default AdminLoginSection; diff --git a/FE/src/components/feature/login/admin/LoginForm.tsx b/FE/src/components/feature/login/admin/LoginForm.tsx new file mode 100644 index 00000000..de846a0e --- /dev/null +++ b/FE/src/components/feature/login/admin/LoginForm.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { FormEvent, useEffect, useState } from "react"; +import { useAdminLoginMutation } from "@/hooks/query/useAuthQuery"; + +const LoginForm = () => { + const [id, setId] = useState(""); + const [password, setPassword] = useState(""); + + const { mutate, isError } = useAdminLoginMutation(); + + const loginToAdmin = async (e: FormEvent) => { + e.preventDefault(); + if (!id || !password) { + alert("아이디와 비밀번호를 입력해주세요."); + return; + } + mutate({ id, password }); + }; + + useEffect(() => { + setPassword(""); + }, [isError]); + + return ( + <form className="mt-12 flex flex-col gap-3" onSubmit={loginToAdmin}> + <input + type="text" + aria-label="아이디" + name="id" + placeholder="아이디" + className="mx-8 p-2" + value={id} + onChange={(e) => setId(e.target.value)} + /> + <input + type="password" + aria-label="비밀번호" + role="password" + name="password" + placeholder="비밀번호" + className="mx-8 p-2" + value={password} + onChange={(e) => setPassword(e.target.value)} + /> + <button className="mt-4 w-full rounded-lg bg-primary p-2 font-semibold"> + 로그인 + </button> + </form> + ); +}; + +export default LoginForm; diff --git a/FE/src/components/feature/login/default/DefaultLoginSection.tsx b/FE/src/components/feature/login/default/DefaultLoginSection.tsx new file mode 100644 index 00000000..5ae12faa --- /dev/null +++ b/FE/src/components/feature/login/default/DefaultLoginSection.tsx @@ -0,0 +1,34 @@ +import GuestLoginBtn from "./GuestLoginBtn"; +import SlackLoginBtn from "./SlackLoginBtn"; +import Title from "@/components/common/Title/Title"; +import { ArrowRight } from "@/components/icons"; + +interface DefaultLoginSectionProps { + changeLoginType: () => void; +} +const DefaultLoginSection = ({ changeLoginType }: DefaultLoginSectionProps) => { + return ( + <div className="flex flex-col items-center justify-center gap-24"> + <Title text={"로그인"} /> + <div className="flex flex-col gap-6"> + <div className="flex flex-col items-center gap-6"> + <p className="font-light">에코노베이션 슬랙으로 로그인</p> + <SlackLoginBtn /> + </div> + <div className="flex flex-col items-center gap-6"> + <p className="font-light">게스트모드로 EEOS 둘러보기</p> + <GuestLoginBtn /> + </div> + <p + onClick={() => changeLoginType()} + className="mx-auto flex select-none" + > + 관리자 로그인 + <ArrowRight /> + </p> + </div> + </div> + ); +}; + +export default DefaultLoginSection; diff --git a/FE/src/components/feature/login/default/GuestLoginBtn.tsx b/FE/src/components/feature/login/default/GuestLoginBtn.tsx new file mode 100644 index 00000000..67e8b20b --- /dev/null +++ b/FE/src/components/feature/login/default/GuestLoginBtn.tsx @@ -0,0 +1,14 @@ +import StyledLinkButton from "../../../common/Button/StyledLinkButton"; + +const GuestLoginBtn = () => { + return ( + <StyledLinkButton + linkUrl="/guest/main" + buttonText="Visit EEOS" + imageUrl="/icons/blackCompany.svg" + color="guest" + /> + ); +}; + +export default GuestLoginBtn; diff --git a/FE/src/components/login/slack/SlackLoginButton.tsx b/FE/src/components/feature/login/default/SlackLoginBtn.tsx similarity index 82% rename from FE/src/components/login/slack/SlackLoginButton.tsx rename to FE/src/components/feature/login/default/SlackLoginBtn.tsx index b1f4ad75..00d13b7e 100644 --- a/FE/src/components/login/slack/SlackLoginButton.tsx +++ b/FE/src/components/feature/login/default/SlackLoginBtn.tsx @@ -1,11 +1,9 @@ -"use client"; - import { useSearchParams } from "next/navigation"; import { useEffect } from "react"; -import StyledLoginButton from "../ui/StyledLoginButton"; +import StyledLinkButton from "../../../common/Button/StyledLinkButton"; import { useSlackLoginMutation } from "@/hooks/query/useAuthQuery"; -const SlackLoginButton = () => { +const SlackLoginBtn = () => { const clientId = process.env.NEXT_PUBLIC_SLACK_CLIENT_ID; const redirectUri = process.env.NEXT_PUBLIC_SLACK_REDIRECT_URI; const teamId = process.env.NEXT_PUBLIC_SLACK_TEAM_ID; @@ -14,6 +12,7 @@ const SlackLoginButton = () => { const searchParams = useSearchParams(); const code = searchParams.get("code"); + const { mutate: loginSlack } = useSlackLoginMutation(); useEffect(() => { @@ -23,13 +22,14 @@ const SlackLoginButton = () => { }, [code]); return ( - <StyledLoginButton + <StyledLinkButton linkUrl={slackLoginUrl} buttonText="슬랙으로 로그인" imageUrl="/icons/slack.svg" color="slack" + alt="슬랙 로그인 버튼" /> ); }; -export default SlackLoginButton; +export default SlackLoginBtn; diff --git a/FE/src/components/icons/index.tsx b/FE/src/components/icons/index.tsx new file mode 100644 index 00000000..8cd3d0fe --- /dev/null +++ b/FE/src/components/icons/index.tsx @@ -0,0 +1,4 @@ +export { default as IntroLogo } from "./items/IntroLogo"; +export { default as Saly } from "./items/Saly"; +export { default as EeosAdminLogo } from "./items/EeosAdminLogo"; +export { default as ArrowRight } from "./items/ArrowRight"; diff --git a/FE/src/components/icons/items/ArrowRight.tsx b/FE/src/components/icons/items/ArrowRight.tsx new file mode 100644 index 00000000..7b1300b6 --- /dev/null +++ b/FE/src/components/icons/items/ArrowRight.tsx @@ -0,0 +1,14 @@ +import Image from "next/image"; + +const ArrowRight = () => { + return ( + <Image + src="/icons/arrowRight.svg" + width={20} + height={20} + alt="arrow right to go admin login page" + /> + ); +}; + +export default ArrowRight; diff --git a/FE/src/components/icons/items/EeosAdminLogo.tsx b/FE/src/components/icons/items/EeosAdminLogo.tsx new file mode 100644 index 00000000..c0af53bf --- /dev/null +++ b/FE/src/components/icons/items/EeosAdminLogo.tsx @@ -0,0 +1,16 @@ +import Image from "next/image"; + +const EeosAdminLogo = () => { + return ( + <Image + src="/icons/eeosAdminLogo.svg" + className="mx-auto" + alt="logo" + width={180} + height={36} + priority + /> + ); +}; + +export default EeosAdminLogo; diff --git a/FE/src/components/login/LogoAndIntro.tsx b/FE/src/components/icons/items/IntroLogo.tsx similarity index 84% rename from FE/src/components/login/LogoAndIntro.tsx rename to FE/src/components/icons/items/IntroLogo.tsx index bf8d1a0c..69e639ff 100644 --- a/FE/src/components/login/LogoAndIntro.tsx +++ b/FE/src/components/icons/items/IntroLogo.tsx @@ -1,6 +1,6 @@ import Image from "next/image"; -const LogoAndIntro = () => { +const IntroLogo = () => { return ( <div className="flex flex-col"> <Image @@ -13,4 +13,5 @@ const LogoAndIntro = () => { </div> ); }; -export default LogoAndIntro; + +export default IntroLogo; diff --git a/FE/src/components/icons/items/Saly.tsx b/FE/src/components/icons/items/Saly.tsx new file mode 100644 index 00000000..ca730368 --- /dev/null +++ b/FE/src/components/icons/items/Saly.tsx @@ -0,0 +1,7 @@ +import Image from "next/image"; + +const Saly = () => { + return <Image src="/saly.svg" alt="login hero" width={400} height={400} />; +}; + +export default Saly; diff --git a/FE/src/components/login/LeftSection.tsx b/FE/src/components/login/LeftSection.tsx deleted file mode 100644 index 6db79e2e..00000000 --- a/FE/src/components/login/LeftSection.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import Image from "next/image"; -import LogoAndIntro from "./LogoAndIntro"; - -const LoginLeftSection = () => { - return ( - <div className="hidden flex-col gap-28 bg-secondary-10 p-8 sm:flex"> - <LogoAndIntro /> - <Image src="/saly.svg" alt="login hero" width={400} height={400} /> - </div> - ); -}; -export default LoginLeftSection; diff --git a/FE/src/components/login/RightSection.tsx b/FE/src/components/login/RightSection.tsx deleted file mode 100644 index 72a2c867..00000000 --- a/FE/src/components/login/RightSection.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import Title from "../common/Title"; -import GuestLoginButton from "./guest/GuestLoginButton"; -import LoginSection from "./slack/LoginSection"; -import SlackLoginButton from "./slack/SlackLoginButton"; - -const LoginRightSection = () => { - return ( - <div - id="right" - className="flex flex-col items-center justify-center gap-24" - > - <Title text={"로그인"} /> - <div className="flex flex-col gap-6"> - <LoginSection - title="에코노베이션 슬랙으로 로그인" - loginBtnComponent={<SlackLoginButton />} - /> - <LoginSection - title="게스트모드로 EEOS 둘러보기" - loginBtnComponent={<GuestLoginButton />} - /> - </div> - </div> - ); -}; -export default LoginRightSection; diff --git a/FE/src/components/login/guest/GuestLoginButton.tsx b/FE/src/components/login/guest/GuestLoginButton.tsx deleted file mode 100644 index 6c556b66..00000000 --- a/FE/src/components/login/guest/GuestLoginButton.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import StyledLoginButton from "../ui/StyledLoginButton"; - -export default function GuestLoginButton() { - return ( - <StyledLoginButton - linkUrl="/guest/main" - buttonText="Visit to EEOS" - imageUrl="/icons/blackCompany.svg" - color="guest" - /> - ); -} diff --git a/FE/src/components/login/guest/GuestLoginSection.tsx b/FE/src/components/login/guest/GuestLoginSection.tsx deleted file mode 100644 index e898203f..00000000 --- a/FE/src/components/login/guest/GuestLoginSection.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import GuestLoginButton from "./GuestLoginButton"; - -const GuestLoginSection = ({ title }) => { - return ( - <div className="flex flex-col items-center gap-4"> - <p className="font-light">{title}</p> - <GuestLoginButton /> - </div> - ); -}; - -export default GuestLoginSection; diff --git a/FE/src/components/login/slack/LoginSection.tsx b/FE/src/components/login/slack/LoginSection.tsx deleted file mode 100644 index a187254b..00000000 --- a/FE/src/components/login/slack/LoginSection.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from "react"; - -interface LoginSectionProps { - title: string; - loginBtnComponent: React.ReactNode; -} - -const LoginSection = ({ title, loginBtnComponent }: LoginSectionProps) => { - return ( - <div className="flex flex-col items-center gap-6"> - <p className="font-light">{title}</p> - {loginBtnComponent} - </div> - ); -}; - -export default LoginSection; diff --git a/FE/src/components/main/Program.compound.tsx b/FE/src/components/main/Program.compound.tsx new file mode 100644 index 00000000..9996e822 --- /dev/null +++ b/FE/src/components/main/Program.compound.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { URLSearchParams } from "url"; +import { useSearchParams } from "next/navigation"; +import { + Suspense, + createContext, + useContext, + useEffect, + useState, +} from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import ErrorFallback from "../common/error/ErrorFallback"; +import Tab from "../common/tabs/tab/Tab"; +import TextTab from "../common/tabs/tab/TextTab"; +import ProgramList from "./ProgramList"; +import ProgramListLoader from "./ProgramList.loader"; +import MAIN from "@/constants/MAIN"; +import PROGRAM from "@/constants/PROGRAM"; +import { AccessType } from "@/types/access"; +import { ProgramCategoryWithAll, ProgramStatus } from "@/types/program"; + +const ProgramContext = createContext(null); + +const parseQuery = (searchParams: URLSearchParams) => ({ + category: (searchParams.get("category") as ProgramCategoryWithAll) ?? "all", + status: (searchParams.get("status") as ProgramStatus) ?? "active", + page: searchParams.get("page") ?? "1", +}); + +//wrapper +interface ProgramWrapperProps { + children: React.ReactNode; +} + +const ProgramWrapper = ({ children }: ProgramWrapperProps) => { + const searchParams = useSearchParams(); + const [queryValue, setQueryValue] = useState(MAIN.DEFAULT_QUERY); + + //TODO: 린트 버그 수정 필요 + useEffect(() => { + setQueryValue({ + ...MAIN.DEFAULT_QUERY, + category: parseQuery(searchParams as URLSearchParams).category, + }); + }, [searchParams]); + + useEffect(() => { + window.history.replaceState( + {}, + "", + `?category=${queryValue.category}&status=${queryValue.status}&page=${queryValue.page}`, + ); + }, [queryValue]); + + const handleSetCategory = (category: ProgramCategoryWithAll) => { + setQueryValue({ + ...queryValue, + category, + page: "1", + }); + }; + + const handleSetStatus = (status: ProgramStatus) => { + setQueryValue({ + ...queryValue, + status, + page: "1", + }); + }; + + const handleSetPage = (page: number) => { + setQueryValue({ + ...queryValue, + page: page.toString(), + }); + }; + + return ( + <ProgramContext.Provider + value={{ queryValue, handleSetCategory, handleSetStatus, handleSetPage }} + > + {children} + </ProgramContext.Provider> + ); +}; + +//children + +const CategoryTab = () => { + const { queryValue, handleSetCategory } = useContext(ProgramContext); + + return ( + <Tab<ProgramCategoryWithAll> + options={Object.values(PROGRAM.CATEGORY_TAB_WITH_ALL)} + selected={queryValue.category} + onItemClick={(v) => handleSetCategory(v)} + size="lg" + baseColor="white" + pointColor="navy" + align="line" + /> + ); +}; + +const StatusTab = () => { + const { queryValue, handleSetStatus } = useContext(ProgramContext); + + return ( + <TextTab<ProgramStatus> + options={Object.values(PROGRAM.STATUS_TAB)} + selected={queryValue.status} + onClick={(v) => handleSetStatus(v)} + /> + ); +}; + +interface ContentProps { + contentType: AccessType; +} +const Content = ({ contentType }: ContentProps) => { + const { queryValue, handleSetPage } = useContext(ProgramContext); + + return ( + <ErrorBoundary FallbackComponent={ErrorFallback}> + <Suspense fallback={<ProgramListLoader />}> + <ProgramList + category={queryValue.category} + programStatus={queryValue.status} + page={+queryValue.page} + setPage={handleSetPage} + contentType={contentType} + /> + </Suspense> + </ErrorBoundary> + ); +}; + +ProgramWrapper.CategoryTab = CategoryTab; +ProgramWrapper.StatusTab = StatusTab; +ProgramWrapper.Content = Content; + +export default ProgramWrapper; diff --git a/FE/src/components/main/Program.tsx b/FE/src/components/main/Program.tsx new file mode 100644 index 00000000..645961e0 --- /dev/null +++ b/FE/src/components/main/Program.tsx @@ -0,0 +1,20 @@ +"use client"; +import ProgramWrapper from "./Program.compound"; +import { AccessType } from "@/types/access"; + +interface ProgramProps { + AccessType: AccessType; +} + +const Program = ({ AccessType }: ProgramProps) => { + //TODO: headless 하도록 변경하기 + return ( + <ProgramWrapper> + <ProgramWrapper.CategoryTab /> + <ProgramWrapper.StatusTab /> + <ProgramWrapper.Content contentType={AccessType} /> + </ProgramWrapper> + ); +}; + +export default Program; diff --git a/FE/src/components/main/ProgramList.tsx b/FE/src/components/main/ProgramList.tsx index b661ff5c..d3dee9ac 100644 --- a/FE/src/components/main/ProgramList.tsx +++ b/FE/src/components/main/ProgramList.tsx @@ -3,6 +3,7 @@ import Paginataion from "../common/pagination/Pagination"; import ProgramListItem from "./ProgramListItem"; import PROGRAM from "@/constants/PROGRAM"; import { useGetProgramList } from "@/hooks/query/useProgramQuery"; +import { AccessType } from "@/types/access"; import { ProgramCategoryWithAll, ProgramStatus } from "@/types/program"; interface ProgramListProps { @@ -10,7 +11,7 @@ interface ProgramListProps { programStatus?: ProgramStatus; page?: number; setPage: (page: number) => void; - isLoggedIn: boolean; + contentType: AccessType; } const ProgramList = ({ @@ -18,17 +19,19 @@ const ProgramList = ({ programStatus = "active", page = 1, setPage: handleSetPage, - isLoggedIn, + contentType, }: ProgramListProps) => { + const isAdmin = contentType === "admin"; const queryClient = useQueryClient(); const { data: programListData } = useGetProgramList({ category, programStatus, page: page - 1, size: PROGRAM.LIST_SIZE, - isLoggedIn, + isAdmin, }); + // TODO: 전역 상태로 관리 queryClient.setQueryData<number>(["totalPage"], programListData.totalPage); const { programs } = programListData; @@ -39,7 +42,7 @@ const ProgramList = ({ <ProgramListItem key={program.programId} programData={program} - isLoggedIn={isLoggedIn} + contentType={contentType} /> ))} </div> diff --git a/FE/src/components/main/ProgramListItem.tsx b/FE/src/components/main/ProgramListItem.tsx index 325fb703..54b93090 100644 --- a/FE/src/components/main/ProgramListItem.tsx +++ b/FE/src/components/main/ProgramListItem.tsx @@ -1,28 +1,46 @@ +import ProgressDisplay from "../common/ProgressDisplay"; import { ProgramSimpleInfoDto } from "@/apis/dtos/program.dto"; import Link from "@/components/common/Link"; import ROUTES from "@/constants/ROUTES"; -import { convertDate } from "@/utils/convert"; +import { AccessType } from "@/types/access"; +import { formatTimestamp } from "@/utils/convert"; interface ProgramListItemProps { programData: ProgramSimpleInfoDto; - isLoggedIn: boolean; + contentType: AccessType; } -const ProgramListItem = ({ programData, isLoggedIn }: ProgramListItemProps) => { - const { programId, title, deadLine } = programData; - const lingUrl = isLoggedIn - ? ROUTES.DETAIL(programId) - : ROUTES.GUEST_DETAIL(programId); +const ProgramListItem = ({ + programData, + contentType, +}: ProgramListItemProps) => { + const { programId, title, deadLine, attendMode } = programData; + + const linkUrl = + contentType === "admin" + ? ROUTES.ADMIN_DETAIL(programId) + : contentType === "public" + ? ROUTES.GUEST_DETAIL(programId) + : ROUTES.DETAIL(programId); + + const isOnChecking = attendMode === "attend" || attendMode === "late"; + return ( <Link className="flex w-full flex-col items-center justify-between gap-4 rounded-lg bg-gray-10 px-8 py-6 transition-all hover:bg-secondary-20 sm:flex-row" - href={lingUrl} + href={linkUrl} key={programId} > <p className="w-full truncate text-center text-lg font-bold sm:text-left"> {title} </p> - <p className="text-base font-normal sm:w-52">{convertDate(deadLine)}</p> + {isOnChecking ? ( + <ProgressDisplay progressText="출석 진행중" color="success" /> + ) : ( + <p className="text-base font-normal sm:w-52"> + {formatTimestamp(deadLine)} + </p> + )} </Link> ); }; diff --git a/FE/src/components/manage/CancleBtn.tsx b/FE/src/components/manage/CancleBtn.tsx new file mode 100644 index 00000000..c3e6e3b6 --- /dev/null +++ b/FE/src/components/manage/CancleBtn.tsx @@ -0,0 +1,16 @@ +"use client"; +import { useRouter } from "next/navigation"; +import Button from "../common/Button/Button"; + +const CancleBtn = () => { + const router = useRouter(); + + const handleCancle = () => router.back(); + return ( + <Button size="lg" color="gray" onClick={handleCancle}> + 취소 + </Button> + ); +}; + +export default CancleBtn; diff --git a/FE/src/components/manage/member/MemberManageSection.tsx b/FE/src/components/manage/member/MemberManageSection.tsx new file mode 100644 index 00000000..4627baab --- /dev/null +++ b/FE/src/components/manage/member/MemberManageSection.tsx @@ -0,0 +1,24 @@ +"use client"; + +import ManageTable from "./memberManageTable/ManageTable"; +import MemberActiveStatusTab from "@/components/common/tabs/MemberActiveStatusTab"; +import Title from "@/components/common/Title/Title"; + +const MemberManageSection = () => { + return ( + <section> + <Title text="회원 관리하기" /> + <div className="mt-8"> + <MemberActiveStatusTab> + {(selectedItem) => ( + <div className="mt-6"> + <ManageTable selectedItem={selectedItem} /> + </div> + )} + </MemberActiveStatusTab> + </div> + </section> + ); +}; + +export default MemberManageSection; diff --git a/FE/src/components/manage/member/memberManageTable/ManageTable.tsx b/FE/src/components/manage/member/memberManageTable/ManageTable.tsx new file mode 100644 index 00000000..8fb9e010 --- /dev/null +++ b/FE/src/components/manage/member/memberManageTable/ManageTable.tsx @@ -0,0 +1,24 @@ +import MemberTableLoader from "@/components/common/memberTable/MemberTable.loader"; +import TableWrapper from "@/components/common/Table/TableWrapper"; +import { useGetMemberByActive } from "@/hooks/query/useMemberQuery"; +import { ActiveStatusWithAll } from "@/types/member"; + +interface TableProps { + selectedItem: ActiveStatusWithAll; +} +const ManageTable = ({ selectedItem }: TableProps) => { + const { data: memberList, isLoading } = useGetMemberByActive(selectedItem); + + const columnWidths = ["7rem", "7.25rem", "1fr", "10rem"]; + const headerItems = ["활동 상태", "이름", "", ""]; + + return ( + <TableWrapper columnWidths={columnWidths} headerItems={headerItems}> + <TableWrapper.Header /> + {isLoading && <MemberTableLoader />} + {memberList && <TableWrapper.MemberManageList memberList={memberList} />} + </TableWrapper> + ); +}; + +export default ManageTable; diff --git a/FE/src/components/manage/team/TeamList.tsx b/FE/src/components/manage/team/TeamList.tsx new file mode 100644 index 00000000..e25d6fcd --- /dev/null +++ b/FE/src/components/manage/team/TeamList.tsx @@ -0,0 +1,63 @@ +import classNames from "classnames"; +import Image from "next/image"; +import { useDeleteTeam } from "@/hooks/query/useTeamQuery"; + +interface TeamListProps { + type?: "select" | "manage"; + teamId: number; + teamName: string; + isSelected?: boolean; +} + +const TeamList = ({ + teamId, + teamName, + isSelected = false, + type = "manage", +}: TeamListProps) => { + const { mutate: deleteTeam } = useDeleteTeam(teamId); + const deleteSelectedTeam = (teamId: number) => { + const ok = confirm("정말로 삭제하시겠습니까?"); + if (ok) deleteTeam(teamId); + }; + + const listClassName = classNames( + "flex select-none justify-between rounded border-2 px-4 py-2 shadow-sm", + { + "bg-success-10": isSelected, + }, + ); + return ( + <li className={listClassName}> + <span>{teamName}</span> + {type == "manage" && ( + <div onClick={() => deleteSelectedTeam(teamId)}> + <Image + src="/icons/trash.svg" + width={22} + height={22} + alt="Delete Btn" + /> + </div> + )} + {isSelected && type == "select" && ( + <Image + src="/icons/non_select.svg" + width={22} + height={22} + alt="Delete Btn" + /> + )} + {!isSelected && type == "select" && ( + <Image + src="/icons/select.svg" + width={22} + height={22} + alt="Delete Btn" + /> + )} + </li> + ); +}; + +export default TeamList; diff --git a/FE/src/components/manage/team/TeamManageSection.tsx b/FE/src/components/manage/team/TeamManageSection.tsx new file mode 100644 index 00000000..7c6d7787 --- /dev/null +++ b/FE/src/components/manage/team/TeamManageSection.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useState } from "react"; +import TeamList from "./TeamList"; +import StatusToggleItem from "@/components/common/StatusToggleItem"; +import Title from "@/components/common/Title/Title"; +import { useCreateTeam, useTeamQuery } from "@/hooks/query/useTeamQuery"; + +const TeamManageSection = () => { + const [inputText, setInputText] = useState(""); + const { mutate: createTeam, isLoading: isCreating } = useCreateTeam(); + const { data, isLoading } = useTeamQuery(); + + const createTeamWithName = (e) => { + e.preventDefault(); + createTeam(inputText); + setInputText(""); + }; + + return ( + <section> + <Title text="팀 관리하기" /> + <form className="relative mt-6" onSubmit={createTeamWithName}> + <input + className="w-full rounded border px-4 py-6 text-lg outline-none" + type="text" + placeholder="팀을 등록해주세요!" + name="teamName" + autoComplete="off" + value={inputText} + onChange={(e) => setInputText(e.target.value)} + /> + <button + className="absolute right-4 top-1/2 -translate-y-1/2" + disabled={isCreating} + > + <StatusToggleItem text="등록" color="green" /> + </button> + </form> + + {/* */} + <ul className="mt-6 grid grid-cols-2 gap-4"> + {/* TODO:로더 적용하기 */} + {isLoading && <></>} + + {data && + data.teams.map(({ teamId, teamName }, i) => ( + <TeamList + key={`${teamId}-${teamName}-${i}`} + teamId={teamId} + teamName={teamName} + /> + ))} + </ul> + </section> + ); +}; + +export default TeamManageSection; diff --git a/FE/src/components/programCreate/Participant.tsx b/FE/src/components/programCreate/Participant.tsx new file mode 100644 index 00000000..73d1e3bc --- /dev/null +++ b/FE/src/components/programCreate/Participant.tsx @@ -0,0 +1,38 @@ +"use client"; + +import MemberActiveStatusTab from "../common/tabs/MemberActiveStatusTab"; +import ParticipantTable from "./participantTable/ParticipantTable"; + +interface MemberTableProps { + members: Set<number>; + setMembers: (memberId: number) => void; + clearMembers: () => void; + setAllMembers: (memberList: number[]) => void; +} + +const Participant = ({ + members, + setMembers, + clearMembers, + setAllMembers, +}: MemberTableProps) => { + return ( + <section> + <MemberActiveStatusTab> + {(selectedItem) => ( + <div className="mt-6"> + <ParticipantTable + members={members} + setMembers={setMembers} + selectedActive={selectedItem} + clearMembers={clearMembers} + setAllMembers={setAllMembers} + /> + </div> + )} + </MemberActiveStatusTab> + </section> + ); +}; + +export default Participant; diff --git a/FE/src/components/programCreate/ProgramCreateForm.tsx b/FE/src/components/programCreate/ProgramCreateForm.tsx deleted file mode 100644 index b27a9ea9..00000000 --- a/FE/src/components/programCreate/ProgramCreateForm.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; -import { toast } from "react-toastify"; -import ProgramForm from "../common/form/program/ProgramForm"; -import MemberTable from "../common/memberTable/MemberTable"; -import FORM_INFO from "@/constants/FORM_INFO"; -import { useCreateProgram } from "@/hooks/query/useProgramQuery"; -import useProgramFormData from "@/hooks/useProgramFormData"; - -const ProgramCreateForm = () => { - const queryClient = useQueryClient(); - const [members, setMembers] = useState<Set<number>>(new Set<number>()); - const formData = useProgramFormData(); - const { title, deadLine, content, category, type, reset } = formData; - - const { mutate: createProgramMutate } = useCreateProgram({ - programData: { - members: Array.from(members, (memberId) => ({ memberId })), - title: type === "demand" ? `${FORM_INFO.DEMAND_PREFIX} ${title}` : title, - deadLine: deadLine, - content: content, - category: category, - type: type, - }, - formReset: reset, - }); - - const updateMembers = (memberId: number) => { - const newMembers = new Set<number>(members); - newMembers.has(memberId) - ? newMembers.delete(memberId) - : newMembers.add(memberId); - setMembers(newMembers); - }; - - const updateAllMembers = (selected: boolean) => { - const newMembers = new Set<number>(members); - const memberIdList: number[] = queryClient.getQueryData(["memberIdList"]); - if (selected) { - memberIdList.forEach((v) => newMembers.add(v)); - } - if (!selected) { - memberIdList.forEach((v) => newMembers.delete(v)); - } - setMembers(newMembers); - }; - - const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { - e.preventDefault(); - if (!title || !content || !deadLine || !category || !type) { - toast.error("모든 항목을 입력해주세요."); - return; - } - createProgramMutate(); - }; - - return ( - <ProgramForm formType="create" onSubmit={handleSubmit} {...formData}> - <MemberTable - formType="create" - members={members} - setMembers={updateMembers} - onClickHeaderCheckBox={updateAllMembers} - /> - </ProgramForm> - ); -}; -export default ProgramCreateForm; diff --git a/FE/src/components/programCreate/ProgramGithubLinkInput.tsx b/FE/src/components/programCreate/ProgramGithubLinkInput.tsx new file mode 100644 index 00000000..1d2c6ea6 --- /dev/null +++ b/FE/src/components/programCreate/ProgramGithubLinkInput.tsx @@ -0,0 +1,23 @@ +import { UseFormRegister } from "react-hook-form"; +import LabeldInputFiled from "../common/form/input/LabeldInputFiled"; +import { ProgramFormDataState } from "../common/form/program/CreateForm"; + +interface ProgramGithubLinkInputProps { + register: UseFormRegister<ProgramFormDataState>; +} +const ProgramGithubLinkInput = ({ register }: ProgramGithubLinkInputProps) => { + return ( + <div> + <LabeldInputFiled<ProgramFormDataState> + register={register} + id="programGithubUrl" + label="Github Link" + placeholder="주간 발표 링크 입력하기 (학기/팀/순서 까지의 폴더의 링크를 추가해주세요!)" + type="text" + prefix="" + /> + </div> + ); +}; + +export default ProgramGithubLinkInput; diff --git a/FE/src/components/programCreate/ProgramTeamList.tsx b/FE/src/components/programCreate/ProgramTeamList.tsx new file mode 100644 index 00000000..248bba17 --- /dev/null +++ b/FE/src/components/programCreate/ProgramTeamList.tsx @@ -0,0 +1,71 @@ +import { useEffect } from "react"; +import Label from "../common/form/input/Label"; +import TeamList from "../manage/team/TeamList"; +import useTeam from "@/hooks/useTeam"; +import type { TeamInputInfo } from "@/types/team"; + +interface ProgramTeamListProps { + programId?: number; + selectedTeamList: TeamInputInfo[]; + handleTeamListChange: (teamList: TeamInputInfo[]) => void; +} +const ProgramTeamList = ({ + programId, + selectedTeamList, + handleTeamListChange, +}: ProgramTeamListProps) => { + const { allOfTeams, joinedTeams, isLoading } = useTeam(programId); + + useEffect(() => { + if (joinedTeams) { + handleTeamListChange( + joinedTeams.teams.map((team) => ({ teamId: team.teamId })), + ); + } + }, [joinedTeams]); + + if (isLoading) { + return <></>; + } + + const selectTeam = (teamId: number) => { + const prevSelectedTeamIdArray = selectedTeamList.map((team) => team.teamId); + const isIncluded = prevSelectedTeamIdArray.includes(teamId); + const nextSelected = isIncluded + ? prevSelectedTeamIdArray + .filter((id) => id !== teamId) + .map((id) => ({ teamId: id })) + : [...prevSelectedTeamIdArray, teamId].map((id) => ({ teamId: id })); + handleTeamListChange(nextSelected); + }; + + const isSelected = (teamId: number) => + selectedTeamList.map((team) => team.teamId).includes(teamId); + + return ( + <div> + <Label label="팀 불러오기" /> + <ul className="mt-2 grid grid-cols-2 gap-4"> + {allOfTeams && + allOfTeams.teams.map(({ teamId, teamName }, i) => ( + <div + key={`${teamId}-${teamName}-${i}`} + onClick={(e) => { + e.stopPropagation(); + selectTeam(teamId); + }} + > + <TeamList + teamId={teamId} + teamName={teamName} + type="select" + isSelected={isSelected(teamId)} + /> + </div> + ))} + </ul> + </div> + ); +}; + +export default ProgramTeamList; diff --git a/FE/src/components/programCreate/participantTable/ParticipantTable.tsx b/FE/src/components/programCreate/participantTable/ParticipantTable.tsx new file mode 100644 index 00000000..e4a6402d --- /dev/null +++ b/FE/src/components/programCreate/participantTable/ParticipantTable.tsx @@ -0,0 +1,57 @@ +import MemberTableLoader from "@/components/common/memberTable/MemberTable.loader"; +import TableWrapper from "@/components/common/Table/TableWrapper"; +import { useGetMemberByActive } from "@/hooks/query/useMemberQuery"; +import { ActiveStatusWithAll } from "@/types/member"; + +interface ParticipantTableProps { + members: Set<number>; + setMembers: (memberId: number) => void; + selectedActive: ActiveStatusWithAll; + clearMembers: () => void; + setAllMembers: (memberList: number[]) => void; +} +const ParticipantTable = ({ + members, + setMembers, + selectedActive, + clearMembers, + setAllMembers, +}: ParticipantTableProps) => { + const { data: memberList, isLoading } = useGetMemberByActive(selectedActive); + + const columnWidths = ["4.75rem", "7rem", "7.25rem", "1fr", "20.5rem"]; + const headerItems = ["활동 상태", "이름"]; + + return ( + <TableWrapper + columnWidths={columnWidths} + headerItems={headerItems} + hasCheckBox + > + <TableWrapper.Header + isChecked={members.size === memberList?.length} + handleResetCheckBox={clearMembers} + handleSetCheckBox={() => + setAllMembers(memberList.map(({ memberId }) => memberId)) + } + /> + {isLoading && <MemberTableLoader />} + {memberList && ( + <> + {memberList.map(({ activeStatus, memberId, name }) => ( + <TableWrapper.SelectMemberList + key={memberId} + activeStatus={activeStatus} + handleCheck={setMembers} + isChecked={members.has(memberId)} + memberId={memberId} + name={name} + /> + ))} + </> + )} + </TableWrapper> + ); +}; + +export default ParticipantTable; diff --git a/FE/src/components/programDetail/attendee/AttendeeInfo.container.tsx b/FE/src/components/programDetail/attendee/AttendeeInfo.container.tsx index 4e24007f..5acb6914 100644 --- a/FE/src/components/programDetail/attendee/AttendeeInfo.container.tsx +++ b/FE/src/components/programDetail/attendee/AttendeeInfo.container.tsx @@ -3,7 +3,7 @@ import { ErrorBoundary } from "react-error-boundary"; import AttendeeInfo from "./AttendeeInfo"; import BluredAttedee from "./BluredAttedee"; -import ErrorFallback from "@/components/common/ErrorFallback"; +import ErrorFallback from "@/components/common/error/ErrorFallback"; interface AttendeeInfoContainerProps { programId: number; @@ -27,7 +27,6 @@ const AttendeeInfoContainer = ({ attendStatuses.map((status) => ( <BluredAttedee key={status} status={status} /> ))} - <ErrorBoundary FallbackComponent={ErrorFallback}> <div className="space-y-16"> {isLoggedIn && diff --git a/FE/src/components/programDetail/program/DashboardCompound/Board/Board.tsx b/FE/src/components/programDetail/program/DashboardCompound/Board/Board.tsx new file mode 100644 index 00000000..232b5d56 --- /dev/null +++ b/FE/src/components/programDetail/program/DashboardCompound/Board/Board.tsx @@ -0,0 +1,33 @@ +import { useContext } from "react"; +import { DashboardContext } from "../DashboardWrapper"; +import Chat from "./Chat"; +import { useGetQuestion } from "@/hooks/query/useQuestionQuery"; + +const Board = () => { + const { + teamValues: { selectedTeamId }, + programValue: { programId }, + } = useContext(DashboardContext); + const { data, isLoading, error } = useGetQuestion(programId, selectedTeamId); + + // TODO: Loader 적용, 에러 처리 + if (isLoading) return <div>Loading...</div>; + if (error) return <div>Error...</div>; + + const { comments } = data; + + return ( + <div className="flex max-h-[36rem] w-full flex-col overflow-hidden overflow-y-auto rounded-sm border"> + {comments.length === 0 && ( + <div className="flex h-full items-center justify-center py-20 text-xl text-gray-30"> + 아직 질문이 없습니다. 🥲 + </div> + )} + {comments.map((props) => ( + <Chat key={props.commentId} {...props} /> + ))} + </div> + ); +}; + +export default Board; diff --git a/FE/src/components/programDetail/program/DashboardCompound/Board/Chat.tsx b/FE/src/components/programDetail/program/DashboardCompound/Board/Chat.tsx new file mode 100644 index 00000000..c8c3930f --- /dev/null +++ b/FE/src/components/programDetail/program/DashboardCompound/Board/Chat.tsx @@ -0,0 +1,35 @@ +import ChatList from "./common/ChatList"; +import ReplyChat from "./ReplyChat"; +import { Comment } from "@/apis/dtos/question.dto"; + +const Chat = ({ + commentId, + content, + writer, + time, + answers, + accessRight, +}: Comment) => { + return ( + <div className="border p-4"> + <ChatList + commentId={commentId} + writer={writer} + accessRight={accessRight} + time={time} + content={content} + /> + <div className="mt-8 px-14"> + {answers && ( + <> + {answers.map((props) => ( + <ReplyChat key={props.commentId} {...props} /> + ))} + </> + )} + </div> + </div> + ); +}; + +export default Chat; diff --git a/FE/src/components/programDetail/program/DashboardCompound/Board/ReplyChat.tsx b/FE/src/components/programDetail/program/DashboardCompound/Board/ReplyChat.tsx new file mode 100644 index 00000000..127f5a2a --- /dev/null +++ b/FE/src/components/programDetail/program/DashboardCompound/Board/ReplyChat.tsx @@ -0,0 +1,27 @@ +import ChatList from "./common/ChatList"; +import { Answer } from "@/apis/dtos/question.dto"; + +interface ReplyChatProps extends Answer {} +const ReplyChat = ({ + commentId, + content, + writer, + time, + accessRight, +}: ReplyChatProps) => { + return ( + <div className="border bg-gray-10 p-4"> + <ChatList + writer={writer} + content={content} + time={time} + commentId={commentId} + accessRight={accessRight} + markdownStyle="!bg-inherit" + showReplyButton={false} + /> + </div> + ); +}; + +export default ReplyChat; diff --git a/FE/src/components/programDetail/program/DashboardCompound/Board/common/ChatList.tsx b/FE/src/components/programDetail/program/DashboardCompound/Board/common/ChatList.tsx new file mode 100644 index 00000000..ab84f030 --- /dev/null +++ b/FE/src/components/programDetail/program/DashboardCompound/Board/common/ChatList.tsx @@ -0,0 +1,143 @@ +import { useContext, useState } from "react"; +import { DashboardContext } from "../../DashboardWrapper"; +import MarkdownViewer from "@/components/common/markdown/MarkdownViewer"; + +//TODO: headless 하게 변경해야함 +interface ChatListProps { + commentId: number; + writer: string; + accessRight: "edit" | "read_only"; + time: string; + content: string; + markdownStyle?: string; + showReplyButton?: boolean; +} +const ChatList = ({ + writer, + accessRight, + commentId, + content, + time, + markdownStyle, + showReplyButton = true, +}: ChatListProps) => { + const { + accessType, + commentValues: { + create: { setParentsCommentId, changeSelectedCommentContent }, + update: { updateComment, isUpdateSuccess }, + delete: { deleteComment, isDeleteSuccess }, + }, + } = useContext(DashboardContext); + + const [userInputToModify, setUserInputToModify] = useState(content); + const [isModify, setIsModify] = useState(false); + + const isGuest = accessType === "public"; + const isHasUpdateRight = accessRight === "edit" && !isGuest; + + const handleReply = () => { + setParentsCommentId(commentId); + changeSelectedCommentContent(content); + }; + + const toggleIsModify = () => { + if (!isHasUpdateRight) return; + setIsModify((prev) => !prev); + setUserInputToModify(content); + }; + + const handleUpdateComment = () => { + if (!isModify) return; + + const newContents = userInputToModify; + if (!newContents) return; + + if (content === newContents) { + setIsModify((prev) => !prev); + return; + } + + updateComment({ commentId, contents: newContents }); + isUpdateSuccess && setUserInputToModify(newContents); + + setIsModify((prev) => !prev); + }; + + const handleDeleteComment = () => { + if (!isHasUpdateRight) return; + + const isOkToDelete = confirm("정말 삭제하시겠습니까?"); + if (!isOkToDelete) return; + deleteComment(commentId); + isDeleteSuccess && setUserInputToModify(""); + }; + return ( + <> + <div className="relative my-4 h-2 w-fit translate-y-4 bg-emerald-300"> + <p className="w-fit -translate-y-4 text-lg font-semibold">{writer}</p> + </div> + <div> + {!isModify && ( + <MarkdownViewer value={content} className={markdownStyle} /> + )} + </div> + {isModify && ( + <textarea + className="mt-4 h-40 w-full rounded-sm border-2 p-4 text-lg" + value={userInputToModify} + onChange={(e) => setUserInputToModify(e.target.value)} + /> + )} + <div className="mt-4 flex items-center gap-4"> + <span className="opacity-60">{time}</span> + {!isModify && showReplyButton && ( + <> + {!isGuest && ( + <button + className="opacity-60 transition-all hover:opacity-100" + onClick={handleReply} + > + 답변하기 + </button> + )} + {isHasUpdateRight && ( + <button + className="opacity-60 transition-all hover:opacity-100" + onClick={toggleIsModify} + > + 수정하기 + </button> + )} + {isHasUpdateRight && ( + <button + className="opacity-60 transition-all hover:opacity-100" + onClick={handleDeleteComment} + > + 삭제하기 + </button> + )} + </> + )} + {isModify && ( + <> + <button + className="opacity-60 transition-all hover:opacity-100" + onClick={handleUpdateComment} + > + 수정완료 + </button> + <button + className="opacity-60 transition-all hover:opacity-100" + onClick={toggleIsModify} + > + 취소 + </button> + </> + )} + </div> + </> + ); +}; + +export default ChatList; diff --git a/FE/src/components/programDetail/program/DashboardCompound/DashboardWrapper.tsx b/FE/src/components/programDetail/program/DashboardCompound/DashboardWrapper.tsx new file mode 100644 index 00000000..d567e309 --- /dev/null +++ b/FE/src/components/programDetail/program/DashboardCompound/DashboardWrapper.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { createContext, useState } from "react"; +import Board from "./Board/Board"; +import Input from "./Form"; +import TeamTab from "./TeamTab"; +import { + useDeleteQuestion, + useUpdateQuestion, +} from "@/hooks/query/useQuestionQuery"; +import { useTeamQuery } from "@/hooks/query/useTeamQuery"; +import { AccessType } from "@/types/access"; +import { TeamInfo } from "@/types/team"; + +interface DashboardContextValue { + accessType: AccessType; + programValue: { + readonly programId: number; + }; + teamValues: { + readonly teams: TeamInfo[]; + readonly isLoading: boolean; + readonly selectedTeamId: number; + readonly changeSelectedTeamId: (teamId: number) => void; + }; + commentValues: { + create: { + readonly parentsCommentId: number; + readonly setParentsCommentId: (id: number) => void; + readonly selectedCommentContent: string; + readonly changeSelectedCommentContent: (content: string) => void; + }; + update: { + readonly updateComment: (question: unknown) => void; + readonly isUpdateSuccess: boolean; + }; + delete: { + readonly deleteComment: (question: unknown) => void; + readonly isDeleteSuccess: boolean; + }; + }; + programId: number; + children: React.ReactNode; +} + +export const DashboardContext = createContext<DashboardContextValue>(null); + +interface DashboardWrapperProps { + programId: number; + accessType: AccessType; //TODO: 현재 accessType에 의존하는 중임. 이는 알지 않아도 되는 정보이므로 추후 수정 필요 + children: React.ReactNode; +} +const DashboardWrapper = ({ + programId, + accessType, + children, +}: DashboardWrapperProps) => { + // teamValues + const { data, isLoading } = useTeamQuery(programId); + const { teams } = data || { teams: [] }; + const [selectedTeamId, setSelectedTeamId] = useState<number>(); + const changeSelectedTeamId = (teamId: number) => { + setSelectedTeamId(teamId); + }; + + // comment + const [parentsCommentId, setParentsCommentId] = useState<number>(-1); + const [selectedCommentContent, setSelectedCommentContent] = + useState<string>(""); + const { mutate: updateComment, isSuccess: isUpdateSuccess } = + useUpdateQuestion(); + const { mutate: deleteComment, isSuccess: isDeleteSuccess } = + useDeleteQuestion(); + + const changeSelectedCommentContent = (content: string) => { + setSelectedCommentContent(content); + }; + + if (isLoading) return null; + if (!data || data.teams.length === 0) return null; + + const programValue = { + programId, + }; + + const teamValues = { + teams, + isLoading, + selectedTeamId, + changeSelectedTeamId, + }; + + const commentValues = { + create: { + parentsCommentId, + setParentsCommentId, + selectedCommentContent, + changeSelectedCommentContent, + }, + update: { + updateComment, + isUpdateSuccess, + }, + delete: { + deleteComment, + isDeleteSuccess, + }, + }; + + //TODO: useMemo 사용해야함 _ 급하게 임의로 수정함. 이는 의존성 문제 존재하므로 이부분 보기 + // const DashBoardValue = useMemo(() => { + // return { + // accessType, + // programValue, + // teamValues, + // commentValues, + // programId, + // children, + // }; + // }, [programValue, teamValues, commentValues, programId, children]); + + const DashBoardValue = { + accessType, + programValue, + teamValues, + commentValues, + programId, + children, + }; + + return ( + <DashboardContext.Provider value={DashBoardValue}> + {children} + </DashboardContext.Provider> + ); +}; + +DashboardWrapper.TeamTab = TeamTab; +DashboardWrapper.Board = Board; +DashboardWrapper.Input = Input; + +export default DashboardWrapper; diff --git a/FE/src/components/programDetail/program/DashboardCompound/Form.tsx b/FE/src/components/programDetail/program/DashboardCompound/Form.tsx new file mode 100644 index 00000000..bed90370 --- /dev/null +++ b/FE/src/components/programDetail/program/DashboardCompound/Form.tsx @@ -0,0 +1,83 @@ +//TODO: 답변 취소 아이콘 추가하기 + +import { useContext, useState } from "react"; +import { DashboardContext } from "./DashboardWrapper"; +import { PostQuestionParams } from "@/apis/question"; +import StatusToggleItem from "@/components/common/StatusToggleItem"; +import { usePostQuestion } from "@/hooks/query/useQuestionQuery"; + +const Input = () => { + const { + accessType, + programValue: { programId }, + teamValues: { teams, selectedTeamId }, + commentValues: { + create: { parentsCommentId, setParentsCommentId, selectedCommentContent }, + }, + } = useContext(DashboardContext); + + const [questionInput, setQuestionInput] = useState<string>(""); + + const { mutate } = usePostQuestion(); + const isReply = parentsCommentId !== -1; + const selectedTeamName = teams?.find((team) => team.teamId === selectedTeamId) + ?.teamName; + + const isAbleToPost = accessType === "private"; + + const handlePostQuestion = () => { + const questionContent = questionInput.trim(); + + if (!questionContent) return; + if (!isAbleToPost) return; + + const postQuestionParams: PostQuestionParams = { + programId, + teamId: selectedTeamId, + questionContent, + parentsCommentId, + }; + mutate(postQuestionParams); + setQuestionInput(""); + setParentsCommentId(-1); + }; + + return ( + <div> + {/* <div className="absolute z-10 text-xl font-bold">{name}</div> */} + {isReply ? ( + <div className="truncate text-lg font-semibold"> + {/* <Image src={"/icons/x.svg"} alt="답글 종료" width={20} height={20} /> */} + <button className="px-2 " onClick={() => setParentsCommentId(-1)}> + x + </button> + <p className="inline text-xl font-bold">답변하기 :</p> + <p className="ml-2 inline opacity-50">{selectedCommentContent}</p> + </div> + ) : ( + <p className="text-xl font-bold">@{selectedTeamName} 에게 질문하기</p> + )} + + <div className="mb-2 " /> + <div className="relative"> + <textarea + className={`h-40 w-full resize-none rounded-sm border-2 p-4 px-8 pr-40 text-lg`} + placeholder="질문을 입력해주세요" + value={questionInput} + onChange={(e) => setQuestionInput(e.target.value)} + /> + <button + className="absolute right-4 top-1/2 -translate-y-1/2" + onClick={handlePostQuestion} + > + <StatusToggleItem + color={isAbleToPost ? "green" : "gray"} + text="전송" + /> + </button> + </div> + </div> + ); +}; + +export default Input; diff --git a/FE/src/components/programDetail/program/DashboardCompound/TeamTab.tsx b/FE/src/components/programDetail/program/DashboardCompound/TeamTab.tsx new file mode 100644 index 00000000..f59dae2e --- /dev/null +++ b/FE/src/components/programDetail/program/DashboardCompound/TeamTab.tsx @@ -0,0 +1,46 @@ +import { useContext, useEffect } from "react"; +import { DashboardContext } from "./DashboardWrapper"; +import Tab from "@/components/common/tabs/tab/Tab"; +import { TabOption } from "@/types/tab"; +import { TeamInfo } from "@/types/team"; + +const TeamTab = () => { + const { + teamValues: { teams, isLoading, selectedTeamId, changeSelectedTeamId }, + } = useContext(DashboardContext); + + useEffect(() => { + if (teams && teams.length > 0) { + changeSelectedTeamId(teams[0].teamId); + } + }, [teams]); + + const teamTabOptions: TabOption<number>[] = + teams?.map(({ teamId, teamName }: TeamInfo) => ({ + text: teamName, + type: teamId, + })) || []; + + const handleTeamSelect = (selected: number) => { + changeSelectedTeamId(selected); + }; + + return ( + <div> + {isLoading && <div>로딩중</div>} + {teams && teams.length > 0 && ( + <Tab<number> + selected={selectedTeamId} + baseColor="gray" + size="md" + pointColor="navy" + align="line" + onItemClick={handleTeamSelect} + options={teamTabOptions} + /> + )} + </div> + ); +}; + +export default TeamTab; diff --git a/FE/src/components/programDetail/program/EditAndDeleteButton.tsx b/FE/src/components/programDetail/program/EditAndDeleteButton.tsx index d01500e0..1a0ab9f4 100644 --- a/FE/src/components/programDetail/program/EditAndDeleteButton.tsx +++ b/FE/src/components/programDetail/program/EditAndDeleteButton.tsx @@ -1,16 +1,41 @@ import Image from "next/image"; import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { toast } from "react-toastify"; import MESSAGE from "@/constants/MESSAGE"; import ROUTES from "@/constants/ROUTES"; import { useDeleteProgram } from "@/hooks/query/useProgramQuery"; const EditAndDeleteButton = ({ programId }) => { - const { mutate: deleteProgram } = useDeleteProgram(programId); + const router = useRouter(); + const { mutate: deleteProgram } = useDeleteProgram(); + const handleClickDelete = () => { if (confirm(MESSAGE.CONFIRM.DELETE)) { - deleteProgram(); + const toastId = toast.loading(MESSAGE.DELETE.PENDING); + deleteProgram(programId, { + onSuccess: () => { + toast.update(toastId, { + render: MESSAGE.DELETE.SUCCESS, + type: "success", + autoClose: 3000, + isLoading: false, + closeOnClick: true, + }); + router.replace(ROUTES.ADMIN_MAIN); + }, + onError: () => + toast.update(toastId, { + type: "error", + render: MESSAGE.DELETE.FAILED, + autoClose: 3000, + isLoading: false, + closeOnClick: true, + }), + }); } }; + return ( <div className="flex items-end gap-3 sm:gap-6"> <Link href={ROUTES.EDIT(programId)}> diff --git a/FE/src/components/programDetail/program/ProgramAttendStatusManageSection.tsx b/FE/src/components/programDetail/program/ProgramAttendStatusManageSection.tsx new file mode 100644 index 00000000..3c38b23e --- /dev/null +++ b/FE/src/components/programDetail/program/ProgramAttendStatusManageSection.tsx @@ -0,0 +1,115 @@ +// TODO: 리팩토링 필요 : 중복되는 코드 줄이기 +// 출석 상태값만 받아오는 API 필요 + +import classNames from "classnames"; +import StatusToggleItem from "@/components/common/StatusToggleItem"; +import Title from "@/components/common/Title/Title"; +import { + useGetProgramByProgramId, + // useGetProgramAttendModeAndStatus, + // useGetProgramByProgramId, + useUpdateProgramAttendMode, +} from "@/hooks/query/useProgramQuery"; + +interface ProgramattendModeManageSectionProps { + programId: number; +} +const ProgramattendModeManageSection = ({ + programId, +}: ProgramattendModeManageSectionProps) => { + const { mutate: updateProgramAttendMode, isLoading } = + useUpdateProgramAttendMode(programId); + + const { data, isLoading: isProgramLoading } = useGetProgramByProgramId( + programId, + true, + ); + + if (isProgramLoading) return <div>Loading...</div>; + + const { attendMode, programStatus } = data; + + const toggleBarStyle = classNames("flex gap-8", { + "cursor-wait opacity-50": isLoading || programStatus === "end", + }); + + const setattendModeToAttend = () => { + if (programStatus === "end" || attendMode === "end") return; + if (attendMode === "non_open") return; + updateProgramAttendMode("attend"); + }; + + const setattendModeToLate = () => { + if (programStatus === "end" || attendMode === "end") return; + if (attendMode === "non_open") return; + updateProgramAttendMode("late"); + }; + + const closeAttend = () => { + if (programStatus === "end" || attendMode === "end") return; + updateProgramAttendMode("end"); + }; + + const startAttend = () => { + if (programStatus === "end") return; + updateProgramAttendMode("attend"); + }; + + return ( + <> + <section> + <Title text="출석 체크 관리하기" /> + <div className="mt-4" /> + <div className="flex items-center justify-between rounded-2xl bg-slate-50 p-8"> + <p className="text-xl font-semibold">출석 체크 상태</p> + <div className={toggleBarStyle}> + <div className={"flex rounded-full border bg-gray-10"}> + {/* TODO: switch 로직 짜기 */} + {attendMode === "attend" ? ( + <> + <button onClick={setattendModeToAttend} disabled={isLoading}> + <StatusToggleItem color="green" text="참석" /> + </button> + <button onClick={setattendModeToLate} disabled={isLoading}> + <StatusToggleItem color="gray" text="지각" /> + </button> + </> + ) : attendMode === "late" ? ( + <> + <button onClick={setattendModeToAttend} disabled={isLoading}> + <StatusToggleItem color="gray" text="참석" /> + </button> + <button onClick={setattendModeToLate} disabled={isLoading}> + <StatusToggleItem color="yellow" text="지각" /> + </button> + </> + ) : ( + <> + <button onClick={setattendModeToAttend} disabled={isLoading}> + <StatusToggleItem color="gray" text="참석" /> + </button> + <button onClick={setattendModeToLate} disabled={isLoading}> + <StatusToggleItem color="gray" text="지각" /> + </button> + </> + )} + </div> + {/* TODO: 아에 프로젝트가 끝났을 때 다시 시작 안되도록 수정 필요 */} + {attendMode === "non_open" || attendMode === "end" ? ( + <button onClick={startAttend} disabled={isLoading}> + <StatusToggleItem color="teal" text="출석체크 시작하기" /> + </button> + ) : ( + <button onClick={closeAttend} disabled={isLoading}> + <StatusToggleItem color="red" text="출석체크 종료하기" /> + </button> + )} + </div> + </div> + </section> + <div className="mt-20" /> + </> + ); +}; + +export default ProgramattendModeManageSection; diff --git a/FE/src/components/programDetail/program/ProgramDashboard.tsx b/FE/src/components/programDetail/program/ProgramDashboard.tsx new file mode 100644 index 00000000..205d9ea3 --- /dev/null +++ b/FE/src/components/programDetail/program/ProgramDashboard.tsx @@ -0,0 +1,24 @@ +"use client"; + +import DashboardWrapper from "./DashboardCompound/DashboardWrapper"; +import Title from "@/components/common/Title/Title"; +import { AccessType } from "@/types/access"; + +interface ProgramDashboardProps { + programId: number; + accessType: AccessType; +} +const ProgramDashboard = ({ programId, accessType }: ProgramDashboardProps) => { + return ( + <DashboardWrapper programId={programId} accessType={accessType}> + <Title text="질문 게시판" /> + <div className="mt-8 flex flex-col gap-8"> + <DashboardWrapper.TeamTab /> + <DashboardWrapper.Board /> + <DashboardWrapper.Input /> + </div> + </DashboardWrapper> + ); +}; + +export default ProgramDashboard; diff --git a/FE/src/components/programDetail/program/ProgramDetail.tsx b/FE/src/components/programDetail/program/ProgramDetail.tsx index 27d3104f..7ec29a4b 100644 --- a/FE/src/components/programDetail/program/ProgramDetail.tsx +++ b/FE/src/components/programDetail/program/ProgramDetail.tsx @@ -1,12 +1,35 @@ +"use client"; +import { useQueryClient } from "@tanstack/react-query"; +import ProgramAttendStatusManageSection from "./ProgramAttendStatusManageSection"; +import ProgramDashboard from "./ProgramDashboard"; +import ProgramPresentations from "./ProgramPresentations"; import { ProgramInfoDto } from "@/apis/dtos/program.dto"; import MarkdownViewer from "@/components/common/markdown/MarkdownViewer"; +import { AccessType } from "@/types/access"; interface ProgramDetailProps { data: ProgramInfoDto; + programId: number; + accessType: AccessType; } -const ProgramDetail = ({ data }: ProgramDetailProps) => { +const ProgramDetail = ({ data, programId, accessType }: ProgramDetailProps) => { + const isAdmin = accessType === "admin"; + const { content } = data; - return <MarkdownViewer value={content} />; + + const queryClient = useQueryClient(); + const githubUrl = queryClient.getQueryData(["githubUrl", programId]); + + return ( + <div> + <MarkdownViewer value={content} /> + {isAdmin && <ProgramAttendStatusManageSection programId={programId} />} + {githubUrl && <ProgramPresentations programId={programId} />} + <div className="mt-12"> + <ProgramDashboard programId={programId} accessType={accessType} /> + </div> + </div> + ); }; export default ProgramDetail; diff --git a/FE/src/components/programDetail/program/ProgramHeader.tsx b/FE/src/components/programDetail/program/ProgramHeader.tsx index d83e1a02..cd4b1ac3 100644 --- a/FE/src/components/programDetail/program/ProgramHeader.tsx +++ b/FE/src/components/programDetail/program/ProgramHeader.tsx @@ -1,15 +1,15 @@ import EditAndDeleteButton from "./EditAndDeleteButton"; import { ProgramInfoDto } from "@/apis/dtos/program.dto"; -import TabItem from "@/components/common/tabs/TabItem"; -import Title from "@/components/common/Title"; +import TabItem from "@/components/common/tabs/tab/TabItem"; +import Title from "@/components/common/Title/Title"; import PROGRAM from "@/constants/PROGRAM"; -import { convertDate } from "@/utils/convert"; +import { formatTimestamp } from "@/utils/convert"; interface ProgramHeaderProps { data: ProgramInfoDto; } -const DEADLINE_TEXT = "마감기한 : "; +const DEADLINE_TEXT = "행사일정 : "; const ProgramHeader = ({ data }: ProgramHeaderProps) => { const { category, title, deadLine, programId, accessRight } = data; @@ -21,7 +21,9 @@ const ProgramHeader = ({ data }: ProgramHeaderProps) => { <TabItem color="yellow" size="sm" text={categoryText} rounded /> <Title text={title} /> <div className="flex justify-between"> - <p className="sm:text-lg">{DEADLINE_TEXT + convertDate(deadLine)}</p> + <p className="sm:text-lg"> + {DEADLINE_TEXT + formatTimestamp(deadLine)} + </p> {accessRight === "edit" && ( <EditAndDeleteButton programId={programId} /> )} diff --git a/FE/src/components/programDetail/program/ProgramInfo.tsx b/FE/src/components/programDetail/program/ProgramInfo.tsx index 6c9a016c..319b411f 100644 --- a/FE/src/components/programDetail/program/ProgramInfo.tsx +++ b/FE/src/components/programDetail/program/ProgramInfo.tsx @@ -3,19 +3,21 @@ import ProgramDetail from "./ProgramDetail"; import ProgramHeader from "./ProgramHeader"; import ProgramInfoLoader from "./ProgramInfo.loader"; -import { useGetProgramById } from "@/hooks/query/useProgramQuery"; +import { useGetProgramByProgramId } from "@/hooks/query/useProgramQuery"; +import { AccessType } from "@/types/access"; interface ProgramInfoProps { programId: number; - isLoggedIn: boolean; + accessType?: AccessType; } -const ProgramInfo = ({ programId, isLoggedIn }: ProgramInfoProps) => { +const ProgramInfo = ({ programId, accessType }: ProgramInfoProps) => { + const isAbleToEdit = accessType === "admin"; const { data: programData, isLoading, isError, - } = useGetProgramById(programId, isLoggedIn); + } = useGetProgramByProgramId(programId, isAbleToEdit); if (isLoading) return <ProgramInfoLoader />; if (isError) return <div>에러 발생</div>; @@ -23,7 +25,11 @@ const ProgramInfo = ({ programId, isLoggedIn }: ProgramInfoProps) => { return ( <section className="space-y-8"> <ProgramHeader data={programData} /> - <ProgramDetail data={programData} /> + <ProgramDetail + data={programData} + programId={programId} + accessType={accessType} + /> </section> ); }; diff --git a/FE/src/components/programDetail/program/ProgramPresentations.tsx b/FE/src/components/programDetail/program/ProgramPresentations.tsx new file mode 100644 index 00000000..e38a302c --- /dev/null +++ b/FE/src/components/programDetail/program/ProgramPresentations.tsx @@ -0,0 +1,51 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import Title from "@/components/common/Title/Title"; +import usePresentations from "@/hooks/query/usePresentations"; + +interface ProgramPresentationsProps { + programId: number; +} +const ProgramPresentations = ({ programId }: ProgramPresentationsProps) => { + const { + data: presentations, + isLoading, + isError, + } = usePresentations(programId); + + if (isError) return null; + + if (isLoading) return null; + if (!presentations) return null; + if (presentations.length === 0) return null; + return ( + <section> + <Title text="발표자료 " /> + {/* TODO: 로더 적용하기 */} + {isLoading && <div>로딩중...</div>} + <div className="mx-auto mt-8 grid w-fit gap-x-40 gap-y-8 px-8 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> + {presentations && + presentations.map(({ download_url, name }) => ( + <Link + key={name} + className="flex gap-4" + href={download_url} + target="_blank" + > + <Image + src="/icons/folder.svg" + width={20} + height={20} + alt="folder" + /> + {name} + </Link> + ))} + </div> + </section> + ); +}; + +export default ProgramPresentations; diff --git a/FE/src/components/programDetail/userAttendModal/AttendStatusView.tsx b/FE/src/components/programDetail/userAttendModal/AttendStatusView.tsx index d8d51365..e925d20e 100644 --- a/FE/src/components/programDetail/userAttendModal/AttendStatusView.tsx +++ b/FE/src/components/programDetail/userAttendModal/AttendStatusView.tsx @@ -1,6 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { UserAttendStatusInfoDto } from "@/apis/dtos/user.dto"; -import StatusToggleItem from "@/components/common/attendStatusToggle/StatusToggleItem"; +import StatusToggleItem from "@/components/common/StatusToggleItem"; import ATTEND_STATUS from "@/constants/ATTEND_STATUS"; import { ProgramType } from "@/types/program"; @@ -17,7 +17,10 @@ const AttendStatusView = ({ userInfo, programId }: AttendStatusViewProps) => { programId, ]); + console.log(programType); + console.log(attendStatus); const { demand_text, text, color } = ATTEND_STATUS.USER[attendStatus]; + console.log(demand_text, text, color); const isDemandNonResponse = programType === "demand" && attendStatus === "nonResponse"; const displayText = isDemandNonResponse ? demand_text : text; diff --git a/FE/src/components/programDetail/userAttendModal/LoginModal.tsx b/FE/src/components/programDetail/userAttendModal/LoginModal.tsx index d3f4a3bf..b710a4c1 100644 --- a/FE/src/components/programDetail/userAttendModal/LoginModal.tsx +++ b/FE/src/components/programDetail/userAttendModal/LoginModal.tsx @@ -1,7 +1,7 @@ // import AttendToggleLabel from "./AttendToggleLabel"; import Link from "next/link"; -import AttendStatusToggle from "@/components/common/attendStatusToggle/AttendStatusToggle"; -import StatusToggleItem from "@/components/common/attendStatusToggle/StatusToggleItem"; +import StatusToggleItem from "@/components/common/StatusToggleItem"; +import AttendStatusToggle from "@/components/common/toggle/AttendStatusToggle"; import ROUTES from "@/constants/ROUTES"; export default function LoginModal() { diff --git a/FE/src/components/programDetail/userAttendModal/UserAttendModal.container.tsx b/FE/src/components/programDetail/userAttendModal/UserAttendModal.container.tsx index 653782ad..db995889 100644 --- a/FE/src/components/programDetail/userAttendModal/UserAttendModal.container.tsx +++ b/FE/src/components/programDetail/userAttendModal/UserAttendModal.container.tsx @@ -5,7 +5,7 @@ import Image from "next/image"; import { ErrorBoundary } from "react-error-boundary"; import LoginModal from "./LoginModal"; import UserAttendModal from "./UserAttendModal"; -import ErrorFallbackNoIcon from "@/components/common/ErrorFallbackNoIcon"; +import ErrorFallbackNoIcon from "@/components/common/error/ErrorFallbackNoIcon"; import useModal from "@/hooks/useModal"; import useOutsideRef from "@/hooks/useOutsideRef"; diff --git a/FE/src/components/programDetail/userAttendModal/UserAttendModal.tsx b/FE/src/components/programDetail/userAttendModal/UserAttendModal.tsx index ad1a8efb..52d79854 100644 --- a/FE/src/components/programDetail/userAttendModal/UserAttendModal.tsx +++ b/FE/src/components/programDetail/userAttendModal/UserAttendModal.tsx @@ -2,11 +2,11 @@ import { useQueryClient } from "@tanstack/react-query"; import AttendStatusModalLoader from "./AttendStatusModal.loader"; import AttendStatusView from "./AttendStatusView"; import AttendToggleLabel from "./AttendToggleLabel"; -import AttendStatusToggle from "@/components/common/attendStatusToggle/AttendStatusToggle"; +import StatusToggleItem from "@/components/common/StatusToggleItem"; import MESSAGE from "@/constants/MESSAGE"; import { useGetMyAttendStatus, - usePutMyAttendStatus, + usePostMyAttendance, } from "@/hooks/query/useUserQuery"; import { EditableStatus } from "@/types/attendStatusModal"; import { AttendStatus } from "@/types/member"; @@ -19,10 +19,7 @@ interface UserAttendModalProps { const UserAttendModal = ({ programId }: UserAttendModalProps) => { const queryClient = useQueryClient(); const { data: userInfo, isLoading } = useGetMyAttendStatus(programId); - const { mutate: updateAttendStatus } = usePutMyAttendStatus({ - programId, - beforeAttendStatus: userInfo ? userInfo.attendStatus : "nonRelated", - }); + const { mutate: updateAttendStatus } = usePostMyAttendance(programId); if (isLoading) return <AttendStatusModalLoader />; @@ -37,25 +34,29 @@ const UserAttendModal = ({ programId }: UserAttendModalProps) => { programStatus: ProgramStatus, ): EditableStatus => { if (attendStatus === "nonRelated") return "NON_RELATED"; - if (programStatus !== "active") return "INACTIVE"; + if (programStatus === "end" || attendStatus === "absent") return "INACTIVE"; + if (attendStatus === "attend" || attendStatus === "late") + return "ALREADY_ATTENDED"; return "EDITABLE"; }; const editableStatus = getEditableStatus(attendStatus, programStatus); - const handleSelectorClick = (value: AttendStatus) => { - confirm(MESSAGE.CONFIRM.EDIT) && updateAttendStatus(value); + const handleSelectorClick = () => { + if (editableStatus === "EDITABLE") + confirm(MESSAGE.CONFIRM.EDIT) && updateAttendStatus(); }; return ( <> <AttendStatusView userInfo={userInfo} programId={programId} /> <AttendToggleLabel editableStatus={editableStatus} /> - <AttendStatusToggle - selectedValue={attendStatus} - disabled={editableStatus !== "EDITABLE"} - onSelect={(v) => handleSelectorClick(v)} - /> + <div onClick={handleSelectorClick}> + <StatusToggleItem + color={editableStatus == "EDITABLE" ? "green" : "gray"} + text="출석 하기" + /> + </div> </> ); }; diff --git a/FE/src/components/programEdit/AttendStateTable/AttendStateTable.tsx b/FE/src/components/programEdit/AttendStateTable/AttendStateTable.tsx new file mode 100644 index 00000000..eea6f1d9 --- /dev/null +++ b/FE/src/components/programEdit/AttendStateTable/AttendStateTable.tsx @@ -0,0 +1,51 @@ +import MemberTableLoader from "@/components/common/memberTable/MemberTable.loader"; +import TableWrapper from "@/components/common/Table/TableWrapper"; +import { useGetProgramMembersByActive } from "@/hooks/query/useMemberQuery"; +import { ActiveStatusWithAll } from "@/types/member"; + +interface AttendStateTableProps { + programId: number; + selectedActive: ActiveStatusWithAll; + isEnableEdit: boolean; + setMembers: (memberId: number, before: string, after: string) => void; +} +const AttendStateTable = ({ + programId, + isEnableEdit, + selectedActive, + setMembers, +}: AttendStateTableProps) => { + const { data: editMemberList, isLoading } = useGetProgramMembersByActive({ + programId, + status: selectedActive, + }); + + const columnWidth = ["4.75rem", "7rem", "7.25rem", "1fr", "20.5rem"]; + const headerItems = ["대상", "활동 상태", "이름", "", "출석 상태"]; + + return ( + <TableWrapper columnWidths={columnWidth} headerItems={headerItems}> + <TableWrapper.Header /> + {isLoading && <MemberTableLoader />} + {editMemberList && ( + <> + {editMemberList.map( + ({ activeStatus, attendStatus, memberId, name }) => ( + <TableWrapper.EditMemberList + key={memberId} + activeStatus={activeStatus} + initAttendStatus={attendStatus} + memberId={memberId} + name={name} + setMembers={setMembers} + isEditable={isEnableEdit} + /> + ), + )} + </> + )} + </TableWrapper> + ); +}; + +export default AttendStateTable; diff --git a/FE/src/components/programEdit/EditForm.tsx b/FE/src/components/programEdit/EditForm.tsx new file mode 100644 index 00000000..116f210b --- /dev/null +++ b/FE/src/components/programEdit/EditForm.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { toast } from "react-toastify"; +import FormBtn from "../common/form/FormBtn"; +import CreateCategory from "../common/form/program/CreateCategory"; +import { ProgramFormDataState } from "../common/form/program/CreateForm"; +import ProgramDate from "../common/form/program/ProgramDate"; +import ProgramTitle from "../common/form/program/ProgramTitle"; +import LoadingSpinner from "../common/LoadingSpinner"; +import MarkdownEditor from "../common/markdown/MarkdownEditor"; +import ProgramGithubLinkInput from "../programCreate/ProgramGithubLinkInput"; +import ProgramTeamList from "../programCreate/ProgramTeamList"; +import ParticipantStateSection from "./ParticipantStateSection"; +import { ProgramInfoDto } from "@/apis/dtos/program.dto"; +import FORM_INFO from "@/constants/FORM_INFO"; +import MESSAGE from "@/constants/MESSAGE"; +import { + useGetProgramByProgramId, + useUpdateProgram, +} from "@/hooks/query/useProgramQuery"; +import { useMemberMap } from "@/hooks/useMemberForm"; +import { ProgramCategory } from "@/types/program"; +import { TeamInputInfo } from "@/types/team"; +import { checkIsValidateGithubUrl } from "@/utils/github"; + +const initialState: ProgramFormDataState = { + title: "", + deadLine: new Date().getTime().toString(), + isDemand: false, + category: "weekly", + content: "", + programGithubUrl: "", + teamList: [], +}; + +interface EditFormProps { + programId: number; +} +const EditForm = ({ programId }: EditFormProps) => { + const route = useRouter(); + const { members, updateMembers } = useMemberMap(); + + const { register, handleSubmit, getValues, reset, watch, setValue } = + useForm<ProgramFormDataState>({ + defaultValues: initialState, + }); + + const { data: programInfo, isLoading: isProgrmaLoading } = + useGetProgramByProgramId(+programId, true); + + const isDemand = watch("isDemand"); + + const setData = (data: ProgramInfoDto) => { + const { title, deadLine, content, category, type, programGithubUrl } = data; + setValue("title", title); + setValue("deadLine", deadLine); + setValue("content", content); + setValue("category", category); + setValue("isDemand", type === "demand"); + setValue("programGithubUrl", programGithubUrl); + }; + + useEffect(() => { + if (isProgrmaLoading) return; + setData(programInfo); + }, [programInfo]); + + const { mutate: updateProgramMutate } = useUpdateProgram({ + programId: +programId, + }); + + const onSubmit: SubmitHandler<ProgramFormDataState> = (data) => { + // TODO: 함수로 분리하기 + const { + title, + content, + deadLine, + category, + isDemand, + programGithubUrl, + teamList, + } = data; + + if (!title || !content || !deadLine || !category || !programGithubUrl) { + toast.error("모든 항목을 입력해주세요."); + return; + } + + const isValidGithubUrl = checkIsValidateGithubUrl(programGithubUrl); + + if (!isValidGithubUrl) { + toast.error("올바른 Github URL을 입력해주세요."); + return; + } + + const toastId = toast.loading(MESSAGE.EDIT.PENDING); + + updateProgramMutate( + { + title, + deadLine, + content, + category, + type: isDemand ? "demand" : "notification", + teams: teamList, + programGithubUrl, + members: Array.from( + members, + ([memberId, { beforeAttendStatus, afterAttendStatus }]) => ({ + memberId, + beforeAttendStatus, + afterAttendStatus, + }), + ), + }, + { + onSuccess: () => { + toast.update(toastId, { + render: MESSAGE.EDIT.SUCCESS, + type: "success", + isLoading: false, + autoClose: 3000, + }); + + reset(); + route.back(); + }, + onError: () => { + toast.error(MESSAGE.EDIT.FAILED); + toast.update(toastId, { + render: MESSAGE.EDIT.FAILED, + type: "error", + isLoading: false, + autoClose: 3000, + }); + }, + }, + ); + }; + + const isLoading = isProgrmaLoading || !programInfo; + + if (isLoading) return <LoadingSpinner />; + const handleReset = () => { + reset(); + route.back(); + }; + + return ( + <form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}> + <ProgramTitle + register={register} + prefix={isDemand && FORM_INFO.DEMAND_PREFIX} + formType="create" + isDemand={isDemand} + /> + <div className="flex flex-col items-end gap-8 sm:flex-row"> + <ProgramDate setValue={setValue} getValues={getValues} /> + <CreateCategory + selectedCategory={watch("category")} + setCategory={(category: ProgramCategory) => + setValue("category", category) + } + /> + </div> + <MarkdownEditor + id={FORM_INFO.PROGRAM.CONTENT.id} + label={FORM_INFO.PROGRAM.CONTENT.label} + placeholder={FORM_INFO.PROGRAM.CONTENT.placeholder} + value={watch("content")} + onChange={(v) => setValue("content", v)} + /> + <div className="my-4 flex flex-col gap-4"> + <ProgramGithubLinkInput register={register} /> + <ProgramTeamList + selectedTeamList={watch("teamList")} + handleTeamListChange={(teamList: TeamInputInfo[]) => + setValue("teamList", teamList) + } + /> + </div> + <ParticipantStateSection + programId={programId} + setMembers={updateMembers} + /> + <FormBtn + submitText={FORM_INFO.SUBMIT_TEXT["edit"]} + formReset={() => { + route.back(); + handleReset; + }} + /> + </form> + ); +}; + +export default EditForm; diff --git a/FE/src/components/programEdit/EditMemberTableItemContainer.tsx b/FE/src/components/programEdit/EditMemberTableItemContainer.tsx deleted file mode 100644 index 8d6898f3..00000000 --- a/FE/src/components/programEdit/EditMemberTableItemContainer.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import MemberTableLoader from "../common/memberTable/MemberTable.loader"; -import EditMemberTableItem from "./EditMemberTableItem"; -import { useGetProgramMembersByActive } from "@/hooks/query/useMemberQuery"; -import { ActiveStatusWithAll, AttendStatus } from "@/types/member"; - -interface EditMemberTableItemContainerProps { - setMembers: ( - memberId: number, - before: AttendStatus, - after: AttendStatus, - ) => void; - status: ActiveStatusWithAll; - programId: number; - isEditable?: boolean; -} - -const EditMemberTableItemContainer = ({ - setMembers, - programId, - status, - isEditable = true, -}: EditMemberTableItemContainerProps) => { - const { data: memberList, isLoading } = useGetProgramMembersByActive({ - programId, - status, - }); - - if (isLoading) return <MemberTableLoader />; - - return ( - <> - {memberList.map((member) => ( - <EditMemberTableItem - key={member.memberId} - memberId={member.memberId} - name={member.name} - activeStatus={member.activeStatus} - initAttendStatus={member.attendStatus} - setMembers={setMembers} - isEditable={isEditable} - /> - ))} - </> - ); -}; -export default EditMemberTableItemContainer; diff --git a/FE/src/components/programEdit/ParticipantStateSection.tsx b/FE/src/components/programEdit/ParticipantStateSection.tsx new file mode 100644 index 00000000..29c1329b --- /dev/null +++ b/FE/src/components/programEdit/ParticipantStateSection.tsx @@ -0,0 +1,37 @@ +import { useGetProgramByProgramId } from "@/hooks/query/useProgramQuery"; +import MemberActiveStatusTab from "../common/tabs/MemberActiveStatusTab"; +import AttendStateTable from "./AttendStateTable/AttendStateTable"; + +interface ParticipantStateSectionProps { + programId: number; + setMembers: (memberId: number, before: string, after: string) => void; +} + +const ParticipantStateSection = ({ + programId, + setMembers, +}: ParticipantStateSectionProps) => { + const { data: programData } = useGetProgramByProgramId(programId, true); + + const isEnableEdit = + programData?.attendMode !== "attend" && programData?.attendMode !== "late"; + + return ( + <section> + <MemberActiveStatusTab> + {(selectedItem) => ( + <div className="mt-6"> + <AttendStateTable + programId={programId} + selectedActive={selectedItem} + isEnableEdit={isEnableEdit} + setMembers={setMembers} + /> + </div> + )} + </MemberActiveStatusTab> + </section> + ); +}; + +export default ParticipantStateSection; diff --git a/FE/src/components/programEdit/ProgramEditForm.tsx b/FE/src/components/programEdit/ProgramEditForm.tsx deleted file mode 100644 index 44c0e450..00000000 --- a/FE/src/components/programEdit/ProgramEditForm.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { toast } from "react-toastify"; -import ProgramForm from "../common/form/program/ProgramForm"; -import MemberTable from "../common/memberTable/MemberTable"; -import { useUpdateProgram } from "@/hooks/query/useProgramQuery"; -import useProgramFormData from "@/hooks/useProgramFormData"; -import { AttendStatus } from "@/types/member"; -import { ProgramInfo } from "@/types/program"; - -interface ProgramEditFormProps { - programId: string; - programInfo: Omit<ProgramInfo, "programId">; -} - -export interface Members { - beforeAttendStatus: AttendStatus; - afterAttendStatus: AttendStatus; -} - -const ProgramEditForm = ({ programId, programInfo }: ProgramEditFormProps) => { - const formData = useProgramFormData(programInfo); - const [members, setMembers] = useState<Map<number, Members>>(new Map()); - - const { mutate: updateProgramMutate } = useUpdateProgram({ - programId: +programId, - body: { - title: formData.title, - deadLine: formData.deadLine, - content: formData.content, - category: formData.category, - type: formData.type, - members: Array.from( - members, - ([memberId, { beforeAttendStatus, afterAttendStatus }]) => ({ - memberId, - beforeAttendStatus, - afterAttendStatus, - }), - ), - }, - }); - - const updateMembers = ( - memberId: number, - before: AttendStatus, - after: AttendStatus, - ) => { - const newMembers = new Map<number, Members>(members); - - if (before === after) { - newMembers.delete(memberId); - } - if (before !== after) { - newMembers.set(memberId, { - beforeAttendStatus: before, - afterAttendStatus: after, - }); - } - setMembers(newMembers); - }; - - const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { - e.preventDefault(); - if ( - !formData.title || - !formData.content || - !formData.deadLine || - !formData.category || - !formData.type - ) { - toast.error("모든 항목을 입력해주세요."); - return; - } - updateProgramMutate(); - }; - - return ( - <ProgramForm formType="edit" onSubmit={handleSubmit} {...formData}> - <MemberTable - formType="edit" - members={members} - setMembers={updateMembers} - programId={+programId} - isEditable={programInfo.programStatus !== "active"} - /> - </ProgramForm> - ); -}; -export default ProgramEditForm; diff --git a/FE/src/components/teamBuildingDetail/inputStatus/InputStatusInfo.container.tsx b/FE/src/components/teamBuildingDetail/inputStatus/InputStatusInfo.container.tsx index c72d7e0c..0c124e8f 100644 --- a/FE/src/components/teamBuildingDetail/inputStatus/InputStatusInfo.container.tsx +++ b/FE/src/components/teamBuildingDetail/inputStatus/InputStatusInfo.container.tsx @@ -2,7 +2,7 @@ import { ErrorBoundary } from "react-error-boundary"; import InputStatusInfo from "./InputStatusInfo"; -import ErrorFallback from "@/components/common/ErrorFallback"; +import ErrorFallback from "@/components/common/error/ErrorFallback"; const InputStatusInfoContainer = () => { return ( diff --git a/FE/src/components/teamBuildingDetail/teamBuilding/TeamBuildingHeader.tsx b/FE/src/components/teamBuildingDetail/teamBuilding/TeamBuildingHeader.tsx index 9b815679..6e6eff84 100644 --- a/FE/src/components/teamBuildingDetail/teamBuilding/TeamBuildingHeader.tsx +++ b/FE/src/components/teamBuildingDetail/teamBuilding/TeamBuildingHeader.tsx @@ -1,6 +1,7 @@ "use client"; -import Title from "@/components/common/Title"; +import ProgressDisplay from "@/components/common/ProgressDisplay"; +import Title from "@/components/common/Title/Title"; import { useCompleteTeamBuildingMutation, useDeleteTeamBuildingMutation, @@ -20,25 +21,15 @@ const TeamBuildingHeader = ({ <section className="flex justify-between border-b-2 py-4"> <Title text={title} /> <div className="flex gap-6"> - {accessRight === "read_only" && <ProgressDisplay />} + {accessRight === "read_only" && ( + <ProgressDisplay progressText="진행중" color="action" /> + )} {accessRight === "edit" && <CloseBtn />} </div> </section> ); }; -const ProgressDisplay = () => { - return ( - <div className="flex items-center gap-3"> - <span className="relative flex h-3 w-3"> - <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-action-20 opacity-40"></span> - <span className="relative inline-flex h-3 w-3 rounded-full bg-action-20"></span> - </span> - <p className="text-lg font-bold text-action-20">진행중</p> - </div> - ); -}; - const CloseBtn = () => { const { mutate: completeTeamBuilding } = useCompleteTeamBuildingMutation(); const { mutate: deleteTeamBuilding } = useDeleteTeamBuildingMutation(); diff --git a/FE/src/components/teamBuildingDetail/userInputModal/InputStatusView.tsx b/FE/src/components/teamBuildingDetail/userInputModal/InputStatusView.tsx index 5e2f58ca..0f703ab9 100644 --- a/FE/src/components/teamBuildingDetail/userInputModal/InputStatusView.tsx +++ b/FE/src/components/teamBuildingDetail/userInputModal/InputStatusView.tsx @@ -1,4 +1,4 @@ -import StatusToggleItem from "@/components/common/attendStatusToggle/StatusToggleItem"; +import StatusToggleItem from "@/components/common/StatusToggleItem"; import INPUT_STATUS from "@/constants/INPUT_STATUS"; import { InputStatus } from "@/types/teamBuilding"; diff --git a/FE/src/components/teamBuildingDetail/userInputModal/SentenceForm.tsx b/FE/src/components/teamBuildingDetail/userInputModal/SentenceForm.tsx index 941ecc16..4585e074 100644 --- a/FE/src/components/teamBuildingDetail/userInputModal/SentenceForm.tsx +++ b/FE/src/components/teamBuildingDetail/userInputModal/SentenceForm.tsx @@ -2,7 +2,7 @@ import classNames from "classnames"; import { Dispatch, SetStateAction, useEffect, useRef } from "react"; import { toast } from "react-toastify"; import { FieldType } from "./SentenceField"; -import Button from "@/components/common/Button"; +import Button from "@/components/common/Button/Button"; import { usePostSentenceMutation, usePutSentenceMutation, diff --git a/FE/src/components/teamBuildingDetail/userInputModal/UserInputModal.container.tsx b/FE/src/components/teamBuildingDetail/userInputModal/UserInputModal.container.tsx index acd89f24..1fbafdb7 100644 --- a/FE/src/components/teamBuildingDetail/userInputModal/UserInputModal.container.tsx +++ b/FE/src/components/teamBuildingDetail/userInputModal/UserInputModal.container.tsx @@ -4,7 +4,7 @@ import classNames from "classnames"; import Image from "next/image"; import { ErrorBoundary } from "react-error-boundary"; import UserInputModal from "./UserInputModal"; -import ErrorFallbackNoIcon from "@/components/common/ErrorFallbackNoIcon"; +import ErrorFallbackNoIcon from "@/components/common/error/ErrorFallbackNoIcon"; import useModal from "@/hooks/useModal"; import useOutsideRef from "@/hooks/useOutsideRef"; diff --git a/FE/src/components/teamBuildingResult/TeamBuildingCloseBtn.tsx b/FE/src/components/teamBuildingResult/TeamBuildingCloseBtn.tsx index e6ec745f..3539b6e8 100644 --- a/FE/src/components/teamBuildingResult/TeamBuildingCloseBtn.tsx +++ b/FE/src/components/teamBuildingResult/TeamBuildingCloseBtn.tsx @@ -1,4 +1,4 @@ -import Button from "../common/Button"; +import Button from "../common/Button/Button"; import { useCloseTeamBuildingMutation } from "@/hooks/query/useTeamBuildingQuery"; const TeamBuildingCloseBtn = () => { diff --git a/FE/src/constants/ACTIVE_STATUS.ts b/FE/src/constants/ACTIVE_STATUS.ts index fb2fa0f4..fa89e73d 100644 --- a/FE/src/constants/ACTIVE_STATUS.ts +++ b/FE/src/constants/ACTIVE_STATUS.ts @@ -1,3 +1,4 @@ +import { StatusToggleItemColor } from "@/components/common/StatusToggleItem"; import { ActiveStatus, ActiveStatusWithAll } from "@/types/member"; import { TabOption } from "@/types/tab"; @@ -7,6 +8,11 @@ type ActiveStatusTab = { type ActiveStatusWithAllTab = { [key in ActiveStatusWithAll]: TabOption<ActiveStatusWithAll>; }; +type ActiveStatusWithColorTab = { + [key in ActiveStatus]: TabOption<ActiveStatus> & { + color: StatusToggleItemColor; + }; +}; const TAB: ActiveStatusTab = { am: { type: "am", text: "AM" }, @@ -20,7 +26,15 @@ const TAB_WITH_ALL: ActiveStatusWithAllTab = { ...TAB, }; +const TAB_WITH_COLOR: ActiveStatusWithColorTab = { + am: { type: "am", text: "AM", color: "green" }, + rm: { type: "rm", text: "RM", color: "yellow" }, + cm: { type: "cm", text: "CM", color: "red" }, + ob: { type: "ob", text: "OB", color: "teal" }, +}; + Object.freeze(TAB); Object.freeze(TAB_WITH_ALL); +Object.freeze(TAB_WITH_COLOR); -export default { TAB, TAB_WITH_ALL }; +export default { TAB, TAB_WITH_ALL, TAB_WITH_COLOR }; diff --git a/FE/src/constants/API.ts b/FE/src/constants/API.ts index 2349f8ed..18c0395a 100644 --- a/FE/src/constants/API.ts +++ b/FE/src/constants/API.ts @@ -4,15 +4,18 @@ const PROGRAM = { GUEST_LIST: "/guest/programs", UPDATE: (programId: number) => `/programs/${programId}`, DELETE: (programId: number) => `/programs/${programId}`, - DETAIL: (programId: number) => `/programs/${programId}`, - GUEST_DETAIL: (programId: number) => `/guest/programs/${programId}`, + DETAIL: (programId: number) => `/guest/programs/${programId}`, + Edit_DETAIL: (programId: number) => `/programs/${programId}`, ACCESS_RIGHT: (programId: number) => `/programs/${programId}/accessRight`, SEND_MESSAGE: (programId: number) => `/programs/${programId}/slack/notification`, + UPDATE_ATTEND_MODE: (programId: number) => `/programs/${programId}`, }; const MEMBER = { LIST: "/members", + UPDATE: (memberId: number) => `/members/activeStatus/${memberId}`, + DELETE: (memberId: number) => `/members/${memberId}`, ACTIVE_STATUS: (programId: number) => `/programs/${programId}/members`, ATTEND_STATUS: (programId: number) => `/attend/programs/${programId}/members`, }; @@ -25,6 +28,7 @@ const USER = { const AUTH = { SLACK_LOGIN: "/auth/login/slack", TOKEN_REISSUE: "/auth/reissue", + ADMIN_LOGIN: "/auth/login", }; const TEAM_BUILDING = { @@ -39,10 +43,25 @@ const TEAM_BUILDING = { DELETE: "/team-building", }; +const TEAM = { + LIST: "/teams", + CREATE: "/teams", + DELETE: (teamId: number) => `/teams/${teamId}`, +}; + +const QUESTION = { + LIST: "comments", + CREATE: "comments", + UPDATE: (commentId: number) => `comments/${commentId}`, + DELETE: (commentId: number) => `comments/${commentId}`, +}; + Object.freeze(PROGRAM); Object.freeze(MEMBER); Object.freeze(USER); Object.freeze(AUTH); Object.freeze(TEAM_BUILDING); +Object.freeze(TEAM); +Object.freeze(QUESTION); -export default { PROGRAM, MEMBER, USER, AUTH, TEAM_BUILDING }; +export default { PROGRAM, MEMBER, USER, AUTH, TEAM_BUILDING, TEAM, QUESTION }; diff --git a/FE/src/constants/ATTEND_STATUS.ts b/FE/src/constants/ATTEND_STATUS.ts index b3456237..2c40e9d9 100644 --- a/FE/src/constants/ATTEND_STATUS.ts +++ b/FE/src/constants/ATTEND_STATUS.ts @@ -1,8 +1,9 @@ +import { StatusToggleItemColor } from "@/components/common/StatusToggleItem"; import { AttendStatus } from "@/types/member"; import { TabOption } from "@/types/tab"; export interface AttendStatusToggleOption extends TabOption<AttendStatus> { - color: string; + color: StatusToggleItemColor; } type AttendStatusToggle = { [key in Exclude< @@ -20,7 +21,7 @@ type AttendStatusList = { type AttendStatusUser = { [key in AttendStatus]: TabOption<AttendStatus> & { - color: string; + color: StatusToggleItemColor; demand_text?: string; }; }; @@ -59,7 +60,24 @@ const LIST: AttendStatusList = { }; const USER: AttendStatusUser = { - ...TOGGLE, + attend: { + type: "attend", + text: "참석", + demand_text: "종료된 행사는 출석 상태를 변경할 수 없습니다.", + color: "green", + }, + absent: { + type: "absent", + text: "불참", + demand_text: "종료된 행사는 출석 상태를 변경할 수 없습니다.", + color: "red", + }, + late: { + type: "late", + text: "지각", + demand_text: "종료된 행사는 출석 상태를 변경할 수 없습니다.", + color: "yellow", + }, nonResponse: { type: "nonResponse", text: "출석체크 해주세요!", @@ -74,9 +92,10 @@ const USER: AttendStatusUser = { }; const LABEL = { - EDITABLE: "본인의 출석 상태를 선택해주세요.", + EDITABLE: "출석 하시겠습니까?", NON_RELATED: "출석 상태를 변경할 수 없습니다.", INACTIVE: "종료된 행사는 출석 상태를 변경할 수 없습니다.", + ALREADY_ATTENDED: "출석은 한 번만 가능합니다.", }; Object.freeze(TOGGLE); diff --git a/FE/src/constants/ERROR_CODE.ts b/FE/src/constants/ERROR_CODE.ts index 7dbed76c..c8101dc7 100644 --- a/FE/src/constants/ERROR_CODE.ts +++ b/FE/src/constants/ERROR_CODE.ts @@ -8,6 +8,7 @@ const ERROR_CODE = { NO_EDIT_PERMISSION: "1005", CANNOT_MODIFY_SURVEY: "1006", CANNOT_MODIFY_ACTIVE: "1007", + UNFORMATTED_GITHUB_URL: "1011", }, ATTEND: { NOT_EXIST_ATTEND_STATUS: "2000", @@ -28,6 +29,7 @@ const ERROR_CODE = { NO_REFRESH_TOKEN: "4004", EXPIRED_REFRESH_TOKEN: "4005", INVALID_NAME: "4006", + INCORRECT_LOGIN_INFO: "4008", }, API: { SLACK_CALL_FAILED: "5000" }, TEAM_BUILDING: { diff --git a/FE/src/constants/ERROR_MESSAGE.ts b/FE/src/constants/ERROR_MESSAGE.ts index 5983fa8c..63cefbbf 100644 --- a/FE/src/constants/ERROR_MESSAGE.ts +++ b/FE/src/constants/ERROR_MESSAGE.ts @@ -26,6 +26,9 @@ const ERROR_MESSAGE = { [ERROR_CODE.PROGRAM.CANNOT_MODIFY_ACTIVE]: { message: "진행 중인 행사의 경우, 참석 정보를 수정할 수 없습니다.", }, + [ERROR_CODE.PROGRAM.UNFORMATTED_GITHUB_URL]: { + message: "올바른 GitHub URL 형식이 아닙니다.", + }, // 참석 [ERROR_CODE.ATTEND.NOT_EXIST_ATTEND_STATUS]: { @@ -75,6 +78,9 @@ const ERROR_MESSAGE = { message: "에코노베이션 슬랙의 표시 이름 형식이 올바르지 않습니다. (예: 25기 홍길동)", }, + [ERROR_CODE.AUTH.INCORRECT_LOGIN_INFO]: { + message: "아이디 또는 비밀번호가 일치하지 않습니다.", + }, // API [ERROR_CODE.API.SLACK_CALL_FAILED]: { diff --git a/FE/src/constants/FORM_INFO.ts b/FE/src/constants/FORM_INFO.ts index 1ec901d0..5763f3b3 100644 --- a/FE/src/constants/FORM_INFO.ts +++ b/FE/src/constants/FORM_INFO.ts @@ -1,24 +1,25 @@ import { FormType } from "./../types/form"; + const PROGRAM = { TITLE: { - id: "program_title", + id: "title", type: "text", label: "행사 이름", placeholder: "행사 이름 입력", }, DATE: { - id: "program_date", + id: "date", type: "text", - label: "마감기한", + label: "행사일정", placeholder: "XXXX-XX-XX", }, CONTENT: { - id: "program_content", + id: "content", type: "text", label: "행사 내용", placeholder: "행사 내용 입력", }, -}; +} as const; const TEAM_BUILDING = { TITLE: { @@ -53,7 +54,7 @@ type SubmitText = { [key in FormType]: string; }; -const SUBMIT_TEXT: SubmitText = { +const SUBMIT_TEXT: Omit<SubmitText, "manage"> = { create: "생성", edit: "수정", }; diff --git a/FE/src/constants/INPUT_STATUS.ts b/FE/src/constants/INPUT_STATUS.ts index b915c833..4eae5567 100644 --- a/FE/src/constants/INPUT_STATUS.ts +++ b/FE/src/constants/INPUT_STATUS.ts @@ -1,8 +1,9 @@ +import { StatusToggleItemColor } from "@/components/common/StatusToggleItem"; import { TabOption } from "@/types/tab"; import { InputStatus } from "@/types/teamBuilding"; export interface InputStatusToggleOption extends TabOption<InputStatus> { - color: string; + color: StatusToggleItemColor; } type InputStatusToggle = { diff --git a/FE/src/constants/ROUTES.ts b/FE/src/constants/ROUTES.ts index a13007d5..ffbc580a 100644 --- a/FE/src/constants/ROUTES.ts +++ b/FE/src/constants/ROUTES.ts @@ -1,10 +1,13 @@ const ROUTES = { MAIN: "/main", GUEST_MAIN: "/guest/main", - CREATE: "/create", + ADMIN_MAIN: "/admin/main", + CREATE: "/admin/create", + MANAGE: "/admin/manage", DETAIL: (programId: number) => `/detail/${programId}`, GUEST_DETAIL: (programId: number) => `/guest/detail/${programId}`, - EDIT: (programId: number) => `/edit/${programId}`, + ADMIN_DETAIL: (programId: number) => `/admin/detail/${programId}`, + EDIT: (programId: number) => `/admin/edit/${programId}`, ERROR: "/error", LOGIN: "/login", LOGGIN_IN: "/login/logging-in", diff --git a/FE/src/hooks/query/useAuthQuery.ts b/FE/src/hooks/query/useAuthQuery.ts index f339572e..69b81944 100644 --- a/FE/src/hooks/query/useAuthQuery.ts +++ b/FE/src/hooks/query/useAuthQuery.ts @@ -1,6 +1,6 @@ import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { postSlackLogin } from "@/apis/auth"; +import { postAdminLogin, postSlackLogin } from "@/apis/auth"; import ERROR_CODE from "@/constants/ERROR_CODE"; import ROUTES from "@/constants/ROUTES"; import { @@ -9,6 +9,7 @@ import { setTokenExpiration, } from "@/utils/authWithStorage"; +// TODO: 라우팅 및 토큰 처리 로직은 컴포넌트에서 수행 export const useSlackLoginMutation = () => { const router = useRouter(); return useMutation( @@ -21,7 +22,6 @@ export const useSlackLoginMutation = () => { const { accessToken, accessExpiredTime } = data; setAccessToken(accessToken); setTokenExpiration(accessExpiredTime); - router.replace(ROUTES.MAIN); }, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -34,6 +34,21 @@ export const useSlackLoginMutation = () => { ); }; +export const useAdminLoginMutation = () => { + const router = useRouter(); + return useMutation({ + mutationFn: ({ id, password }: { id: string; password: string }) => + postAdminLogin(id, password), + + onSuccess: (data) => { + const { accessToken, accessExpiredTime } = data; + setAccessToken(accessToken); + setTokenExpiration(accessExpiredTime); + router.replace(ROUTES.ADMIN_MAIN); + }, + }); +}; + export const useLogoutMutation = () => { return { mutate: deleteTokenInfo }; }; diff --git a/FE/src/hooks/query/useMemberQuery.ts b/FE/src/hooks/query/useMemberQuery.ts index 44dfcc6c..54b79c2a 100644 --- a/FE/src/hooks/query/useMemberQuery.ts +++ b/FE/src/hooks/query/useMemberQuery.ts @@ -1,16 +1,24 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { MemberActiveStatusInfoDto } from "@/apis/dtos/member.dto"; import { + deleteMember, getMembersByActiveStatus, getProgramMembersByActiveStatus, getProgramMembersByAttendStatus, + updateMemberActiveStatus, } from "@/apis/member"; import API from "@/constants/API"; -import { ActiveStatusWithAll, AttendStatus } from "@/types/member"; +import { + ActiveStatus, + ActiveStatusWithAll, + AttendStatus, +} from "@/types/member"; export const useGetMemberByActive = (activeStatus: ActiveStatusWithAll) => { return useQuery({ queryKey: [API.MEMBER.LIST, activeStatus], queryFn: () => getMembersByActiveStatus(activeStatus), + staleTime: 1000 * 60 * 5, }); }; @@ -22,6 +30,9 @@ interface GetProgramMemebersByAttend { status: AttendStatus; programId: number; } +interface UpdateMemberActiveStatus { + memberId: number; +} export const useGetProgramMembersByActive = ({ programId, @@ -30,6 +41,7 @@ export const useGetProgramMembersByActive = ({ return useQuery({ queryKey: [API.MEMBER.ACTIVE_STATUS(programId), status], queryFn: () => getProgramMembersByActiveStatus(programId, status), + staleTime: 1000 * 60 * 5, }); }; @@ -40,5 +52,50 @@ export const useGetProgramMembersByAttend = ({ return useQuery({ queryKey: [API.MEMBER.ATTEND_STATUS(programId), status], queryFn: () => getProgramMembersByAttendStatus(programId, status), + staleTime: 1000 * 60 * 5, + }); +}; + +export const useUpdateMemberActiveStatus = ({ + memberId, +}: UpdateMemberActiveStatus) => { + const queryClient = useQueryClient(); + const data = useMutation<void, Error, { activeStatus: ActiveStatus }>({ + mutationFn: ({ activeStatus }: { activeStatus: ActiveStatus }) => + updateMemberActiveStatus(memberId, activeStatus), + + onMutate: ({ activeStatus: newActiveStatus }) => { + const prevMemberActiveData = queryClient.getQueryData< + MemberActiveStatusInfoDto[] + >([API.MEMBER.LIST, "all"]); + + const newMemberActiveData = prevMemberActiveData.map((v) => + memberId === v.memberId + ? { + activeStatus: newActiveStatus, + memberId: v.memberId, + name: v.name, + } + : v, + ); + + queryClient.setQueryData([API.MEMBER.LIST, "all"], newMemberActiveData); + }, + onError: () => { + queryClient.invalidateQueries([API.MEMBER.LIST]); + }, + }); + return data; +}; + +export const useDeleteMember = () => { + const queryClient = useQueryClient(); + const data = useMutation<void, Error, { memberId: number }>({ + mutationFn: ({ memberId }) => deleteMember(memberId), + + onSettled: () => { + queryClient.invalidateQueries([API.MEMBER.LIST]); + }, }); + return data; }; diff --git a/FE/src/hooks/query/usePresentations.ts b/FE/src/hooks/query/usePresentations.ts new file mode 100644 index 00000000..8d18d9c6 --- /dev/null +++ b/FE/src/hooks/query/usePresentations.ts @@ -0,0 +1,23 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { getPresentations } from "@/apis/proxy/github"; + +const usePresentations = (programId: number) => { + const queryClient = useQueryClient(); + const githubUrl: string = queryClient.getQueryData(["githubUrl", programId]); + console.log(githubUrl); + + return useGetPresentation(programId, githubUrl); +}; + +export default usePresentations; + +const useGetPresentation = (programId: number, githubUrl: string) => { + console.log(githubUrl); + + return useQuery({ + queryKey: ["presentations", programId], + queryFn: () => getPresentations(githubUrl as string), + staleTime: 1000 * 60 * 60 * 24, + enabled: !!githubUrl, + }); +}; diff --git a/FE/src/hooks/query/useProgramQuery.ts b/FE/src/hooks/query/useProgramQuery.ts index 1534a5a5..dad34904 100644 --- a/FE/src/hooks/query/useProgramQuery.ts +++ b/FE/src/hooks/query/useProgramQuery.ts @@ -1,8 +1,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; +import { ProgramInfoDto } from "@/apis/dtos/program.dto"; import { GetProgramListRequest, - PatchProgramRequest, + PatchProgramBody, PostProgramRequest, deleteProgram, getProgramAccessRight, @@ -11,43 +12,47 @@ import { patchProgram, postProgram, sendSlackMessage, + updateProgramAttendMode, } from "@/apis/program"; import API from "@/constants/API"; import ROUTES from "@/constants/ROUTES"; import { ActiveStatusWithAll } from "@/types/member"; -import { ProgramStatus, ProgramType } from "@/types/program"; - -interface CreateProgram { - programData: PostProgramRequest; - formReset: () => void; -} +import { + ProgramAttendStatus, + ProgramStatus, + ProgramType, +} from "@/types/program"; -export const useCreateProgram = ({ programData, formReset }: CreateProgram) => { - const router = useRouter(); +export const useCreateProgram = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (programData: PostProgramRequest) => postProgram(programData), + onSuccess: () => queryClient.invalidateQueries([API.PROGRAM.LIST]), + }); +}; +export const useSendSlackMessage = () => { return useMutation({ - mutationKey: [API.PROGRAM.CREATE], - mutationFn: async () => { - const { programId } = await postProgram(programData); - await sendSlackMessage(programId); - return programId; - }, - onSuccess: (programId) => { - formReset(); - programId && router.replace(ROUTES.DETAIL(programId)); - }, + mutationFn: (programId: number) => sendSlackMessage(programId), }); }; -export const useUpdateProgram = ({ programId, body }: PatchProgramRequest) => { +interface useUpdateProgramProps { + programId: number; +} +export const useUpdateProgram = ({ programId }: useUpdateProgramProps) => { const router = useRouter(); const queryClient = useQueryClient(); return useMutation({ mutationKey: [API.PROGRAM.UPDATE(programId)], - mutationFn: () => patchProgram({ programId, body }), - onSettled: (data) => { - data && router.replace(ROUTES.DETAIL(data?.programId)); + mutationFn: (body: PatchProgramBody) => { + console.log("body", body); + return patchProgram({ programId, body }); + }, + onSettled: ({ programId }) => { + router.replace(ROUTES.DETAIL(programId)); + const statuses: ActiveStatusWithAll[] = ["all", "am", "cm", "rm", "ob"]; statuses.forEach((status) => { queryClient.invalidateQueries([ @@ -59,25 +64,29 @@ export const useUpdateProgram = ({ programId, body }: PatchProgramRequest) => { }); }; -export const useDeleteProgram = (programId: number) => { - const router = useRouter(); +export const useDeleteProgram = () => { + const queryClient = useQueryClient(); return useMutation({ - mutationKey: [API.PROGRAM.DELETE(programId)], - mutationFn: () => deleteProgram(programId), - onSettled: () => { - router.replace(ROUTES.MAIN); + mutationFn: async ({ programId }: { programId: number }) => + await deleteProgram(programId), + onSuccess: () => { + queryClient.invalidateQueries([API.PROGRAM.LIST]); }, }); }; -export const useGetProgramById = (programId: number, isLoggedIn: boolean) => { +export const useGetProgramByProgramId = ( + programId: number, + isAbleToEdit: boolean, +) => { const queryClient = useQueryClient(); return useQuery({ - queryKey: [API.PROGRAM.DETAIL(programId)], + queryKey: [API.PROGRAM.Edit_DETAIL(programId)], queryFn: () => - getProgramById(programId, isLoggedIn).then((res) => { + getProgramById(programId, isAbleToEdit).then((res) => { + //TODO: setquery 지양하기 queryClient.setQueryData<ProgramStatus>( ["programStatus", programId], res.programStatus, @@ -86,8 +95,13 @@ export const useGetProgramById = (programId: number, isLoggedIn: boolean) => { ["programType", programId], res.type, ); + queryClient.setQueryData<string>( + ["githubUrl", programId], + res.programGithubUrl, + ); return res; }), + staleTime: 1000 * 60 * 5, }); }; @@ -96,17 +110,18 @@ export const useGetProgramList = ({ programStatus, size, page, - isLoggedIn, + isAdmin, }: GetProgramListRequest) => { return useQuery({ queryKey: [API.PROGRAM.LIST, category, programStatus, size, page], queryFn: () => - getProgramList({ category, programStatus, size, page, isLoggedIn }), + getProgramList({ category, programStatus, size, page, isAdmin }), select: (data) => ({ totalPage: data?.totalPage, programs: data?.programs, }), suspense: true, + staleTime: 1000 * 60 * 60, }); }; @@ -116,3 +131,29 @@ export const useGetProgramAccessRight = (programId: number) => { queryFn: () => getProgramAccessRight(programId), }); }; + +export const useUpdateProgramAttendMode = (programId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: [API.PROGRAM.UPDATE_ATTEND_MODE(programId)], + mutationFn: (attendMode: ProgramAttendStatus) => { + queryClient.invalidateQueries([API.PROGRAM.Edit_DETAIL(programId)]); + return updateProgramAttendMode(programId, attendMode); + }, + onSuccess: (_, targetAttendMode) => { + const prevProgram = queryClient.getQueryData<ProgramInfoDto>([ + API.PROGRAM.Edit_DETAIL(programId), + ]); + + const newProgram: ProgramInfoDto = { + ...prevProgram, + attendMode: targetAttendMode, + }; + + queryClient.setQueryData<ProgramInfoDto>( + [API.PROGRAM.Edit_DETAIL(programId)], + newProgram, + ); + }, + }); +}; diff --git a/FE/src/hooks/query/useQuestionQuery.ts b/FE/src/hooks/query/useQuestionQuery.ts new file mode 100644 index 00000000..918eb72f --- /dev/null +++ b/FE/src/hooks/query/useQuestionQuery.ts @@ -0,0 +1,58 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + deleteQuestion, + getQuestionsByTeam, + postQuestion, + PostQuestionParams, + updateQuestion, +} from "@/apis/question"; + +export const useGetQuestion = (programId: number, teamId: number) => { + return useQuery({ + queryKey: ["question", programId, teamId], + queryFn: () => getQuestionsByTeam(programId, teamId), + enabled: (!!programId || programId === 0) && (!!teamId || teamId === 0), + refetchInterval: 10 * 1000, + staleTime: 10 * 1000, + }); +}; + +export const usePostQuestion = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["question", "post"], + mutationFn: async (postQuestionParams: PostQuestionParams) => { + const res = await postQuestion(postQuestionParams); + + const { programId, teamId } = postQuestionParams; + queryClient.invalidateQueries(["question", programId, teamId]); + + return res; + }, + }); +}; + +export const useUpdateQuestion = () => { + return useMutation({ + mutationKey: ["question", "update"], + mutationFn: ({ + commentId, + contents, + }: { + commentId: number; + contents: string; + }) => updateQuestion(commentId, contents), + }); +}; + +export const useDeleteQuestion = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["question", "delete"], + mutationFn: async (commentId: number) => { + const res = await deleteQuestion(commentId); + queryClient.invalidateQueries(["question", "get"]); + return res; + }, + }); +}; diff --git a/FE/src/hooks/query/useTeamQuery.ts b/FE/src/hooks/query/useTeamQuery.ts new file mode 100644 index 00000000..fbe9e554 --- /dev/null +++ b/FE/src/hooks/query/useTeamQuery.ts @@ -0,0 +1,57 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createTeam, deleteTeam, getTeamList } from "@/apis/team"; +import { TeamInfo } from "@/types/team"; + +export const useTeamQuery = (programId?: number) => { + return useQuery({ + queryKey: ["teams", programId || programId == 0 ? programId : "all"], + queryFn: () => getTeamList(programId), + staleTime: 1000 * 60 * 5, + cacheTime: 1000 * 60 * 5, + }); +}; + +export const useCreateTeam = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["teams", "all"], + mutationFn: (teamName: string) => createTeam(teamName), + onSettled: (_, __, teamName) => { + const { teams: prevTeams } = queryClient.getQueryData([ + "teams", + "all", + ]) as { teams: TeamInfo[] }; + const newTeam: TeamInfo = { teamId: Date.now(), teamName }; + prevTeams.push(newTeam); + queryClient.setQueryData(["teams", "all"], { teams: prevTeams }); + }, + onSuccess: () => { + queryClient.invalidateQueries(["teams", "all"]); + }, + onError: () => { + queryClient.invalidateQueries(["teams", "all"]); + }, + }); +}; + +export const useDeleteTeam = (teamId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["teams", "all"], + mutationFn: (teamId: number) => deleteTeam(teamId), + onSettled: () => { + const { teams: prevTeams } = queryClient.getQueryData([ + "teams", + "all", + ]) as { teams: TeamInfo[] }; + const newTeams = prevTeams.filter((team) => team.teamId !== teamId); + queryClient.setQueryData(["teams", "all"], { teams: newTeams }); + }, + onSuccess: () => { + queryClient.invalidateQueries(["teams", "all"]); + }, + onError: () => { + queryClient.invalidateQueries(["teams", "all"]); + }, + }); +}; diff --git a/FE/src/hooks/query/useUserQuery.ts b/FE/src/hooks/query/useUserQuery.ts index c2042c4b..8d80275c 100644 --- a/FE/src/hooks/query/useUserQuery.ts +++ b/FE/src/hooks/query/useUserQuery.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getMyActiveStatus, getMyAttendStatus, + postMyAttendance, putMyActiveStatus, putMyAttendStatus, } from "@/apis/user"; @@ -32,6 +33,7 @@ export const useGetMyAttendStatus = (programId: number) => { return useQuery({ queryKey: [API.USER.ATTEND_STATUS(programId)], queryFn: () => getMyAttendStatus(programId), + staleTime: 1000 * 60 * 5, }); }; @@ -40,6 +42,10 @@ interface PutMyAttendStatus { beforeAttendStatus: AttendStatus; } +/** + * 이전에 직접 참석 여부를 변경할 떄 사용하던 api 입니다. + * 현재는 사용하지 않습니다. + */ export const usePutMyAttendStatus = ({ programId, beforeAttendStatus, @@ -71,3 +77,27 @@ export const usePutMyAttendStatus = ({ }, }); }; + +export const usePostMyAttendance = (programId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: [API.USER.ATTEND_STATUS(programId)], + mutationFn: () => postMyAttendance(programId), + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: [API.USER.ATTEND_STATUS(programId)], + }); + const statuses: AttendStatus[] = [ + "attend", + "late", + "absent", + "nonResponse", + ]; + statuses.forEach((status) => { + queryClient.invalidateQueries({ + queryKey: [API.MEMBER.ATTEND_STATUS(programId), status], + }); + }); + }, + }); +}; diff --git a/FE/src/hooks/useAuth.tsx b/FE/src/hooks/useAuth.tsx new file mode 100644 index 00000000..79349103 --- /dev/null +++ b/FE/src/hooks/useAuth.tsx @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; +import { CheckIsLoggedIn } from "@/utils/authWithStorage"; + +/** + * 토큰값을 확인하여 로그인 여부를 반환하는 hook + * isLoggedIn이 true일 경우 로그인이 되어있는 상태이며, false일 경우 로그인이 되어있지 않은 상태이다. + * isLoading이 true일 경우 토큰값을 확인하는 중이며, false일 경우 토큰값 확인이 완료된 상태이다. + * + * @returns [isLoggedIn, isLoading] + */ + +const useAuth = () => { + const [isLoggedIn, setIsLoggedIn] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const isLoggedIn = CheckIsLoggedIn(); + setIsLoggedIn(isLoggedIn); + setIsLoading(false); + }, []); + + return { isLoggedIn, isLoading }; +}; + +export default useAuth; diff --git a/FE/src/hooks/useInput.ts b/FE/src/hooks/useInput.ts deleted file mode 100644 index b36f76c4..00000000 --- a/FE/src/hooks/useInput.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useCallback, useState } from "react"; - -const useInput = () => { - const [value, setValue] = useState(""); - - const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { - setValue(e.target.value); - }, []); - - return { value, onChange }; -}; - -export default useInput; diff --git a/FE/src/hooks/useMemberForm.ts b/FE/src/hooks/useMemberForm.ts new file mode 100644 index 00000000..0a8d9cb8 --- /dev/null +++ b/FE/src/hooks/useMemberForm.ts @@ -0,0 +1,60 @@ +"use client"; + +import { useState } from "react"; +import type { AttendStatus } from "@/types/member"; +import { updateSet } from "@/utils/set"; + +export interface Members { + beforeAttendStatus: AttendStatus; + afterAttendStatus: AttendStatus; +} + +export const useMemberSet = () => { + const [members, setMembers] = useState<Set<number>>(new Set<number>()); + + const updateMembers = (memberId: number) => { + setMembers(updateSet<number>(members, memberId)); + }; + + const clearMembers = () => { + setMembers(new Set<number>()); + }; + const setAllMembers = (memberList: number[]) => { + setMembers(new Set<number>(memberList)); + }; + + return { + members, + updateMembers, + clearMembers, + setAllMembers, + }; +}; + +export const useMemberMap = () => { + const [members, setMembers] = useState<Map<number, Members>>(new Map()); + + const updateMembers = ( + memberId: number, + before: AttendStatus, + after: AttendStatus, + ) => { + const newMembers = new Map<number, Members>(members); + + if (before === after) { + newMembers.delete(memberId); + } + if (before !== after) { + newMembers.set(memberId, { + beforeAttendStatus: before, + afterAttendStatus: after, + }); + } + setMembers(newMembers); + }; + + return { + members, + updateMembers, + }; +}; diff --git a/FE/src/hooks/useProgramFormData.ts b/FE/src/hooks/useProgramFormData.ts deleted file mode 100644 index 5ba9de76..00000000 --- a/FE/src/hooks/useProgramFormData.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useState } from "react"; -import { ProgramCategory, ProgramType } from "@/types/program"; - -export interface ProgramFormDataState { - title: string; - deadLine: string; - type: ProgramType; - category: ProgramCategory; - content: string; -} - -export interface ProgramFormDataAction { - setTitle: React.Dispatch<React.SetStateAction<string>>; - setDeadLine: React.Dispatch<React.SetStateAction<string>>; - setType: React.Dispatch<React.SetStateAction<ProgramType>>; - setCategory: React.Dispatch<React.SetStateAction<ProgramCategory>>; - setContent: React.Dispatch<React.SetStateAction<string>>; - reset: () => void; -} - -const initialState: ProgramFormDataState = { - title: "", - deadLine: new Date().getTime().toString(), - type: "notification", - category: "weekly", - content: "", -}; - -export interface ProgramFormData - extends ProgramFormDataState, - ProgramFormDataAction {} - -const useProgramFormData = ( - defaultData: ProgramFormDataState = initialState, -) => { - const [title, setTitle] = useState<string>(defaultData.title); - const [deadLine, setDeadLine] = useState<string>(defaultData.deadLine); - const [type, setType] = useState<ProgramType>(defaultData.type); - const [category, setCategory] = useState<ProgramCategory>( - defaultData.category, - ); - const [content, setContent] = useState<string>(defaultData.content); - - const reset = () => { - setTitle(defaultData.title); - setDeadLine(defaultData.deadLine); - setType(defaultData.type); - setCategory(defaultData.category); - setContent(defaultData.content); - }; - - return { - title, - setTitle, - deadLine, - setDeadLine, - type, - setType, - category, - setCategory, - content, - setContent, - reset, - }; -}; -export default useProgramFormData; diff --git a/FE/src/hooks/useTeam.ts b/FE/src/hooks/useTeam.ts new file mode 100644 index 00000000..44a55ef7 --- /dev/null +++ b/FE/src/hooks/useTeam.ts @@ -0,0 +1,17 @@ +import { useTeamQuery } from "./query/useTeamQuery"; + +const useTeam = (programId: number) => { + const { data: allOfTeams, isLoading: isAllOfTeamsLoading } = useTeamQuery(); + const { data: joinedTeams, isLoading: isJoinedTeamsLoading } = + useTeamQuery(programId); + + const isLoading = isAllOfTeamsLoading || isJoinedTeamsLoading; + + return { + allOfTeams, + joinedTeams, + isLoading, + }; +}; + +export default useTeam; diff --git a/FE/src/react-test-renderer.d.ts b/FE/src/react-test-renderer.d.ts new file mode 100644 index 00000000..8c039609 --- /dev/null +++ b/FE/src/react-test-renderer.d.ts @@ -0,0 +1 @@ +declare module "react-test-renderer"; diff --git a/FE/src/types/access.ts b/FE/src/types/access.ts new file mode 100644 index 00000000..941498af --- /dev/null +++ b/FE/src/types/access.ts @@ -0,0 +1 @@ +export type AccessType = "admin" | "private" | "public"; diff --git a/FE/src/types/form.ts b/FE/src/types/form.ts index 00988f60..99a44b74 100644 --- a/FE/src/types/form.ts +++ b/FE/src/types/form.ts @@ -1 +1 @@ -export type FormType = "create" | "edit"; +export type FormType = "create" | "edit" | "manage"; diff --git a/FE/src/types/program.ts b/FE/src/types/program.ts index af2c62cd..02dfbc25 100644 --- a/FE/src/types/program.ts +++ b/FE/src/types/program.ts @@ -1,9 +1,12 @@ +import { TeamInfo } from "./team"; + export type ProgramCategory = "weekly" | "presidentTeam" | "eventTeam" | "etc"; export type ProgramCategoryWithAll = ProgramCategory | "all"; export type ProgramStatus = "active" | "end"; export type ProgramType = "demand" | "notification"; export type AccessRight = "edit" | "read_only"; +export type ProgramAttendStatus = "attend" | "late" | "non_open" | "end"; export interface ProgramInfo { programId: number; @@ -14,6 +17,10 @@ export interface ProgramInfo { programStatus: ProgramStatus; type: ProgramType; accessRight: AccessRight; + programGithubUrl: string; + teams: TeamInfo[]; + eventStatus: "active" | "end"; + attendMode: ProgramAttendStatus; } export interface ProgramSimpleInfo extends Omit<ProgramInfo, "content"> {} diff --git a/FE/src/types/team.ts b/FE/src/types/team.ts new file mode 100644 index 00000000..0f5a2e42 --- /dev/null +++ b/FE/src/types/team.ts @@ -0,0 +1,5 @@ +export interface TeamInfo { + teamId: number; + teamName: string; +} +export interface TeamInputInfo extends Omit<TeamInfo, "teamName"> {} diff --git a/FE/src/utils/authWithStorage.ts b/FE/src/utils/authWithStorage.ts index 2fa269c9..acd0e353 100644 --- a/FE/src/utils/authWithStorage.ts +++ b/FE/src/utils/authWithStorage.ts @@ -31,21 +31,14 @@ export const getTokenExpiration = () => { }; export const CheckIsLoggedIn = () => { - // if (typeof window !== "undefined") return false; const accessToken = getAccessToken(); const tokenExpiration = getTokenExpiration(); - if (!accessToken || !tokenExpiration) { - deleteTokenInfo(); - return false; - } + if (!accessToken || !tokenExpiration) return false; const tokenExpirationDate = new Date(+tokenExpiration); const now = new Date(); - if (tokenExpirationDate < now) { - deleteTokenInfo(); - return false; - } + if (tokenExpirationDate < now) return false; return true; }; diff --git a/FE/src/utils/convert.ts b/FE/src/utils/convert.ts index a8f7b491..59056e32 100644 --- a/FE/src/utils/convert.ts +++ b/FE/src/utils/convert.ts @@ -1,6 +1,8 @@ +import { checkIsValidateGithubUrl } from "./github"; + const WEEK = ["일", "월", "화", "수", "목", "금", "토"]; -export const convertDate = ( +export const formatTimestamp = ( timestamp: string, type: "default" | "short" = "default", ) => { @@ -16,6 +18,29 @@ export const convertDate = ( }; // text에서 문자열 제거 +//TODO: 이름 명확하게 수정 export const convertText = (text: string, str: string) => { return text.replace(str, ""); }; + +//githubUrl을 owner, repo, branch, path로 분리 +export const convertGitHubUrl = (githubUrl: string) => { + const isValidateGithubUrl = checkIsValidateGithubUrl(githubUrl); + if (!isValidateGithubUrl) throw new Error("올바르지 않은 깃허브 url입니다."); + + const parsedUrl = new URL(githubUrl); + const parts = parsedUrl.pathname.split("/").filter(Boolean); + + const owner = parts[0]; + const repo = parts[1]; + let branch = "main"; + let path = ""; + + const treeIndex = parts.indexOf("tree"); + if (treeIndex !== -1 && parts.length > treeIndex + 1) { + branch = parts[treeIndex + 1]; + path = parts.slice(treeIndex + 2).join("/"); + } + + return { owner, repo, branch, path }; +}; diff --git a/FE/src/utils/demo.ts b/FE/src/utils/demo.ts new file mode 100644 index 00000000..268b703e --- /dev/null +++ b/FE/src/utils/demo.ts @@ -0,0 +1,7 @@ +export const notAllowDecorator = ( + fn: unknown, + alertText = "현재는 사용할 수 없는 기능입니다", +) => { + fn; + return () => alert(alertText); +}; diff --git a/FE/src/utils/github.ts b/FE/src/utils/github.ts new file mode 100644 index 00000000..8d1fad46 --- /dev/null +++ b/FE/src/utils/github.ts @@ -0,0 +1,11 @@ +export const checkIsValidateGithubUrl = (githubUrl: string) => { + const urlPattern = + /^https:\/\/github\.com\/JNU-econovation\/weekly_presentation\/tree\/(\d{4}-[12])\/(\d{4}-[12])\/(A_team|B_team)\/([1-9](st|nd|rd|th))$/; + + if (!urlPattern.test(githubUrl)) { + return false; + } + + return true; + return true; +}; diff --git a/FE/src/utils/set.ts b/FE/src/utils/set.ts new file mode 100644 index 00000000..7aabceb1 --- /dev/null +++ b/FE/src/utils/set.ts @@ -0,0 +1,5 @@ +export const updateSet = <T>(set: Set<T>, value: T): Set<T> => { + const newSet = new Set(set); + set.has(value) ? newSet.delete(value) : newSet.add(value); + return newSet; +};