diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4754f91..e9ddc27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: pull_request: - branches: [main, dev] + branches: [main, develop] concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fc8fee7..90be25e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: Deploy Frontend on: pull_request: - branches: [main, dev] + branches: [main] types: [closed] jobs: @@ -32,6 +32,7 @@ jobs: env: VITE_API_URL: ${{ secrets.VITE_API_URL }} VITE_APP_URL: ${{ secrets.VITE_APP_URL }} + VITE_KAKAO_MAP_KEY: ${{ secrets.VITE_KAKAO_MAP_KEY }} - name: Deploy to EC2 uses: appleboy/scp-action@v0.1.7 diff --git a/eslint.config.js b/eslint.config.js index d6b3e63..375b80e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -42,6 +42,13 @@ export default defineConfig([ { selector: 'variable', format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + leadingUnderscore: 'allow', + }, + // 매개변수: camelCase, 언더스코어로 시작 가능 (unused params) + { + selector: 'parameter', + format: ['camelCase'], + leadingUnderscore: 'allow', }, // 함수: camelCase, PascalCase (컴포넌트) { diff --git a/index.html b/index.html index e42eaac..accc0fd 100644 --- a/index.html +++ b/index.html @@ -8,10 +8,6 @@ 독크독크 -
diff --git a/package.json b/package.json index c783cb8..b993684 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "globals": "^16.5.0", "lefthook": "^2.0.15", "prettier": "^3.7.4", + "shadcn": "^3.8.4", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", @@ -49,6 +50,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.16", "axios": "^1.13.2", @@ -56,11 +58,13 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.562.0", + "next-themes": "^0.4.6", "pretendard": "^1.3.9", "react": "18.2.0", "react-day-picker": "^9.13.0", "react-dom": "18.2.0", "react-router-dom": "^6.30.3", + "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "zustand": "^5.0.10" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5dbc859..88794c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,9 @@ importers: '@radix-ui/react-toggle-group': specifier: ^1.1.11 version: 1.1.11(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@20.19.28)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -61,6 +64,9 @@ importers: lucide-react: specifier: ^0.562.0 version: 0.562.0(react@18.2.0) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) pretendard: specifier: ^1.3.9 version: 1.3.9 @@ -76,6 +82,9 @@ importers: react-router-dom: specifier: ^6.30.3 version: 6.30.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -134,6 +143,9 @@ importers: prettier: specifier: ^3.7.4 version: 3.7.4 + shadcn: + specifier: ^3.8.4 + version: 3.8.4(@types/node@20.19.28)(typescript@5.9.3) tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -148,6 +160,13 @@ importers: version: 7.3.1(@types/node@20.19.28)(jiti@2.6.1)(lightningcss@1.30.2) packages: + '@antfu/ni@25.0.0': + resolution: + { + integrity: sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==, + } + hasBin: true + '@babel/code-frame@7.27.1': resolution: { @@ -155,6 +174,13 @@ packages: } engines: { node: '>=6.9.0' } + '@babel/code-frame@7.29.0': + resolution: + { + integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==, + } + engines: { node: '>=6.9.0' } + '@babel/compat-data@7.28.5': resolution: { @@ -176,6 +202,20 @@ packages: } engines: { node: '>=6.9.0' } + '@babel/generator@7.29.1': + resolution: + { + integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: + { + integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==, + } + engines: { node: '>=6.9.0' } + '@babel/helper-compilation-targets@7.27.2': resolution: { @@ -183,6 +223,15 @@ packages: } engines: { node: '>=6.9.0' } + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: + { + integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-globals@7.28.0': resolution: { @@ -190,6 +239,13 @@ packages: } engines: { node: '>=6.9.0' } + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: + { + integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==, + } + engines: { node: '>=6.9.0' } + '@babel/helper-module-imports@7.27.1': resolution: { @@ -197,6 +253,13 @@ packages: } engines: { node: '>=6.9.0' } + '@babel/helper-module-imports@7.28.6': + resolution: + { + integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==, + } + engines: { node: '>=6.9.0' } + '@babel/helper-module-transforms@7.28.3': resolution: { @@ -206,6 +269,22 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.28.6': + resolution: + { + integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: + { + integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==, + } + engines: { node: '>=6.9.0' } + '@babel/helper-plugin-utils@7.27.1': resolution: { @@ -213,6 +292,29 @@ packages: } engines: { node: '>=6.9.0' } + '@babel/helper-plugin-utils@7.28.6': + resolution: + { + integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-replace-supers@7.28.6': + resolution: + { + integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: + { + integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==, + } + engines: { node: '>=6.9.0' } + '@babel/helper-string-parser@7.27.1': resolution: { @@ -249,6 +351,41 @@ packages: engines: { node: '>=6.0.0' } hasBin: true + '@babel/parser@7.29.0': + resolution: + { + integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==, + } + engines: { node: '>=6.0.0' } + hasBin: true + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: + { + integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: + { + integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: + { + integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: { @@ -267,6 +404,24 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.28.6': + resolution: + { + integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: + { + integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/template@7.27.2': resolution: { @@ -274,6 +429,13 @@ packages: } engines: { node: '>=6.9.0' } + '@babel/template@7.28.6': + resolution: + { + integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==, + } + engines: { node: '>=6.9.0' } + '@babel/traverse@7.28.5': resolution: { @@ -281,6 +443,13 @@ packages: } engines: { node: '>=6.9.0' } + '@babel/traverse@7.29.0': + resolution: + { + integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==, + } + engines: { node: '>=6.9.0' } + '@babel/types@7.28.5': resolution: { @@ -288,6 +457,13 @@ packages: } engines: { node: '>=6.9.0' } + '@babel/types@7.29.0': + resolution: + { + integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==, + } + engines: { node: '>=6.9.0' } + '@commitlint/cli@20.3.1': resolution: { @@ -414,6 +590,22 @@ packages: integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==, } + '@dotenvx/dotenvx@1.52.0': + resolution: + { + integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==, + } + hasBin: true + + '@ecies/ciphers@0.2.5': + resolution: + { + integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==, + } + engines: { bun: '>=1', deno: '>=2', node: '>=16' } + peerDependencies: + '@noble/ciphers': ^1.0.0 + '@esbuild/aix-ppc64@0.27.2': resolution: { @@ -740,6 +932,15 @@ packages: integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==, } + '@hono/node-server@1.19.9': + resolution: + { + integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==, + } + engines: { node: '>=18.14.1' } + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: { @@ -768,6 +969,63 @@ packages: } engines: { node: '>=18.18' } + '@inquirer/ansi@1.0.2': + resolution: + { + integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==, + } + engines: { node: '>=18' } + + '@inquirer/confirm@5.1.21': + resolution: + { + integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==, + } + engines: { node: '>=18' } + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: + { + integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==, + } + engines: { node: '>=18' } + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: + { + integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==, + } + engines: { node: '>=18' } + + '@inquirer/type@3.0.10': + resolution: + { + integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==, + } + engines: { node: '>=18' } + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/cliui@9.0.0': + resolution: + { + integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==, + } + engines: { node: '>=18' } + '@jridgewell/gen-mapping@0.3.13': resolution: { @@ -799,6 +1057,86 @@ packages: integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, } + '@modelcontextprotocol/sdk@1.26.0': + resolution: + { + integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==, + } + engines: { node: '>=18' } + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@mswjs/interceptors@0.41.3': + resolution: + { + integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==, + } + engines: { node: '>=18' } + + '@noble/ciphers@1.3.0': + resolution: + { + integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==, + } + engines: { node: ^14.21.3 || >=16 } + + '@noble/curves@1.9.7': + resolution: + { + integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==, + } + engines: { node: ^14.21.3 || >=16 } + + '@noble/hashes@1.8.0': + resolution: + { + integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==, + } + engines: { node: ^14.21.3 || >=16 } + + '@nodelib/fs.scandir@2.1.5': + resolution: + { + integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==, + } + engines: { node: '>= 8' } + + '@nodelib/fs.stat@2.0.5': + resolution: + { + integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==, + } + engines: { node: '>= 8' } + + '@nodelib/fs.walk@1.2.8': + resolution: + { + integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, + } + engines: { node: '>= 8' } + + '@open-draft/deferred-promise@2.2.0': + resolution: + { + integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==, + } + + '@open-draft/logger@0.3.0': + resolution: + { + integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==, + } + + '@open-draft/until@2.1.0': + resolution: + { + integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==, + } + '@radix-ui/number@1.1.1': resolution: { @@ -1227,6 +1565,22 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tooltip@1.2.8': + resolution: + { + integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==, + } + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: { @@ -1570,6 +1924,19 @@ packages: cpu: [x64] os: [win32] + '@sec-ant/readable-stream@0.4.1': + resolution: + { + integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==, + } + + '@sindresorhus/merge-streams@4.0.0': + resolution: + { + integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==, + } + engines: { node: '>=18' } + '@tailwindcss/node@4.1.18': resolution: { @@ -1727,10 +2094,16 @@ packages: peerDependencies: react: ^18 || ^19 - '@types/babel__core@7.20.5': + '@ts-morph/common@0.27.0': resolution: { - integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, + integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==, + } + + '@types/babel__core@7.20.5': + resolution: + { + integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, } '@types/babel__generator@7.27.0': @@ -1799,6 +2172,18 @@ packages: integrity: sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==, } + '@types/statuses@2.0.6': + resolution: + { + integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==, + } + + '@types/validate-npm-package-name@4.0.2': + resolution: + { + integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==, + } + '@typescript-eslint/eslint-plugin@8.52.0': resolution: { @@ -1904,6 +2289,13 @@ packages: } hasBin: true + accepts@2.0.0: + resolution: + { + integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==, + } + engines: { node: '>= 0.6' } + acorn-jsx@5.3.2: resolution: { @@ -1920,6 +2312,24 @@ packages: engines: { node: '>=0.4.0' } hasBin: true + agent-base@7.1.4: + resolution: + { + integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==, + } + engines: { node: '>= 14' } + + ajv-formats@3.0.1: + resolution: + { + integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==, + } + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: { @@ -1939,6 +2349,13 @@ packages: } engines: { node: '>=8' } + ansi-regex@6.2.2: + resolution: + { + integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==, + } + engines: { node: '>=12' } + ansi-styles@4.3.0: resolution: { @@ -1946,6 +2363,13 @@ packages: } engines: { node: '>=8' } + ansis@4.2.0: + resolution: + { + integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==, + } + engines: { node: '>=14' } + argparse@2.0.1: resolution: { @@ -1965,6 +2389,13 @@ packages: integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==, } + ast-types@0.16.1: + resolution: + { + integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==, + } + engines: { node: '>=4' } + asynckit@0.4.0: resolution: { @@ -1983,6 +2414,13 @@ packages: integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==, } + balanced-match@4.0.2: + resolution: + { + integrity: sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==, + } + engines: { node: 20 || >=22 } + baseline-browser-mapping@2.9.14: resolution: { @@ -1990,6 +2428,13 @@ packages: } hasBin: true + body-parser@2.2.2: + resolution: + { + integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==, + } + engines: { node: '>=18' } + brace-expansion@1.1.12: resolution: { @@ -2002,6 +2447,20 @@ packages: integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==, } + brace-expansion@5.0.2: + resolution: + { + integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==, + } + engines: { node: 20 || >=22 } + + braces@3.0.3: + resolution: + { + integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, + } + engines: { node: '>=8' } + browserslist@4.28.1: resolution: { @@ -2010,6 +2469,20 @@ packages: engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } hasBin: true + bundle-name@4.1.0: + resolution: + { + integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==, + } + engines: { node: '>=18' } + + bytes@3.1.2: + resolution: + { + integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==, + } + engines: { node: '>= 0.8' } + call-bind-apply-helpers@1.0.2: resolution: { @@ -2017,6 +2490,13 @@ packages: } engines: { node: '>= 0.4' } + call-bound@1.0.4: + resolution: + { + integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==, + } + engines: { node: '>= 0.4' } + callsites@3.1.0: resolution: { @@ -2050,6 +2530,27 @@ packages: integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==, } + cli-cursor@5.0.0: + resolution: + { + integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==, + } + engines: { node: '>=18' } + + cli-spinners@2.9.2: + resolution: + { + integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==, + } + engines: { node: '>=6' } + + cli-width@4.1.0: + resolution: + { + integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==, + } + engines: { node: '>= 12' } + cliui@8.0.1: resolution: { @@ -2064,6 +2565,12 @@ packages: } engines: { node: '>=6' } + code-block-writer@13.0.3: + resolution: + { + integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==, + } + color-convert@2.0.1: resolution: { @@ -2084,6 +2591,20 @@ packages: } engines: { node: '>= 0.8' } + commander@11.1.0: + resolution: + { + integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==, + } + engines: { node: '>=16' } + + commander@14.0.3: + resolution: + { + integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==, + } + engines: { node: '>=20' } + compare-func@2.0.0: resolution: { @@ -2096,6 +2617,20 @@ packages: integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, } + content-disposition@1.0.1: + resolution: + { + integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==, + } + engines: { node: '>=18' } + + content-type@1.0.5: + resolution: + { + integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==, + } + engines: { node: '>= 0.6' } + conventional-changelog-angular@7.0.0: resolution: { @@ -2124,6 +2659,34 @@ packages: integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, } + cookie-signature@1.2.2: + resolution: + { + integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==, + } + engines: { node: '>=6.6.0' } + + cookie@0.7.2: + resolution: + { + integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==, + } + engines: { node: '>= 0.6' } + + cookie@1.1.1: + resolution: + { + integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==, + } + engines: { node: '>=18' } + + cors@2.8.6: + resolution: + { + integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==, + } + engines: { node: '>= 0.10' } + cosmiconfig-typescript-loader@6.2.0: resolution: { @@ -2154,6 +2717,14 @@ packages: } engines: { node: '>= 8' } + cssesc@3.0.0: + resolution: + { + integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==, + } + engines: { node: '>=4' } + hasBin: true + csstype@3.2.3: resolution: { @@ -2167,6 +2738,13 @@ packages: } engines: { node: '>=12' } + data-uri-to-buffer@4.0.1: + resolution: + { + integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==, + } + engines: { node: '>= 12' } + date-fns-jalali@4.1.0-0: resolution: { @@ -2191,12 +2769,51 @@ packages: supports-color: optional: true + dedent@1.7.1: + resolution: + { + integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==, + } + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deep-is@0.1.4: resolution: { integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, } + deepmerge@4.3.1: + resolution: + { + integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==, + } + engines: { node: '>=0.10.0' } + + default-browser-id@5.0.1: + resolution: + { + integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==, + } + engines: { node: '>=18' } + + default-browser@5.5.0: + resolution: + { + integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==, + } + engines: { node: '>=18' } + + define-lazy-prop@3.0.0: + resolution: + { + integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==, + } + engines: { node: '>=12' } + delayed-stream@1.0.0: resolution: { @@ -2204,6 +2821,13 @@ packages: } engines: { node: '>=0.4.0' } + depd@2.0.0: + resolution: + { + integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==, + } + engines: { node: '>= 0.8' } + detect-libc@2.1.2: resolution: { @@ -2217,6 +2841,13 @@ packages: integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==, } + diff@8.0.3: + resolution: + { + integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==, + } + engines: { node: '>=0.3.1' } + dot-prop@5.3.0: resolution: { @@ -2224,6 +2855,13 @@ packages: } engines: { node: '>=8' } + dotenv@17.3.1: + resolution: + { + integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==, + } + engines: { node: '>=12' } + dunder-proto@1.0.1: resolution: { @@ -2231,18 +2869,44 @@ packages: } engines: { node: '>= 0.4' } + eciesjs@0.4.17: + resolution: + { + integrity: sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==, + } + engines: { bun: '>=1', deno: '>=2', node: '>=16' } + + ee-first@1.1.1: + resolution: + { + integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==, + } + electron-to-chromium@1.5.267: resolution: { integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==, } + emoji-regex@10.6.0: + resolution: + { + integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==, + } + emoji-regex@8.0.0: resolution: { integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==, } + encodeurl@2.0.0: + resolution: + { + integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==, + } + engines: { node: '>= 0.8' } + enhanced-resolve@5.18.4: resolution: { @@ -2306,6 +2970,12 @@ packages: } engines: { node: '>=6' } + escape-html@1.0.3: + resolution: + { + integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==, + } + escape-string-regexp@4.0.0: resolution: { @@ -2388,6 +3058,14 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + esprima@4.0.1: + resolution: + { + integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==, + } + engines: { node: '>=4' } + hasBin: true + esquery@1.7.0: resolution: { @@ -2416,83 +3094,175 @@ packages: } engines: { node: '>=0.10.0' } - fast-deep-equal@3.1.3: + etag@1.8.1: resolution: { - integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, + integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==, } + engines: { node: '>= 0.6' } - fast-json-stable-stringify@2.1.0: + eventsource-parser@3.0.6: resolution: { - integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==, + integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==, } + engines: { node: '>=18.0.0' } - fast-levenshtein@2.0.6: + eventsource@3.0.7: resolution: { - integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, + integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==, } + engines: { node: '>=18.0.0' } - fast-uri@3.1.0: + execa@5.1.1: resolution: { - integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==, + integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==, } + engines: { node: '>=10' } - fdir@6.5.0: + execa@9.6.1: resolution: { - integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, + integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==, } - engines: { node: '>=12.0.0' } - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true + engines: { node: ^18.19.0 || >=20.5.0 } - file-entry-cache@8.0.0: + express-rate-limit@8.2.1: resolution: { - integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==, + integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==, } - engines: { node: '>=16.0.0' } + engines: { node: '>= 16' } + peerDependencies: + express: '>= 4.11' - find-up@5.0.0: + express@5.2.1: resolution: { - integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, + integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==, } - engines: { node: '>=10' } + engines: { node: '>= 18' } - find-up@7.0.0: + fast-deep-equal@3.1.3: resolution: { - integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==, + integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, } - engines: { node: '>=18' } - flat-cache@4.0.1: + fast-glob@3.3.3: resolution: { - integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==, + integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==, } - engines: { node: '>=16' } + engines: { node: '>=8.6.0' } - flatted@3.3.3: + fast-json-stable-stringify@2.1.0: resolution: { - integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==, + integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==, } - follow-redirects@1.15.11: + fast-levenshtein@2.0.6: resolution: { - integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==, + integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, } - engines: { node: '>=4.0' } - peerDependencies: + + fast-uri@3.1.0: + resolution: + { + integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==, + } + + fastq@1.20.1: + resolution: + { + integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==, + } + + fdir@6.5.0: + resolution: + { + integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, + } + engines: { node: '>=12.0.0' } + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: + { + integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==, + } + engines: { node: ^12.20 || >= 14.13 } + + figures@6.1.0: + resolution: + { + integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==, + } + engines: { node: '>=18' } + + file-entry-cache@8.0.0: + resolution: + { + integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==, + } + engines: { node: '>=16.0.0' } + + fill-range@7.1.1: + resolution: + { + integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==, + } + engines: { node: '>=8' } + + finalhandler@2.1.1: + resolution: + { + integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==, + } + engines: { node: '>= 18.0.0' } + + find-up@5.0.0: + resolution: + { + integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, + } + engines: { node: '>=10' } + + find-up@7.0.0: + resolution: + { + integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==, + } + engines: { node: '>=18' } + + flat-cache@4.0.1: + resolution: + { + integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==, + } + engines: { node: '>=16' } + + flatted@3.3.3: + resolution: + { + integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==, + } + + follow-redirects@1.15.11: + resolution: + { + integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==, + } + engines: { node: '>=4.0' } + peerDependencies: debug: '*' peerDependenciesMeta: debug: @@ -2505,6 +3275,34 @@ packages: } engines: { node: '>= 6' } + formdata-polyfill@4.0.10: + resolution: + { + integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==, + } + engines: { node: '>=12.20.0' } + + forwarded@0.2.0: + resolution: + { + integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==, + } + engines: { node: '>= 0.6' } + + fresh@2.0.0: + resolution: + { + integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==, + } + engines: { node: '>= 0.8' } + + fs-extra@11.3.3: + resolution: + { + integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==, + } + engines: { node: '>=14.14' } + fsevents@2.3.3: resolution: { @@ -2519,6 +3317,18 @@ packages: integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==, } + fuzzysort@3.1.0: + resolution: + { + integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==, + } + + fzf@0.5.2: + resolution: + { + integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==, + } + gensync@1.0.0-beta.2: resolution: { @@ -2533,6 +3343,13 @@ packages: } engines: { node: 6.* || 8.* || >= 10.* } + get-east-asian-width@1.4.0: + resolution: + { + integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==, + } + engines: { node: '>=18' } + get-intrinsic@1.3.0: resolution: { @@ -2547,6 +3364,13 @@ packages: } engines: { node: '>=6' } + get-own-enumerable-keys@1.0.0: + resolution: + { + integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==, + } + engines: { node: '>=14.16' } + get-proto@1.0.1: resolution: { @@ -2554,6 +3378,20 @@ packages: } engines: { node: '>= 0.4' } + get-stream@6.0.1: + resolution: + { + integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==, + } + engines: { node: '>=10' } + + get-stream@9.0.1: + resolution: + { + integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==, + } + engines: { node: '>=18' } + git-raw-commits@4.0.0: resolution: { @@ -2562,6 +3400,13 @@ packages: engines: { node: '>=16' } hasBin: true + glob-parent@5.1.2: + resolution: + { + integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==, + } + engines: { node: '>= 6' } + glob-parent@6.0.2: resolution: { @@ -2603,6 +3448,13 @@ packages: integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, } + graphql@16.12.0: + resolution: + { + integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==, + } + engines: { node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0 } + has-flag@4.0.0: resolution: { @@ -2631,6 +3483,12 @@ packages: } engines: { node: '>= 0.4' } + headers-polyfill@4.0.3: + resolution: + { + integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==, + } + hermes-estree@0.25.1: resolution: { @@ -2643,6 +3501,48 @@ packages: integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==, } + hono@4.11.9: + resolution: + { + integrity: sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==, + } + engines: { node: '>=16.9.0' } + + http-errors@2.0.1: + resolution: + { + integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, + } + engines: { node: '>= 0.8' } + + https-proxy-agent@7.0.6: + resolution: + { + integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==, + } + engines: { node: '>= 14' } + + human-signals@2.1.0: + resolution: + { + integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==, + } + engines: { node: '>=10.17.0' } + + human-signals@8.0.1: + resolution: + { + integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==, + } + engines: { node: '>=18.18.0' } + + iconv-lite@0.7.2: + resolution: + { + integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==, + } + engines: { node: '>=0.10.0' } + ignore@5.3.2: resolution: { @@ -2677,6 +3577,12 @@ packages: } engines: { node: '>=0.8.19' } + inherits@2.0.4: + resolution: + { + integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==, + } + ini@4.1.1: resolution: { @@ -2684,12 +3590,34 @@ packages: } engines: { node: ^14.17.0 || ^16.13.0 || >=18.0.0 } + ip-address@10.0.1: + resolution: + { + integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==, + } + engines: { node: '>= 12' } + + ipaddr.js@1.9.1: + resolution: + { + integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==, + } + engines: { node: '>= 0.10' } + is-arrayish@0.2.1: resolution: { integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==, } + is-docker@3.0.0: + resolution: + { + integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==, + } + engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + hasBin: true + is-extglob@2.1.1: resolution: { @@ -2711,128 +3639,271 @@ packages: } engines: { node: '>=0.10.0' } - is-obj@2.0.0: + is-in-ssh@1.0.0: resolution: { - integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==, + integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==, } - engines: { node: '>=8' } + engines: { node: '>=20' } - is-text-path@2.0.0: + is-inside-container@1.0.0: resolution: { - integrity: sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==, + integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==, } - engines: { node: '>=8' } + engines: { node: '>=14.16' } + hasBin: true - isexe@2.0.0: + is-interactive@2.0.0: resolution: { - integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, + integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==, } + engines: { node: '>=12' } - jiti@2.6.1: + is-node-process@1.2.0: resolution: { - integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==, + integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==, } - hasBin: true - js-tokens@4.0.0: + is-number@7.0.0: resolution: { - integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, + integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==, } + engines: { node: '>=0.12.0' } - js-yaml@4.1.1: + is-obj@2.0.0: resolution: { - integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==, + integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==, } - hasBin: true + engines: { node: '>=8' } - jsesc@3.1.0: + is-obj@3.0.0: resolution: { - integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==, + integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==, } - engines: { node: '>=6' } - hasBin: true + engines: { node: '>=12' } - json-buffer@3.0.1: + is-plain-obj@4.1.0: resolution: { - integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==, + integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==, } + engines: { node: '>=12' } - json-parse-even-better-errors@2.3.1: + is-promise@4.0.0: resolution: { - integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==, + integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==, } - json-schema-traverse@0.4.1: + is-regexp@3.1.0: resolution: { - integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, + integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==, } + engines: { node: '>=12' } - json-schema-traverse@1.0.0: + is-stream@2.0.1: resolution: { - integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==, + integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==, } + engines: { node: '>=8' } - json-stable-stringify-without-jsonify@1.0.1: + is-stream@4.0.1: resolution: { - integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, + integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==, } + engines: { node: '>=18' } - json5@2.2.3: + is-text-path@2.0.0: resolution: { - integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==, + integrity: sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==, } - engines: { node: '>=6' } - hasBin: true + engines: { node: '>=8' } - jsonparse@1.3.1: + is-unicode-supported@1.3.0: resolution: { - integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==, + integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==, } - engines: { '0': node >= 0.2.0 } + engines: { node: '>=12' } - keyv@4.5.4: + is-unicode-supported@2.1.0: resolution: { - integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, + integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==, } + engines: { node: '>=18' } - lefthook-darwin-arm64@2.0.15: + is-wsl@3.1.1: resolution: { - integrity: sha512-ygAqG/NzOgY9bEiqeQtiOmCRTtp9AmOd3eyrpEaSrRB9V9f3RHRgWDrWbde9BiHSsCzcbeY9/X2NuKZ69eUsNA==, + integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==, } - cpu: [arm64] - os: [darwin] + engines: { node: '>=16' } - lefthook-darwin-x64@2.0.15: + isexe@2.0.0: resolution: { - integrity: sha512-3wA30CzdSL5MFKD6dk7v8BMq7ScWQivpLbmIn3Pv67AaBavN57N/hcdGqOFnDDFI5WazVwDY7UqDfMIk5HZjEA==, + integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, } - cpu: [x64] - os: [darwin] - lefthook-freebsd-arm64@2.0.15: + isexe@3.1.5: resolution: { - integrity: sha512-FbYBBLVbX8BjdO+icN1t/pC3TOW3FAvTKv/zggBKNihv6jHNn/3s/0j2xIS0k0Pw9oOE7MVmEni3qp2j5vqHrQ==, + integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==, } - cpu: [arm64] - os: [freebsd] + engines: { node: '>=18' } + + jackspeak@4.2.3: + resolution: + { + integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==, + } + engines: { node: 20 || >=22 } + + jiti@2.6.1: + resolution: + { + integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==, + } + hasBin: true + + jose@6.1.3: + resolution: + { + integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==, + } + + js-tokens@4.0.0: + resolution: + { + integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, + } + + js-yaml@4.1.1: + resolution: + { + integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==, + } + hasBin: true + + jsesc@3.1.0: + resolution: + { + integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==, + } + engines: { node: '>=6' } + hasBin: true + + json-buffer@3.0.1: + resolution: + { + integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==, + } + + json-parse-even-better-errors@2.3.1: + resolution: + { + integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==, + } + + json-schema-traverse@0.4.1: + resolution: + { + integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, + } + + json-schema-traverse@1.0.0: + resolution: + { + integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==, + } + + json-schema-typed@8.0.2: + resolution: + { + integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==, + } + + json-stable-stringify-without-jsonify@1.0.1: + resolution: + { + integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, + } + + json5@2.2.3: + resolution: + { + integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==, + } + engines: { node: '>=6' } + hasBin: true + + jsonfile@6.2.0: + resolution: + { + integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==, + } + + jsonparse@1.3.1: + resolution: + { + integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==, + } + engines: { '0': node >= 0.2.0 } + + keyv@4.5.4: + resolution: + { + integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, + } + + kleur@3.0.3: + resolution: + { + integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==, + } + engines: { node: '>=6' } + + kleur@4.1.5: + resolution: + { + integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==, + } + engines: { node: '>=6' } + + lefthook-darwin-arm64@2.0.15: + resolution: + { + integrity: sha512-ygAqG/NzOgY9bEiqeQtiOmCRTtp9AmOd3eyrpEaSrRB9V9f3RHRgWDrWbde9BiHSsCzcbeY9/X2NuKZ69eUsNA==, + } + cpu: [arm64] + os: [darwin] + + lefthook-darwin-x64@2.0.15: + resolution: + { + integrity: sha512-3wA30CzdSL5MFKD6dk7v8BMq7ScWQivpLbmIn3Pv67AaBavN57N/hcdGqOFnDDFI5WazVwDY7UqDfMIk5HZjEA==, + } + cpu: [x64] + os: [darwin] + + lefthook-freebsd-arm64@2.0.15: + resolution: + { + integrity: sha512-FbYBBLVbX8BjdO+icN1t/pC3TOW3FAvTKv/zggBKNihv6jHNn/3s/0j2xIS0k0Pw9oOE7MVmEni3qp2j5vqHrQ==, + } + cpu: [arm64] + os: [freebsd] lefthook-freebsd-x64@2.0.15: resolution: @@ -3084,6 +4155,13 @@ packages: integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==, } + log-symbols@6.0.0: + resolution: + { + integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==, + } + engines: { node: '>=18' } + loose-envify@1.4.0: resolution: { @@ -3118,6 +4196,13 @@ packages: } engines: { node: '>= 0.4' } + media-typer@1.1.0: + resolution: + { + integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==, + } + engines: { node: '>= 0.8' } + meow@12.1.1: resolution: { @@ -3125,6 +4210,33 @@ packages: } engines: { node: '>=16.10' } + merge-descriptors@2.0.0: + resolution: + { + integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==, + } + engines: { node: '>=18' } + + merge-stream@2.0.0: + resolution: + { + integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==, + } + + merge2@1.4.1: + resolution: + { + integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, + } + engines: { node: '>= 8' } + + micromatch@4.0.8: + resolution: + { + integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==, + } + engines: { node: '>=8.6' } + mime-db@1.52.0: resolution: { @@ -3132,6 +4244,13 @@ packages: } engines: { node: '>= 0.6' } + mime-db@1.54.0: + resolution: + { + integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==, + } + engines: { node: '>= 0.6' } + mime-types@2.1.35: resolution: { @@ -3139,6 +4258,34 @@ packages: } engines: { node: '>= 0.6' } + mime-types@3.0.2: + resolution: + { + integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==, + } + engines: { node: '>=18' } + + mimic-fn@2.1.0: + resolution: + { + integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==, + } + engines: { node: '>=6' } + + mimic-function@5.0.1: + resolution: + { + integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==, + } + engines: { node: '>=18' } + + minimatch@10.2.0: + resolution: + { + integrity: sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==, + } + engines: { node: 20 || >=22 } + minimatch@3.1.2: resolution: { @@ -3164,6 +4311,26 @@ packages: integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, } + msw@2.12.10: + resolution: + { + integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==, + } + engines: { node: '>=18' } + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@2.0.0: + resolution: + { + integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==, + } + engines: { node: ^18.17.0 || >=20.5.0 } + nanoid@3.3.11: resolution: { @@ -3178,323 +4345,762 @@ packages: integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, } - node-releases@2.0.27: + negotiator@1.0.0: resolution: { - integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==, + integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==, } + engines: { node: '>= 0.6' } - optionator@0.9.4: + next-themes@0.4.6: resolution: { - integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, + integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==, } - engines: { node: '>= 0.8.0' } + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - p-limit@3.1.0: + node-domexception@1.0.0: resolution: { - integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, + integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==, } - engines: { node: '>=10' } + engines: { node: '>=10.5.0' } + deprecated: Use your platform's native DOMException instead - p-limit@4.0.0: + node-fetch@3.3.2: resolution: { - integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==, + integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==, } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } - p-locate@5.0.0: + node-releases@2.0.27: resolution: { - integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, + integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==, } - engines: { node: '>=10' } - p-locate@6.0.0: + npm-run-path@4.0.1: resolution: { - integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==, + integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==, } - engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + engines: { node: '>=8' } - parent-module@1.0.1: + npm-run-path@6.0.0: resolution: { - integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, + integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==, } - engines: { node: '>=6' } + engines: { node: '>=18' } - parse-json@5.2.0: + object-assign@4.1.1: resolution: { - integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==, + integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, } - engines: { node: '>=8' } + engines: { node: '>=0.10.0' } - path-exists@4.0.0: + object-inspect@1.13.4: resolution: { - integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, + integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==, } - engines: { node: '>=8' } + engines: { node: '>= 0.4' } - path-exists@5.0.0: + object-treeify@1.1.33: resolution: { - integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==, + integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==, } - engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + engines: { node: '>= 10' } - path-key@3.1.1: + on-finished@2.4.1: resolution: { - integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, + integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==, } - engines: { node: '>=8' } + engines: { node: '>= 0.8' } - picocolors@1.1.1: + once@1.4.0: resolution: { - integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, + integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==, } - picomatch@4.0.3: + onetime@5.1.2: resolution: { - integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==, + integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==, } - engines: { node: '>=12' } + engines: { node: '>=6' } - postcss@8.5.6: + onetime@7.0.0: resolution: { - integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==, + integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==, } - engines: { node: ^10 || ^12 || >=14 } + engines: { node: '>=18' } - prelude-ls@1.2.1: + open@11.0.0: resolution: { - integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, + integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==, + } + engines: { node: '>=20' } + + optionator@0.9.4: + resolution: + { + integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, + } + engines: { node: '>= 0.8.0' } + + ora@8.2.0: + resolution: + { + integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==, + } + engines: { node: '>=18' } + + outvariant@1.4.3: + resolution: + { + integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==, + } + + p-limit@3.1.0: + resolution: + { + integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, + } + engines: { node: '>=10' } + + p-limit@4.0.0: + resolution: + { + integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==, + } + engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + + p-locate@5.0.0: + resolution: + { + integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, + } + engines: { node: '>=10' } + + p-locate@6.0.0: + resolution: + { + integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==, + } + engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + + package-manager-detector@1.6.0: + resolution: + { + integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==, + } + + parent-module@1.0.1: + resolution: + { + integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, + } + engines: { node: '>=6' } + + parse-json@5.2.0: + resolution: + { + integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==, + } + engines: { node: '>=8' } + + parse-ms@4.0.0: + resolution: + { + integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==, + } + engines: { node: '>=18' } + + parseurl@1.3.3: + resolution: + { + integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==, + } + engines: { node: '>= 0.8' } + + path-browserify@1.0.1: + resolution: + { + integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==, + } + + path-exists@4.0.0: + resolution: + { + integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, + } + engines: { node: '>=8' } + + path-exists@5.0.0: + resolution: + { + integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==, + } + engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + + path-key@3.1.1: + resolution: + { + integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, + } + engines: { node: '>=8' } + + path-key@4.0.0: + resolution: + { + integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==, + } + engines: { node: '>=12' } + + path-to-regexp@6.3.0: + resolution: + { + integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==, + } + + path-to-regexp@8.3.0: + resolution: + { + integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==, + } + + picocolors@1.1.1: + resolution: + { + integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, + } + + picomatch@2.3.1: + resolution: + { + integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==, + } + engines: { node: '>=8.6' } + + picomatch@4.0.3: + resolution: + { + integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==, + } + engines: { node: '>=12' } + + pkce-challenge@5.0.1: + resolution: + { + integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==, + } + engines: { node: '>=16.20.0' } + + postcss-selector-parser@7.1.1: + resolution: + { + integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==, + } + engines: { node: '>=4' } + + postcss@8.5.6: + resolution: + { + integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==, + } + engines: { node: ^10 || ^12 || >=14 } + + powershell-utils@0.1.0: + resolution: + { + integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==, + } + engines: { node: '>=20' } + + prelude-ls@1.2.1: + resolution: + { + integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, + } + engines: { node: '>= 0.8.0' } + + pretendard@1.3.9: + resolution: + { + integrity: sha512-PaQAADyLY5v4kYFwkpSJHbSSYIkiriY/1xXw75TKoZ9UQQqeU+tvP05yTdZAWibiIYoo8ZKtRv8PM7w0IaywSw==, + } + + prettier@3.7.4: + resolution: + { + integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==, + } + engines: { node: '>=14' } + hasBin: true + + pretty-ms@9.3.0: + resolution: + { + integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==, + } + engines: { node: '>=18' } + + prompts@2.4.2: + resolution: + { + integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==, + } + engines: { node: '>= 6' } + + proxy-addr@2.0.7: + resolution: + { + integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==, + } + engines: { node: '>= 0.10' } + + proxy-from-env@1.1.0: + resolution: + { + integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==, + } + + punycode@2.3.1: + resolution: + { + integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, + } + engines: { node: '>=6' } + + qs@6.15.0: + resolution: + { + integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==, + } + engines: { node: '>=0.6' } + + queue-microtask@1.2.3: + resolution: + { + integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==, + } + + range-parser@1.2.1: + resolution: + { + integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==, + } + engines: { node: '>= 0.6' } + + raw-body@3.0.2: + resolution: + { + integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==, + } + engines: { node: '>= 0.10' } + + react-day-picker@9.13.0: + resolution: + { + integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==, + } + engines: { node: '>=18' } + peerDependencies: + react: '>=16.8.0' + + react-dom@18.2.0: + resolution: + { + integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==, + } + peerDependencies: + react: ^18.2.0 + + react-refresh@0.18.0: + resolution: + { + integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==, + } + engines: { node: '>=0.10.0' } + + react-remove-scroll-bar@2.3.8: + resolution: + { + integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==, + } + engines: { node: '>=10' } + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: + { + integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==, + } + engines: { node: '>=10' } + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-dom@6.30.3: + resolution: + { + integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==, + } + engines: { node: '>=14.0.0' } + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.3: + resolution: + { + integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==, + } + engines: { node: '>=14.0.0' } + peerDependencies: + react: '>=16.8' + + react-style-singleton@2.2.3: + resolution: + { + integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==, + } + engines: { node: '>=10' } + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@18.2.0: + resolution: + { + integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==, + } + engines: { node: '>=0.10.0' } + + recast@0.23.11: + resolution: + { + integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==, + } + engines: { node: '>= 4' } + + require-directory@2.1.1: + resolution: + { + integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==, + } + engines: { node: '>=0.10.0' } + + require-from-string@2.0.2: + resolution: + { + integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==, + } + engines: { node: '>=0.10.0' } + + resolve-from@4.0.0: + resolution: + { + integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==, + } + engines: { node: '>=4' } + + resolve-from@5.0.0: + resolution: + { + integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==, + } + engines: { node: '>=8' } + + restore-cursor@5.1.0: + resolution: + { + integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==, + } + engines: { node: '>=18' } + + rettime@0.10.1: + resolution: + { + integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==, + } + + reusify@1.1.0: + resolution: + { + integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==, + } + engines: { iojs: '>=1.0.0', node: '>=0.10.0' } + + rollup@4.55.1: + resolution: + { + integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==, + } + engines: { node: '>=18.0.0', npm: '>=8.0.0' } + hasBin: true + + router@2.2.0: + resolution: + { + integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==, + } + engines: { node: '>= 18' } + + run-applescript@7.1.0: + resolution: + { + integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==, + } + engines: { node: '>=18' } + + run-parallel@1.2.0: + resolution: + { + integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==, + } + + safer-buffer@2.1.2: + resolution: + { + integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, + } + + scheduler@0.23.2: + resolution: + { + integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==, + } + + semver@6.3.1: + resolution: + { + integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==, + } + hasBin: true + + semver@7.7.3: + resolution: + { + integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==, + } + engines: { node: '>=10' } + hasBin: true + + send@1.2.1: + resolution: + { + integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==, } - engines: { node: '>= 0.8.0' } + engines: { node: '>= 18' } - pretendard@1.3.9: + serve-static@2.2.1: resolution: { - integrity: sha512-PaQAADyLY5v4kYFwkpSJHbSSYIkiriY/1xXw75TKoZ9UQQqeU+tvP05yTdZAWibiIYoo8ZKtRv8PM7w0IaywSw==, + integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==, } + engines: { node: '>= 18' } - prettier@3.7.4: + setprototypeof@1.2.0: resolution: { - integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==, + integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==, } - engines: { node: '>=14' } - hasBin: true - proxy-from-env@1.1.0: + shadcn@3.8.4: resolution: { - integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==, + integrity: sha512-pSad/m1+PGzB0aLsRBV0EkyGg9al1nJqYUuucg6d8v8xZspPZ5/ehGNEp5M4b1KQYqdO5/gGPbkhVbgmXqG9Pw==, } + hasBin: true - punycode@2.3.1: + shebang-command@2.0.0: resolution: { - integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, + integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, } - engines: { node: '>=6' } + engines: { node: '>=8' } - react-day-picker@9.13.0: + shebang-regex@3.0.0: resolution: { - integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==, + integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, } - engines: { node: '>=18' } - peerDependencies: - react: '>=16.8.0' + engines: { node: '>=8' } - react-dom@18.2.0: + side-channel-list@1.0.0: resolution: { - integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==, + integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==, } - peerDependencies: - react: ^18.2.0 + engines: { node: '>= 0.4' } - react-refresh@0.18.0: + side-channel-map@1.0.1: resolution: { - integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==, + integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==, } - engines: { node: '>=0.10.0' } + engines: { node: '>= 0.4' } - react-remove-scroll-bar@2.3.8: + side-channel-weakmap@1.0.2: resolution: { - integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==, + integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==, } - engines: { node: '>=10' } - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true + engines: { node: '>= 0.4' } - react-remove-scroll@2.7.2: + side-channel@1.1.0: resolution: { - integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==, + integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==, } - engines: { node: '>=10' } - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true + engines: { node: '>= 0.4' } - react-router-dom@6.30.3: + signal-exit@3.0.7: resolution: { - integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==, + integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==, } - engines: { node: '>=14.0.0' } - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - react-router@6.30.3: + signal-exit@4.1.0: resolution: { - integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==, + integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==, } - engines: { node: '>=14.0.0' } - peerDependencies: - react: '>=16.8' + engines: { node: '>=14' } - react-style-singleton@2.2.3: + sisteransi@1.0.5: resolution: { - integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==, + integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==, } - engines: { node: '>=10' } - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - react@18.2.0: + sonner@2.0.7: resolution: { - integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==, + integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==, } - engines: { node: '>=0.10.0' } + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - require-directory@2.1.1: + source-map-js@1.2.1: resolution: { - integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==, + integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, } engines: { node: '>=0.10.0' } - require-from-string@2.0.2: + source-map@0.6.1: resolution: { - integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==, + integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==, } engines: { node: '>=0.10.0' } - resolve-from@4.0.0: + split2@4.2.0: resolution: { - integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==, + integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==, } - engines: { node: '>=4' } + engines: { node: '>= 10.x' } - resolve-from@5.0.0: + statuses@2.0.2: resolution: { - integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==, + integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==, } - engines: { node: '>=8' } + engines: { node: '>= 0.8' } - rollup@4.55.1: + stdin-discarder@0.2.2: resolution: { - integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==, + integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==, } - engines: { node: '>=18.0.0', npm: '>=8.0.0' } - hasBin: true + engines: { node: '>=18' } - scheduler@0.23.2: + strict-event-emitter@0.5.1: resolution: { - integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==, + integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==, } - semver@6.3.1: + string-width@4.2.3: resolution: { - integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==, + integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==, } - hasBin: true + engines: { node: '>=8' } - semver@7.7.3: + string-width@7.2.0: resolution: { - integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==, + integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==, } - engines: { node: '>=10' } - hasBin: true + engines: { node: '>=18' } - shebang-command@2.0.0: + stringify-object@5.0.0: resolution: { - integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, + integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==, } - engines: { node: '>=8' } + engines: { node: '>=14.16' } - shebang-regex@3.0.0: + strip-ansi@6.0.1: resolution: { - integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, + integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==, } engines: { node: '>=8' } - source-map-js@1.2.1: + strip-ansi@7.1.2: resolution: { - integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, + integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==, } - engines: { node: '>=0.10.0' } + engines: { node: '>=12' } - split2@4.2.0: + strip-bom@3.0.0: resolution: { - integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==, + integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==, } - engines: { node: '>= 10.x' } + engines: { node: '>=4' } - string-width@4.2.3: + strip-final-newline@2.0.0: resolution: { - integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==, + integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==, } - engines: { node: '>=8' } + engines: { node: '>=6' } - strip-ansi@6.0.1: + strip-final-newline@4.0.0: resolution: { - integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==, + integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==, } - engines: { node: '>=8' } + engines: { node: '>=18' } strip-json-comments@3.1.1: resolution: @@ -3510,6 +5116,13 @@ packages: } engines: { node: '>=8' } + tagged-tag@1.0.0: + resolution: + { + integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==, + } + engines: { node: '>=20' } + tailwind-merge@3.4.0: resolution: { @@ -3542,6 +5155,12 @@ packages: integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==, } + tiny-invariant@1.3.3: + resolution: + { + integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==, + } + tinyexec@1.0.2: resolution: { @@ -3556,6 +5175,40 @@ packages: } engines: { node: '>=12.0.0' } + tldts-core@7.0.23: + resolution: + { + integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==, + } + + tldts@7.0.23: + resolution: + { + integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==, + } + hasBin: true + + to-regex-range@5.0.1: + resolution: + { + integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==, + } + engines: { node: '>=8.0' } + + toidentifier@1.0.1: + resolution: + { + integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==, + } + engines: { node: '>=0.6' } + + tough-cookie@6.0.0: + resolution: + { + integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==, + } + engines: { node: '>=16' } + ts-api-utils@2.4.0: resolution: { @@ -3565,6 +5218,19 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-morph@26.0.0: + resolution: + { + integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==, + } + + tsconfig-paths@4.2.0: + resolution: + { + integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==, + } + engines: { node: '>=6' } + tslib@2.8.1: resolution: { @@ -3584,6 +5250,20 @@ packages: } engines: { node: '>= 0.8.0' } + type-fest@5.4.4: + resolution: + { + integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==, + } + engines: { node: '>=20' } + + type-is@2.0.1: + resolution: + { + integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==, + } + engines: { node: '>= 0.6' } + typescript-eslint@8.52.0: resolution: { @@ -3615,6 +5295,33 @@ packages: } engines: { node: '>=18' } + unicorn-magic@0.3.0: + resolution: + { + integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==, + } + engines: { node: '>=18' } + + universalify@2.0.1: + resolution: + { + integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==, + } + engines: { node: '>= 10.0.0' } + + unpipe@1.0.0: + resolution: + { + integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==, + } + engines: { node: '>= 0.8' } + + until-async@3.0.2: + resolution: + { + integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==, + } + update-browserslist-db@1.2.3: resolution: { @@ -3664,6 +5371,26 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: + resolution: + { + integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, + } + + validate-npm-package-name@7.0.2: + resolution: + { + integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==, + } + engines: { node: ^20.17.0 || >=22.9.0 } + + vary@1.1.2: + resolution: + { + integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==, + } + engines: { node: '>= 0.8' } + vite@7.3.1: resolution: { @@ -3707,6 +5434,13 @@ packages: yaml: optional: true + web-streams-polyfill@3.3.3: + resolution: + { + integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==, + } + engines: { node: '>= 8' } + which@2.0.2: resolution: { @@ -3715,6 +5449,14 @@ packages: engines: { node: '>= 8' } hasBin: true + which@4.0.0: + resolution: + { + integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==, + } + engines: { node: ^16.13.0 || >=18.0.0 } + hasBin: true + word-wrap@1.2.5: resolution: { @@ -3722,6 +5464,13 @@ packages: } engines: { node: '>=0.10.0' } + wrap-ansi@6.2.0: + resolution: + { + integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==, + } + engines: { node: '>=8' } + wrap-ansi@7.0.0: resolution: { @@ -3729,6 +5478,19 @@ packages: } engines: { node: '>=10' } + wrappy@1.0.2: + resolution: + { + integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, + } + + wsl-utils@0.3.1: + resolution: + { + integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==, + } + engines: { node: '>=20' } + y18n@5.0.8: resolution: { @@ -3756,19 +5518,41 @@ packages: } engines: { node: '>=12' } - yocto-queue@0.1.0: + yocto-queue@0.1.0: + resolution: + { + integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, + } + engines: { node: '>=10' } + + yocto-queue@1.2.2: + resolution: + { + integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==, + } + engines: { node: '>=12.20' } + + yoctocolors-cjs@2.1.3: + resolution: + { + integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==, + } + engines: { node: '>=18' } + + yoctocolors@2.1.2: resolution: { - integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, + integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==, } - engines: { node: '>=10' } + engines: { node: '>=18' } - yocto-queue@1.2.2: + zod-to-json-schema@3.25.1: resolution: { - integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==, + integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==, } - engines: { node: '>=12.20' } + peerDependencies: + zod: ^3.25 || ^4 zod-validation-error@4.0.2: resolution: @@ -3779,6 +5563,12 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + zod@3.25.76: + resolution: + { + integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, + } + zod@4.3.5: resolution: { @@ -3807,12 +5597,25 @@ packages: optional: true snapshots: + '@antfu/ni@25.0.0': + dependencies: + ansis: 4.2.0 + fzf: 0.5.2 + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.28.5': {} '@babel/core@7.28.5': @@ -3843,6 +5646,18 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.28.5 + '@babel/helper-compilation-targets@7.27.2': dependencies: '@babel/compat-data': 7.28.5 @@ -3851,8 +5666,28 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.28.5) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.28.0': {} + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.28.5 @@ -3860,6 +5695,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -3869,8 +5711,39 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.28.5 + '@babel/helper-plugin-utils@7.27.1': {} + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -3886,6 +5759,28 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -3896,12 +5791,40 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.28.5) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.5) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.28.5) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.28.5) + transitivePeerDependencies: + - supports-color + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 '@babel/parser': 7.28.5 '@babel/types': 7.28.5 + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@babel/traverse@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -3914,11 +5837,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.28.5': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@commitlint/cli@20.3.1(@types/node@20.19.28)(typescript@5.9.3)': dependencies: '@commitlint/format': 20.3.1 @@ -4031,6 +5971,22 @@ snapshots: '@date-fns/tz@1.4.1': {} + '@dotenvx/dotenvx@1.52.0': + dependencies: + commander: 11.1.0 + dotenv: 17.3.1 + eciesjs: 0.4.17 + execa: 5.1.1 + fdir: 6.5.0(picomatch@4.0.3) + ignore: 5.3.2 + object-treeify: 1.1.33 + picomatch: 4.0.3 + which: 4.0.0 + + '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': + dependencies: + '@noble/ciphers': 1.3.0 + '@esbuild/aix-ppc64@0.27.2': optional: true @@ -4172,6 +6128,10 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@hono/node-server@1.19.9(hono@4.11.9)': + dependencies: + hono: 4.11.9 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -4183,6 +6143,36 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@inquirer/ansi@1.0.2': {} + + '@inquirer/confirm@5.1.21(@types/node@20.19.28)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.28) + '@inquirer/type': 3.0.10(@types/node@20.19.28) + optionalDependencies: + '@types/node': 20.19.28 + + '@inquirer/core@10.3.2(@types/node@20.19.28)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.28) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.28 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/type@3.0.10(@types/node@20.19.28)': + optionalDependencies: + '@types/node': 20.19.28 + + '@isaacs/cliui@9.0.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4202,6 +6192,66 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.11.9) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.11.9 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@mswjs/interceptors@0.41.3': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -4552,6 +6602,26 @@ snapshots: '@types/react': 18.2.14 '@types/react-dom': 18.2.7 + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.14 + '@types/react-dom': 18.2.7 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.2.14)(react@18.2.0)': dependencies: react: 18.2.0 @@ -4703,6 +6773,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 @@ -4786,6 +6860,12 @@ snapshots: '@tanstack/query-core': 5.90.16 react: 18.2.0 + '@ts-morph/common@0.27.0': + dependencies: + fast-glob: 3.3.3 + minimatch: 10.2.0 + path-browserify: 1.0.1 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -4833,6 +6913,10 @@ snapshots: '@types/scheduler@0.26.0': {} + '@types/statuses@2.0.6': {} + + '@types/validate-npm-package-name@4.0.2': {} + '@typescript-eslint/eslint-plugin@8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4941,12 +7025,23 @@ snapshots: jsonparse: 1.3.1 through: 2.3.8 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 acorn@8.15.0: {} + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -4963,10 +7058,14 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansis@4.2.0: {} + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -4975,6 +7074,10 @@ snapshots: array-ify@1.0.0: {} + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + asynckit@0.4.0: {} axios@1.13.2: @@ -4987,8 +7090,26 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.2: + dependencies: + jackspeak: 4.2.3 + baseline-browser-mapping@2.9.14: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -4998,6 +7119,14 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.2: + dependencies: + balanced-match: 4.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.14 @@ -5006,11 +7135,22 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} caniuse-lite@1.0.30001764: {} @@ -5026,6 +7166,14 @@ snapshots: dependencies: clsx: 2.1.1 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-width@4.1.0: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -5034,6 +7182,8 @@ snapshots: clsx@2.1.1: {} + code-block-writer@13.0.3: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5044,6 +7194,10 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@11.1.0: {} + + commander@14.0.3: {} + compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -5051,6 +7205,10 @@ snapshots: concat-map@0.0.1: {} + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + conventional-changelog-angular@7.0.0: dependencies: compare-func: 2.0.0 @@ -5068,6 +7226,17 @@ snapshots: convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cosmiconfig-typescript-loader@6.2.0(@types/node@20.19.28)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): dependencies: '@types/node': 20.19.28 @@ -5090,10 +7259,14 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + cssesc@3.0.0: {} + csstype@3.2.3: {} dargs@8.1.0: {} + data-uri-to-buffer@4.0.1: {} + date-fns-jalali@4.1.0-0: {} date-fns@4.1.0: {} @@ -5102,28 +7275,60 @@ snapshots: dependencies: ms: 2.1.3 + dedent@1.7.1: {} + deep-is@0.1.4: {} + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + delayed-stream@1.0.0: {} + depd@2.0.0: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} + diff@8.0.3: {} + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 + dotenv@17.3.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 + eciesjs@0.4.17: + dependencies: + '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + + ee-first@1.1.1: {} + electron-to-chromium@1.5.267: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} + encodeurl@2.0.0: {} + enhanced-resolve@5.18.4: dependencies: graceful-fs: 4.2.11 @@ -5181,6 +7386,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): @@ -5262,6 +7469,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -5274,22 +7483,131 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + 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 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + + express-rate-limit@8.2.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.0.1 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} fast-uri@3.1.0: {} + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -5318,15 +7636,35 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@11.3.3: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fsevents@2.3.3: optional: true function-bind@1.1.2: {} + fuzzysort@3.1.0: {} + + fzf@0.5.2: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} + get-east-asian-width@1.4.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5342,17 +7680,30 @@ snapshots: get-nonce@1.0.1: {} + get-own-enumerable-keys@1.0.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@6.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + git-raw-commits@4.0.0: dependencies: dargs: 8.1.0 meow: 12.1.1 split2: 4.2.0 + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -5369,6 +7720,8 @@ snapshots: graceful-fs@4.2.11: {} + graphql@16.12.0: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -5381,12 +7734,39 @@ snapshots: dependencies: function-bind: 1.1.2 + headers-polyfill@4.0.3: {} + hermes-estree@0.25.1: {} hermes-parser@0.25.1: dependencies: hermes-estree: 0.25.1 + hono@4.11.9: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + human-signals@8.0.1: {} + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -5400,10 +7780,18 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + ini@4.1.1: {} + ip-address@10.0.1: {} + + ipaddr.js@1.9.1: {} + is-arrayish@0.2.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -5412,16 +7800,56 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + + is-node-process@1.2.0: {} + + is-number@7.0.0: {} + is-obj@2.0.0: {} + is-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-promise@4.0.0: {} + + is-regexp@3.1.0: {} + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + is-text-path@2.0.0: dependencies: text-extensions: 2.4.0 + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isexe@2.0.0: {} + isexe@3.1.5: {} + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + jiti@2.6.1: {} + jose@6.1.3: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -5438,16 +7866,28 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + jsonparse@1.3.1: {} keyv@4.5.4: dependencies: json-buffer: 3.0.1 + kleur@3.0.3: {} + + kleur@4.1.5: {} + lefthook-darwin-arm64@2.0.15: optional: true @@ -5573,6 +8013,11 @@ snapshots: lodash.upperfirst@4.3.1: {} + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -5591,14 +8036,41 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@1.1.0: {} + meow@12.1.1: {} + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimatch@10.2.0: + dependencies: + brace-expansion: 5.0.2 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -5611,12 +8083,94 @@ snapshots: ms@2.1.3: {} + msw@2.12.10(@types/node@20.19.28)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@20.19.28) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.12.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.4.4 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + mute-stream@2.0.0: {} + nanoid@3.3.11: {} natural-compare@1.4.0: {} + negotiator@1.0.0: {} + + next-themes@0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-releases@2.0.27: {} + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-treeify@1.1.33: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5626,6 +8180,20 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.1.2 + + outvariant@1.4.3: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -5642,6 +8210,8 @@ snapshots: dependencies: p-limit: 4.0.0 + package-manager-detector@1.6.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5653,32 +8223,84 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-ms@4.0.0: {} + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-exists@5.0.0: {} path-key@3.1.1: {} + path-key@4.0.0: {} + + path-to-regexp@6.3.0: {} + + path-to-regexp@8.3.0: {} + picocolors@1.1.1: {} + picomatch@2.3.1: {} + picomatch@4.0.3: {} + pkce-challenge@5.0.1: {} + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss@8.5.6: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + prelude-ls@1.2.1: {} pretendard@1.3.9: {} prettier@3.7.4: {} + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} punycode@2.3.1: {} + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + react-day-picker@9.13.0(react@18.2.0): dependencies: '@date-fns/tz': 1.4.1 @@ -5737,6 +8359,14 @@ snapshots: dependencies: loose-envify: 1.4.0 + recast@0.23.11: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -5745,6 +8375,15 @@ snapshots: resolve-from@5.0.0: {} + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + rettime@0.10.1: {} + + reusify@1.1.0: {} + rollup@4.55.1: dependencies: '@types/estree': 1.0.8 @@ -5776,6 +8415,24 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.55.1 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safer-buffer@2.1.2: {} + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -5784,32 +8441,174 @@ snapshots: semver@7.7.3: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shadcn@3.8.4(@types/node@20.19.28)(typescript@5.9.3): + dependencies: + '@antfu/ni': 25.0.0 + '@babel/core': 7.28.5 + '@babel/parser': 7.28.5 + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.28.5) + '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) + '@dotenvx/dotenvx': 1.52.0 + '@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76) + '@types/validate-npm-package-name': 4.0.2 + browserslist: 4.28.1 + commander: 14.0.3 + cosmiconfig: 9.0.0(typescript@5.9.3) + dedent: 1.7.1 + deepmerge: 4.3.1 + diff: 8.0.3 + execa: 9.6.1 + fast-glob: 3.3.3 + fs-extra: 11.3.3 + fuzzysort: 3.1.0 + https-proxy-agent: 7.0.6 + kleur: 4.1.5 + msw: 2.12.10(@types/node@20.19.28)(typescript@5.9.3) + node-fetch: 3.3.2 + open: 11.0.0 + ora: 8.2.0 + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + prompts: 2.4.2 + recast: 0.23.11 + stringify-object: 5.0.0 + tailwind-merge: 3.4.0 + ts-morph: 26.0.0 + tsconfig-paths: 4.2.0 + validate-npm-package-name: 7.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@types/node' + - babel-plugin-macros + - supports-color + - typescript + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + sonner@2.0.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + source-map-js@1.2.1: {} + source-map@0.6.1: {} + split2@4.2.0: {} + statuses@2.0.2: {} + + stdin-discarder@0.2.2: {} + + strict-event-emitter@0.5.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + + stringify-object@5.0.0: + dependencies: + get-own-enumerable-keys: 1.0.0 + is-obj: 3.0.0 + is-regexp: 3.1.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@4.0.0: {} + strip-json-comments@3.1.1: {} supports-color@7.2.0: dependencies: has-flag: 4.0.0 + tagged-tag@1.0.0: {} + tailwind-merge@3.4.0: {} tailwindcss@4.1.18: {} @@ -5820,6 +8619,8 @@ snapshots: through@2.3.8: {} + tiny-invariant@1.3.3: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -5827,10 +8628,37 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tldts-core@7.0.23: {} + + tldts@7.0.23: + dependencies: + tldts-core: 7.0.23 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.23 + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 + ts-morph@26.0.0: + dependencies: + '@ts-morph/common': 0.27.0 + code-block-writer: 13.0.3 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + tslib@2.8.1: {} tw-animate-css@1.4.0: {} @@ -5839,6 +8667,16 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@5.4.4: + dependencies: + tagged-tag: 1.0.0 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript-eslint@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -5856,6 +8694,14 @@ snapshots: unicorn-magic@0.1.0: {} + unicorn-magic@0.3.0: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + until-async@3.0.2: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -5885,6 +8731,12 @@ snapshots: dependencies: react: 18.2.0 + util-deprecate@1.0.2: {} + + validate-npm-package-name@7.0.2: {} + + vary@1.1.2: {} + vite@7.3.1(@types/node@20.19.28)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.2 @@ -5899,18 +8751,37 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 + web-streams-polyfill@3.3.3: {} + which@2.0.2: dependencies: isexe: 2.0.0 + which@4.0.0: + dependencies: + isexe: 3.1.5 + word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + wrappy@1.0.2: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + y18n@5.0.8: {} yallist@3.1.1: {} @@ -5931,10 +8802,20 @@ snapshots: yocto-queue@1.2.2: {} + yoctocolors-cjs@2.1.3: {} + + yoctocolors@2.1.2: {} + + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod-validation-error@4.0.2(zod@4.3.5): dependencies: zod: 4.3.5 + zod@3.25.76: {} + zod@4.3.5: {} zustand@5.0.10(@types/react@18.2.14)(react@18.2.0)(use-sync-external-store@1.6.0(react@18.2.0)): diff --git a/src/App.tsx b/src/App.tsx index e3f3454..cf008f6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { RouterProvider } from 'react-router-dom' import { setupInterceptors } from '@/api' import { queryClient } from '@/shared/lib/tanstack-query' -import { GlobalModalHost } from '@/shared/ui/GlobalModalHost' +import { GlobalModalHost, Sonner, TooltipProvider } from '@/shared/ui' import { router } from './routes' @@ -13,8 +13,11 @@ setupInterceptors() function App() { return ( - - + + + + + ) } diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index 4988b80..e545287 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -11,6 +11,7 @@ export const API_PATHS = { AUTH: `${API_BASE}/auth`, USERS: `${API_BASE}/users`, BOOK: `${API_BASE}/book`, + KEYWORDS: `${API_BASE}/keywords`, GATHERINGS: `${API_BASE}/gatherings`, MEETINGS: `${API_BASE}/meetings`, } as const diff --git a/src/api/index.ts b/src/api/index.ts index 28a1467..12ab87a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,4 +3,9 @@ export { API_BASE, API_PATHS } from './endpoints' export type { ErrorCodeType } from './errors' export { ApiError, ErrorCode, ErrorMessage } from './errors' export { setupInterceptors } from './interceptors' -export type { ApiErrorResponse, ApiResponse, PaginatedResponse } from './types' +export type { + ApiErrorResponse, + ApiResponse, + CursorPaginatedResponse, + PaginatedResponse, +} from './types' diff --git a/src/api/interceptors.ts b/src/api/interceptors.ts index 56cdc3f..63fb391 100644 --- a/src/api/interceptors.ts +++ b/src/api/interceptors.ts @@ -104,8 +104,9 @@ export const setupInterceptors = (): void => { const isAlreadyOnLogin = currentPath === ROUTES.LOGIN const isInvitePage = currentPath.startsWith(ROUTES.INVITE_BASE) - // 초대 페이지 또는 로그인 페이지에서는 리다이렉트하지 않음 - const shouldSkipRedirect = isInvitePage || isAlreadyOnLogin + const isLandingPage = currentPath === ROUTES.LANDING + // 초대 페이지, 로그인 페이지, 랜딩 페이지에서는 리다이렉트하지 않음 + const shouldSkipRedirect = isInvitePage || isAlreadyOnLogin || isLandingPage if (!shouldSkipRedirect) { // React Query 캐시 전체 삭제 (인증 정보 포함) // 세션이 만료되었으므로 모든 캐시된 데이터는 더 이상 유효하지 않음 diff --git a/src/api/types.ts b/src/api/types.ts index 4620621..4a55394 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -78,3 +78,34 @@ export type PaginatedResponse = { /** 전체 페이지 수 */ totalPages: number } + +/** + * 커서 기반 페이지네이션 응답 타입 + * + * @template T - 아이템의 타입 + * @template C - 커서의 타입 + * + * @example + * ```typescript + * // 커서 기반 페이지네이션 응답 예시 + * { + * "items": [{ "id": 1, "name": "모임1" }, { "id": 2, "name": "모임2" }], + * "pageSize": 10, + * "hasNext": true, + * "nextCursor": { "joinedAt": "2024-01-01T00:00:00", "id": 2 }, + * "totalCount": 50 + * } + * ``` + */ +export type CursorPaginatedResponse = { + /** 현재 페이지의 아이템 배열 */ + items: T[] + /** 페이지 크기 (한 페이지당 아이템 수) */ + pageSize: number + /** 다음 페이지 존재 여부 */ + hasNext: boolean + /** 다음 페이지 커서 (없으면 null) */ + nextCursor: C | null + /** 전체 아이템 수 (첫 페이지 응답에만 포함될 수 있음) */ + totalCount?: number +} diff --git a/src/features/book/book.api.ts b/src/features/book/book.api.ts index 6e2e677..b6074c1 100644 --- a/src/features/book/book.api.ts +++ b/src/features/book/book.api.ts @@ -8,16 +8,22 @@ import { ApiError, ErrorCode } from '@/api/errors' import type { BookDetail, + BookListItem, BookReview, + CreateBookBody, CreateBookRecordBody, CreateBookReviewBody, GetBookRecordsParams, GetBookRecordsResponse, GetBookReviewHistoryParams, GetBookReviewHistoryResponse, + GetBooksParams, + GetBooksResponse, GetGatheringsParams, GetGatheringsResponse, PersonalRecord, + SearchBooksParams, + SearchBooksResponse, UpdateBookRecordBody, } from './book.types' @@ -37,14 +43,48 @@ const mockBookDetail: BookDetail = { thumbnail: 'https://contents.kyobobook.co.kr/sih/fit-in/458x0/pdt/9791189327156.jpg', } +const mockBookListItems: BookListItem[] = [ + { + bookId: 1, + title: '우리에게는 매일 철학이 필요하다', + publisher: '피터 홀린스', + authors: '피터 홀린스', + bookReadingStatus: 'READING', + thumbnail: 'https://contents.kyobobook.co.kr/sih/fit-in/458x0/pdt/9791189327156.jpg', + rating: 0.5, + gatheringNames: ['책책책 책을 읽자', 'FCDE'], + }, + { + bookId: 2, + title: '데미안', + publisher: '민음사', + authors: '헤르만 헤세', + bookReadingStatus: 'READING', + thumbnail: 'https://contents.kyobobook.co.kr/sih/fit-in/458x0/pdt/9788937460449.jpg', + rating: 4.5, + gatheringNames: ['주말 독서 모임'], + }, + { + bookId: 3, + title: '1984', + publisher: '민음사', + authors: '조지 오웰', + bookReadingStatus: 'COMPLETED', + thumbnail: + 'https://i.namu.wiki/i/OuId9i6YhTdBIk5XDIZWVre8GtdOv_OaaXSL_WlGUvPisTnbN2jwn0lf_b8sJp_bjBLoKgl6Fa4-enbgZJIRLA.webp', + rating: 5, + gatheringNames: [], + }, +] + const mockBookReview: BookReview = { reviewId: 1, bookId: 1, userId: 1, rating: 3.5, keywords: [ - { id: 3, name: '감동', type: 'BOOK' }, - { id: 7, name: '몰입', type: 'IMPRESSION' }, + { id: 43, name: '관계', type: 'BOOK' }, + { id: 18, name: '흥미로운', type: 'IMPRESSION' }, ], createdAt: '2026-01-11T10:00:00', } @@ -422,20 +462,20 @@ const mockBookReviewHistoryResponse: GetBookReviewHistoryResponse = { createdAt: '2025-12-15T10:30:00', rating: 3.5, bookKeywords: [ - { id: 1, name: '관계', type: 'BOOK' }, - { id: 2, name: '성장', type: 'BOOK' }, + { id: 43, name: '관계', type: 'BOOK' }, + { id: 47, name: '성장', type: 'BOOK' }, ], impressionKeywords: [ - { id: 10, name: '즐거운', type: 'IMPRESSION' }, - { id: 11, name: '여운이 남는', type: 'IMPRESSION' }, + { id: 9, name: '즐거운', type: 'IMPRESSION' }, + { id: 20, name: '여운이 남는', type: 'IMPRESSION' }, ], }, { bookReviewHistoryId: 1, createdAt: '2025-12-08T14:20:00', rating: 4, - bookKeywords: [{ id: 3, name: '감동', type: 'BOOK' }], - impressionKeywords: [{ id: 12, name: '몰입되는', type: 'IMPRESSION' }], + bookKeywords: [{ id: 52, name: '삶', type: 'BOOK' }], + impressionKeywords: [{ id: 12, name: '뭉클한', type: 'IMPRESSION' }], }, ], pageSize: 5, @@ -570,6 +610,25 @@ export async function deleteBookRecord(personalBookId: number, recordId: number) return api.delete(`/api/book/${personalBookId}/records/${recordId}`) } +/** + * 책 삭제 + * + * @param bookId - 삭제할 책 ID + * + * @example + * ```typescript + * await deleteBook(1) + * ``` + */ +export async function deleteBook(bookId: number): Promise { + if (USE_MOCK) { + await delay(MOCK_DELAY) + return + } + + return api.delete(`/api/book/${bookId}`) +} + /** * 책 평가 생성 * @@ -605,6 +664,92 @@ export async function createBookReview( return api.post(`/api/book/${bookId}/reviews`, body) } +// ============================================================ +// Book List (책 목록) API +// ============================================================ + +/** + * 책 목록 조회 + * + * 커서 기반 페이지네이션을 지원하며, 상태별 필터링이 가능합니다. + * + * @param params - 조회 파라미터 (status, pageSize, cursorAddedAt, cursorBookId) + * @returns 책 목록 및 페이지네이션 정보 + * + * @example + * ```typescript + * // 전체 책 목록 조회 + * const result = await getBooks() + * + * // 읽는 중인 책만 조회 + * const readingBooks = await getBooks({ status: 'READING' }) + * + * // 다음 페이지 조회 + * const nextPage = await getBooks({ + * cursorAddedAt: result.nextCursor?.addedAt, + * cursorBookId: result.nextCursor?.bookId, + * }) + * ``` + */ +export async function getBooks(params: GetBooksParams = {}): Promise { + if (USE_MOCK) { + await delay(MOCK_DELAY) + return filterMockBooks(params) + } + + return api.get('/api/book', { params }) +} + +function filterMockBooks(params: GetBooksParams): GetBooksResponse { + const { status, gatheringId, ratingMin, ratingMax, sort = 'LATEST' } = params + + let filteredItems = [...mockBookListItems] + + // 상태 필터 + if (status) { + filteredItems = filteredItems.filter((item) => item.bookReadingStatus === status) + } + + // 모임 필터 - gatheringId가 있으면 해당 모임 이름을 가진 책만 필터링 + if (gatheringId !== undefined) { + const gathering = mockGatheringsResponse.items.find((g) => g.gatheringId === gatheringId) + if (gathering) { + filteredItems = filteredItems.filter((item) => + item.gatheringNames.includes(gathering.gatheringName) + ) + } + } + + // 별점 필터 - 선택한 범위 내의 별점만 필터링 (정수 기준) + if (ratingMin !== undefined && ratingMax !== undefined && ratingMin > 0) { + filteredItems = filteredItems.filter((item) => { + const floorRating = Math.floor(item.rating) + return floorRating >= ratingMin && floorRating <= ratingMax + }) + } + + // 정렬 처리 (bookId 기준으로 시뮬레이션) + const sortMultiplier = sort === 'LATEST' ? -1 : 1 + filteredItems.sort((a, b) => sortMultiplier * (a.bookId - b.bookId)) + + const readingCount = mockBookListItems.filter( + (item) => item.bookReadingStatus === 'READING' + ).length + const completedCount = mockBookListItems.filter( + (item) => item.bookReadingStatus === 'COMPLETED' + ).length + + return { + items: filteredItems, + pageSize: params.pageSize ?? 10, + hasNext: false, + nextCursor: null, + totalCount: mockBookListItems.length, + readingCount, + completedCount, + } +} + function filterMockBookRecords( data: GetBookRecordsResponse, params: GetBookRecordsParams @@ -666,3 +811,49 @@ function filterMockBookRecords( meetingPreOpinions, } } + +// ============================================================ +// Book Search (도서 검색) API +// ============================================================ + +/** + * 도서 검색 + * + * 외부 API를 통해 도서를 검색합니다. + * 페이지 기반 페이지네이션을 지원합니다. + * + * @param params - 검색 파라미터 (query, page) + * @returns 검색된 도서 목록 및 페이지네이션 정보 + * + * @example + * ```typescript + * const result = await searchBooks({ query: '데미안' }) + * console.log(result.items) // 검색된 도서 목록 + * ``` + */ +export async function searchBooks(params: SearchBooksParams): Promise { + return api.get('/api/book/search', { params }) +} + +/** + * 책 등록 + * + * 검색한 도서를 내 책장에 등록합니다. + * + * @param body - 책 등록 요청 바디 + * @returns 등록된 책 상세 정보 + * + * @example + * ```typescript + * const book = await createBook({ + * title: '데미안', + * authors: '헤르만 헤세', + * publisher: '민음사', + * isbn: '9788937460449', + * thumbnail: 'https://...', + * }) + * ``` + */ +export async function createBook(body: CreateBookBody): Promise { + return api.post('/api/book', body) +} diff --git a/src/features/book/book.types.ts b/src/features/book/book.types.ts index 673bb27..ef13fe1 100644 --- a/src/features/book/book.types.ts +++ b/src/features/book/book.types.ts @@ -3,8 +3,10 @@ * @description Book 도메인 관련 타입 정의 */ +import type { CursorPaginatedResponse } from '@/api/types' + /** 책 읽기 상태 */ -export type BookReadingStatus = 'READING' | 'COMPLETED' | 'PENDING' +export type BookReadingStatus = 'READING' | 'COMPLETED' /** 책 상세 정보 */ export interface BookDetail { @@ -16,6 +18,47 @@ export interface BookDetail { thumbnail: string } +// ============================================================ +// Book List (책 목록) 관련 타입 +// ============================================================ + +/** 책 목록 아이템 */ +export interface BookListItem { + bookId: number + title: string + publisher: string + authors: string + bookReadingStatus: BookReadingStatus + thumbnail: string + rating: number + gatheringNames: string[] +} + +/** 책 목록 조회 요청 파라미터 */ +export interface GetBooksParams { + status?: BookReadingStatus + gatheringId?: number + ratingMin?: number + ratingMax?: number + sort?: RecordSortType + pageSize?: number + cursorAddedAt?: string + cursorBookId?: number +} + +/** 책 목록 조회 커서 */ +export interface BookListCursor { + addedAt: string + bookId: number +} + +/** 책 목록 조회 응답 */ +export interface GetBooksResponse extends CursorPaginatedResponse { + totalCount: number + readingCount: number + completedCount: number +} + /** 리뷰 키워드 종류 */ type ReviewKeywordType = 'BOOK' | 'IMPRESSION' @@ -65,12 +108,7 @@ export interface GetGatheringsParams { } /** 모임 목록 조회 응답 */ -export interface GetGatheringsResponse { - items: Gathering[] - pageSize: number - hasNext: boolean - nextCursor: string | null -} +export type GetGatheringsResponse = CursorPaginatedResponse // ============================================================ // Book Records (감상 기록) 관련 타입 @@ -216,14 +254,10 @@ export interface GetBookReviewHistoryParams { } /** 책 평가 히스토리 조회 응답 */ -export interface GetBookReviewHistoryResponse { - items: BookReviewHistoryItem[] - pageSize: number - hasNext: boolean - nextCursor: { - historyId: number | null - } -} +export type GetBookReviewHistoryResponse = CursorPaginatedResponse< + BookReviewHistoryItem, + { historyId: number | null } +> /** 감상 기록 생성 요청 바디 */ export interface CreateBookRecordBody { @@ -265,3 +299,46 @@ export interface CreateBookReviewBody { rating: number keywordIds: number[] } + +// ============================================================ +// Book Search (도서 검색) 관련 타입 +// ============================================================ + +/** 검색된 도서 아이템 */ +export interface SearchBookItem { + title: string + contents: string + authors: string[] + publisher: string + isbn: string + thumbnail: string +} + +/** 도서 검색 요청 파라미터 */ +export interface SearchBooksParams { + query: string + page?: number + pageSize?: number +} + +/** 도서 검색 커서 */ +export interface SearchBooksCursor { + page: number +} + +/** 도서 검색 응답 */ +export interface SearchBooksResponse extends CursorPaginatedResponse< + SearchBookItem, + SearchBooksCursor +> { + totalCount: number +} + +/** 책 등록 요청 바디 */ +export interface CreateBookBody { + title: string + authors: string + publisher: string + isbn: string + thumbnail: string +} diff --git a/src/features/book/components/BookCard.tsx b/src/features/book/components/BookCard.tsx new file mode 100644 index 0000000..c99c7c5 --- /dev/null +++ b/src/features/book/components/BookCard.tsx @@ -0,0 +1,111 @@ +import { Star } from 'lucide-react' +import { Link } from 'react-router-dom' + +import { Badge, Checkbox } from '@/shared/ui' + +import type { BookListItem } from '../book.types' + +type BookCardProps = { + book: BookListItem + /** 필터에서 선택된 모임명 (일치하는 Badge는 green으로 표시) */ + selectedGatheringName?: string + /** 편집 모드 여부 */ + isEditMode?: boolean + /** 선택 여부 (편집 모드에서 사용) */ + isSelected?: boolean + /** 선택 토글 핸들러 (편집 모드에서 사용) */ + onSelectToggle?: (bookId: number) => void +} + +/** + * 책 카드 컴포넌트 + * + * 책 목록에서 개별 책을 표시하는 카드입니다. + * 썸네일, 제목, 저자, 별점, 소속 모임을 표시합니다. + * + * @example + * ```tsx + * + * + * // 편집 모드 + * toggleSelection(id)} + * /> + * ``` + */ +function BookCard({ + book, + selectedGatheringName, + isEditMode = false, + isSelected = false, + onSelectToggle, +}: BookCardProps) { + const { bookId, title, authors, thumbnail, rating, gatheringNames } = book + + const handleClick = (e: React.MouseEvent) => { + if (isEditMode) { + e.preventDefault() + onSelectToggle?.(bookId) + } + } + + const CardContent = ( +
+ {/* 책 표지 */} +
+ {`${title} + {isEditMode && ( +
+ onSelectToggle?.(bookId)} + onClick={(e) => e.stopPropagation()} + /> +
+ )} +
+ + {/* 책 정보 */} +
+

{title}

+

{authors}

+ + {/* 별점 */} +
+ + {rating.toFixed(1)} +
+ + {/* 모임 태그 */} + {gatheringNames.length > 0 && ( +
+ {gatheringNames.map((name) => ( + + {name} + + ))} +
+ )} +
+
+ ) + + if (isEditMode) { + return ( +
+ {CardContent} +
+ ) + } + + return ( + + {CardContent} + + ) +} + +export default BookCard diff --git a/src/features/book/components/BookCarousel.tsx b/src/features/book/components/BookCarousel.tsx new file mode 100644 index 0000000..6671dbf --- /dev/null +++ b/src/features/book/components/BookCarousel.tsx @@ -0,0 +1,81 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' + +import { cn } from '@/shared/lib/utils' + +const THUMBNAIL_HEIGHT = 260 +const SCROLL_AMOUNT = 408 + +interface BookCarouselProps { + children: ReactNode + className?: string +} + +export default function BookCarousel({ children, className }: BookCarouselProps) { + const scrollRef = useRef(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(false) + + const updateScrollState = useCallback(() => { + const el = scrollRef.current + if (!el) return + setCanScrollLeft(el.scrollLeft > 0) + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1) + }, []) + + useEffect(() => { + const el = scrollRef.current + if (!el) return + updateScrollState() + + el.addEventListener('scroll', updateScrollState, { passive: true }) + const observer = new ResizeObserver(updateScrollState) + observer.observe(el) + + return () => { + el.removeEventListener('scroll', updateScrollState) + observer.disconnect() + } + }, [updateScrollState]) + + const scroll = (direction: 'left' | 'right') => { + const el = scrollRef.current + if (!el) return + const amount = direction === 'left' ? -SCROLL_AMOUNT : SCROLL_AMOUNT + el.scrollBy({ left: amount, behavior: 'smooth' }) + } + + return ( +
+
+ {children} +
+ + {/* 좌측 화살표 — 썸네일 영역 세로 중앙 기준 */} + {canScrollLeft && ( + + )} + + {/* 우측 화살표 */} + {canScrollRight && ( + + )} +
+ ) +} diff --git a/src/features/book/components/BookInfo.tsx b/src/features/book/components/BookInfo.tsx index a22dc1d..589d99a 100644 --- a/src/features/book/components/BookInfo.tsx +++ b/src/features/book/components/BookInfo.tsx @@ -1,4 +1,5 @@ import { Division } from '@/shared/components/Division' +import { Spinner } from '@/shared/ui' import { Switch } from '@/shared/ui/Switch' import { useBookDetail } from '../hooks' @@ -14,7 +15,12 @@ const BookInfo = ({ bookId, isRecording, onToggleRecording }: BookInfoProps) => const { data, isLoading, isError } = useBookDetail(bookId) // if (isLoading) return - if (isLoading) return
로딩중...
+ if (isLoading) + return ( +
+ +
+ ) if (isError || !data) return
책 정보를 불러올 수 없습니다.
return ( diff --git a/src/features/book/components/BookList.tsx b/src/features/book/components/BookList.tsx new file mode 100644 index 0000000..af0b211 --- /dev/null +++ b/src/features/book/components/BookList.tsx @@ -0,0 +1,277 @@ +import { useEffect, useMemo, useRef, useState } from 'react' + +import { + FilterDropdown, + StarRatingFilter, + type StarRatingRange, + Tabs, + TabsList, + TabsTrigger, +} from '@/shared/ui' + +import type { BookReadingStatus, RecordSortType } from '../book.types' +import { useBooks, useMyGatherings } from '../hooks' +import BookCard from './BookCard' + +type BookListProps = { + status?: BookReadingStatus + /** 현재 활성 탭인지 여부 (비활성 탭에서 onFilteredBooksChange 호출 방지) */ + isActive?: boolean + /** 편집 모드 여부 */ + isEditMode?: boolean + /** 선택된 책 ID 목록 */ + selectedBookIds?: Set + /** 선택 토글 핸들러 */ + onSelectToggle?: (bookId: number) => void + /** 필터링된 책 목록이 변경될 때 호출되는 콜백 */ + onFilteredBooksChange?: (bookIds: number[]) => void +} + +/** + * 책 목록 컴포넌트 + * + * 상태에 따라 책 목록을 조회하고 그리드로 표시합니다. + * 무한스크롤, 로딩, 에러, 빈 상태를 처리합니다. + * + * @example + * ```tsx + * // 전체 책 목록 + * + * + * // 읽는 중인 책만 + * + * + * // 읽기 완료된 책만 + * + * + * // 편집 모드 + * toggleSelection(id)} + * /> + * ``` + */ +function BookList({ + status, + isActive = true, + isEditMode = false, + selectedBookIds, + onSelectToggle, + onFilteredBooksChange, +}: BookListProps) { + // 필터 상태 + const [selectedGathering, setSelectedGathering] = useState('') + const [rating, setRating] = useState(null) + const [sortType, setSortType] = useState('LATEST') + const [openDropdown, setOpenDropdown] = useState<'gathering' | null>(null) + + // 무한스크롤 감지용 ref + const loadMoreRef = useRef(null) + + // 모임 목록 조회 + const { + data: gatheringsData, + isLoading: isGatheringsLoading, + fetchNextPage: fetchNextGatherings, + hasNextPage: hasNextGatherings, + } = useMyGatherings() + + const gatherings = gatheringsData?.pages.flatMap((page) => page.items) ?? [] + + // 선택된 모임명 찾기 + const selectedGatheringName = gatherings.find( + (g) => String(g.gatheringId) === selectedGathering + )?.gatheringName + + // 책 목록 조회 (필터 적용, 무한스크롤) + const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useBooks({ + status, + gatheringId: selectedGathering ? Number(selectedGathering) : undefined, + ratingMin: rating?.min, + ratingMax: rating?.max, + sort: sortType, + }) + + // 무한스크롤 Intersection Observer + useEffect(() => { + const target = loadMoreRef.current + if (!target) return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, + { threshold: 0.1 } + ) + + observer.observe(target) + return () => observer.disconnect() + }, [hasNextPage, isFetchingNextPage, fetchNextPage]) + + const handleGatheringChange = (value: string) => { + setSelectedGathering(value) + } + + // 모든 페이지의 책 목록 합치기 (참조 안정성을 위해 메모이제이션) + const books = useMemo(() => data?.pages.flatMap((page) => page.items) ?? [], [data?.pages]) + + // 필터링된 책 ID 목록 + const bookIds = useMemo(() => books.map((book) => book.bookId), [books]) + + // 필터링된 책 목록이 변경될 때 부모에 알림 (활성 탭일 때만) + useEffect(() => { + if (isActive) { + onFilteredBooksChange?.(bookIds) + } + }, [bookIds, onFilteredBooksChange, isActive]) + + if (isError) { + return ( +
+

책 목록을 불러오는데 실패했습니다.

+
+ ) + } + + const isEmpty = !isLoading && books.length === 0 + + return ( +
+
+
+ setOpenDropdown(open ? 'gathering' : null)} + > + {gatherings.map((gathering) => ( + + {gathering.gatheringName} + + ))} + {hasNextGatherings && ( + + )} + + +
+ setSortType(v as RecordSortType)}> + + + 최신순 + + · + + 오래된순 + + + +
+ {isLoading ? ( + + ) : isEmpty ? ( + + ) : ( + <> +
+ {books.map((book) => ( + + ))} +
+ {/* 무한스크롤 트리거 */} +
+ {isFetchingNextPage && } +
+ + )} +
+ ) +} + +function BookListSkeleton() { + return ( +
+ {[...Array(6).keys()].map((index) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ) +} + +function BookListLoadingMore() { + return ( +
+
+
+ ) +} + +function BookListEmpty({ + status, + hasFilters, +}: { + status?: BookReadingStatus + hasFilters?: boolean +}) { + const getMessage = () => { + if (hasFilters) { + return '조건에 맞는 책이 없어요.' + } + + switch (status) { + case 'READING': + return '기록 중인 책이 없어요.' + case 'COMPLETED': + return '기록 완료인 책이 없어요.' + default: + return ( + <> + 저장된 책이 없어요. +
첫 책을 추가해보세요! + + ) + } + } + + return ( +
+

{getMessage()}

+
+ ) +} + +export default BookList diff --git a/src/features/book/components/BookLogList.tsx b/src/features/book/components/BookLogList.tsx index 600a5d8..904f0bc 100644 --- a/src/features/book/components/BookLogList.tsx +++ b/src/features/book/components/BookLogList.tsx @@ -111,7 +111,7 @@ const BookLogList = ({ bookId, isRecording }: BookLogListProps) => { return (
{/* 감상 기록 헤더 - sticky */} -
+

감상 기록

@@ -174,8 +174,8 @@ const BookLogList = ({ bookId, isRecording }: BookLogListProps) => {
{/* 기록 목록 - full-bleed 배경 */} -
-
+
+
{allRecords.length === 0 ? (

@@ -236,7 +236,7 @@ const BookLogList = ({ bookId, isRecording }: BookLogListProps) => { })}

)} -
+
setFormValues(values)} /> + * + * // 기존 데이터가 있는 경우 + * setFormValues(values)} + * /> + * ``` + */ +export interface BookReviewFormProps { + /** 초기 별점 값 */ + initialRating?: number + /** 초기 선택된 키워드 ID 목록 */ + initialKeywordIds?: number[] + /** 폼 상태 변경 콜백 */ + onChange?: (values: BookReviewFormValues) => void +} + +export function BookReviewForm({ + initialRating = 0, + initialKeywordIds = [], + onChange, +}: BookReviewFormProps) { + const [rating, setRating] = useState(initialRating) + const [selectedKeywordIds, setSelectedKeywordIds] = useState(initialKeywordIds) + const [selectedBookCategoryId, setSelectedBookCategoryId] = useState(null) + const [selectedImpressionCategoryId, setSelectedImpressionCategoryId] = useState( + null + ) + + const { + data: keywordsData, + isLoading: isLoadingKeywords, + isError: isKeywordsError, + } = useKeywords() + + // 책 키워드 카테고리 (level 1) + const bookCategories = useMemo( + () => + keywordsData?.keywords.filter((k) => k.type === 'BOOK' && k.level === 1 && !k.isSelectable) || + [], + [keywordsData] + ) + + // 감정 키워드 카테고리 (level 1) + const impressionCategories = useMemo( + () => + keywordsData?.keywords.filter( + (k) => k.type === 'IMPRESSION' && k.level === 1 && !k.isSelectable + ) || [], + [keywordsData] + ) + + // 선택 가능한 책 키워드 (level 2) + const bookKeywords = useMemo(() => { + const allKeywords = + keywordsData?.keywords.filter((k) => k.type === 'BOOK' && k.isSelectable) || [] + + if (selectedBookCategoryId === null) { + return allKeywords + } + + return allKeywords.filter((k) => k.parentId === selectedBookCategoryId) + }, [keywordsData, selectedBookCategoryId]) + + // 선택 가능한 감정 키워드 (level 2) + const impressionKeywords = useMemo(() => { + const allKeywords = + keywordsData?.keywords.filter((k) => k.type === 'IMPRESSION' && k.isSelectable) || [] + + if (selectedImpressionCategoryId === null) { + return allKeywords + } + + return allKeywords.filter((k) => k.parentId === selectedImpressionCategoryId) + }, [keywordsData, selectedImpressionCategoryId]) + + // 선택된 책 키워드 + const selectedBookKeywords = useMemo(() => { + return ( + keywordsData?.keywords.filter( + (k) => k.type === 'BOOK' && selectedKeywordIds.includes(k.id) + ) || [] + ) + }, [keywordsData, selectedKeywordIds]) + + // 선택된 감정 키워드 + const selectedImpressionKeywords = useMemo(() => { + return ( + keywordsData?.keywords.filter( + (k) => k.type === 'IMPRESSION' && selectedKeywordIds.includes(k.id) + ) || [] + ) + }, [keywordsData, selectedKeywordIds]) + + const handleRatingChange = (newRating: number) => { + setRating(newRating) + onChange?.({ + rating: newRating, + keywordIds: selectedKeywordIds, + isValid: selectedBookKeywords.length > 0 && selectedImpressionKeywords.length > 0, + }) + } + + const handleKeywordToggle = (keywordId: number) => { + const nextIds = selectedKeywordIds.includes(keywordId) + ? selectedKeywordIds.filter((id) => id !== keywordId) + : [...selectedKeywordIds, keywordId] + setSelectedKeywordIds(nextIds) + + const nextBookCount = + keywordsData?.keywords.filter((k) => k.type === 'BOOK' && nextIds.includes(k.id)).length ?? 0 + const nextImpressionCount = + keywordsData?.keywords.filter((k) => k.type === 'IMPRESSION' && nextIds.includes(k.id)) + .length ?? 0 + + onChange?.({ + rating, + keywordIds: nextIds, + isValid: nextBookCount > 0 && nextImpressionCount > 0, + }) + } + + if (isKeywordsError) { + return ( +
+

키워드를 불러오지 못했습니다. 다시 시도해주세요.

+
+ ) + } + + if (isLoadingKeywords) { + return ( +
+

키워드를 불러오는 중...

+
+ ) + } + + return ( + <> +
+ {/* 별점 */} +
+

별점

+
+ +

{rating.toFixed(1)}

+ {rating >= 0.5 && ( + handleRatingChange(0)}>별점 초기화 + )} +
+
+ + {/* 책 키워드 */} +
+

책 키워드

+ + {/* 카테고리 탭 */} + + setSelectedBookCategoryId(value === 'all' ? null : Number(value)) + } + className="mb-tiny ml-xtiny" + > + + 전체 + {bookCategories.map((category) => ( + + {category.name} + + ))} + + + + {/* 키워드 목록 */} +
+ {bookKeywords.map((keyword) => { + const isSelected = selectedKeywordIds.includes(keyword.id) + return ( + handleKeywordToggle(keyword.id)} + className="cursor-pointer" + > + {keyword.name} + + ) + })} +
+ + {/* 선택한 키워드 */} + {selectedBookKeywords.length > 0 && ( +
+

선택한 키워드

+
+ {selectedBookKeywords.map((keyword) => ( + handleKeywordToggle(keyword.id)} + className="cursor-pointer bg-white text-black" + > + {keyword.name} + + ))} +
+
+ )} +
+ + {/* 감정 키워드 */} +
+

감상 키워드

+ + {/* 카테고리 탭 */} + + setSelectedImpressionCategoryId(value === 'all' ? null : Number(value)) + } + className="mb-tiny ml-xtiny" + > + + 전체 + {impressionCategories.map((category) => ( + + {category.name} + + ))} + + + + {/* 키워드 목록 */} +
+ {impressionKeywords.map((keyword) => { + const isSelected = selectedKeywordIds.includes(keyword.id) + return ( + handleKeywordToggle(keyword.id)} + className="cursor-pointer" + > + {keyword.name} + + ) + })} +
+ + {/* 선택한 키워드 */} + {selectedImpressionKeywords.length > 0 && ( +
+

선택한 키워드

+
+ {selectedImpressionKeywords.map((keyword) => ( + handleKeywordToggle(keyword.id)} + className="cursor-pointer bg-white text-black" + > + {keyword.name} + + ))} +
+
+ )} +
+
+ + ) +} diff --git a/src/features/book/components/BookReviewModal.tsx b/src/features/book/components/BookReviewModal.tsx index 4a9f182..c807159 100644 --- a/src/features/book/components/BookReviewModal.tsx +++ b/src/features/book/components/BookReviewModal.tsx @@ -1,10 +1,6 @@ -import { X } from 'lucide-react' -import { useMemo, useState } from 'react' +import { useState } from 'react' -import { useKeywords } from '@/features/keywords' -import { StarRate } from '@/shared/components/StarRate' import { Button } from '@/shared/ui/Button' -import { Chip } from '@/shared/ui/Chip' import { Modal, ModalBody, @@ -13,10 +9,10 @@ import { ModalHeader, ModalTitle, } from '@/shared/ui/Modal' -import { Tabs, TabsList, TabsTrigger } from '@/shared/ui/Tabs' import { useGlobalModalStore } from '@/store' import { useCreateBookReview } from '../hooks' +import { BookReviewForm, type BookReviewFormValues } from './BookReviewForm' /** * 책 평가하기 모달 @@ -38,118 +34,30 @@ export interface BookReviewModalProps { onOpenChange: (open: boolean) => void } -export function BookReviewModal({ bookId, open, onOpenChange }: BookReviewModalProps) { - const [rating, setRating] = useState(0) - const [selectedKeywordIds, setSelectedKeywordIds] = useState([]) - const [selectedBookCategoryId, setSelectedBookCategoryId] = useState(null) - const [selectedImpressionCategoryId, setSelectedImpressionCategoryId] = useState( - null - ) +const INITIAL_FORM_VALUES: BookReviewFormValues = { + rating: 0, + keywordIds: [], + isValid: false, +} - const { - data: keywordsData, - isLoading: isLoadingKeywords, - isError: isKeywordsError, - } = useKeywords() +export function BookReviewModal({ bookId, open, onOpenChange }: BookReviewModalProps) { + const [formValues, setFormValues] = useState(INITIAL_FORM_VALUES) const { mutate: submitReview, isPending } = useCreateBookReview(bookId) const { openError } = useGlobalModalStore() - const handleOpenChange = (nextOpen: boolean) => { - if (!nextOpen) { - setRating(0) - setSelectedKeywordIds([]) - setSelectedBookCategoryId(null) - setSelectedImpressionCategoryId(null) + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + setFormValues(INITIAL_FORM_VALUES) } - onOpenChange(nextOpen) - } - - // 책 키워드 카테고리 (level 1) - const bookCategories = useMemo( - () => - keywordsData?.keywords.filter((k) => k.type === 'BOOK' && k.level === 1 && !k.isSelectable) || - [], - [keywordsData] - ) - - // 감정 키워드 카테고리 (level 1) - const impressionCategories = useMemo( - () => - keywordsData?.keywords.filter( - (k) => k.type === 'IMPRESSION' && k.level === 1 && !k.isSelectable - ) || [], - [keywordsData] - ) - - // 선택 가능한 책 키워드 (level 2) - const bookKeywords = useMemo(() => { - const allKeywords = - keywordsData?.keywords.filter((k) => k.type === 'BOOK' && k.isSelectable) || [] - - if (selectedBookCategoryId === null) { - return allKeywords - } - - return allKeywords.filter((k) => k.parentId === selectedBookCategoryId) - }, [keywordsData, selectedBookCategoryId]) - - // 선택 가능한 감정 키워드 (level 2) - const impressionKeywords = useMemo(() => { - const allKeywords = - keywordsData?.keywords.filter((k) => k.type === 'IMPRESSION' && k.isSelectable) || [] - - if (selectedImpressionCategoryId === null) { - return allKeywords - } - - return allKeywords.filter((k) => k.parentId === selectedImpressionCategoryId) - }, [keywordsData, selectedImpressionCategoryId]) - - // 선택된 책 키워드 - const selectedBookKeywords = useMemo(() => { - return ( - keywordsData?.keywords.filter( - (k) => k.type === 'BOOK' && selectedKeywordIds.includes(k.id) - ) || [] - ) - }, [keywordsData, selectedKeywordIds]) - - // 선택된 감정 키워드 - const selectedImpressionKeywords = useMemo(() => { - return ( - keywordsData?.keywords.filter( - (k) => k.type === 'IMPRESSION' && selectedKeywordIds.includes(k.id) - ) || [] - ) - }, [keywordsData, selectedKeywordIds]) - - // 저장 버튼 활성화 조건 - const isFormValid = useMemo(() => { - return rating > 0 && selectedBookKeywords.length > 0 && selectedImpressionKeywords.length > 0 - }, [rating, selectedBookKeywords.length, selectedImpressionKeywords.length]) - - const handleKeywordToggle = (keywordId: number) => { - setSelectedKeywordIds((prev) => - prev.includes(keywordId) ? prev.filter((id) => id !== keywordId) : [...prev, keywordId] - ) + onOpenChange(newOpen) } const handleSubmit = () => { - if (rating === 0) { - openError('별점 필요', '별점을 선택해주세요.') - return - } - submitReview( - { rating, keywordIds: selectedKeywordIds }, + { rating: formValues.rating, keywordIds: formValues.keywordIds }, { onSuccess: () => { - onOpenChange(false) - // 상태 초기화 - setRating(0) - setSelectedKeywordIds([]) - setSelectedBookCategoryId(null) - setSelectedImpressionCategoryId(null) + handleOpenChange(false) }, onError: (error) => { openError('평가 저장 실패', error.message) @@ -158,178 +66,21 @@ export function BookReviewModal({ bookId, open, onOpenChange }: BookReviewModalP ) } - if (isKeywordsError) { - return ( - - - - 책 평가하기 - - -
-

- 키워드를 불러오지 못했습니다. 다시 시도해주세요. -

-
-
-
-
- ) - } - - if (isLoadingKeywords) { - return ( - - - - 책 평가하기 - - -
-

키워드를 불러오는 중...

-
-
-
-
- ) - } - return ( - + 책 평가하기 -
- {/* 별점 */} -
-

별점

-
- -

{rating.toFixed(1)}

-
-
- - {/* 책 키워드 */} -
-

책 키워드

- - {/* 카테고리 탭 */} - - setSelectedBookCategoryId(value === 'all' ? null : Number(value)) - } - className="mb-tiny ml-xtiny" - > - - 전체 - {bookCategories.map((category) => ( - - {category.name} - - ))} - - - - {/* 키워드 목록 */} -
- {bookKeywords.map((keyword) => { - const isSelected = selectedKeywordIds.includes(keyword.id) - return ( - handleKeywordToggle(keyword.id)} - className="cursor-pointer" - > - {keyword.name} - - ) - })} -
- - {/* 선택한 키워드 */} - {selectedBookKeywords.length > 0 && ( -
-

선택한 키워드

-
- {selectedBookKeywords.map((keyword) => ( - handleKeywordToggle(keyword.id)} - className="cursor-pointer bg-white text-black" - > - {keyword.name} - - ))} -
-
- )} -
- - {/* 감정 키워드 */} -
-

감상 키워드

- - {/* 카테고리 탭 */} - - setSelectedImpressionCategoryId(value === 'all' ? null : Number(value)) - } - className="mb-tiny ml-xtiny" - > - - 전체 - {impressionCategories.map((category) => ( - - {category.name} - - ))} - - - - {/* 키워드 목록 */} -
- {impressionKeywords.map((keyword) => { - const isSelected = selectedKeywordIds.includes(keyword.id) - return ( - handleKeywordToggle(keyword.id)} - className="cursor-pointer" - > - {keyword.name} - - ) - })} -
- - {/* 선택한 키워드 */} - {selectedImpressionKeywords.length > 0 && ( -
-

선택한 키워드

-
- {selectedImpressionKeywords.map((keyword) => ( - handleKeywordToggle(keyword.id)} - className="cursor-pointer bg-white text-black" - > - {keyword.name} - - ))} -
-
- )} -
-
+
- @@ -337,3 +88,5 @@ export function BookReviewModal({ bookId, open, onOpenChange }: BookReviewModalP
) } + +export default BookReviewModal diff --git a/src/features/book/components/BookSearchModal.tsx b/src/features/book/components/BookSearchModal.tsx new file mode 100644 index 0000000..c87d2ae --- /dev/null +++ b/src/features/book/components/BookSearchModal.tsx @@ -0,0 +1,173 @@ +/** + * @file BookSearchModal.tsx + * @description 도서 검색 모달 컴포넌트 + */ + +import { useCallback, useEffect, useRef, useState } from 'react' + +import { Modal, ModalBody, ModalContent, ModalHeader, ModalTitle, SearchField } from '@/shared/ui' + +import type { SearchBookItem } from '../book.types' +import { useSearchBooks } from '../hooks/useSearchBooks' + +export interface BookSearchModalProps { + /** 모달 열림 상태 */ + open: boolean + /** 모달 열림 상태 변경 핸들러 */ + onOpenChange: (open: boolean) => void + /** 책 선택 시 호출되는 핸들러 */ + onSelectBook: (book: SearchBookItem) => void | Promise + /** 책 선택 처리 중 여부 (true일 경우 중복 선택 방지) */ + isPending?: boolean +} + +/** + * 도서 검색 모달 + * + * 외부 API를 통해 도서를 검색하고 내 책장에 등록할 수 있는 모달입니다. + * 디바운싱과 무한스크롤을 지원합니다. + * + * @example + * ```tsx + * { + * await registerBook({ title: book.title, ... }) + * }} + * isPending={isRegistering} + * /> + * ``` + */ +export default function BookSearchModal({ + open, + onOpenChange, + onSelectBook, + isPending, +}: BookSearchModalProps) { + const [searchQuery, setSearchQuery] = useState('') + const [debouncedQuery, setDebouncedQuery] = useState('') + const listRef = useRef(null) + + // 디바운싱: 입력 후 300ms 후에 검색 + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(searchQuery) + }, 300) + + return () => clearTimeout(timer) + }, [searchQuery]) + + // 도서 검색 + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useSearchBooks(debouncedQuery) + + const books = data?.pages.flatMap((page) => page.items) ?? [] + + // 무한스크롤 처리 + const handleScroll = useCallback(() => { + if (!listRef.current || isFetchingNextPage || !hasNextPage) return + + const { scrollTop, scrollHeight, clientHeight } = listRef.current + if (scrollHeight - scrollTop - clientHeight < 100) { + fetchNextPage() + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]) + + // 책 선택 + const handleSelectBook = async (book: SearchBookItem) => { + if (isPending) return + + try { + await onSelectBook(book) + handleOpenChange(false) + } catch { + // 에러는 onSelectBook 호출부에서 처리됨 + } + } + + // 모달 닫을 때 상태 초기화 + const resetState = () => { + setSearchQuery('') + setDebouncedQuery('') + } + + const handleOpenChange = (nextOpen: boolean) => { + onOpenChange(nextOpen) + if (!nextOpen) resetState() + } + + return ( + + + + 도서 검색 + + + + setSearchQuery(e.target.value)} + /> + +
+
+ {books.map((book) => ( + handleSelectBook(book)} + disabled={isPending} + /> + ))} + {isFetchingNextPage && ( +
로딩 중...
+ )} +
+
+
+
+
+ ) +} + +interface BookSearchItemProps { + book: SearchBookItem + onClick: () => void + disabled?: boolean +} + +function BookSearchItem({ book, onClick, disabled }: BookSearchItemProps) { + return ( + + ) +} diff --git a/src/features/book/components/index.ts b/src/features/book/components/index.ts new file mode 100644 index 0000000..80dc554 --- /dev/null +++ b/src/features/book/components/index.ts @@ -0,0 +1,15 @@ +export { default as BookCard } from './BookCard' +export { default as BookCarousel } from './BookCarousel' +export { default as BookInfo } from './BookInfo' +export { default as BookList } from './BookList' +export { default as BookLogList } from './BookLogList' +export { default as BookLogModal } from './BookLogModal' +export { default as BookReview } from './BookReview' +export { default as BookReviewModal } from './BookReviewModal' +export { default as BookSearchModal } from './BookSearchModal' +export { default as ExcerptBlock } from './ExcerptBlock' +export { default as MeetingGroupRecordItem } from './MeetingGroupRecordItem' +export { default as MeetingPreOpinionItem } from './MeetingPreOpinionItem' +export { default as MeetingRetrospectiveItem } from './MeetingRetrospectiveItem' +export { default as PersonalRecordItem } from './PersonalRecordItem' +export { default as ReviewHistoryCard } from './ReviewHistoryCard' diff --git a/src/features/book/hooks/.gitkeep b/src/features/book/hooks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/book/hooks/index.ts b/src/features/book/hooks/index.ts index 080ddb5..55488ea 100644 --- a/src/features/book/hooks/index.ts +++ b/src/features/book/hooks/index.ts @@ -2,8 +2,12 @@ export * from './useBookDetail' export * from './useBookRecords' export * from './useBookReview' export * from './useBookReviewHistory' +export * from './useBooks' +export * from './useCreateBook' export * from './useCreateBookRecord' export * from './useCreateBookReview' +export * from './useDeleteBook' export * from './useDeleteBookRecord' export * from './useMyGatherings' +export * from './useSearchBooks' export * from './useUpdateBookRecord' diff --git a/src/features/book/hooks/useBooks.ts b/src/features/book/hooks/useBooks.ts new file mode 100644 index 0000000..6da108b --- /dev/null +++ b/src/features/book/hooks/useBooks.ts @@ -0,0 +1,61 @@ +/** + * @file useBooks.ts + * @description 책 목록 조회 훅 (무한스크롤 지원) + */ + +import { useInfiniteQuery } from '@tanstack/react-query' + +import { getBooks } from '../book.api' +import type { GetBooksParams, GetBooksResponse } from '../book.types' +import { bookKeys } from './useBookDetail' + +/** 책 목록 쿼리 키 확장 */ +export const bookListKeys = { + ...bookKeys, + list: (params?: Omit) => + [...bookKeys.all, 'list', params ?? {}] as const, +} + +/** + * 책 목록을 조회하는 훅 (무한스크롤 지원) + * + * @param params - 필터링/정렬 파라미터 (status, gatheringId, rating, sort) + * + * @example + * ```tsx + * function BookListPage() { + * const { + * data, + * fetchNextPage, + * hasNextPage, + * isFetchingNextPage, + * } = useBooks({ status: 'READING' }) + * + * const books = data?.pages.flatMap(page => page.items) ?? [] + * const totalCount = data?.pages[0]?.totalCount ?? 0 + * + * return ( + *
+ *

전체: {totalCount}권

+ * {books.map(book => )} + * {hasNextPage && ( + * + * )} + *
+ * ) + * } + * ``` + */ +export function useBooks(params?: Omit) { + return useInfiniteQuery({ + queryKey: bookListKeys.list(params), + queryFn: ({ pageParam }) => + getBooks({ + ...params, + cursorAddedAt: pageParam?.addedAt, + cursorBookId: pageParam?.bookId, + }), + initialPageParam: undefined as GetBooksResponse['nextCursor'] | undefined, + getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.nextCursor : undefined), + }) +} diff --git a/src/features/book/hooks/useCreateBook.ts b/src/features/book/hooks/useCreateBook.ts new file mode 100644 index 0000000..5dc8594 --- /dev/null +++ b/src/features/book/hooks/useCreateBook.ts @@ -0,0 +1,43 @@ +/** + * @file useCreateBook.ts + * @description 책 등록 훅 + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { createBook } from '../book.api' +import type { CreateBookBody } from '../book.types' +import { bookListKeys } from './useBooks' + +/** + * 책을 등록하는 뮤테이션 훅 + * + * @example + * ```tsx + * function BookSearchModal() { + * const { mutateAsync: registerBook, isPending } = useCreateBook() + * + * const handleSelect = async (book: SearchBookItem) => { + * await registerBook({ + * title: book.title, + * authors: book.authors.join(', '), + * publisher: book.publisher, + * isbn: book.isbn, + * thumbnail: book.thumbnail, + * }) + * } + * + * return (...) + * } + * ``` + */ +export function useCreateBook() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (body: CreateBookBody) => createBook(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bookListKeys.all }) + }, + }) +} diff --git a/src/features/book/hooks/useDeleteBook.ts b/src/features/book/hooks/useDeleteBook.ts new file mode 100644 index 0000000..c04b935 --- /dev/null +++ b/src/features/book/hooks/useDeleteBook.ts @@ -0,0 +1,32 @@ +/** + * @file useDeleteBook.ts + * @description 책 삭제 뮤테이션 훅 + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { deleteBook } from '../book.api' +import { bookListKeys } from './useBooks' + +/** + * 책을 삭제하는 뮤테이션 훅 + * + * @example + * ```tsx + * const { mutate, mutateAsync } = useDeleteBook() + * mutate(bookId) + * + * // 여러 책 삭제 + * await Promise.all(bookIds.map(id => mutateAsync(id))) + * ``` + */ +export function useDeleteBook() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (bookId: number) => deleteBook(bookId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bookListKeys.all }) + }, + }) +} diff --git a/src/features/book/hooks/useSearchBooks.ts b/src/features/book/hooks/useSearchBooks.ts new file mode 100644 index 0000000..2e6bebb --- /dev/null +++ b/src/features/book/hooks/useSearchBooks.ts @@ -0,0 +1,56 @@ +/** + * @file useSearchBooks.ts + * @description 도서 검색 훅 (무한스크롤 지원) + */ + +import { useInfiniteQuery } from '@tanstack/react-query' + +import { searchBooks } from '../book.api' + +/** 도서 검색 쿼리 키 */ +export const searchBooksKeys = { + all: ['searchBooks'] as const, + search: (query: string) => [...searchBooksKeys.all, query] as const, +} + +/** + * 도서를 검색하는 훅 (무한스크롤 지원) + * + * @param query - 검색어 + * + * @example + * ```tsx + * function BookSearchModal() { + * const [query, setQuery] = useState('') + * const { + * data, + * fetchNextPage, + * hasNextPage, + * isFetching, + * } = useSearchBooks(query) + * + * const books = data?.pages.flatMap(page => page.items) ?? [] + * + * return ( + *
+ * setQuery(e.target.value)} /> + * {books.map(book => )} + *
+ * ) + * } + * ``` + */ +export function useSearchBooks(query: string) { + return useInfiniteQuery({ + queryKey: searchBooksKeys.search(query), + queryFn: ({ pageParam }) => + searchBooks({ + query, + page: pageParam, + }), + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.hasNext ? (lastPage.nextCursor?.page ?? undefined) : undefined, + enabled: query.trim().length > 0, + }) +} diff --git a/src/features/book/index.ts b/src/features/book/index.ts index e83b2e5..bab2772 100644 --- a/src/features/book/index.ts +++ b/src/features/book/index.ts @@ -1,3 +1,20 @@ export * from './book.api' export * from './book.types' +export { + BookCard, + BookCarousel, + BookInfo, + BookList, + BookLogList, + BookLogModal, + BookReview as BookReviewComponent, + BookReviewModal, + BookSearchModal, + ExcerptBlock, + MeetingGroupRecordItem, + MeetingPreOpinionItem, + MeetingRetrospectiveItem, + PersonalRecordItem, + ReviewHistoryCard, +} from './components' export * from './hooks' diff --git a/src/features/gatherings/components/EmptyState.tsx b/src/features/gatherings/components/EmptyState.tsx new file mode 100644 index 0000000..54360fa --- /dev/null +++ b/src/features/gatherings/components/EmptyState.tsx @@ -0,0 +1,42 @@ +type EmptyStateType = 'all' | 'favorites' | 'meetings' | 'bookshelf' + +interface EmptyStateProps { + type?: EmptyStateType +} + +const EMPTY_STATE_MESSAGES: Record = { + all: ( + <> + 아직 참여 중인 모임이 없어요. +
첫 번째 모임을 시작해 보세요! + + ), + favorites: ( + <> + 즐겨찾기한 모임이 없어요. +
+ 자주 방문하는 모임을 즐겨찾기에 추가해 보세요! + + ), + meetings: ( + <> + 등록된 약속이 없어요. +
첫 약속을 추가해보세요! + + ), + bookshelf: ( + <> + 함께 읽은 책이 없어요 +
+ 약속을 만들어 책을 추가해보세요! + + ), +} + +export default function EmptyState({ type = 'all' }: EmptyStateProps) { + return ( +
+

{EMPTY_STATE_MESSAGES[type]}

+
+ ) +} diff --git a/src/features/gatherings/components/GatheringBookCard.tsx b/src/features/gatherings/components/GatheringBookCard.tsx new file mode 100644 index 0000000..82bf955 --- /dev/null +++ b/src/features/gatherings/components/GatheringBookCard.tsx @@ -0,0 +1,55 @@ +import { Book, Star } from 'lucide-react' +import { useNavigate } from 'react-router-dom' + +import { ROUTES } from '@/shared/constants' + +import type { GatheringBookItem } from '../gatherings.types' + +interface GatheringBookCardProps { + book: GatheringBookItem +} + +export default function GatheringBookCard({ book }: GatheringBookCardProps) { + const navigate = useNavigate() + + const { bookId, bookName, author, thumbnail, ratingAverage } = book + + const handleClick = () => { + navigate(ROUTES.BOOK_DETAIL(bookId)) + } + + return ( +
+ {/* 책 커버 */} +
+ {thumbnail ? ( + {bookName} + ) : ( +
+ +
+ )} +
+ + {/* 책 정보 */} +
+ {/* 제목 + 저자 */} +
+

{bookName}

+

{author}

+
+ + {/* 모임 평균 평점 */} + {ratingAverage !== null && ( +
+ 모임 평균 +
+ + {ratingAverage.toFixed(1)} +
+
+ )} +
+
+ ) +} diff --git a/src/features/gatherings/components/GatheringBookshelfSection.tsx b/src/features/gatherings/components/GatheringBookshelfSection.tsx new file mode 100644 index 0000000..430e7c9 --- /dev/null +++ b/src/features/gatherings/components/GatheringBookshelfSection.tsx @@ -0,0 +1,101 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useRef } from 'react' + +import { Spinner } from '@/shared/ui' + +import { useGatheringBooks } from '../hooks/useGatheringBooks' +import EmptyState from './EmptyState' +import GatheringBookCard from './GatheringBookCard' + +interface GatheringBookshelfSectionProps { + gatheringId: number +} + +/** 캐러셀 스크롤 이동량 (픽셀) */ +const SCROLL_AMOUNT = 300 + +export default function GatheringBookshelfSection({ gatheringId }: GatheringBookshelfSectionProps) { + const scrollContainerRef = useRef(null) + const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useGatheringBooks({ + gatheringId, + size: 20, + }) + + const books = data?.pages.flatMap((page) => page.items) ?? [] + const hasBooks = books.length > 0 + + const handleScrollLeft = () => { + scrollContainerRef.current?.scrollBy({ left: -SCROLL_AMOUNT, behavior: 'smooth' }) + } + + const handleScrollRight = async () => { + const container = scrollContainerRef.current + if (!container) return + + // 스크롤 끝에 도달했는지 확인 (여유값 10px) + const isAtEnd = container.scrollLeft + container.clientWidth >= container.scrollWidth - 10 + + if (isAtEnd && hasNextPage && !isFetchingNextPage) { + await fetchNextPage() + } + + container.scrollBy({ left: SCROLL_AMOUNT, behavior: 'smooth' }) + } + + if (isLoading) { + return ( +
+

모임 책장

+
+ +
+
+ ) + } + + return ( +
+ {/* 헤더: 제목 + 좌우 화살표 (데이터 있을 때만) */} +
+

모임 책장

+ {hasBooks && ( +
+ + +
+ )} +
+ + {/* 책 목록 또는 Empty State */} + {hasBooks ? ( +
+ {books.map((book) => ( +
+ +
+ ))} +
+ ) : ( + + )} +
+ ) +} diff --git a/src/features/gatherings/components/GatheringCard.tsx b/src/features/gatherings/components/GatheringCard.tsx new file mode 100644 index 0000000..d0494cd --- /dev/null +++ b/src/features/gatherings/components/GatheringCard.tsx @@ -0,0 +1,84 @@ +import { Star } from 'lucide-react' +import type { MouseEvent } from 'react' + +import { cn } from '@/shared/lib/utils' + +import type { GatheringListItem } from '../gatherings.types' + +interface GatheringCardProps { + gathering: GatheringListItem + onFavoriteToggle: (gatheringId: number) => void + onClick: () => void +} + +export default function GatheringCard({ + gathering, + onFavoriteToggle, + onClick, +}: GatheringCardProps) { + const { + gatheringId, + gatheringName, + isFavorite, + totalMembers, + totalMeetings, + currentUserRole, + daysFromJoined, + } = gathering + + const isLeader = currentUserRole === 'LEADER' + + const handleFavoriteClick = (e: MouseEvent) => { + e.stopPropagation() + onFavoriteToggle(gatheringId) + } + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() + } + }} + > + {/* 상단 영역: 배지 + 모임 이름 */} +
+ {isLeader && ( + + 모임장 + + )} +

{gatheringName}

+
+ + {/* 하단 영역: 메타 정보 */} +
+ 멤버 {totalMembers}명 + + 시작한지 {daysFromJoined}일 + + 약속 {totalMeetings}회 +
+ + {/* 즐겨찾기 버튼 */} + +
+ ) +} diff --git a/src/features/gatherings/components/GatheringDetailHeader.tsx b/src/features/gatherings/components/GatheringDetailHeader.tsx new file mode 100644 index 0000000..f54312d --- /dev/null +++ b/src/features/gatherings/components/GatheringDetailHeader.tsx @@ -0,0 +1,72 @@ +import { Link2, Settings, Star } from 'lucide-react' + +import { cn } from '@/shared/lib/utils' +import { TextButton } from '@/shared/ui' + +import type { GatheringUserRole } from '../gatherings.types' + +interface GatheringDetailHeaderProps { + gatheringName: string + isFavorite: boolean + currentUserRole: GatheringUserRole + /** 스크롤되어 헤더가 고정된 상태인지 */ + isSticky?: boolean + onFavoriteToggle: () => void + onSettingsClick: () => void + onInviteClick: () => void +} + +export default function GatheringDetailHeader({ + gatheringName, + isFavorite, + currentUserRole, + isSticky = false, + onFavoriteToggle, + onSettingsClick, + onInviteClick, +}: GatheringDetailHeaderProps) { + const isLeader = currentUserRole === 'LEADER' + + return ( +
+
+ {/* 좌측: 모임명 + 즐겨찾기 */} +
+

{gatheringName}

+ +
+ + {/* 우측: 설정/초대링크 버튼 */} +
+ {/* 모임장 전용 - 설정 버튼 */} + {isLeader && ( + + 설정 + + )} + {/* 초대링크 버튼 */} + + 초대링크 + +
+
+
+ ) +} diff --git a/src/features/gatherings/components/GatheringDetailInfo.tsx b/src/features/gatherings/components/GatheringDetailInfo.tsx new file mode 100644 index 0000000..7e760f2 --- /dev/null +++ b/src/features/gatherings/components/GatheringDetailInfo.tsx @@ -0,0 +1,93 @@ +import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from '@/shared/ui' + +import type { GatheringMember } from '../gatherings.types' + +interface GatheringDetailInfoProps { + daysFromCreation: number + totalMeetings: number + totalMembers: number + members: GatheringMember[] + description: string | null +} + +/** 멤버 아바타 그룹에 표시할 최대 인원 수 */ +const MAX_VISIBLE_MEMBERS = 5 + +export default function GatheringDetailInfo({ + daysFromCreation, + totalMeetings, + totalMembers, + members, + description, +}: GatheringDetailInfoProps) { + const leader = members.find((m) => m.role === 'LEADER') + const otherMembers = members.filter((m) => m.role !== 'LEADER') + const visibleMembers = otherMembers.slice(0, MAX_VISIBLE_MEMBERS) + const remainingCount = totalMembers - 1 - visibleMembers.length + + return ( +
+ {/* 첫 번째 줄: 통계 정보 (좌측) + 모임장 (우측) */} +
+ {/* 통계 정보 */} +
+ 시작한지 {daysFromCreation}일 + + 약속 {totalMeetings}회 + + 총 구성원 {totalMembers}명 +
+ + {/* 모임장 + 멤버 그룹 */} +
+ {/* 모임장 */} + {leader && ( +
+ 모임장 + + + {leader.nickname.slice(0, 1)} + +
+ )} + + {/* 멤버 아바타 그룹 (멤버가 있을 때만) */} + {otherMembers.length > 0 && ( + + {visibleMembers.map((member) => ( + + + {member.nickname.slice(0, 1)} + + ))} + {remainingCount > 0 && ( + ({ + id: String(m.gatheringMemberId), + name: m.nickname, + src: m.profileImageUrl ?? undefined, + }))} + preview={ + otherMembers[MAX_VISIBLE_MEMBERS] + ? { + name: otherMembers[MAX_VISIBLE_MEMBERS].nickname, + src: otherMembers[MAX_VISIBLE_MEMBERS].profileImageUrl ?? undefined, + } + : undefined + } + > + +{remainingCount} + + )} + + )} +
+
+ + {/* 두 번째 줄: 모임 설명 */} + {description && ( +

{description}

+ )} +
+ ) +} diff --git a/src/features/gatherings/components/GatheringMeetingCard.tsx b/src/features/gatherings/components/GatheringMeetingCard.tsx new file mode 100644 index 0000000..89107ba --- /dev/null +++ b/src/features/gatherings/components/GatheringMeetingCard.tsx @@ -0,0 +1,171 @@ +import { BarChart3, FileQuestion, NotebookPen } from 'lucide-react' +import { useNavigate } from 'react-router-dom' + +import { ROUTES } from '@/shared/constants/routes' +import { formatToDateTimeRange, getDdayText } from '@/shared/lib/date' +import { cn } from '@/shared/lib/utils' +import { Badge } from '@/shared/ui/Badge' + +import type { GatheringMeetingItem } from '../gatherings.types' +import { getMeetingDisplayStatus } from '../lib/meetingStatus' + +interface GatheringMeetingCardProps { + meeting: GatheringMeetingItem + /** 모임 ID */ + gatheringId: number + /** 약속장 여부 */ + isHost?: boolean +} + +export default function GatheringMeetingCard({ + meeting, + gatheringId, + isHost = false, +}: GatheringMeetingCardProps) { + const navigate = useNavigate() + + const { meetingId, meetingName, bookName, startDateTime, endDateTime } = meeting + + const status = getMeetingDisplayStatus(startDateTime, endDateTime) + const ddayText = getDdayText(startDateTime, endDateTime) + const formattedDate = formatToDateTimeRange(startDateTime, endDateTime) + + const isOngoing = status === 'IN_PROGRESS' + + const handleClick = () => { + navigate(ROUTES.MEETING_DETAIL(gatheringId, meetingId)) + } + + // 사전답변, 약속회고, 개인회고 버튼 핸들러 + const handlePreAnswerClick = (e: React.MouseEvent) => { + e.stopPropagation() + // TODO: 사전답변 작성/조회 페이지로 이동 + console.log('사전답변 클릭') + } + + const handleMeetingReviewClick = (e: React.MouseEvent) => { + e.stopPropagation() + // TODO: 약속회고 페이지로 이동 + console.log('약속회고 클릭') + } + + const handlePersonalReviewClick = (e: React.MouseEvent) => { + e.stopPropagation() + // TODO: 개인회고 작성/조회 페이지로 이동 + console.log('개인회고 클릭') + } + + // 약속 중 카드 (빨간 배경) + if (isOngoing) { + return ( +
+
+ {/* 상태 배지 - Badge 컴포넌트는 red color가 accent-200/300 사용 */} + + 약속 중 + + + {/* 약속 정보 */} +
+
+
+ {meetingName} + | + {bookName} +
+ {isHost && ( + + 약속장 + + )} +
+ {formattedDate} +
+
+
+ ) + } + + // 예정/종료 카드 + const isUpcoming = status === 'UPCOMING' + const statusLabel = isUpcoming ? '예정' : '종료' + const badgeColor = isUpcoming ? 'yellow' : 'grey' + const ddayColor = isUpcoming ? 'text-yellow-300' : 'text-grey-600' + + return ( +
+ {/* 좌측: 배지 + 정보 */} +
+ {/* 상태 배지 */} + + {statusLabel} + + + {/* 약속 정보 */} +
+
+
+ {meetingName} + | + {bookName} +
+ {isHost && ( + + 약속장 + + )} +
+
+ {ddayText && ( + {ddayText} + )} + {formattedDate} +
+
+
+ + {/* 우측: 사전답변, 약속회고, 개인회고 버튼 */} +
+ {/* 사전답변 */} + + +
+ + {/* 약속회고 */} + + +
+ + {/* 개인회고 */} + +
+
+ ) +} diff --git a/src/features/gatherings/components/GatheringMeetingSection.tsx b/src/features/gatherings/components/GatheringMeetingSection.tsx new file mode 100644 index 0000000..05f5854 --- /dev/null +++ b/src/features/gatherings/components/GatheringMeetingSection.tsx @@ -0,0 +1,197 @@ +import { useEffect, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +import { useUserProfile } from '@/features/user' +import { PAGE_SIZES, ROUTES } from '@/shared/constants' +import { + Button, + Pagination, + Spinner, + Tabs, + TabsList, + TabsTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/shared/ui' + +import type { GatheringUserRole, MeetingFilter } from '../gatherings.types' +import { useGatheringMeetings, useMeetingTabCounts } from '../hooks' +import { sortMeetings } from '../lib/meetingStatus' +import EmptyState from './EmptyState' +import GatheringMeetingCard from './GatheringMeetingCard' + +interface GatheringMeetingSectionProps { + gatheringId: number + currentUserRole: GatheringUserRole +} + +/** 탭 필터 목록 */ +const TAB_FILTERS: MeetingFilter[] = ['ALL', 'UPCOMING', 'DONE', 'JOINED'] + +const FILTER_LABELS: Record = { + ALL: '전체 약속', + UPCOMING: '예정된 약속', + DONE: '종료한 약속', + JOINED: '내가 참여한 약속', +} + +export default function GatheringMeetingSection({ + gatheringId, + currentUserRole, +}: GatheringMeetingSectionProps) { + const navigate = useNavigate() + const location = useLocation() + const [activeTab, setActiveTab] = useState('ALL') + const [currentPage, setCurrentPage] = useState(0) + const [showCreateTooltip, setShowCreateTooltip] = useState( + () => location.state?.justCreated === true + ) + + const isLeader = currentUserRole === 'LEADER' + + // 모임 생성 직후 state를 소비한 뒤 히스토리에서 제거 (새로고침 시 재표시 방지) + useEffect(() => { + if (location.state?.justCreated) { + window.history.replaceState({}, '') + } + }, [location.state]) + + // 현재 사용자 정보 + const { data: currentUser } = useUserProfile() + const currentUserNickname = currentUser?.nickname ?? '' + + // 탭별 카운트 조회 (서버 API) + const { data: tabCounts } = useMeetingTabCounts(gatheringId) + + // 약속 목록 조회 (서버 페이지네이션) + const { data, isLoading } = useGatheringMeetings({ + gatheringId, + filter: activeTab, + page: currentPage, + size: PAGE_SIZES.GATHERING_MEETINGS, + }) + + // 서버에서 받은 데이터 + const meetings = data?.items ?? [] + const totalPages = data?.totalPages ?? 0 + const totalCount = data?.totalCount ?? 0 + + // 정렬: 진행 중 → 예정 → 종료 (sortMeetings에서 처리) + const displayMeetings = sortMeetings(meetings) + + // 탭 변경 시 페이지 초기화 + const handleTabChange = (filter: MeetingFilter) => { + setActiveTab(filter) + setCurrentPage(0) + } + + // 약속 설정 버튼 핸들러 + const handleMeetingSettings = () => { + navigate(ROUTES.MEETING_SETTING(gatheringId)) + } + + // 약속 만들기 버튼 핸들러 + const handleCreateMeeting = () => { + navigate(ROUTES.MEETING_CREATE(gatheringId)) + } + + // 탭별 카운트 (서버 데이터 또는 0) + const getTabCount = (filter: MeetingFilter): number => { + if (!tabCounts) return 0 + switch (filter) { + case 'ALL': + return tabCounts.all + case 'UPCOMING': + return tabCounts.upcoming + case 'DONE': + return tabCounts.done + case 'JOINED': + return tabCounts.joined + } + } + + return ( +
+ {/* 섹션 헤더: 약속 + 탭들 + 버튼들 (한 줄) */} +
+
+ {/* 섹션 제목 */} +

약속

+ + {/* 탭들 */} + handleTabChange(value as MeetingFilter)} + > + + {TAB_FILTERS.map((filter) => ( + + {FILTER_LABELS[filter]} + + ))} + + +
+ + {/* 버튼들 (모임장만) */} + {isLeader && ( +
+ + {showCreateTooltip ? ( + !open && setShowCreateTooltip(false)}> + + + + 약속을 만들어 함께 책을 읽어보세요! + + ) : ( + + )} +
+ )} +
+ + {/* 약속 목록 */} + {isLoading ? ( +
+ +
+ ) : totalCount === 0 ? ( + + ) : ( +
+ {displayMeetings.map((meeting) => ( + + ))} +
+ )} + + {/* 페이지네이션 */} + {totalPages > 1 && ( + + )} +
+ ) +} diff --git a/src/features/gatherings/components/MemberCard.tsx b/src/features/gatherings/components/MemberCard.tsx new file mode 100644 index 0000000..b1a6eac --- /dev/null +++ b/src/features/gatherings/components/MemberCard.tsx @@ -0,0 +1,81 @@ +import { Button } from '@/shared/ui/Button' + +import type { GatheringMember } from '../gatherings.types' + +export type MemberCardAction = 'approve' | 'reject' | 'remove' + +interface MemberCardProps { + member: GatheringMember + /** 표시할 날짜 문자열 (YY.MM.DD) */ + dateLabel?: string + /** 표시할 액션 버튼 목록 */ + actions: MemberCardAction[] + /** 액션 버튼 클릭 핸들러 */ + onAction: (action: MemberCardAction, member: GatheringMember) => void + /** 버튼 비활성화 여부 */ + disabled?: boolean +} + +export default function MemberCard({ + member, + dateLabel, + actions, + onAction, + disabled, +}: MemberCardProps) { + return ( +
+
+ {member.profileImageUrl ? ( + {member.nickname} + ) : ( +
+ {member.nickname.charAt(0)} +
+ )} +
+ {member.nickname} + {dateLabel && {dateLabel}} +
+
+
+ {actions.includes('reject') && ( + + )} + {actions.includes('approve') && ( + + )} + {actions.includes('remove') && ( + + )} +
+
+ ) +} diff --git a/src/features/gatherings/components/index.ts b/src/features/gatherings/components/index.ts new file mode 100644 index 0000000..9531209 --- /dev/null +++ b/src/features/gatherings/components/index.ts @@ -0,0 +1,9 @@ +export { default as EmptyState } from './EmptyState' +export { default as GatheringBookCard } from './GatheringBookCard' +export { default as GatheringBookshelfSection } from './GatheringBookshelfSection' +export { default as GatheringCard } from './GatheringCard' +export { default as GatheringDetailHeader } from './GatheringDetailHeader' +export { default as GatheringDetailInfo } from './GatheringDetailInfo' +export { default as GatheringMeetingCard } from './GatheringMeetingCard' +export { default as GatheringMeetingSection } from './GatheringMeetingSection' +export { default as MemberCard, type MemberCardAction } from './MemberCard' diff --git a/src/features/gatherings/gatherings.api.ts b/src/features/gatherings/gatherings.api.ts index 7c40713..824f29c 100644 --- a/src/features/gatherings/gatherings.api.ts +++ b/src/features/gatherings/gatherings.api.ts @@ -2,10 +2,24 @@ import { apiClient, type ApiResponse } from '@/api' import { GATHERINGS_ENDPOINTS } from './gatherings.endpoints' import type { + ApproveType, CreateGatheringRequest, CreateGatheringResponse, + FavoriteGatheringListResponse, + GatheringBookListResponse, GatheringByInviteCodeResponse, + GatheringDetailResponse, GatheringJoinResponse, + GatheringListResponse, + GatheringMeetingListResponse, + GatheringMemberListResponse, + GatheringUpdateRequest, + GatheringUpdateResponse, + GetGatheringBooksParams, + GetGatheringMeetingsParams, + GetGatheringMembersParams, + GetGatheringsParams, + MeetingTabCountsResponse, } from './gatherings.types' /** @@ -49,3 +63,177 @@ export const joinGathering = async (invitationCode: string) => { ) return response.data } + +/** + * 내 모임 전체 목록 조회 (커서 기반 무한 스크롤) + * + * @param params - 조회 파라미터 + * @param params.pageSize - 페이지 크기 (기본: 9) + * @param params.cursorJoinedAt - 마지막 항목의 가입일시 (ISO 8601) + * @param params.cursorId - 마지막 항목의 ID + * @returns 모임 목록 및 페이지네이션 정보 + */ +export const getGatherings = async (params?: GetGatheringsParams) => { + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.BASE, + { + params, + } + ) + return response.data +} + +/** + * 즐겨찾기 모임 목록 조회 + * + * @returns 즐겨찾기 모임 목록 (최대 4개) + */ +export const getFavoriteGatherings = async () => { + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.FAVORITES + ) + return response.data +} + +/** + * 모임 즐겨찾기 토글 + * + * @param gatheringId - 모임 ID + */ +export const toggleFavorite = async (gatheringId: number) => { + const response = await apiClient.patch>( + GATHERINGS_ENDPOINTS.TOGGLE_FAVORITE(gatheringId) + ) + return response.data +} + +/** + * 모임 상세 조회 + * + * @param gatheringId - 모임 ID + * @returns 모임 상세 정보 + */ +export const getGatheringDetail = async (gatheringId: number) => { + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.DETAIL(gatheringId) + ) + return response.data +} + +/** + * 모임 약속 목록 조회 (페이지 기반) + * + * @param params - 조회 파라미터 + * @returns 약속 목록 및 페이지네이션 정보 + */ +export const getGatheringMeetings = async (params: GetGatheringMeetingsParams) => { + const { gatheringId, ...queryParams } = params + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.MEETINGS(gatheringId), + { params: queryParams } + ) + return response.data +} + +/** + * 모임 책장 조회 (페이지 기반) + * + * @param params - 조회 파라미터 + * @returns 책 목록 및 페이지네이션 정보 + */ +export const getGatheringBooks = async (params: GetGatheringBooksParams) => { + const { gatheringId, ...queryParams } = params + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.BOOKS(gatheringId), + { params: queryParams } + ) + return response.data +} + +/** + * 모임 약속 탭별 카운트 조회 + * + * @param gatheringId - 모임 ID + * @returns 탭별 약속 카운트 + */ +export const getMeetingTabCounts = async (gatheringId: number) => { + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.MEETING_TAB_COUNTS, + { params: { gatheringId } } + ) + return response.data +} + +/** + * 모임 멤버 목록 조회 (커서 기반 무한 스크롤) + * + * @param params - 조회 파라미터 + * @returns 멤버 목록 및 페이지네이션 정보 + */ +export const getGatheringMembers = async (params: GetGatheringMembersParams) => { + const { gatheringId, ...queryParams } = params + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.MEMBERS(gatheringId), + { params: queryParams } + ) + return response.data +} + +/** + * 모임 정보 수정 + * + * @param gatheringId - 모임 ID + * @param data - 수정할 모임 정보 + * @returns 수정된 모임 정보 + */ +export const updateGathering = async (gatheringId: number, data: GatheringUpdateRequest) => { + const response = await apiClient.patch>( + GATHERINGS_ENDPOINTS.DETAIL(gatheringId), + data + ) + return response.data +} + +/** + * 모임 삭제 + * + * @param gatheringId - 모임 ID + */ +export const deleteGathering = async (gatheringId: number) => { + const response = await apiClient.delete>( + GATHERINGS_ENDPOINTS.DETAIL(gatheringId) + ) + return response.data +} + +/** + * 가입 요청 승인/거절 + * + * @param gatheringId - 모임 ID + * @param memberId - 처리할 멤버의 유저 ID + * @param approveType - 승인(ACTIVE) 또는 거절(REJECTED) + */ +export const handleJoinRequest = async ( + gatheringId: number, + memberId: number, + approveType: ApproveType +) => { + const response = await apiClient.patch>( + GATHERINGS_ENDPOINTS.HANDLE_JOIN_REQUEST(gatheringId, memberId), + { approve_type: approveType } + ) + return response.data +} + +/** + * 멤버 삭제(강퇴) + * + * @param gatheringId - 모임 ID + * @param userId - 강퇴할 유저 ID + */ +export const removeMember = async (gatheringId: number, userId: number) => { + const response = await apiClient.delete>( + GATHERINGS_ENDPOINTS.REMOVE_MEMBER(gatheringId, userId) + ) + return response.data +} diff --git a/src/features/gatherings/gatherings.endpoints.ts b/src/features/gatherings/gatherings.endpoints.ts index 6f82784..ee0b1d3 100644 --- a/src/features/gatherings/gatherings.endpoints.ts +++ b/src/features/gatherings/gatherings.endpoints.ts @@ -3,7 +3,27 @@ import { API_PATHS } from '@/api' export const GATHERINGS_ENDPOINTS = { /** 모임 목록/생성 */ BASE: API_PATHS.GATHERINGS, + /** 모임 상세 조회 */ + DETAIL: (gatheringId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}`, + /** 즐겨찾기 모임 목록 조회 */ + FAVORITES: `${API_PATHS.GATHERINGS}/favorites`, + /** 즐겨찾기 토글 */ + TOGGLE_FAVORITE: (gatheringId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}/favorites`, /** 초대 코드로 모임 정보 조회 / 가입 신청 */ JOIN_REQUEST: (invitationCode: string) => `${API_PATHS.GATHERINGS}/join-request/${invitationCode}`, + /** 모임 약속 목록 조회 */ + MEETINGS: (gatheringId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}/meetings`, + /** 모임 책장 조회 */ + BOOKS: (gatheringId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}/books`, + /** 약속 탭별 카운트 조회 */ + MEETING_TAB_COUNTS: `${API_PATHS.MEETINGS}/tab-counts`, + /** 멤버 목록 조회 */ + MEMBERS: (gatheringId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}/members`, + /** 가입 요청 승인/거절 */ + HANDLE_JOIN_REQUEST: (gatheringId: number, memberId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/join-requests/${memberId}`, + /** 멤버 삭제(강퇴) */ + REMOVE_MEMBER: (gatheringId: number, userId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/members/${userId}`, } as const diff --git a/src/features/gatherings/gatherings.types.ts b/src/features/gatherings/gatherings.types.ts index 0fddb93..0b6d212 100644 --- a/src/features/gatherings/gatherings.types.ts +++ b/src/features/gatherings/gatherings.types.ts @@ -1,3 +1,6 @@ +import type { CursorPaginatedResponse, PaginatedResponse } from '@/api' +import type { MeetingStatus } from '@/features/meetings' + /** 모임 기본 정보 (공통) */ export interface GatheringBase { /** 모임 이름 */ @@ -43,3 +46,232 @@ export interface GatheringJoinResponse { /** 가입 상태 */ memberStatus: GatheringMemberStatus } + +/** 모임 상태 */ +export type GatheringStatus = 'ACTIVE' | 'INACTIVE' + +/** 모임 내 사용자 역할 */ +export type GatheringUserRole = 'LEADER' | 'MEMBER' + +/** 모임 목록 아이템 */ +export interface GatheringListItem { + /** 모임 ID */ + gatheringId: number + /** 모임 이름 */ + gatheringName: string + /** 즐겨찾기 여부 */ + isFavorite: boolean + /** 모임 상태 */ + gatheringStatus: GatheringStatus + /** 전체 멤버 수 */ + totalMembers: number + /** 전체 약속 수 */ + totalMeetings: number + /** 현재 사용자 역할 */ + currentUserRole: GatheringUserRole + /** 가입 후 경과 일수 */ + daysFromJoined: number +} + +/** 커서 정보 (서버 응답) */ +export interface GatheringCursor { + joinedAt: string + gatheringMemberId: number +} + +/** 모임 목록 응답 (커서 기반 페이지네이션) */ +export type GatheringListResponse = CursorPaginatedResponse + +/** 즐겨찾기 모임 목록 응답 */ +export interface FavoriteGatheringListResponse { + /** 즐겨찾기 모임 목록 */ + gatherings: GatheringListItem[] +} + +/** 모임 목록 조회 파라미터 */ +export interface GetGatheringsParams { + /** 페이지 크기 (기본: 9) */ + pageSize?: number + /** 커서 - 마지막 항목의 가입일시 (ISO 8601) */ + cursorJoinedAt?: string + /** 커서 - 마지막 항목의 ID */ + cursorId?: number +} + +// ========== 모임 상세 조회 ========== + +/** 모임 멤버 정보 */ +export interface GatheringMember { + /** 모임 멤버 ID */ + gatheringMemberId: number + /** 사용자 ID */ + userId: number + /** 닉네임 */ + nickname: string + /** 프로필 이미지 URL */ + profileImageUrl: string | null + /** 역할 */ + role: GatheringUserRole + /** 멤버 상태 (멤버 목록 API에서만 제공) */ + memberStatus?: GatheringMemberStatus + /** 가입일 (ISO 8601, 멤버 목록 API에서만 제공, PENDING이면 null) */ + joinedAt?: string | null +} + +/** 모임 상세 응답 */ +export interface GatheringDetailResponse { + /** 모임 ID */ + gatheringId: number + /** 모임 이름 */ + gatheringName: string + /** 모임 설명 */ + description: string | null + /** 모임 상태 */ + gatheringStatus: GatheringStatus + /** 즐겨찾기 여부 */ + isFavorite: boolean + /** 초대 링크 (초대 코드) */ + invitationLink: string + /** 모임 생성 후 경과 일수 */ + daysFromCreation: number + /** 현재 사용자 역할 */ + currentUserRole: GatheringUserRole + /** 멤버 목록 */ + members: GatheringMember[] + /** 전체 멤버 수 */ + totalMembers: number + /** 전체 약속 수 */ + totalMeetings: number +} + +// ========== 모임 약속 목록 ========== + +/** 약속 필터 타입 */ +export type MeetingFilter = 'ALL' | 'UPCOMING' | 'DONE' | 'JOINED' + +/** 모임 약속 아이템 */ +export interface GatheringMeetingItem { + /** 약속 ID */ + meetingId: number + /** 약속 이름 */ + meetingName: string + /** 약속장 이름 */ + meetingLeaderName: string + /** 책 이름 */ + bookName: string + /** 시작 일시 (ISO 8601) */ + startDateTime: string + /** 종료 일시 (ISO 8601) */ + endDateTime: string + /** 약속 상태 */ + meetingStatus: MeetingStatus +} + +/** 모임 약속 목록 응답 (페이지 기반) */ +export type GatheringMeetingListResponse = PaginatedResponse + +/** 모임 약속 목록 조회 파라미터 */ +export interface GetGatheringMeetingsParams { + /** 모임 ID */ + gatheringId: number + /** 필터 */ + filter?: MeetingFilter + /** 페이지 번호 (0부터 시작) */ + page?: number + /** 페이지 크기 */ + size?: number +} + +/** 약속 탭별 카운트 응답 */ +export interface MeetingTabCountsResponse { + /** 전체 확정된 약속 수 */ + all: number + /** 다가오는 약속 수 (3일 이내) */ + upcoming: number + /** 완료된 약속 수 */ + done: number + /** 내가 참여한 완료 약속 수 */ + joined: number +} + +// ========== 모임 책장 ========== + +/** 모임 책장 아이템 */ +export interface GatheringBookItem { + /** 책 ID */ + bookId: number + /** 책 이름 */ + bookName: string + /** 저자 */ + author: string + /** 책 표지 URL */ + thumbnail: string | null + /** 평균 평점 (없으면 null) */ + ratingAverage: number | null +} + +/** 모임 책장 응답 (페이지 기반) */ +export type GatheringBookListResponse = PaginatedResponse + +/** 모임 책장 조회 파라미터 */ +export interface GetGatheringBooksParams { + /** 모임 ID */ + gatheringId: number + /** 페이지 번호 (0부터 시작) */ + page?: number + /** 페이지 크기 */ + size?: number +} + +// ========== 모임 설정 (수정/삭제/멤버 관리) ========== + +/** 모임 수정 요청 */ +export interface GatheringUpdateRequest { + /** 모임 이름 (필수, 최대 12자, 공백만 불가) */ + gatheringName: string + /** 모임 설명 (선택, 최대 150자) */ + description?: string +} + +/** 모임 수정 응답 */ +export interface GatheringUpdateResponse { + /** 모임 ID */ + gatheringId: number + /** 모임 이름 */ + gatheringName: string + /** 모임 설명 */ + description: string | null + /** 수정 일시 (ISO 8601) */ + updatedAt: string +} + +// ========== 모임 멤버 목록 조회 ========== + +/** 멤버 목록 필터 상태 (GatheringMemberStatus에서 파생) */ +export type MemberStatusFilter = Extract + +/** 멤버 목록 커서 */ +export interface GatheringMemberListCursor { + gatheringMemberId: number +} + +/** 멤버 목록 응답 (커서 기반 페이지네이션) */ +export type GatheringMemberListResponse = CursorPaginatedResponse< + GatheringMember, + GatheringMemberListCursor +> + +/** 멤버 목록 조회 파라미터 */ +export interface GetGatheringMembersParams { + /** 모임 ID */ + gatheringId: number + /** 멤버 상태 필터 */ + status: MemberStatusFilter + /** 페이지 크기 (기본: 10) */ + pageSize?: number + /** 커서 - 마지막 항목의 모임 멤버 ID */ + cursorId?: number +} + +/** 가입 요청 승인/거절 타입 (GatheringMemberStatus에서 파생) */ +export type ApproveType = Extract diff --git a/src/features/gatherings/hooks/gatheringQueryKeys.ts b/src/features/gatherings/hooks/gatheringQueryKeys.ts index 5110181..d72312b 100644 --- a/src/features/gatherings/hooks/gatheringQueryKeys.ts +++ b/src/features/gatherings/hooks/gatheringQueryKeys.ts @@ -1,10 +1,26 @@ +import type { MeetingFilter, MemberStatusFilter } from '../gatherings.types' + /** * 모임 관련 Query Key를 일관되게 관리하기 위한 팩토리 함수 */ export const gatheringQueryKeys = { all: ['gatherings'] as const, lists: () => [...gatheringQueryKeys.all, 'list'] as const, + favorites: () => [...gatheringQueryKeys.all, 'favorites'] as const, detail: (id: number | string) => [...gatheringQueryKeys.all, 'detail', id] as const, byInviteCode: (invitationCode: string) => [...gatheringQueryKeys.all, 'invite', invitationCode] as const, + // 모임 약속 관련 키 + meetings: (gatheringId: number) => + [...gatheringQueryKeys.detail(gatheringId), 'meetings'] as const, + meetingsByFilter: (gatheringId: number, filter: MeetingFilter) => + [...gatheringQueryKeys.meetings(gatheringId), filter] as const, + meetingTabCounts: (gatheringId: number) => + [...gatheringQueryKeys.meetings(gatheringId), 'tabCounts'] as const, + // 모임 멤버 관련 키 + members: (gatheringId: number) => [...gatheringQueryKeys.detail(gatheringId), 'members'] as const, + membersByStatus: (gatheringId: number, status: MemberStatusFilter) => + [...gatheringQueryKeys.members(gatheringId), status] as const, + // 모임 책장 관련 키 + books: (gatheringId: number) => [...gatheringQueryKeys.detail(gatheringId), 'books'] as const, } as const diff --git a/src/features/gatherings/hooks/index.ts b/src/features/gatherings/hooks/index.ts index 49ddaab..3f4ab65 100644 --- a/src/features/gatherings/hooks/index.ts +++ b/src/features/gatherings/hooks/index.ts @@ -1,4 +1,17 @@ export * from './gatheringQueryKeys' export * from './useCreateGathering' +export * from './useDeleteGathering' +export * from './useFavoriteGatherings' +export * from './useGatheringBooks' export * from './useGatheringByInviteCode' +export * from './useGatheringDetail' +export * from './useGatheringMeetings' +export * from './useGatheringMembers' +export * from './useGatherings' +export * from './useGatheringSettingForm' +export * from './useHandleJoinRequest' export * from './useJoinGathering' +export * from './useMeetingTabCounts' +export * from './useRemoveMember' +export * from './useToggleFavorite' +export * from './useUpdateGathering' diff --git a/src/features/gatherings/hooks/useDeleteGathering.ts b/src/features/gatherings/hooks/useDeleteGathering.ts new file mode 100644 index 0000000..79fc542 --- /dev/null +++ b/src/features/gatherings/hooks/useDeleteGathering.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError, type ApiResponse } from '@/api' + +import { deleteGathering } from '../gatherings.api' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 모임 삭제 mutation 훅 + */ +export const useDeleteGathering = () => { + const queryClient = useQueryClient() + + return useMutation, ApiError, number>({ + mutationFn: deleteGathering, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.all }) + }, + }) +} diff --git a/src/features/gatherings/hooks/useFavoriteGatherings.ts b/src/features/gatherings/hooks/useFavoriteGatherings.ts new file mode 100644 index 0000000..52a9c6c --- /dev/null +++ b/src/features/gatherings/hooks/useFavoriteGatherings.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { getFavoriteGatherings } from '../gatherings.api' +import type { FavoriteGatheringListResponse } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 즐겨찾기 모임 목록 조회 훅 + */ +export const useFavoriteGatherings = () => { + return useQuery({ + queryKey: gatheringQueryKeys.favorites(), + queryFn: async () => { + const response = await getFavoriteGatherings() + return response.data + }, + }) +} diff --git a/src/features/gatherings/hooks/useGatheringBooks.ts b/src/features/gatherings/hooks/useGatheringBooks.ts new file mode 100644 index 0000000..0d25378 --- /dev/null +++ b/src/features/gatherings/hooks/useGatheringBooks.ts @@ -0,0 +1,47 @@ +import { useInfiniteQuery } from '@tanstack/react-query' + +import { PAGE_SIZES } from '@/shared/constants' + +import { getGatheringBooks } from '../gatherings.api' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +interface UseGatheringBooksOptions { + gatheringId: number + size?: number +} + +/** + * 모임 책장 조회 훅 (무한 스크롤) + * + * @param options - 조회 옵션 + * @param options.gatheringId - 모임 ID + * @param options.size - 페이지 크기 (기본: 12) + * @returns 책장 목록 무한 쿼리 결과 + * + * @example + * ```tsx + * const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useGatheringBooks({ + * gatheringId: 1, + * }) + * + * const books = data?.pages.flatMap(page => page.items) ?? [] + * ``` + */ +export const useGatheringBooks = ({ + gatheringId, + size = PAGE_SIZES.GATHERING_BOOKS, +}: UseGatheringBooksOptions) => { + return useInfiniteQuery({ + queryKey: [...gatheringQueryKeys.books(gatheringId), size], + queryFn: async ({ pageParam }) => { + const response = await getGatheringBooks({ gatheringId, page: pageParam, size }) + return response.data + }, + initialPageParam: 0, + getNextPageParam: (lastPage) => { + if (lastPage.currentPage >= lastPage.totalPages - 1) return undefined + return lastPage.currentPage + 1 + }, + enabled: gatheringId > 0, + }) +} diff --git a/src/features/gatherings/hooks/useGatheringDetail.ts b/src/features/gatherings/hooks/useGatheringDetail.ts new file mode 100644 index 0000000..df3fb53 --- /dev/null +++ b/src/features/gatherings/hooks/useGatheringDetail.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query' + +import { getGatheringDetail } from '../gatherings.api' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 모임 상세 정보 조회 훅 + * + * @param gatheringId - 모임 ID + * @returns 모임 상세 정보 쿼리 결과 + * + * @example + * ```tsx + * const { data, isLoading, error } = useGatheringDetail(gatheringId) + * + * if (data) { + * const { gatheringName, currentUserRole, members } = data + * } + * ``` + */ +export const useGatheringDetail = (gatheringId: number) => { + return useQuery({ + queryKey: gatheringQueryKeys.detail(gatheringId), + queryFn: async () => { + const response = await getGatheringDetail(gatheringId) + return response.data + }, + enabled: gatheringId > 0, + }) +} diff --git a/src/features/gatherings/hooks/useGatheringMeetings.ts b/src/features/gatherings/hooks/useGatheringMeetings.ts new file mode 100644 index 0000000..4664651 --- /dev/null +++ b/src/features/gatherings/hooks/useGatheringMeetings.ts @@ -0,0 +1,58 @@ +import { useQuery } from '@tanstack/react-query' + +import { PAGE_SIZES } from '@/shared/constants' + +import { getGatheringMeetings } from '../gatherings.api' +import type { MeetingFilter } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +interface UseGatheringMeetingsOptions { + gatheringId: number + filter?: MeetingFilter + page?: number + size?: number +} + +/** + * 모임 약속 목록 조회 훅 (서버 페이지네이션) + * + * @param options - 조회 옵션 + * @param options.gatheringId - 모임 ID + * @param options.filter - 필터 (기본: ALL) + * @param options.page - 페이지 번호 (0부터 시작, 기본: 0) + * @param options.size - 페이지 크기 + * @returns 약속 목록 쿼리 결과 + * + * @example + * ```tsx + * const { data, isLoading } = useGatheringMeetings({ + * gatheringId: 1, + * filter: 'UPCOMING', + * page: 0, + * size: 5, + * }) + * + * const meetings = data?.items ?? [] + * const totalPages = data?.totalPages ?? 0 + * ``` + */ +export const useGatheringMeetings = ({ + gatheringId, + filter = 'ALL', + page = 0, + size = PAGE_SIZES.GATHERING_MEETINGS, +}: UseGatheringMeetingsOptions) => { + return useQuery({ + queryKey: [...gatheringQueryKeys.meetingsByFilter(gatheringId, filter), page, size], + queryFn: async () => { + const response = await getGatheringMeetings({ + gatheringId, + filter, + page, + size, + }) + return response.data + }, + enabled: gatheringId > 0, + }) +} diff --git a/src/features/gatherings/hooks/useGatheringMembers.ts b/src/features/gatherings/hooks/useGatheringMembers.ts new file mode 100644 index 0000000..4b66280 --- /dev/null +++ b/src/features/gatherings/hooks/useGatheringMembers.ts @@ -0,0 +1,43 @@ +import { type InfiniteData, useInfiniteQuery } from '@tanstack/react-query' + +import { PAGE_SIZES } from '@/shared/constants' + +import { getGatheringMembers } from '../gatherings.api' +import type { GatheringMemberListResponse, MemberStatusFilter } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** 페이지 파라미터 타입: undefined = 첫 페이지, number = 다음 커서 ID */ +type PageParam = number | undefined + +/** + * 모임 멤버 목록 조회 훅 (커서 기반 무한 스크롤) + * + * @param gatheringId - 모임 ID + * @param status - 멤버 상태 필터 (PENDING | ACTIVE) + */ +export const useGatheringMembers = (gatheringId: number, status: MemberStatusFilter) => { + return useInfiniteQuery< + GatheringMemberListResponse, + Error, + InfiniteData, + readonly (string | number)[], + PageParam + >({ + queryKey: gatheringQueryKeys.membersByStatus(gatheringId, status), + queryFn: async ({ pageParam }: { pageParam: PageParam }) => { + const response = await getGatheringMembers({ + gatheringId, + status, + pageSize: PAGE_SIZES.GATHERING_MEMBERS, + cursorId: pageParam, + }) + return response.data + }, + initialPageParam: undefined as PageParam, + getNextPageParam: (lastPage: GatheringMemberListResponse): PageParam => { + if (!lastPage.hasNext || !lastPage.nextCursor) return undefined + return lastPage.nextCursor.gatheringMemberId + }, + enabled: gatheringId > 0, + }) +} diff --git a/src/features/gatherings/hooks/useGatheringSettingForm.ts b/src/features/gatherings/hooks/useGatheringSettingForm.ts new file mode 100644 index 0000000..e152a31 --- /dev/null +++ b/src/features/gatherings/hooks/useGatheringSettingForm.ts @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useState } from 'react' + +import type { GatheringDetailResponse } from '../gatherings.types' + +export const MAX_NAME_LENGTH = 12 +export const MAX_DESCRIPTION_LENGTH = 150 + +/** + * 모임 설정 폼 상태 관리 훅 + * + * gathering 데이터가 변경되면 폼을 리셋합니다. + */ +export const useGatheringSettingForm = (gathering: GatheringDetailResponse | undefined) => { + const [name, setName] = useState('') + const [description, setDescription] = useState('') + + // gathering 데이터가 로드/변경되면 폼 리셋 + const gatheringId = gathering?.gatheringId + useEffect(() => { + if (gathering) { + setName(gathering.gatheringName) + setDescription(gathering.description ?? '') + } + // gatheringId 변경 시에만 리셋 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gatheringId]) + + const isValid = name.trim().length > 0 && name.trim().length <= MAX_NAME_LENGTH + + const getFormData = useCallback( + () => ({ + gatheringName: name.trim(), + description: description.trim() || undefined, + }), + [name, description] + ) + + return { + name, + setName, + description, + setDescription, + isValid, + getFormData, + } +} diff --git a/src/features/gatherings/hooks/useGatherings.ts b/src/features/gatherings/hooks/useGatherings.ts new file mode 100644 index 0000000..d962396 --- /dev/null +++ b/src/features/gatherings/hooks/useGatherings.ts @@ -0,0 +1,53 @@ +import { type InfiniteData, useInfiniteQuery } from '@tanstack/react-query' + +import { getGatherings } from '../gatherings.api' +import type { GatheringCursor, GatheringListResponse } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** 초기 로드 개수 (3열 × 4행) */ +const INITIAL_PAGE_SIZE = 12 +/** 추가 로드 개수 (3열 × 3행) */ +const NEXT_PAGE_SIZE = 9 + +/** 페이지 파라미터 타입: undefined = 첫 페이지, GatheringCursor = 다음 페이지 */ +type PageParam = GatheringCursor | undefined + +/** + * 내 모임 전체 목록 조회 훅 (무한 스크롤) + * + * @example + * ```tsx + * const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useGatherings() + * + * // 모임 목록 접근 + * const gatherings = data?.pages.flatMap(page => page.items) ?? [] + * + * // 다음 페이지 로드 + * if (hasNextPage) fetchNextPage() + * ``` + */ +export const useGatherings = () => { + return useInfiniteQuery< + GatheringListResponse, + Error, + InfiniteData, + readonly string[], + PageParam + >({ + queryKey: gatheringQueryKeys.lists(), + queryFn: async ({ pageParam }: { pageParam: PageParam }) => { + const isFirstPage = !pageParam + const response = await getGatherings({ + pageSize: isFirstPage ? INITIAL_PAGE_SIZE : NEXT_PAGE_SIZE, + cursorJoinedAt: pageParam?.joinedAt, + cursorId: pageParam?.gatheringMemberId, + }) + return response.data + }, + initialPageParam: undefined as PageParam, + getNextPageParam: (lastPage: GatheringListResponse): PageParam => { + if (!lastPage.hasNext || !lastPage.nextCursor) return undefined + return lastPage.nextCursor + }, + }) +} diff --git a/src/features/gatherings/hooks/useHandleJoinRequest.ts b/src/features/gatherings/hooks/useHandleJoinRequest.ts new file mode 100644 index 0000000..eae3dc9 --- /dev/null +++ b/src/features/gatherings/hooks/useHandleJoinRequest.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError, type ApiResponse } from '@/api' + +import { handleJoinRequest } from '../gatherings.api' +import type { ApproveType } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +type HandleJoinRequestVariables = { + gatheringId: number + memberId: number + approveType: ApproveType +} + +/** + * 가입 요청 승인/거절 mutation 훅 + */ +export const useHandleJoinRequest = () => { + const queryClient = useQueryClient() + + return useMutation, ApiError, HandleJoinRequestVariables>({ + mutationFn: ({ gatheringId, memberId, approveType }) => + handleJoinRequest(gatheringId, memberId, approveType), + onSuccess: (_, { gatheringId }) => { + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.detail(gatheringId) }) + }, + }) +} diff --git a/src/features/gatherings/hooks/useMeetingTabCounts.ts b/src/features/gatherings/hooks/useMeetingTabCounts.ts new file mode 100644 index 0000000..8556d31 --- /dev/null +++ b/src/features/gatherings/hooks/useMeetingTabCounts.ts @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query' + +import { getMeetingTabCounts } from '../gatherings.api' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 모임 약속 탭별 카운트 조회 훅 + * + * @param gatheringId - 모임 ID + * @returns 탭별 약속 카운트 + * + * @example + * ```tsx + * const { data: tabCounts } = useMeetingTabCounts(gatheringId) + * // tabCounts?.all, tabCounts?.upcoming, tabCounts?.done, tabCounts?.joined + * ``` + */ +export const useMeetingTabCounts = (gatheringId: number) => { + return useQuery({ + queryKey: gatheringQueryKeys.meetingTabCounts(gatheringId), + queryFn: async () => { + const response = await getMeetingTabCounts(gatheringId) + return response.data + }, + enabled: gatheringId > 0, + }) +} diff --git a/src/features/gatherings/hooks/useRemoveMember.ts b/src/features/gatherings/hooks/useRemoveMember.ts new file mode 100644 index 0000000..6768fa2 --- /dev/null +++ b/src/features/gatherings/hooks/useRemoveMember.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError, type ApiResponse } from '@/api' + +import { removeMember } from '../gatherings.api' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +type RemoveMemberVariables = { + gatheringId: number + userId: number +} + +/** + * 멤버 삭제(강퇴) mutation 훅 + */ +export const useRemoveMember = () => { + const queryClient = useQueryClient() + + return useMutation, ApiError, RemoveMemberVariables>({ + mutationFn: ({ gatheringId, userId }) => removeMember(gatheringId, userId), + onSuccess: (_, { gatheringId }) => { + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.detail(gatheringId) }) + }, + }) +} diff --git a/src/features/gatherings/hooks/useToggleFavorite.ts b/src/features/gatherings/hooks/useToggleFavorite.ts new file mode 100644 index 0000000..d793964 --- /dev/null +++ b/src/features/gatherings/hooks/useToggleFavorite.ts @@ -0,0 +1,109 @@ +import { type InfiniteData, useMutation, useQueryClient } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { toggleFavorite } from '../gatherings.api' +import type { + FavoriteGatheringListResponse, + GatheringDetailResponse, + GatheringListResponse, +} from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 모임 즐겨찾기 토글 훅 + * + * - Optimistic update 적용 + * - 실패 시 롤백 + */ +interface ToggleFavoriteContext { + previousLists: unknown + previousFavorites: unknown + previousDetail: unknown +} + +export const useToggleFavorite = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (gatheringId: number) => { + await toggleFavorite(gatheringId) + }, + onMutate: async (gatheringId) => { + // 진행 중인 쿼리 취소 + await Promise.all([ + queryClient.cancelQueries({ queryKey: gatheringQueryKeys.lists() }), + queryClient.cancelQueries({ queryKey: gatheringQueryKeys.favorites() }), + queryClient.cancelQueries({ queryKey: gatheringQueryKeys.detail(gatheringId) }), + ]) + + // 이전 데이터 스냅샷 + const previousLists = queryClient.getQueryData(gatheringQueryKeys.lists()) + const previousFavorites = queryClient.getQueryData(gatheringQueryKeys.favorites()) + const previousDetail = queryClient.getQueryData(gatheringQueryKeys.detail(gatheringId)) + + // Optimistic update - 목록에서 isFavorite 토글 + queryClient.setQueryData>( + gatheringQueryKeys.lists(), + (old) => { + if (!old) return old + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((item) => + item.gatheringId === gatheringId ? { ...item, isFavorite: !item.isFavorite } : item + ), + })), + } + } + ) + + // Optimistic update - 즐겨찾기 목록 + queryClient.setQueryData( + gatheringQueryKeys.favorites(), + (old) => { + if (!old) return old + const existing = old.gatherings.find((g) => g.gatheringId === gatheringId) + if (existing) { + // 즐겨찾기에서 제거 + return { + ...old, + gatherings: old.gatherings.filter((g) => g.gatheringId !== gatheringId), + } + } + return old + } + ) + + // Optimistic update - 상세 페이지 + queryClient.setQueryData( + gatheringQueryKeys.detail(gatheringId), + (old) => { + if (!old) return old + return { ...old, isFavorite: !old.isFavorite } + } + ) + + return { previousLists, previousFavorites, previousDetail } + }, + onError: (error, gatheringId, context) => { + console.error('Failed to toggle favorite:', { error, gatheringId }) + // 에러 시 롤백 + if (context?.previousLists) { + queryClient.setQueryData(gatheringQueryKeys.lists(), context.previousLists) + } + if (context?.previousFavorites) { + queryClient.setQueryData(gatheringQueryKeys.favorites(), context.previousFavorites) + } + if (context?.previousDetail) { + queryClient.setQueryData(gatheringQueryKeys.detail(gatheringId), context.previousDetail) + } + }, + onSettled: (_data, _error, gatheringId) => { + // 즐겨찾기 목록만 최신 데이터로 갱신 (전체 목록은 optimistic update로 충분) + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.favorites() }) + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.detail(gatheringId) }) + }, + }) +} diff --git a/src/features/gatherings/hooks/useUpdateGathering.ts b/src/features/gatherings/hooks/useUpdateGathering.ts new file mode 100644 index 0000000..c8de265 --- /dev/null +++ b/src/features/gatherings/hooks/useUpdateGathering.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError, type ApiResponse } from '@/api' + +import { updateGathering } from '../gatherings.api' +import type { GatheringUpdateRequest, GatheringUpdateResponse } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +type UpdateGatheringVariables = { + gatheringId: number + data: GatheringUpdateRequest +} + +/** + * 모임 정보 수정 mutation 훅 + */ +export const useUpdateGathering = () => { + const queryClient = useQueryClient() + + return useMutation, ApiError, UpdateGatheringVariables>({ + mutationFn: ({ gatheringId, data }) => updateGathering(gatheringId, data), + onSuccess: (_, { gatheringId }) => { + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.detail(gatheringId) }) + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.lists() }) + }, + }) +} diff --git a/src/features/gatherings/index.ts b/src/features/gatherings/index.ts index a356069..d53a639 100644 --- a/src/features/gatherings/index.ts +++ b/src/features/gatherings/index.ts @@ -1,15 +1,45 @@ // Hooks export * from './hooks' +// Components +export * from './components' + // API export * from './gatherings.api' +// Lib +export * from './lib/meetingStatus' + // Types export type { + ApproveType, CreateGatheringRequest, CreateGatheringResponse, + FavoriteGatheringListResponse, GatheringBase, + GatheringBookItem, + GatheringBookListResponse, GatheringByInviteCodeResponse, + GatheringCursor, + GatheringDetailResponse, GatheringJoinResponse, + GatheringListItem, + GatheringListResponse, + GatheringMeetingItem, + GatheringMeetingListResponse, + GatheringMember, + GatheringMemberListCursor, + GatheringMemberListResponse, GatheringMemberStatus, + GatheringStatus, + GatheringUpdateRequest, + GatheringUpdateResponse, + GatheringUserRole, + GetGatheringBooksParams, + GetGatheringMeetingsParams, + GetGatheringMembersParams, + GetGatheringsParams, + MeetingFilter, + MeetingTabCountsResponse, + MemberStatusFilter, } from './gatherings.types' diff --git a/src/features/gatherings/lib/meetingStatus.ts b/src/features/gatherings/lib/meetingStatus.ts new file mode 100644 index 0000000..306c566 --- /dev/null +++ b/src/features/gatherings/lib/meetingStatus.ts @@ -0,0 +1,55 @@ +import type { GatheringMeetingItem } from '../gatherings.types' + +/** 약속 표시 상태 타입 */ +export type MeetingDisplayStatus = 'UPCOMING' | 'IN_PROGRESS' | 'DONE' + +/** + * 약속 시간 기반 상태 계산 + */ +export const getMeetingDisplayStatus = ( + startDateTime: string, + endDateTime: string +): MeetingDisplayStatus => { + const now = new Date() + const start = new Date(startDateTime) + const end = new Date(endDateTime) + + if (now < start) return 'UPCOMING' + if (now >= start && now <= end) return 'IN_PROGRESS' + return 'DONE' +} + +/** + * 약속 정렬 함수 + * 1. 약속 중 → 최상단 + * 2. 예정 → 오늘 기준 가까운 순 + * 3. 종료 → 오늘 기준 가까운 순 + */ +export const sortMeetings = (meetings: GatheringMeetingItem[]): GatheringMeetingItem[] => { + return [...meetings].sort((a, b) => { + const statusA = getMeetingDisplayStatus(a.startDateTime, a.endDateTime) + const statusB = getMeetingDisplayStatus(b.startDateTime, b.endDateTime) + + // 약속 중이 최상단 + if (statusA === 'IN_PROGRESS' && statusB !== 'IN_PROGRESS') return -1 + if (statusA !== 'IN_PROGRESS' && statusB === 'IN_PROGRESS') return 1 + + // 예정 > 종료 순서 + if (statusA === 'UPCOMING' && statusB === 'DONE') return -1 + if (statusA === 'DONE' && statusB === 'UPCOMING') return 1 + + // 같은 상태 내에서 정렬 + const startA = new Date(a.startDateTime) + const startB = new Date(b.startDateTime) + + if (statusA === 'UPCOMING') { + // 예정: 가까운 미래 순 (오름차순) + return startA.getTime() - startB.getTime() + } else { + // 종료: 최근 종료 순 (내림차순) - endDateTime 기준 + const endA = new Date(a.endDateTime) + const endB = new Date(b.endDateTime) + return endB.getTime() - endA.getTime() + } + }) +} diff --git a/src/features/kakaomap/components/Map.tsx b/src/features/kakaomap/components/Map.tsx new file mode 100644 index 0000000..0af9208 --- /dev/null +++ b/src/features/kakaomap/components/Map.tsx @@ -0,0 +1,91 @@ +/** + * @file Map.tsx + * @description 카카오 지도 컨테이너 컴포넌트 + * + * 동작 흐름: + * 1.
DOM 생성 + * 2. useLayoutEffect: new kakao.maps.Map(div, options) 인스턴스 생성 (깜빡임 없음) + * 3. KakaoMapContext.Provider로 자식(MapMarker 등)에게 map 인스턴스 전달 + * 4. center/level props 변경 → SDK setter 직접 호출 (리렌더 없이 동기화) + * + * @example + * const [loading] = useKakaoLoader() + * if (loading) return + * + * return ( + * + * + * + * ) + */ + +import { useLayoutEffect, useRef, useState } from 'react' + +import { KakaoMapContext } from '../context/KakaoMapContext' +import type { KakaoMap } from '../kakaoMap.types' + +export type MapProps = { + /** 지도 중심 좌표 */ + center: { lat: number; lng: number } + /** 지도 줌 레벨 (기본값: 3) */ + level?: number + style?: React.CSSProperties + className?: string + /** 지도 인스턴스 생성 완료 콜백 — 외부에서 map 인스턴스에 직접 접근할 때 사용 */ + onCreate?: (map: KakaoMap) => void + children?: React.ReactNode +} + +export function Map({ center, level = 3, style, className, onCreate, children }: MapProps) { + const containerRef = useRef(null) + const [map, setMap] = useState(null) + + // onCreate 콜백을 ref로 안정화 — deps 변경 없이 항상 최신 참조 유지 + const onCreateRef = useRef(onCreate) + onCreateRef.current = onCreate + + // ── 최초 마운트 시 지도 인스턴스 생성 ──────────────────────── + // useLayoutEffect: DOM paint 직전 실행 → 깜빡임 없음 + useLayoutEffect(() => { + if (!containerRef.current || !window.kakao?.maps) return + + const kakaoMap = new window.kakao.maps.Map(containerRef.current, { + center: new window.kakao.maps.LatLng(center.lat, center.lng), + level, + }) + + setMap(kakaoMap) + onCreateRef.current?.(kakaoMap) + + return () => { + setMap(null) + } + // center/level 초기값은 한 번만 사용. 이후 변경은 아래 effect에서 SDK setter로 처리 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // ── center props 변경 → map.setCenter() 호출 ───────────── + useLayoutEffect(() => { + if (!map) return + map.setCenter(new window.kakao.maps.LatLng(center.lat, center.lng)) + }, [map, center.lat, center.lng]) + + // ── level props 변경 → map.setLevel() 호출 ─────────────── + useLayoutEffect(() => { + if (!map) return + map.setLevel(level) + }, [map, level]) + + return ( + +
+ {/* map 인스턴스가 준비된 후에만 자식 렌더링 */} + {map && children} + + ) +} diff --git a/src/features/kakaomap/components/MapMarker.tsx b/src/features/kakaomap/components/MapMarker.tsx new file mode 100644 index 0000000..44c225c --- /dev/null +++ b/src/features/kakaomap/components/MapMarker.tsx @@ -0,0 +1,87 @@ +/** + * @file MapMarker.tsx + * @description 카카오 지도 마커 공개 컴포넌트 + * + * Map 컴포넌트의 자식으로 사용합니다. + * position을 useMemo로 최적화하고, 실제 마커 로직은 Marker에 위임합니다. + * + * @example + * + * console.log(marker)} + * > + *
마커 내용
+ *
+ *
+ */ + +import { forwardRef, type PropsWithChildren, useMemo } from 'react' + +import { useKakaoMapContext } from '../context/KakaoMapContext' +import type { KakaoMarker } from '../kakaoMap.types' +import { Marker, type MarkerImageProp } from './Marker' + +export type MapMarkerProps = { + /** + * 마커 표시 좌표 + */ + position: { lat: number; lng: number } | { x: number; y: number } + + /** 마커 이미지 커스터마이징 */ + image?: MarkerImageProp + + /** 마커 툴팁 텍스트 */ + title?: string + + /** 드래그 가능 여부 */ + draggable?: boolean + + /** 클릭 가능 여부 */ + clickable?: boolean + + /** z-index */ + zIndex?: number + + /** 투명도 (0–1) */ + opacity?: number + + /** 마커 클릭 이벤트 */ + onClick?: (marker: KakaoMarker) => void + + /** 마커 마우스오버 이벤트 */ + onMouseOver?: (marker: KakaoMarker) => void + + /** 마커 마우스아웃 이벤트 */ + onMouseOut?: (marker: KakaoMarker) => void + + /** 마커 드래그 시작 이벤트 */ + onDragStart?: (marker: KakaoMarker) => void + + /** 마커 드래그 종료 이벤트 */ + onDragEnd?: (marker: KakaoMarker) => void + + /** 마커 생성 완료 콜백 */ + onCreate?: (marker: KakaoMarker) => void + + /** InfoWindow 옵션 */ + infoWindowOptions?: { + disableAutoPan?: boolean + removable?: boolean + zIndex?: number + } +} + +export const MapMarker = forwardRef>( + function MapMarker({ position, ...props }, ref) { + const map = useKakaoMapContext('MapMarker') + + // 복잡한 표현식을 변수로 추출해 useMemo deps를 정적으로 분석 가능하게 함 + const lat = 'lat' in position ? position.lat : position.y + const lng = 'lng' in position ? position.lng : position.x + + const markerPosition = useMemo(() => new window.kakao.maps.LatLng(lat, lng), [lat, lng]) + + return + } +) diff --git a/src/features/kakaomap/components/Marker.tsx b/src/features/kakaomap/components/Marker.tsx new file mode 100644 index 0000000..fc9446a --- /dev/null +++ b/src/features/kakaomap/components/Marker.tsx @@ -0,0 +1,207 @@ +/** + * @file Marker.tsx + * @description 카카오 마커 내부 구현 컴포넌트 + * + * MapMarker에서 위임받아 실제 kakao.maps.Marker 인스턴스를 관리합니다. + * - useMemo: 렌더 중 동기적으로 마커 인스턴스 생성 (타이밍 문제 없음) + * - useLayoutEffect: map 등록/해제 및 props setter 동기화 + * - useKakaoEvent: 이벤트 등록/해제 자동화 + * - createPortal: children을 CustomOverlay DOM에 주입 + * - forwardRef: 외부에서 마커 인스턴스에 직접 접근 가능 + */ + +import { + forwardRef, + type PropsWithChildren, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { createPortal } from 'react-dom' + +import type { + KakaoCustomOverlay, + KakaoLatLng, + KakaoMap, + KakaoMarker, + KakaoMarkerImageOptions, + KakaoSdkMarkerImage, +} from '../kakaoMap.types' +import { useKakaoEvent } from '../lib/useKakaoEvent' + +export type MarkerImageProp = { + src: string + size: { width: number; height: number } + options?: KakaoMarkerImageOptions +} + +export type MarkerProps = { + map: KakaoMap + position: KakaoLatLng + image?: MarkerImageProp + title?: string + draggable?: boolean + clickable?: boolean + zIndex?: number + opacity?: number + onClick?: (marker: KakaoMarker) => void + onMouseOver?: (marker: KakaoMarker) => void + onMouseOut?: (marker: KakaoMarker) => void + onDragStart?: (marker: KakaoMarker) => void + onDragEnd?: (marker: KakaoMarker) => void + onCreate?: (marker: KakaoMarker) => void + infoWindowOptions?: { + disableAutoPan?: boolean + removable?: boolean + zIndex?: number + } +} + +function buildMarkerImage(image: MarkerImageProp): KakaoSdkMarkerImage { + const { kakao } = window + const size = new kakao.maps.Size(image.size.width, image.size.height) + const options = image.options + ? { + ...image.options, + offset: image.options.offset + ? new kakao.maps.Point(image.options.offset.x, image.options.offset.y) + : undefined, + spriteOrigin: image.options.spriteOrigin + ? new kakao.maps.Point(image.options.spriteOrigin.x, image.options.spriteOrigin.y) + : undefined, + spriteSize: image.options.spriteSize + ? new kakao.maps.Size(image.options.spriteSize.width, image.options.spriteSize.height) + : undefined, + } + : undefined + return new kakao.maps.MarkerImage(image.src, size, options) +} + +export const Marker = forwardRef>(function Marker( + { + map, + position, + image, + title, + draggable, + clickable, + zIndex, + opacity, + onClick, + onMouseOver, + onMouseOut, + onDragStart, + onDragEnd, + onCreate, + children, + }, + ref +) { + const overlayRef = useRef(null) + const [overlayEl, setOverlayEl] = useState(null) + + const hasChildren = children != null + + // ── 마커 인스턴스 생성 ──────── + const marker = useMemo( + () => + new window.kakao.maps.Marker({ + position, + image: image ? buildMarkerImage(image) : undefined, + title, + draggable, + clickable, + zIndex, + opacity, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + + // 마커 인스턴스를 ref로 외부에 노출 + useImperativeHandle(ref, () => marker, [marker]) + + // ── map 등록/해제 ────────────────────────────────────────── + useLayoutEffect(() => { + marker.setMap(map) + return () => marker.setMap(null) + }, [map, marker]) + + // ── onCreate 콜백 ────────────────────────────────────────── + useLayoutEffect(() => { + onCreate?.(marker) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [marker]) + + // ── props 변경 → SDK setter 호출 ────────────────────────── + useLayoutEffect(() => { + marker.setPosition(position) + }, [marker, position]) + + useLayoutEffect(() => { + if (!image) return + marker.setImage(buildMarkerImage(image)) + }, [marker, image]) + + useLayoutEffect(() => { + if (title !== undefined) marker.setTitle(title) + }, [marker, title]) + + useLayoutEffect(() => { + if (draggable !== undefined) marker.setDraggable(draggable) + }, [marker, draggable]) + + useLayoutEffect(() => { + if (clickable !== undefined) marker.setClickable(clickable) + }, [marker, clickable]) + + useLayoutEffect(() => { + if (zIndex !== undefined) marker.setZIndex(zIndex) + }, [marker, zIndex]) + + useLayoutEffect(() => { + if (opacity !== undefined) marker.setOpacity(opacity) + }, [marker, opacity]) + + // ── 이벤트 자동 등록/해제 ───────────────────────────────── + useKakaoEvent(marker, 'click', onClick ? () => onClick(marker) : undefined) + useKakaoEvent(marker, 'mouseover', onMouseOver ? () => onMouseOver(marker) : undefined) + useKakaoEvent(marker, 'mouseout', onMouseOut ? () => onMouseOut(marker) : undefined) + useKakaoEvent(marker, 'dragstart', onDragStart ? () => onDragStart(marker) : undefined) + useKakaoEvent(marker, 'dragend', onDragEnd ? () => onDragEnd(marker) : undefined) + + // ── children → CustomOverlay + createPortal ─────────────── + useLayoutEffect(() => { + if (!hasChildren) return + + const { kakao } = window + const container = document.createElement('div') + + const overlay = new kakao.maps.CustomOverlay({ + position, + content: container, + map, + yAnchor: 1, + }) + + overlayRef.current = overlay + setOverlayEl(container) + + return () => { + overlay.setMap(null) + overlayRef.current = null + setOverlayEl(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map, hasChildren]) + + // overlay position 동기화 + useLayoutEffect(() => { + overlayRef.current?.setPosition(position) + }, [position]) + + if (!overlayEl || !hasChildren) return null + return createPortal(children, overlayEl) +}) diff --git a/src/features/kakaomap/components/ZoomControl.tsx b/src/features/kakaomap/components/ZoomControl.tsx new file mode 100644 index 0000000..3b7dcc3 --- /dev/null +++ b/src/features/kakaomap/components/ZoomControl.tsx @@ -0,0 +1,48 @@ +/** + * @file ZoomControl.tsx + * @description 카카오 지도 줌 컨트롤 컴포넌트 + * + * 내부에서 사용하며, KakaoMapContext로 map 인스턴스를 획득해 + * 줌 컨트롤을 추가/제거합니다. + * + * @example + * + * + * + */ + +import { forwardRef, useImperativeHandle, useLayoutEffect, useMemo } from 'react' + +import { useKakaoMapContext } from '../context/KakaoMapContext' +import type { KakaoControl } from '../kakaoMap.types' + +type KakaoControlPositionKey = keyof Window['kakao']['maps']['ControlPosition'] + +export type ZoomControlProps = { + /** ZoomControl 표시 위치 (기본값: 'RIGHT') */ + position?: number | KakaoControlPositionKey +} + +export const ZoomControl = forwardRef(function ZoomControl( + { position: _position = 'RIGHT' }, + ref +) { + const map = useKakaoMapContext('ZoomControl') + + const position = + typeof _position === 'string' ? window.kakao.maps.ControlPosition[_position] : _position + + const zoomControl = useMemo(() => new window.kakao.maps.ZoomControl(), []) + + useImperativeHandle(ref, () => zoomControl, [zoomControl]) + + useLayoutEffect(() => { + map.addControl(zoomControl, position) + + return () => { + map.removeControl(zoomControl) + } + }, [map, position, zoomControl]) + + return null +}) diff --git a/src/features/kakaomap/components/index.ts b/src/features/kakaomap/components/index.ts new file mode 100644 index 0000000..dc1cec3 --- /dev/null +++ b/src/features/kakaomap/components/index.ts @@ -0,0 +1,8 @@ +export type { MapProps } from './Map' +export { Map } from './Map' +export type { MapMarkerProps } from './MapMarker' +export { MapMarker } from './MapMarker' +export type { MarkerImageProp, MarkerProps } from './Marker' +export { Marker } from './Marker' +export type { ZoomControlProps } from './ZoomControl' +export { ZoomControl } from './ZoomControl' diff --git a/src/features/kakaomap/context/KakaoMapContext.ts b/src/features/kakaomap/context/KakaoMapContext.ts new file mode 100644 index 0000000..80231f2 --- /dev/null +++ b/src/features/kakaomap/context/KakaoMapContext.ts @@ -0,0 +1,27 @@ +/** + * @file KakaoMapContext.ts + * @description 카카오 map 인스턴스를 자식 컴포넌트에 전달하는 Context + * + * 컴포넌트가 Provider가 되고, 자식의 마커·인포윈도우 등이 + * useContext(KakaoMapContext)로 map 인스턴스를 획득합니다. + */ + +import { createContext, useContext } from 'react' + +import type { KakaoMap } from '../kakaoMap.types' + +export const KakaoMapContext = createContext(null) + +/** + * map 인스턴스가 필요한 자식 컴포넌트에서 사용합니다. + * 컴포넌트 내부가 아니라면 Error를 발생시킵니다. + */ +export function useKakaoMapContext(componentName?: string): KakaoMap { + const map = useContext(KakaoMapContext) + if (!map) { + throw new Error( + `${componentName ? componentName + ' Component' : 'useKakaoMapContext'} must exist inside Map Component!` + ) + } + return map +} diff --git a/src/features/kakaomap/context/index.ts b/src/features/kakaomap/context/index.ts new file mode 100644 index 0000000..878377f --- /dev/null +++ b/src/features/kakaomap/context/index.ts @@ -0,0 +1 @@ +export { KakaoMapContext, useKakaoMapContext } from './KakaoMapContext' diff --git a/src/features/kakaomap/hooks/index.ts b/src/features/kakaomap/hooks/index.ts new file mode 100644 index 0000000..7080059 --- /dev/null +++ b/src/features/kakaomap/hooks/index.ts @@ -0,0 +1,3 @@ +export { useKakaoLoader } from './useKakaoLoader' +export type { UseKakaoPlaceSearchOptions } from './useKakaoPlaceSearch' +export { useKakaoPlaceSearch } from './useKakaoPlaceSearch' diff --git a/src/features/kakaomap/hooks/useKakaoLoader.ts b/src/features/kakaomap/hooks/useKakaoLoader.ts new file mode 100644 index 0000000..6340f24 --- /dev/null +++ b/src/features/kakaomap/hooks/useKakaoLoader.ts @@ -0,0 +1,48 @@ +/** + * @file useKakaoLoader.ts + * @description 카카오 Maps SDK 로드 상태를 관리하는 훅 + * + * 내부적으로 KakaoMapApiLoader.load()를 호출하고 [loading, error] 상태를 반환합니다. + * loading이 false가 된 후에 컴포넌트를 렌더링해야 합니다. + * + * @example + * const [loading, error] = useKakaoLoader() + * if (loading) return + * if (error) return + * return + */ + +import { useEffect, useState } from 'react' + +import { KakaoMapApiLoader } from '../lib/kakaoMapApiLoader' + +const appkey = import.meta.env.VITE_KAKAO_MAP_KEY + +export function useKakaoLoader(): [loading: boolean, error: Error | null] { + const [state, setState] = useState<[loading: boolean, error: Error | null]>( + appkey ? [true, null] : [false, new Error('VITE_KAKAO_MAP_KEY 환경변수가 설정되지 않았습니다.')] + ) + + useEffect(() => { + if (!appkey) return + + const loader = KakaoMapApiLoader.getInstance({ + appkey, + libraries: ['services'], + }) + + loader + .load() + .then(() => { + setState([false, null]) + }) + .catch((err: unknown) => { + setState([ + false, + err instanceof Error ? err : new Error('카카오 지도 SDK 로드에 실패했습니다.'), + ]) + }) + }, []) + + return state +} diff --git a/src/features/kakaomap/hooks/useKakaoPlaceSearch.ts b/src/features/kakaomap/hooks/useKakaoPlaceSearch.ts new file mode 100644 index 0000000..1f1c25f --- /dev/null +++ b/src/features/kakaomap/hooks/useKakaoPlaceSearch.ts @@ -0,0 +1,81 @@ +/** + * @file useKakaoPlaceSearch.ts + * @description Kakao Places API 검색 로직 훅 + * + * initializeMap() 완료 후 호출해야 window.kakao.maps.services가 존재합니다. + */ + +import { useCallback, useRef, useState } from 'react' + +import type { KakaoPlace, KakaoPlacesService } from '../kakaoMap.types' + +export type UseKakaoPlaceSearchOptions = { + /** 검색 성공 콜백 */ + onSearchSuccess?: (places: KakaoPlace[]) => void + /** 검색 오류 콜백 */ + onSearchError?: (message: string) => void +} + +export function useKakaoPlaceSearch({ + onSearchSuccess, + onSearchError, +}: UseKakaoPlaceSearchOptions = {}) { + const [places, setPlaces] = useState([]) + const [error, setError] = useState(null) + + const placesServiceRef = useRef(null) + + const search = useCallback( + (searchKeyword: string) => { + const trimmedKeyword = searchKeyword.trim() + if (!trimmedKeyword) return false + + const { kakao } = window + if (!kakao?.maps?.services) { + console.warn('[카카오 장소 검색] SDK가 아직 준비되지 않았습니다.') + return false + } + + if (!placesServiceRef.current) { + placesServiceRef.current = new kakao.maps.services.Places() + } + + const ps = placesServiceRef.current + + ps.keywordSearch(trimmedKeyword, (data, status) => { + if (status === kakao.maps.services.Status.OK) { + setError(null) + setPlaces(data) + onSearchSuccess?.(data) + } else if (status === kakao.maps.services.Status.ZERO_RESULT) { + setError(null) + setPlaces([]) + onSearchSuccess?.([]) + } else { + const message = '검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + setError(message) + setPlaces([]) + onSearchError?.(message) + console.error('[카카오 장소 검색] 오류 발생 - status:', status) + } + }) + + return true + }, + [onSearchSuccess, onSearchError] + ) + + /** 검색 상태 초기화 */ + const reset = useCallback(() => { + setPlaces([]) + setError(null) + placesServiceRef.current = null + }, []) + + return { + places, + error, + search, + reset, + } +} diff --git a/src/features/kakaomap/index.ts b/src/features/kakaomap/index.ts new file mode 100644 index 0000000..6cbac28 --- /dev/null +++ b/src/features/kakaomap/index.ts @@ -0,0 +1,31 @@ +// Types +export type { + KakaoCustomOverlay, + KakaoCustomOverlayOptions, + KakaoInfoWindow, + KakaoLatLng, + KakaoLatLngBounds, + KakaoMap, + KakaoMarker, + KakaoMarkerImageOptions, + KakaoPlace, + KakaoPlacesService, + KakaoPoint, + KakaoSdkMarkerImage, + KakaoSearchMeta, + KakaoSearchParams, + KakaoSearchResponse, + KakaoSize, +} from './kakaoMap.types' + +// Context +export * from './context' + +// Lib +export * from './lib' + +// Hooks +export * from './hooks' + +// Components +export * from './components' diff --git a/src/features/kakaomap/kakaoMap.types.ts b/src/features/kakaomap/kakaoMap.types.ts new file mode 100644 index 0000000..1c8b527 --- /dev/null +++ b/src/features/kakaomap/kakaoMap.types.ts @@ -0,0 +1,230 @@ +/** + * @file kakaoMap.types.ts + * @description 카카오 Maps SDK 타입 정의 + * @note 외부 API 응답 스펙을 따르기 위해 snake_case 사용 + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +// ─── 카카오 장소 검색 응답 타입 ────────────────────────────────────────── + +/** 카카오 장소 검색 응답 문서 타입 */ +export type KakaoPlace = { + /** 장소명, 업체명 */ + place_name: string + /** 전체 지번 주소 */ + address_name: string + /** 전체 도로명 주소 */ + road_address_name: string + /** X 좌표값, 경도(longitude) */ + x: string + /** Y 좌표값, 위도(latitude) */ + y: string + /** 장소 ID */ + id: string + /** 카테고리 그룹 코드 */ + category_group_code: string + /** 카테고리 그룹명 */ + category_group_name: string + /** 카테고리 이름 */ + category_name: string + /** 전화번호 */ + phone: string + /** 장소 상세페이지 URL */ + place_url: string + /** 중심좌표까지의 거리 (단, x,y 파라미터를 준 경우에만 존재) */ + distance?: string +} + +/** 카카오 장소 검색 API 응답 메타 정보 */ +export type KakaoSearchMeta = { + total_count: number + pageable_count: number + is_end: boolean + same_name?: { + region: string[] + keyword: string + selected_region: string + } +} + +/** 카카오 장소 검색 API 응답 타입 */ +export type KakaoSearchResponse = { + documents: KakaoPlace[] + meta: KakaoSearchMeta +} + +/** 카카오 장소 검색 API 요청 파라미터 */ +export type KakaoSearchParams = { + query: string + category_group_code?: string + x?: string + y?: string + radius?: number + page?: number + size?: number + sort?: 'distance' | 'accuracy' +} + +// ─── 카카오 Maps SDK 내부 타입 ──────────────────────────────────────────── + +export interface KakaoLatLng { + getLat(): number + getLng(): number +} + +export interface KakaoLatLngBounds { + extend(latlng: KakaoLatLng): void + isEmpty(): boolean +} + +/** 카카오 SDK Size 객체 */ +export interface KakaoSize { + width: number + height: number +} + +/** 카카오 SDK Point 객체 */ +export interface KakaoPoint { + x: number + y: number +} + +/** kakao.maps.MarkerImage 인스턴스 */ +export type KakaoSdkMarkerImage = object + +/** MarkerImage 생성 옵션 */ +export interface KakaoMarkerImageOptions { + alt?: string + coords?: string + offset?: KakaoPoint + shape?: 'default' | 'rect' | 'circle' | 'poly' + spriteOrigin?: KakaoPoint + spriteSize?: KakaoSize +} + +export interface KakaoMarker { + setMap(map: KakaoMap | null): void + getPosition(): KakaoLatLng + setPosition(latlng: KakaoLatLng): void + setImage(image: KakaoSdkMarkerImage): void + setTitle(title: string): void + setDraggable(draggable: boolean): void + setClickable(clickable: boolean): void + setZIndex(zIndex: number): void + setOpacity(opacity: number): void +} + +export interface KakaoCustomOverlay { + setMap(map: KakaoMap | null): void + setContent(content: HTMLElement | string): void + setPosition(latlng: KakaoLatLng): void + getContent(): HTMLElement | string +} + +export interface KakaoCustomOverlayOptions { + map?: KakaoMap + position: KakaoLatLng + content?: HTMLElement | string + zIndex?: number + /** 마커 기준 Y 오프셋 (0: 하단, 1: 중앙, 2: 상단) */ + yAnchor?: number + xAnchor?: number +} + +export interface KakaoInfoWindow { + open(map: KakaoMap, marker: KakaoMarker): void + close(): void + setContent(content: string): void +} + +export interface KakaoMap { + setCenter(latlng: KakaoLatLng): void + setLevel(level: number): void + setBounds(bounds: KakaoLatLngBounds): void + relayout(): void + addControl(control: KakaoControl, position: number): void + removeControl(control: KakaoControl): void +} + +export type KakaoControl = object + +export interface KakaoMapOptions { + center: KakaoLatLng + level?: number +} + +export interface KakaoMarkerOptions { + position: KakaoLatLng + map?: KakaoMap + image?: KakaoSdkMarkerImage + title?: string + draggable?: boolean + clickable?: boolean + zIndex?: number + opacity?: number +} + +export interface KakaoInfoWindowOptions { + zIndex?: number + content?: string +} + +export interface KakaoPlacesService { + keywordSearch( + keyword: string, + callback: (data: KakaoPlace[], status: string) => void, + options?: Partial + ): void +} + +// ─── window.kakao 글로벌 타입 선언 ──────────────────────────────────────── + +declare global { + interface Window { + kakao: { + maps: { + Map: new (container: HTMLElement, options: KakaoMapOptions) => KakaoMap + LatLng: new (lat: number, lng: number) => KakaoLatLng + LatLngBounds: new () => KakaoLatLngBounds + Size: new (width: number, height: number) => KakaoSize + Point: new (x: number, y: number) => KakaoPoint + MarkerImage: new ( + src: string, + size: KakaoSize, + options?: KakaoMarkerImageOptions + ) => KakaoSdkMarkerImage + Marker: new (options: KakaoMarkerOptions) => KakaoMarker + CustomOverlay: new (options: KakaoCustomOverlayOptions) => KakaoCustomOverlay + InfoWindow: new (options?: KakaoInfoWindowOptions) => KakaoInfoWindow + MapTypeControl: new () => KakaoControl + ZoomControl: new () => KakaoControl + ControlPosition: { + TOPRIGHT: number + RIGHT: number + TOP: number + TOPLEFT: number + LEFT: number + BOTTOMLEFT: number + BOTTOM: number + BOTTOMRIGHT: number + } + event: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addListener(target: object, type: string, handler: (...args: any[]) => void): void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + removeListener(target: object, type: string, handler: (...args: any[]) => void): void + } + services: { + Places: new () => KakaoPlacesService + Status: { + OK: string + ZERO_RESULT: string + ERROR: string + } + } + load(callback: () => void): void + } + } + } +} diff --git a/src/features/kakaomap/lib/index.ts b/src/features/kakaomap/lib/index.ts new file mode 100644 index 0000000..dae0914 --- /dev/null +++ b/src/features/kakaomap/lib/index.ts @@ -0,0 +1,3 @@ +export type { KakaoMapLoaderOptions } from './kakaoMapApiLoader' +export { KakaoMapApiLoader } from './kakaoMapApiLoader' +export { useKakaoEvent } from './useKakaoEvent' diff --git a/src/features/kakaomap/lib/kakaoMapApiLoader.ts b/src/features/kakaomap/lib/kakaoMapApiLoader.ts new file mode 100644 index 0000000..99bdb87 --- /dev/null +++ b/src/features/kakaomap/lib/kakaoMapApiLoader.ts @@ -0,0 +1,202 @@ +/** + * @description KakaoMapApiLoader 클래스 + * + * - 동일 appkey/libraries 옵션이면 단일 인스턴스 재사용 + * - 옵션이 달라지면 에러 throw + * - 이미 window.kakao가 존재하면 즉시 resolve + * - 실패 시 지수 백오프(exponential backoff) 방식으로 자동 재시도 + */ + +type LoadState = 'INITIALIZED' | 'LOADING' | 'SUCCESS' | 'FAILURE' + +export type KakaoMapLoaderOptions = { + appkey: string + libraries?: string[] + /** 최대 재시도 횟수 (기본값: 3) */ + maxRetries?: number + /** 초기 재시도 대기 시간 ms (기본값: 500) */ + retryDelay?: number +} + +const KAKAO_STATUS_MESSAGES: Record = { + 400: '잘못된 요청입니다. API에 필요한 필수 파라미터를 확인해주세요. (400 Bad Request)', + 401: '인증 오류입니다. 앱키(VITE_KAKAO_MAP_KEY)가 올바른지 확인해주세요. (401 Unauthorized)', + 403: '권한 오류입니다. 앱 등록 및 도메인 설정을 확인해주세요. (403 Forbidden)', + 429: '쿼터를 초과했습니다. 정해진 사용량이나 초당 요청 한도를 초과했습니다. (429 Too Many Request)', + 500: '카카오 서버 내부 오류입니다. 잠시 후 다시 시도해주세요. (500 Internal Server Error)', + 502: '카카오 게이트웨이 오류입니다. 잠시 후 다시 시도해주세요. (502 Bad Gateway)', + 503: '카카오 서비스 점검 중입니다. 잠시 후 다시 시도해주세요. (503 Service Unavailable)', +} + +export class KakaoMapApiLoader { + private static instance: KakaoMapApiLoader | null = null + + private state: LoadState = 'INITIALIZED' + private promise: Promise | null = null + + private readonly appkey: string + private readonly libraries: string[] + private readonly maxRetries: number + private readonly retryDelay: number + + private constructor({ + appkey, + libraries = [], + maxRetries = 3, + retryDelay = 500, + }: KakaoMapLoaderOptions) { + this.appkey = appkey + this.libraries = libraries + this.maxRetries = maxRetries + this.retryDelay = retryDelay + } + + /** + * 싱글톤 인스턴스 반환 + * - 처음 호출 시 인스턴스 생성 + * - 이후 호출 시 options 없이 기존 인스턴스 반환 가능 + * - options가 달라지면 에러 throw + */ + static getInstance(options?: KakaoMapLoaderOptions): KakaoMapApiLoader { + if (!KakaoMapApiLoader.instance) { + if (!options) { + throw new Error('[KakaoMapLoader] 처음 호출 시 options가 필요합니다.') + } + KakaoMapApiLoader.instance = new KakaoMapApiLoader(options) + return KakaoMapApiLoader.instance + } + + if (options) { + const isSameKey = KakaoMapApiLoader.instance.appkey === options.appkey + const isSameLibs = + JSON.stringify(KakaoMapApiLoader.instance.libraries.sort()) === + JSON.stringify((options.libraries ?? []).sort()) + + if (!isSameKey || !isSameLibs) { + throw new Error( + '[KakaoMapLoader] appkey 또는 libraries 옵션이 기존 인스턴스와 다릅니다. 앱에서 동일한 옵션을 사용해야 합니다.' + ) + } + } + + return KakaoMapApiLoader.instance + } + + /** 테스트 등에서 인스턴스 초기화 시 사용 */ + static reset(): void { + KakaoMapApiLoader.instance = null + } + + /** SDK 스크립트 URL 생성 */ + private buildScriptUrl(): string { + const libs = this.libraries.join(',') + const libsQuery = libs ? `&libraries=${libs}` : '' + return `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${this.appkey}&autoload=false${libsQuery}` + } + + /** 지수 백오프 대기 */ + private wait(attempt: number): Promise { + const delay = this.retryDelay * Math.pow(2, attempt) + return new Promise((resolve) => setTimeout(resolve, delay)) + } + + /** script 태그 삽입 후 로드 시도 (단일 시도) */ + private tryLoad(): Promise { + return new Promise((resolve, reject) => { + const url = this.buildScriptUrl() + const script = document.createElement('script') + script.src = url + script.async = true + + script.onload = () => { + try { + window.kakao.maps.load(() => resolve()) + } catch { + script.remove() + reject(new Error('카카오 지도 SDK 초기화에 실패했습니다.')) + } + } + + script.onerror = () => { + script.remove() + // fetch로 실제 HTTP 상태 코드 확인 + fetch(url) + .then((res) => { + if (res.ok) { + reject( + new Error( + '카카오 지도 SDK 로드에 실패했습니다. 일시적인 네트워크 오류일 수 있으니 잠시 후 다시 시도해주세요.' + ) + ) + return + } + const message = + KAKAO_STATUS_MESSAGES[res.status] ?? + `카카오 지도 SDK 로드에 실패했습니다. (HTTP ${res.status})` + reject(new Error(message)) + }) + .catch(() => { + reject(new Error('카카오 지도 SDK를 로드할 수 없습니다. 네트워크 연결을 확인해주세요.')) + }) + } + + document.head.appendChild(script) + }) + } + + /** + * SDK 로드 (지수 백오프 재시도 포함) + * - 이미 SUCCESS 상태면 즉시 resolve + * - LOADING 중이면 동일 Promise 반환 + * - INITIALIZED / FAILURE 상태에서 새로 시도 + */ + load(): Promise { + // 이미 window.kakao가 존재하면 즉시 resolve + if (window.kakao?.maps) { + this.state = 'SUCCESS' + return Promise.resolve() + } + + if (this.state === 'SUCCESS') { + return Promise.resolve() + } + + // LOADING 중이면 동일 Promise 반환 + if (this.state === 'LOADING' && this.promise) { + return this.promise + } + + this.state = 'LOADING' + + this.promise = (async () => { + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + try { + await this.tryLoad() + this.state = 'SUCCESS' + return + } catch (err) { + const isLastAttempt = attempt === this.maxRetries + const message = err instanceof Error ? err.message : '알 수 없는 오류' + console.error( + `[KakaoMapLoader] 로드 실패 (시도 ${attempt + 1}/${this.maxRetries + 1}):`, + message + ) + + if (isLastAttempt) { + this.state = 'FAILURE' + this.promise = null + throw err + } + + await this.wait(attempt) + } + } + })() + + return this.promise + } + + get currentState(): LoadState { + return this.state + } +} diff --git a/src/features/kakaomap/lib/useKakaoEvent.ts b/src/features/kakaomap/lib/useKakaoEvent.ts new file mode 100644 index 0000000..da83745 --- /dev/null +++ b/src/features/kakaomap/lib/useKakaoEvent.ts @@ -0,0 +1,39 @@ +/** + * @file useKakaoEvent.ts + * @description 카카오 이벤트 자동 등록/해제 훅 + * + * 마운트 시 addListener, 언마운트 시 자동 removeListener + * 핸들러를 ref로 안정화하여 target이 바뀔 때만 재등록합니다. + * + * @example + * useKakaoEvent(marker, 'click', () => onClick?.(marker)) + */ + +import { useLayoutEffect, useRef } from 'react' + +export function useKakaoEvent( + target: T | null, + type: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handler: ((...args: any[]) => void) | undefined +) { + // 핸들러를 ref로 안정화 — target이 바뀔 때만 이벤트를 재등록하고, 핸들러 변경은 ref를 통해 반영 + const handlerRef = useRef(handler) + useLayoutEffect(() => { + handlerRef.current = handler + }, [handler]) + + useLayoutEffect(() => { + if (!target || !handlerRef.current) return + + const { kakao } = window + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stableHandler = (...args: any[]) => handlerRef.current?.(...args) + + kakao.maps.event.addListener(target, type, stableHandler) + + return () => { + kakao.maps.event.removeListener(target, type, stableHandler) + } + }, [target, type]) +} diff --git a/src/features/keywords/index.ts b/src/features/keywords/index.ts index 3d8b699..f1985d8 100644 --- a/src/features/keywords/index.ts +++ b/src/features/keywords/index.ts @@ -1,3 +1,5 @@ export * from './hooks' export * from './keywords.api' +export * from './keywords.endpoints' +export * from './keywords.mock' export * from './keywords.types' diff --git a/src/features/keywords/keywords.api.ts b/src/features/keywords/keywords.api.ts index f9405ae..eb5e48b 100644 --- a/src/features/keywords/keywords.api.ts +++ b/src/features/keywords/keywords.api.ts @@ -1,732 +1,16 @@ /** * @file keywords.api.ts - * @description 키워드 API 함수 + * @description 키워드 API 요청 함수 */ import { api } from '@/api' - -import type { GetKeywordsResponse } from './keywords.types' - -// ============================================================ -// Mock Data -// ============================================================ +import { KEYWORDS_ENDPOINTS } from '@/features/keywords/keywords.endpoints' +import { getMockKeywords } from '@/features/keywords/keywords.mock' +import type { GetKeywordsResponse } from '@/features/keywords/keywords.types' /** 목데이터 사용 여부 플래그 */ const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' -const mockKeywordsResponse: GetKeywordsResponse = { - keywords: [ - // ============================================================ - // 책 키워드 카테고리 (BOOK, level 1) - // ============================================================ - { - id: 1, - name: '인간관계', - type: 'BOOK', - parentId: null, - parentName: null, - level: 1, - sortOrder: 1, - isSelectable: false, - }, - { - id: 2, - name: '개인', - type: 'BOOK', - parentId: null, - parentName: null, - level: 1, - sortOrder: 2, - isSelectable: false, - }, - { - id: 3, - name: '삶과 죽음', - type: 'BOOK', - parentId: null, - parentName: null, - level: 1, - sortOrder: 3, - isSelectable: false, - }, - { - id: 4, - name: '사회', - type: 'BOOK', - parentId: null, - parentName: null, - level: 1, - sortOrder: 4, - isSelectable: false, - }, - { - id: 5, - name: '기타', - type: 'BOOK', - parentId: null, - parentName: null, - level: 1, - sortOrder: 5, - isSelectable: false, - }, - - // ============================================================ - // 인간관계 키워드 (level 2, parentId: 1) - // ============================================================ - { - id: 11, - name: '사랑', - type: 'BOOK', - parentId: 1, - parentName: '인간관계', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 12, - name: '관계', - type: 'BOOK', - parentId: 1, - parentName: '인간관계', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 13, - name: '가족', - type: 'BOOK', - parentId: 1, - parentName: '인간관계', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 14, - name: '우정', - type: 'BOOK', - parentId: 1, - parentName: '인간관계', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - { - id: 15, - name: '이별', - type: 'BOOK', - parentId: 1, - parentName: '인간관계', - level: 2, - sortOrder: 5, - isSelectable: true, - }, - - // ============================================================ - // 개인 키워드 (level 2, parentId: 2) - // ============================================================ - { - id: 21, - name: '성장', - type: 'BOOK', - parentId: 2, - parentName: '개인', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 22, - name: '자아', - type: 'BOOK', - parentId: 2, - parentName: '개인', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 23, - name: '고독', - type: 'BOOK', - parentId: 2, - parentName: '개인', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 24, - name: '선택', - type: 'BOOK', - parentId: 2, - parentName: '개인', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - { - id: 25, - name: '자유', - type: 'BOOK', - parentId: 2, - parentName: '개인', - level: 2, - sortOrder: 5, - isSelectable: true, - }, - - // ============================================================ - // 삶과 죽음 키워드 (level 2, parentId: 3) - // ============================================================ - { - id: 31, - name: '삶', - type: 'BOOK', - parentId: 3, - parentName: '삶과 죽음', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 32, - name: '죽음', - type: 'BOOK', - parentId: 3, - parentName: '삶과 죽음', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 33, - name: '상실', - type: 'BOOK', - parentId: 3, - parentName: '삶과 죽음', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 34, - name: '치유', - type: 'BOOK', - parentId: 3, - parentName: '삶과 죽음', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - { - id: 35, - name: '기억', - type: 'BOOK', - parentId: 3, - parentName: '삶과 죽음', - level: 2, - sortOrder: 5, - isSelectable: true, - }, - - // ============================================================ - // 사회 키워드 (level 2, parentId: 4) - // ============================================================ - { - id: 41, - name: '사회', - type: 'BOOK', - parentId: 4, - parentName: '사회', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 42, - name: '현실', - type: 'BOOK', - parentId: 4, - parentName: '사회', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 43, - name: '역사', - type: 'BOOK', - parentId: 4, - parentName: '사회', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 44, - name: '노동', - type: 'BOOK', - parentId: 4, - parentName: '사회', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - { - id: 45, - name: '여성', - type: 'BOOK', - parentId: 4, - parentName: '사회', - level: 2, - sortOrder: 5, - isSelectable: true, - }, - { - id: 46, - name: '윤리', - type: 'BOOK', - parentId: 4, - parentName: '사회', - level: 2, - sortOrder: 6, - isSelectable: true, - }, - - // ============================================================ - // 기타 키워드 (level 2, parentId: 5) - // ============================================================ - { - id: 51, - name: '청춘', - type: 'BOOK', - parentId: 5, - parentName: '기타', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 52, - name: '모험', - type: 'BOOK', - parentId: 5, - parentName: '기타', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 53, - name: '판타지', - type: 'BOOK', - parentId: 5, - parentName: '기타', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 54, - name: '추리', - type: 'BOOK', - parentId: 5, - parentName: '기타', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - - // ============================================================ - // 감상 키워드 카테고리 (IMPRESSION, level 1) - // ============================================================ - { - id: 101, - name: '긍정', - type: 'IMPRESSION', - parentId: null, - parentName: null, - level: 1, - sortOrder: 1, - isSelectable: false, - }, - { - id: 102, - name: '감상', - type: 'IMPRESSION', - parentId: null, - parentName: null, - level: 1, - sortOrder: 2, - isSelectable: false, - }, - { - id: 103, - name: '부정', - type: 'IMPRESSION', - parentId: null, - parentName: null, - level: 1, - sortOrder: 3, - isSelectable: false, - }, - - // ============================================================ - // 긍정 키워드 (level 2, parentId: 101) - // ============================================================ - { - id: 111, - name: '즐거운', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 112, - name: '감동적인', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 113, - name: '위로받은', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 114, - name: '뭉클한', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - { - id: 115, - name: '후련한', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 5, - isSelectable: true, - }, - { - id: 116, - name: '벅찬', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 6, - isSelectable: true, - }, - { - id: 117, - name: '안도한', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 7, - isSelectable: true, - }, - { - id: 118, - name: '희망이 생긴', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 8, - isSelectable: true, - }, - { - id: 119, - name: '설레는', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 9, - isSelectable: true, - }, - { - id: 120, - name: '흥미로운', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 10, - isSelectable: true, - }, - { - id: 121, - name: '빠져든', - type: 'IMPRESSION', - parentId: 101, - parentName: '긍정', - level: 2, - sortOrder: 11, - isSelectable: true, - }, - - // ============================================================ - // 감상 키워드 (level 2, parentId: 102) - // ============================================================ - { - id: 122, - name: '여운이 남는', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 123, - name: '먹먹한', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 124, - name: '울컥한', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 125, - name: '찡한', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - { - id: 126, - name: '그리운', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 5, - isSelectable: true, - }, - { - id: 127, - name: '익숙한', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 6, - isSelectable: true, - }, - { - id: 128, - name: '이해가 되는', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 7, - isSelectable: true, - }, - { - id: 129, - name: '의문이 남는', - type: 'IMPRESSION', - parentId: 102, - parentName: '감상', - level: 2, - sortOrder: 8, - isSelectable: true, - }, - - // ============================================================ - // 부정 키워드 (level 2, parentId: 103) - // ============================================================ - { - id: 131, - name: '지루한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 1, - isSelectable: true, - }, - { - id: 132, - name: '씁쓸한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 2, - isSelectable: true, - }, - { - id: 133, - name: '허무한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 3, - isSelectable: true, - }, - { - id: 134, - name: '찝찝한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 4, - isSelectable: true, - }, - { - id: 135, - name: '공허한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 5, - isSelectable: true, - }, - { - id: 136, - name: '서글픈', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 6, - isSelectable: true, - }, - { - id: 137, - name: '분노가 이는', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 7, - isSelectable: true, - }, - { - id: 138, - name: '복잡한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 8, - isSelectable: true, - }, - { - id: 139, - name: '허탈한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 9, - isSelectable: true, - }, - { - id: 140, - name: '불안한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 10, - isSelectable: true, - }, - { - id: 141, - name: '괴로운', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 11, - isSelectable: true, - }, - { - id: 142, - name: '안타까운', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 12, - isSelectable: true, - }, - { - id: 143, - name: '답답한', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 13, - isSelectable: true, - }, - { - id: 144, - name: '슬픈', - type: 'IMPRESSION', - parentId: 103, - parentName: '부정', - level: 2, - sortOrder: 14, - isSelectable: true, - }, - ], -} - -/** 목데이터 응답 지연 시뮬레이션 (ms) */ -const MOCK_DELAY = 500 - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -// ============================================================ -// API Functions -// ============================================================ - /** * 키워드 목록 조회 * @@ -741,9 +25,9 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) */ export async function getKeywords(): Promise { if (USE_MOCK) { - await delay(MOCK_DELAY) - return mockKeywordsResponse + await new Promise((resolve) => setTimeout(resolve, 500)) + return getMockKeywords() } - return api.get('/api/keywords') + return api.get(KEYWORDS_ENDPOINTS.LIST) } diff --git a/src/features/keywords/keywords.endpoints.ts b/src/features/keywords/keywords.endpoints.ts new file mode 100644 index 0000000..8235aa4 --- /dev/null +++ b/src/features/keywords/keywords.endpoints.ts @@ -0,0 +1,6 @@ +import { API_PATHS } from '@/api' + +export const KEYWORDS_ENDPOINTS = { + // 키워드 목록 조회 (GET /api/keywords) + LIST: API_PATHS.KEYWORDS, +} as const diff --git a/src/features/keywords/keywords.mock.ts b/src/features/keywords/keywords.mock.ts new file mode 100644 index 0000000..e6d0d5f --- /dev/null +++ b/src/features/keywords/keywords.mock.ts @@ -0,0 +1,723 @@ +/** + * @file keywords.mock.ts + * @description 키워드 API 목데이터 + */ + +import type { GetKeywordsResponse } from '@/features/keywords/keywords.types' + +/** + * 키워드 목록 조회 목데이터 + */ +const mockKeywordsResponse: GetKeywordsResponse = { + keywords: [ + // ============================================================ + // 책 키워드 카테고리 (BOOK, level 1) + // ============================================================ + { + id: 1, + name: '인간관계', + type: 'BOOK', + parentId: null, + parentName: null, + level: 1, + sortOrder: 1, + isSelectable: false, + }, + { + id: 2, + name: '개인', + type: 'BOOK', + parentId: null, + parentName: null, + level: 1, + sortOrder: 2, + isSelectable: false, + }, + { + id: 3, + name: '삶과 죽음', + type: 'BOOK', + parentId: null, + parentName: null, + level: 1, + sortOrder: 3, + isSelectable: false, + }, + { + id: 4, + name: '사회', + type: 'BOOK', + parentId: null, + parentName: null, + level: 1, + sortOrder: 4, + isSelectable: false, + }, + { + id: 5, + name: '기타', + type: 'BOOK', + parentId: null, + parentName: null, + level: 1, + sortOrder: 5, + isSelectable: false, + }, + + // ============================================================ + // 인간관계 키워드 (level 2, parentId: 1) + // ============================================================ + { + id: 42, + name: '사랑', + type: 'BOOK', + parentId: 1, + parentName: '인간관계', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 43, + name: '관계', + type: 'BOOK', + parentId: 1, + parentName: '인간관계', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 44, + name: '가족', + type: 'BOOK', + parentId: 1, + parentName: '인간관계', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 45, + name: '우정', + type: 'BOOK', + parentId: 1, + parentName: '인간관계', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + { + id: 46, + name: '이별', + type: 'BOOK', + parentId: 1, + parentName: '인간관계', + level: 2, + sortOrder: 5, + isSelectable: true, + }, + + // ============================================================ + // 개인 키워드 (level 2, parentId: 2) + // ============================================================ + { + id: 47, + name: '성장', + type: 'BOOK', + parentId: 2, + parentName: '개인', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 48, + name: '자아', + type: 'BOOK', + parentId: 2, + parentName: '개인', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 49, + name: '고독', + type: 'BOOK', + parentId: 2, + parentName: '개인', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 50, + name: '선택', + type: 'BOOK', + parentId: 2, + parentName: '개인', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + { + id: 51, + name: '자유', + type: 'BOOK', + parentId: 2, + parentName: '개인', + level: 2, + sortOrder: 5, + isSelectable: true, + }, + + // ============================================================ + // 삶과 죽음 키워드 (level 2, parentId: 3) + // ============================================================ + { + id: 52, + name: '삶', + type: 'BOOK', + parentId: 3, + parentName: '삶과 죽음', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 53, + name: '죽음', + type: 'BOOK', + parentId: 3, + parentName: '삶과 죽음', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 54, + name: '상실', + type: 'BOOK', + parentId: 3, + parentName: '삶과 죽음', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 55, + name: '치유', + type: 'BOOK', + parentId: 3, + parentName: '삶과 죽음', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + { + id: 56, + name: '기억', + type: 'BOOK', + parentId: 3, + parentName: '삶과 죽음', + level: 2, + sortOrder: 5, + isSelectable: true, + }, + + // ============================================================ + // 사회 키워드 (level 2, parentId: 4) + // ============================================================ + { + id: 57, + name: '사회', + type: 'BOOK', + parentId: 4, + parentName: '사회', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 58, + name: '현실', + type: 'BOOK', + parentId: 4, + parentName: '사회', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 59, + name: '역사', + type: 'BOOK', + parentId: 4, + parentName: '사회', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 60, + name: '노동', + type: 'BOOK', + parentId: 4, + parentName: '사회', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + { + id: 61, + name: '여성', + type: 'BOOK', + parentId: 4, + parentName: '사회', + level: 2, + sortOrder: 5, + isSelectable: true, + }, + { + id: 62, + name: '윤리', + type: 'BOOK', + parentId: 4, + parentName: '사회', + level: 2, + sortOrder: 6, + isSelectable: true, + }, + + // ============================================================ + // 기타 키워드 (level 2, parentId: 5) + // ============================================================ + { + id: 63, + name: '청춘', + type: 'BOOK', + parentId: 5, + parentName: '기타', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 64, + name: '모험', + type: 'BOOK', + parentId: 5, + parentName: '기타', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 65, + name: '판타지', + type: 'BOOK', + parentId: 5, + parentName: '기타', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 66, + name: '추리', + type: 'BOOK', + parentId: 5, + parentName: '기타', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + + // ============================================================ + // 감상 키워드 카테고리 (IMPRESSION, level 1) + // ============================================================ + { + id: 6, + name: '긍정', + type: 'IMPRESSION', + parentId: null, + parentName: null, + level: 1, + sortOrder: 1, + isSelectable: false, + }, + { + id: 7, + name: '감상', + type: 'IMPRESSION', + parentId: null, + parentName: null, + level: 1, + sortOrder: 2, + isSelectable: false, + }, + { + id: 8, + name: '부정', + type: 'IMPRESSION', + parentId: null, + parentName: null, + level: 1, + sortOrder: 3, + isSelectable: false, + }, + + // ============================================================ + // 긍정 키워드 (level 2, parentId: 6) + // ============================================================ + { + id: 9, + name: '즐거운', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 10, + name: '감동적인', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 11, + name: '위로받은', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 12, + name: '뭉클한', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + { + id: 13, + name: '후련한', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 5, + isSelectable: true, + }, + { + id: 14, + name: '벅찬', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 6, + isSelectable: true, + }, + { + id: 15, + name: '안도한', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 7, + isSelectable: true, + }, + { + id: 16, + name: '희망이 생긴', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 8, + isSelectable: true, + }, + { + id: 17, + name: '설레는', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 9, + isSelectable: true, + }, + { + id: 18, + name: '흥미로운', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 10, + isSelectable: true, + }, + { + id: 19, + name: '빠져든', + type: 'IMPRESSION', + parentId: 6, + parentName: '긍정', + level: 2, + sortOrder: 11, + isSelectable: true, + }, + + // ============================================================ + // 감상 키워드 (level 2, parentId: 7) + // ============================================================ + { + id: 20, + name: '여운이 남는', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 21, + name: '먹먹한', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 22, + name: '울컥한', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 23, + name: '찡한', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + { + id: 24, + name: '그리운', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 5, + isSelectable: true, + }, + { + id: 25, + name: '익숙한', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 6, + isSelectable: true, + }, + { + id: 26, + name: '이해가 되는', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 7, + isSelectable: true, + }, + { + id: 27, + name: '의문이 남는', + type: 'IMPRESSION', + parentId: 7, + parentName: '감상', + level: 2, + sortOrder: 8, + isSelectable: true, + }, + + // ============================================================ + // 부정 키워드 (level 2, parentId: 8) + // ============================================================ + { + id: 28, + name: '지루한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 1, + isSelectable: true, + }, + { + id: 29, + name: '씁쓸한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 2, + isSelectable: true, + }, + { + id: 30, + name: '허무한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 3, + isSelectable: true, + }, + { + id: 31, + name: '찝찝한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 4, + isSelectable: true, + }, + { + id: 32, + name: '공허한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 5, + isSelectable: true, + }, + { + id: 33, + name: '서글픈', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 6, + isSelectable: true, + }, + { + id: 34, + name: '분노가 이는', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 7, + isSelectable: true, + }, + { + id: 35, + name: '복잡한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 8, + isSelectable: true, + }, + { + id: 36, + name: '허탈한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 9, + isSelectable: true, + }, + { + id: 37, + name: '불안한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 10, + isSelectable: true, + }, + { + id: 38, + name: '괴로운', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 11, + isSelectable: true, + }, + { + id: 39, + name: '안타까운', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 12, + isSelectable: true, + }, + { + id: 40, + name: '답답한', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 13, + isSelectable: true, + }, + { + id: 41, + name: '슬픈', + type: 'IMPRESSION', + parentId: 8, + parentName: '부정', + level: 2, + sortOrder: 14, + isSelectable: true, + }, + ], +} + +/** + * 키워드 목록 조회 목데이터 반환 함수 + * + * @description + * 실제 API 호출을 시뮬레이션하여 키워드 목데이터를 반환합니다. + */ +export const getMockKeywords = (): GetKeywordsResponse => { + return mockKeywordsResponse +} diff --git a/src/features/meetings/components/MapModal.tsx b/src/features/meetings/components/MapModal.tsx deleted file mode 100644 index c9f5581..0000000 --- a/src/features/meetings/components/MapModal.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { MeetingLocation } from '@/features/meetings/meetings.types' -import { Modal, ModalBody, ModalContent, ModalHeader, ModalTitle } from '@/shared/ui' - -interface MapModalProps { - open: boolean - onOpenChange: (open: boolean) => void - location: MeetingLocation -} - -export default function MapModal({ open, onOpenChange, location }: MapModalProps) { - return ( - - - - {location.name} - - -
- 지도 API 연동 예정 -
-
-
-
- ) -} diff --git a/src/features/meetings/components/MeetingApprovalItem.tsx b/src/features/meetings/components/MeetingApprovalItem.tsx index 978467f..210edd9 100644 --- a/src/features/meetings/components/MeetingApprovalItem.tsx +++ b/src/features/meetings/components/MeetingApprovalItem.tsx @@ -16,6 +16,8 @@ import { useGlobalModalStore } from '@/store' export type MeetingApprovalItemProps = { /** 약속 승인 아이템 데이터 */ item: MeetingApprovalItemType + /** 모임 ID */ + gatheringId: number } /** @@ -24,13 +26,13 @@ export type MeetingApprovalItemProps = { * @description * 약속 승인 리스트의 개별 아이템을 렌더링합니다. */ -export default function MeetingApprovalItem({ item }: MeetingApprovalItemProps) { +export default function MeetingApprovalItem({ item, gatheringId }: MeetingApprovalItemProps) { const { meetingName, bookName, nickname, startDateTime, endDateTime, meetingStatus, meetingId } = item - const confirmMutation = useConfirmMeeting() - const rejectMutation = useRejectMeeting() - const deleteMutation = useDeleteMeeting() + const confirmMutation = useConfirmMeeting(gatheringId) + const rejectMutation = useRejectMeeting(gatheringId) + const deleteMutation = useDeleteMeeting(gatheringId) const isPending = confirmMutation.isPending || rejectMutation.isPending || deleteMutation.isPending const { openConfirm, openError } = useGlobalModalStore() @@ -41,6 +43,7 @@ export default function MeetingApprovalItem({ item }: MeetingApprovalItemProps) if (!confirmed) return confirmMutation.mutate(meetingId, { + //Todo : 동시간에 승인할 수 없다고 별도로 알려주면 좋을듯 onError: (error) => openError('에러', error.userMessage), }) } @@ -94,7 +97,7 @@ export default function MeetingApprovalItem({ item }: MeetingApprovalItemProps) 거절 ) : ( diff --git a/src/features/meetings/components/MeetingApprovalList.tsx b/src/features/meetings/components/MeetingApprovalList.tsx index 6039ab0..476958e 100644 --- a/src/features/meetings/components/MeetingApprovalList.tsx +++ b/src/features/meetings/components/MeetingApprovalList.tsx @@ -3,74 +3,50 @@ * @description 약속 승인 리스트 컴포넌트 */ -import { useEffect, useState } from 'react' -import { useNavigate } from 'react-router-dom' - -import { MeetingApprovalItem, type MeetingStatus, useMeetingApprovals } from '@/features/meetings' +import type { PaginatedResponse } from '@/api/types' +import { MeetingApprovalItem, type MeetingApprovalItemType } from '@/features/meetings' import { PAGE_SIZES } from '@/shared/constants' import { Pagination } from '@/shared/ui/Pagination' -import { useGlobalModalStore } from '@/store' export type MeetingApprovalListProps = { - /** 모임 식별자 */ + /** 약속 승인 리스트 데이터 */ + data: PaginatedResponse + /** 모임 ID */ gatheringId: number - /** 약속 상태 (PENDING: 확정 대기, CONFIRMED: 확정 완료) */ - status: MeetingStatus + /** 현재 페이지 */ + currentPage: number + /** 페이지 변경 핸들러 */ + onPageChange: (page: number) => void } -export default function MeetingApprovalList({ gatheringId, status }: MeetingApprovalListProps) { - const navigate = useNavigate() - const [currentPage, setCurrentPage] = useState(0) - const pageSize = PAGE_SIZES.MEETING_APPROVALS - const { openError } = useGlobalModalStore() - - const { data, isLoading, isError, error } = useMeetingApprovals({ - gatheringId, - status, - page: currentPage, - size: pageSize, - }) - - useEffect(() => { - if (isError) { - openError('에러', error.userMessage, () => { - navigate('/', { replace: true }) - }) - } - }, [isError, openError, error, navigate]) - - if (isLoading) { - return ( -
-

로딩 중...

-
- ) - } +export default function MeetingApprovalList({ + data, + gatheringId, + currentPage, + onPageChange, +}: MeetingApprovalListProps) { if (!data || data.items.length === 0) { return ( -
+

약속이 없습니다.

) } const { items, totalPages, totalCount } = data + const pageSize = PAGE_SIZES.MEETING_APPROVALS const showPagination = totalCount > pageSize return (
    {items.map((item) => ( - + ))}
{showPagination && ( - setCurrentPage(page)} - /> + )}
) diff --git a/src/features/meetings/components/MeetingApprovalListSkeleton.tsx b/src/features/meetings/components/MeetingApprovalListSkeleton.tsx new file mode 100644 index 0000000..9cf38d3 --- /dev/null +++ b/src/features/meetings/components/MeetingApprovalListSkeleton.tsx @@ -0,0 +1,25 @@ +/** + * @file MeetingApprovalListSkeleton.tsx + * @description 약속 승인 리스트 스켈레톤 컴포넌트 + */ + +export default function MeetingApprovalListSkeleton() { + const SKELETON_COUNT = 10 + + return ( +
    + {[...Array(SKELETON_COUNT).keys()].map((i) => ( +
  • +
    +
    +
    +
    +
    +
  • + ))} +
+ ) +} diff --git a/src/features/meetings/components/MeetingDetailButton.tsx b/src/features/meetings/components/MeetingDetailButton.tsx index 0a43a82..5246913 100644 --- a/src/features/meetings/components/MeetingDetailButton.tsx +++ b/src/features/meetings/components/MeetingDetailButton.tsx @@ -1,5 +1,9 @@ +import { useNavigate } from 'react-router-dom' + import { useCancelJoinMeeting, useJoinMeeting } from '@/features/meetings/hooks' import type { MeetingDetailActionStateType } from '@/features/meetings/meetings.types' +import { ROUTES } from '@/shared/constants' +import { showToast } from '@/shared/lib/toast' import { Button } from '@/shared/ui' import { useGlobalModalStore } from '@/store' @@ -7,6 +11,7 @@ interface MeetingDetailButtonProps { buttonLabel: string isEnabled: boolean type: MeetingDetailActionStateType + gatheringId: number meetingId: number } @@ -14,8 +19,10 @@ export default function MeetingDetailButton({ buttonLabel, isEnabled, type, + gatheringId, meetingId, }: MeetingDetailButtonProps) { + const navigate = useNavigate() const joinMutation = useJoinMeeting() const cancelJoinMutation = useCancelJoinMeeting() const { openError, openConfirm } = useGlobalModalStore() @@ -25,9 +32,9 @@ export default function MeetingDetailButton({ const handleClick = async () => { if (!isEnabled || isPending) return - // 약속 수정 - 페이지 이동 예정 (TODO) + // 약속 수정 if (type === 'CAN_EDIT') { - // 페이지 이동 로직 추가 예정 + navigate(ROUTES.MEETING_UPDATE(gatheringId, meetingId)) return } @@ -38,7 +45,7 @@ export default function MeetingDetailButton({ joinMutation.mutate(meetingId, { onSuccess: () => { - alert('참가 신청이 완료되었습니다.') + showToast('참가 신청이 완료되었습니다.') }, onError: (error) => { openError('에러', error.userMessage) @@ -54,7 +61,7 @@ export default function MeetingDetailButton({ cancelJoinMutation.mutate(meetingId, { onSuccess: () => { - alert('참가 취소가 완료되었습니다.') + showToast('참가 취소가 완료되었습니다.') }, onError: (error) => { openError('에러', error.userMessage) @@ -74,7 +81,7 @@ export default function MeetingDetailButton({ > {buttonLabel} - {isEnabled && ( + {!isEnabled && (

{type === 'EDIT_TIME_EXPIRED' && '약속 24시간 전까지만 약속 정보를 수정할 수 있어요'} {(type === 'CANCEL_TIME_EXPIRED' || type === 'JOIN_TIME_EXPIRED') && diff --git a/src/features/meetings/components/MeetingDetailHeader.tsx b/src/features/meetings/components/MeetingDetailHeader.tsx index ab2d34e..6c59a90 100644 --- a/src/features/meetings/components/MeetingDetailHeader.tsx +++ b/src/features/meetings/components/MeetingDetailHeader.tsx @@ -9,7 +9,10 @@ type ProgressBadge = { text: '약속 전' | '약속 중' | '약속 후' color: 'yellow' | 'blue' | 'red' } -export function MeetingDetailHeader({ children, progressStatus }: MeetingDetailHeaderProps) { +export default function MeetingDetailHeader({ + children, + progressStatus, +}: MeetingDetailHeaderProps) { const progressStatusLabelMap: Record = { PRE: { text: '약속 전', color: 'yellow' }, ONGOING: { text: '약속 중', color: 'red' }, diff --git a/src/features/meetings/components/MeetingDetailInfo.tsx b/src/features/meetings/components/MeetingDetailInfo.tsx index 797b12f..69b9bb8 100644 --- a/src/features/meetings/components/MeetingDetailInfo.tsx +++ b/src/features/meetings/components/MeetingDetailInfo.tsx @@ -1,7 +1,5 @@ import { MapPin } from 'lucide-react' -import { useState } from 'react' -import MapModal from '@/features/meetings/components/MapModal' import { Avatar, AvatarFallback, @@ -20,9 +18,7 @@ interface MeetingDetailInfoProps { meeting: GetMeetingDetailResponse } -export function MeetingDetailInfo({ meeting }: MeetingDetailInfoProps) { - const [isMapModalOpen, setIsMapModalOpen] = useState(false) - +export default function MeetingDetailInfo({ meeting }: MeetingDetailInfoProps) { const leader = meeting.participants.members.find((member) => member.role === 'LEADER') const members = meeting.participants.members.filter((member) => member.role === 'MEMBER') const displayedMembers = members.slice(0, MAX_DISPLAYED_AVATARS) @@ -32,14 +28,19 @@ export function MeetingDetailInfo({ meeting }: MeetingDetailInfoProps) { const [startDate, endDate] = meeting.schedule.displayDate.split(' ~ ') + const location = meeting.location + return (

{/* 도서 */}
도서
-
-

{meeting.book.bookName}

+
+
+

{meeting.book.bookName}

+

{meeting.book.authors}

+
장소
- {meeting.location && ( + {location && ( setIsMapModalOpen(true)} + onClick={() => { + window.open( + `https://map.kakao.com/link/map/${location.name},${location.latitude},${location.longitude}`, + '_blank', + 'noopener,noreferrer' + ) + }} > - {meeting.location.name} + {location.name} )}
- - {/* 지도 모달 */} - {meeting.location && ( - - )}
) } diff --git a/src/features/meetings/components/PlaceList.tsx b/src/features/meetings/components/PlaceList.tsx index 9f99b42..6af8c68 100644 --- a/src/features/meetings/components/PlaceList.tsx +++ b/src/features/meetings/components/PlaceList.tsx @@ -3,51 +3,47 @@ * @description 장소 검색 결과 목록 컴포넌트 */ -import type { KakaoPlace } from '../kakaoMap.types' +import type { KakaoPlace } from '@/features/kakaomap' +import { Button } from '@/shared/ui' export type PlaceListProps = { /** 장소 목록 */ places: KakaoPlace[] - /** 장소 클릭 핸들러 */ + /** li 클릭 시 지도 포커스 핸들러 */ + onPlaceFocus: (place: KakaoPlace) => void + /** 선택 버튼 클릭 핸들러 */ onPlaceClick: (place: KakaoPlace) => void - /** 장소 hover 핸들러 */ - onPlaceHover?: (place: KakaoPlace, index: number) => void - /** 장소 hover 종료 핸들러 */ - onPlaceHoverEnd?: () => void } -export default function PlaceList({ - places, - onPlaceClick, - onPlaceHover, - onPlaceHoverEnd, -}: PlaceListProps) { - if (places.length === 0) { - return ( -
-

검색 결과가 없습니다

-
- ) - } - +export default function PlaceList({ places, onPlaceFocus, onPlaceClick }: PlaceListProps) { return ( -
- {places.map((place, index) => ( - +
+

{place.place_name}

+ {place.category_group_name} +
+

{place.road_address_name}

+ +
+ +
+ ))} -
+ ) } diff --git a/src/features/meetings/components/PlaceListSkeleton.tsx b/src/features/meetings/components/PlaceListSkeleton.tsx new file mode 100644 index 0000000..f7cc107 --- /dev/null +++ b/src/features/meetings/components/PlaceListSkeleton.tsx @@ -0,0 +1,19 @@ +type PlaceListSkeletonProps = { + count?: number +} + +export default function PlaceListSkeleton({ count = 5 }: PlaceListSkeletonProps) { + return ( +
    + {[...Array(count).keys()].map((i) => ( +
  • +
    +
    +
    +
    +
    +
  • + ))} +
+ ) +} diff --git a/src/features/meetings/components/PlaceSearchModal.tsx b/src/features/meetings/components/PlaceSearchModal.tsx index dfd54f5..0dc5d2c 100644 --- a/src/features/meetings/components/PlaceSearchModal.tsx +++ b/src/features/meetings/components/PlaceSearchModal.tsx @@ -1,33 +1,17 @@ /** * @file PlaceSearchModal.tsx - * @description 카카오 장소 검색 모달 컴포넌트 + * @description 장소 검색 모달 컴포넌트 + * + * UI는 searchState 기준으로만 화면을 분기합니다. + * 모든 상태 관리와 비동기 로직은 usePlaceSearch 훅에서 처리합니다. */ -import { Search } from 'lucide-react' -import { useEffect, useRef } from 'react' - -import { - Button, - Input, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - ModalTitle, -} from '@/shared/ui' - -import { useKakaoMap } from '../hooks/useKakaoMap' -import { useKakaoPlaceSearch } from '../hooks/useKakaoPlaceSearch' -import type { KakaoPlace } from '../kakaoMap.types' -import PlaceList from './PlaceList' - -declare global { - interface Window { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - kakao: any - } -} +import { Map, MapMarker, ZoomControl } from '@/features/kakaomap' +import PlaceList from '@/features/meetings/components/PlaceList' +import PlaceListSkeleton from '@/features/meetings/components/PlaceListSkeleton' +import { usePlaceSearch } from '@/features/meetings/hooks' +import { cn } from '@/shared/lib/utils' +import { Modal, ModalBody, ModalContent, ModalHeader, ModalTitle, SearchField } from '@/shared/ui' export type PlaceSearchModalProps = { /** 모달 열림 상태 */ @@ -48,59 +32,21 @@ export default function PlaceSearchModal({ onOpenChange, onSelectPlace, }: PlaceSearchModalProps) { - // 지도 관리 - const { mapElement, isInitialized, initializeMap, renderMarkers, setCenter, cleanup } = - useKakaoMap() - - // 장소 검색 관리 - const keywordRef = useRef(null) - const { places, search, reset } = useKakaoPlaceSearch({ - onSearchSuccess: renderMarkers, - }) - - // 모달 열릴 때 지도 초기화 - useEffect(() => { - if (open && !isInitialized) { - initializeMap() - } - }, [open, isInitialized, initializeMap]) - - // 검색 실행 - const handleSearch = () => { - const keyword = keywordRef.current?.value || '' - search(keyword) - } - - // Enter 키 처리 - const handleKeyUp = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleSearch() - } - } - - // 장소 선택 - const handlePlaceClick = (place: KakaoPlace) => { - setCenter(Number(place.y), Number(place.x)) - - onSelectPlace({ - name: place.place_name, - address: place.road_address_name || place.address_name, - latitude: Number(place.y), - longitude: Number(place.x), - }) - - onOpenChange(false) - reset() - cleanup() - } - - // 모달 닫기 - const handleClose = () => { - onOpenChange(false) - reset() - cleanup() - } + const { + searchState, + errorMessage, + places, + isMapMounted, + isMapVisible, + hoveredPlaceId, + keywordRef, + setMapInstance, + setHoveredPlaceId, + handleKeyDown, + handlePlaceClick, + handlePlaceFocus, + handleClose, + } = usePlaceSearch({ open, onOpenChange, onSelectPlace }) return ( @@ -110,46 +56,68 @@ export default function PlaceSearchModal({ -
- - -
- -
- {/* 지도 영역 */} -
-
+ + + {/* 지도 + 리스트 영역 + isMapMounted: 첫 검색 전 / 에러는 마운트하지 않음 + isMapVisible: noResults일 때 Map 인스턴스를 유지한 채 CSS로만 숨김 */} + {isMapMounted && ( +
+ + + {places.map((place) => ( + setHoveredPlaceId(place.id)} + onMouseOut={() => setHoveredPlaceId(null)} + > + {hoveredPlaceId === place.id && ( +
+ {place.place_name} +
+ )} +
+ ))} +
+ +
+ {searchState === 'searching' ? ( + + ) : ( + + )} +
+
+ )} - {/* 검색 전 안내 메시지 오버레이 */} - {!isInitialized && ( -
-
- -

장소를 검색하면

-

지도에 표시됩니다

-
-
- )} + {/* 검색 결과 없음 */} + {searchState === 'noResults' && ( +
+

검색 결과가 없습니다

+ )} - {/* 장소 리스트 */} - -
+ {/* SDK 오류 또는 검색 오류 */} + {searchState === 'error' && ( +
+

{errorMessage}

+
+ )} - - - - ) diff --git a/src/features/meetings/components/index.ts b/src/features/meetings/components/index.ts index 272ec4f..68df722 100644 --- a/src/features/meetings/components/index.ts +++ b/src/features/meetings/components/index.ts @@ -1,8 +1,9 @@ -export { default as MapModal } from './MapModal' export { default as MeetingApprovalItem } from './MeetingApprovalItem' export { default as MeetingApprovalList } from './MeetingApprovalList' +export { default as MeetingApprovalListSkeleton } from './MeetingApprovalListSkeleton' export { default as MeetingDetailButton } from './MeetingDetailButton' -export { MeetingDetailHeader } from './MeetingDetailHeader' -export { MeetingDetailInfo } from './MeetingDetailInfo' +export { default as MeetingDetailHeader } from './MeetingDetailHeader' +export { default as MeetingDetailInfo } from './MeetingDetailInfo' export { default as PlaceList } from './PlaceList' +export { default as PlaceListSkeleton } from './PlaceListSkeleton' export { default as PlaceSearchModal } from './PlaceSearchModal' diff --git a/src/features/meetings/hooks/index.ts b/src/features/meetings/hooks/index.ts index 68684c0..9915b1a 100644 --- a/src/features/meetings/hooks/index.ts +++ b/src/features/meetings/hooks/index.ts @@ -1,13 +1,15 @@ export * from './meetingQueryKeys' +export * from './myMeetingQueryKeys' export * from './useCancelJoinMeeting' export * from './useConfirmMeeting' export * from './useCreateMeeting' export * from './useDeleteMeeting' export * from './useJoinMeeting' -export * from './useKakaoMap' -export * from './useKakaoPlaceSearch' export * from './useMeetingApprovals' -export * from './useMeetingApprovalsCount' export * from './useMeetingDetail' export * from './useMeetingForm' +export * from './useMyMeetings' +export * from './useMyMeetingTabCounts' +export * from './usePlaceSearch' export * from './useRejectMeeting' +export * from './useUpdateMeeting' diff --git a/src/features/meetings/hooks/myMeetingQueryKeys.ts b/src/features/meetings/hooks/myMeetingQueryKeys.ts new file mode 100644 index 0000000..cd6bfbc --- /dev/null +++ b/src/features/meetings/hooks/myMeetingQueryKeys.ts @@ -0,0 +1,8 @@ +import type { MyMeetingFilter } from '@/features/meetings/meetings.types' + +export const myMeetingQueryKeys = { + all: ['myMeetings'] as const, + lists: () => [...myMeetingQueryKeys.all, 'list'] as const, + list: (filter: MyMeetingFilter) => [...myMeetingQueryKeys.lists(), filter] as const, + tabCounts: () => [...myMeetingQueryKeys.all, 'tabCounts'] as const, +} diff --git a/src/features/meetings/hooks/useCancelJoinMeeting.ts b/src/features/meetings/hooks/useCancelJoinMeeting.ts index 3d80a28..d424b74 100644 --- a/src/features/meetings/hooks/useCancelJoinMeeting.ts +++ b/src/features/meetings/hooks/useCancelJoinMeeting.ts @@ -24,8 +24,7 @@ export const useCancelJoinMeeting = () => { return useMutation, ApiError, number>({ mutationFn: (meetingId: number) => cancelJoinMeeting(meetingId), - onSuccess: (data, variables) => { - void data // 사용하지 않는 파라미터 + onSuccess: (_data, variables) => { // 약속 상세 캐시 무효화 queryClient.invalidateQueries({ queryKey: meetingQueryKeys.detail(variables), diff --git a/src/features/meetings/hooks/useConfirmMeeting.ts b/src/features/meetings/hooks/useConfirmMeeting.ts index 1fa80ac..86b0a95 100644 --- a/src/features/meetings/hooks/useConfirmMeeting.ts +++ b/src/features/meetings/hooks/useConfirmMeeting.ts @@ -5,8 +5,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { ApiError } from '@/api/errors' -import type { ApiResponse } from '@/api/types' +import type { ApiError, ApiResponse } from '@/api' +import { gatheringQueryKeys } from '@/features/gatherings' import { confirmMeeting, type ConfirmMeetingResponse } from '@/features/meetings' import { meetingQueryKeys } from './meetingQueryKeys' @@ -18,12 +18,13 @@ import { meetingQueryKeys } from './meetingQueryKeys' * 약속을 승인하고 관련 쿼리 캐시를 무효화합니다. * - 약속 승인 리스트 캐시 무효화 * - 약속 승인 카운트 캐시 무효화 + * - 모임 약속 리스트 캐시 무효화 * * @example - * const confirmMutation = useConfirmMeeting() + * const confirmMutation = useConfirmMeeting(gatheringId) * confirmMutation.mutate(meetingId) */ -export const useConfirmMeeting = () => { +export const useConfirmMeeting = (gatheringId: number) => { const queryClient = useQueryClient() return useMutation, ApiError, number>({ @@ -33,6 +34,8 @@ export const useConfirmMeeting = () => { queryClient.invalidateQueries({ queryKey: meetingQueryKeys.approvals(), }) + // 모임 약속 리스트 캐시 무효화 + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.meetings(gatheringId) }) }, }) } diff --git a/src/features/meetings/hooks/useCreateMeeting.ts b/src/features/meetings/hooks/useCreateMeeting.ts index e56733b..6610439 100644 --- a/src/features/meetings/hooks/useCreateMeeting.ts +++ b/src/features/meetings/hooks/useCreateMeeting.ts @@ -20,20 +20,6 @@ import { meetingQueryKeys } from './meetingQueryKeys' * * @description * 새로운 약속을 생성하고 관련 쿼리 캐시를 무효화합니다. - * - 약속 승인 리스트 캐시 무효화 - * - 약속 승인 카운트 캐시 무효화 - * - * @example - * const createMutation = useCreateMeeting() - * createMutation.mutate({ - * gatheringId: 1, - * bookId: 1, - * meetingName: '1월 독서 모임', - * meetingStartDate: '2025-02-01T14:00:00', - * meetingEndDate: '2025-02-01T16:00:00', - * maxParticipants: 10, - * place: '강남역 스타벅스' - * }) */ export const useCreateMeeting = () => { const queryClient = useQueryClient() @@ -41,7 +27,7 @@ export const useCreateMeeting = () => { return useMutation, ApiError, CreateMeetingRequest>({ mutationFn: (data: CreateMeetingRequest) => createMeeting(data), onSuccess: () => { - // 약속 승인 관련 모든 캐시 무효화 (리스트 + 카운트) + // 약속 승인 관련 모든 캐시 무효화 queryClient.invalidateQueries({ queryKey: meetingQueryKeys.approvals(), }) diff --git a/src/features/meetings/hooks/useDeleteMeeting.ts b/src/features/meetings/hooks/useDeleteMeeting.ts index 4f09f3e..561caee 100644 --- a/src/features/meetings/hooks/useDeleteMeeting.ts +++ b/src/features/meetings/hooks/useDeleteMeeting.ts @@ -7,6 +7,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { ApiError } from '@/api/errors' import type { ApiResponse } from '@/api/types' +import { gatheringQueryKeys } from '@/features/gatherings' import { deleteMeeting } from '@/features/meetings' import { meetingQueryKeys } from './meetingQueryKeys' @@ -23,7 +24,7 @@ import { meetingQueryKeys } from './meetingQueryKeys' * const deleteMutation = useDeleteMeeting() * deleteMutation.mutate(meetingId) */ -export const useDeleteMeeting = () => { +export const useDeleteMeeting = (gatheringId: number) => { const queryClient = useQueryClient() return useMutation, ApiError, number>({ @@ -33,6 +34,7 @@ export const useDeleteMeeting = () => { queryClient.invalidateQueries({ queryKey: meetingQueryKeys.approvals(), }) + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.meetings(gatheringId) }) }, }) } diff --git a/src/features/meetings/hooks/useJoinMeeting.ts b/src/features/meetings/hooks/useJoinMeeting.ts index 34aa980..ea63b82 100644 --- a/src/features/meetings/hooks/useJoinMeeting.ts +++ b/src/features/meetings/hooks/useJoinMeeting.ts @@ -24,8 +24,7 @@ export const useJoinMeeting = () => { return useMutation, ApiError, number>({ mutationFn: (meetingId: number) => joinMeeting(meetingId), - onSuccess: (data, variables) => { - void data // 사용하지 않는 파라미터 + onSuccess: (_data, variables) => { // 약속 상세 캐시 무효화 queryClient.invalidateQueries({ queryKey: meetingQueryKeys.detail(variables), diff --git a/src/features/meetings/hooks/useKakaoMap.ts b/src/features/meetings/hooks/useKakaoMap.ts deleted file mode 100644 index 9d67bee..0000000 --- a/src/features/meetings/hooks/useKakaoMap.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * @file useKakaoMap.ts - * @description Kakao Maps 지도 및 마커 관리 훅 - */ - -import { useRef, useState } from 'react' - -import type { KakaoPlace } from '../kakaoMap.types' - -export type UseKakaoMapOptions = { - /** 초기 중심 좌표 */ - initialCenter?: { lat: number; lng: number } - /** 초기 줌 레벨 */ - initialLevel?: number -} - -export function useKakaoMap({ initialCenter, initialLevel = 3 }: UseKakaoMapOptions = {}) { - const [mapElement, setMapElement] = useState(null) - const [isInitialized, setIsInitialized] = useState(false) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mapRef = useRef(null) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const markersRef = useRef([]) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const infowindowRef = useRef(null) - - const defaultCenter = useRef(initialCenter ?? { lat: 37.566826, lng: 126.9786567 }) - - // 마커 제거 - const clearMarkers = () => { - markersRef.current.forEach((marker) => { - marker.setMap(null) - }) - markersRef.current = [] - } - - // 인포윈도우 닫기 - const closeInfoWindow = () => { - infowindowRef.current?.close() - } - - // HTML escape 유틸리티 - const escapeHtml = (text: string) => { - const div = document.createElement('div') - div.textContent = text - return div.innerHTML - } - - // 인포윈도우 열기 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const openInfoWindow = (marker: any, title: string) => { - if (!mapRef.current || !infowindowRef.current) return - const escapedTitle = escapeHtml(title) - infowindowRef.current.setContent(`
${escapedTitle}
`) - infowindowRef.current.open(mapRef.current, marker) - } - - // 지도 수동 초기화 - const initializeMap = () => { - if (!mapElement) { - console.warn('Map element not ready') - return false - } - - if (mapRef.current) { - // 이미 초기화된 경우 relayout만 실행 - mapRef.current.relayout() - return true - } - - const kakao = window.kakao - - if (!kakao?.maps) { - console.error('Kakao Maps SDK not loaded') - return false - } - - // 지도 생성 - const map = new kakao.maps.Map(mapElement, { - center: new kakao.maps.LatLng(defaultCenter.current.lat, defaultCenter.current.lng), - level: initialLevel, - }) - - mapRef.current = map - - infowindowRef.current = new kakao.maps.InfoWindow({ zIndex: 1 }) - setIsInitialized(true) - - // Portal/Modal에서 사이즈 계산 이슈 방지 - setTimeout(() => { - map.relayout() - map.setCenter(new kakao.maps.LatLng(defaultCenter.current.lat, defaultCenter.current.lng)) - }, 0) - - return true - } - - // 지도 정리 - const cleanup = () => { - clearMarkers() - closeInfoWindow() - mapRef.current = null - infowindowRef.current = null - setIsInitialized(false) - } - - // 장소 목록에 대한 마커 렌더링 - const renderMarkers = (places: KakaoPlace[]) => { - if (!mapRef.current || !window.kakao) return - - const kakao = window.kakao - const map = mapRef.current - - clearMarkers() - closeInfoWindow() - - const bounds = new kakao.maps.LatLngBounds() - - places.forEach((place) => { - const position = new kakao.maps.LatLng(Number(place.y), Number(place.x)) - - const marker = new kakao.maps.Marker({ - position, - map, - }) - - // 마커 hover 이벤트 - kakao.maps.event.addListener(marker, 'mouseover', () => { - openInfoWindow(marker, place.place_name) - }) - kakao.maps.event.addListener(marker, 'mouseout', () => { - closeInfoWindow() - }) - - markersRef.current.push(marker) - bounds.extend(position) - }) - - // 마커들이 모두 보이도록 bounds 조정 - if (places.length > 0) { - map.setBounds(bounds) - } - } - - // 특정 좌표로 지도 중심 이동 - const setCenter = (lat: number, lng: number) => { - if (!mapRef.current || !window.kakao) return - const kakao = window.kakao - const position = new kakao.maps.LatLng(lat, lng) - mapRef.current.setCenter(position) - } - - return { - mapElement: setMapElement, - isInitialized, - initializeMap, - renderMarkers, - closeInfoWindow, - openInfoWindow, - setCenter, - cleanup, - } -} diff --git a/src/features/meetings/hooks/useKakaoPlaceSearch.ts b/src/features/meetings/hooks/useKakaoPlaceSearch.ts deleted file mode 100644 index e9829c8..0000000 --- a/src/features/meetings/hooks/useKakaoPlaceSearch.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * @file useKakaoPlaceSearch.ts - * @description Kakao Places API 검색 로직 훅 - */ - -import { useRef, useState } from 'react' - -import type { KakaoPlace } from '../kakaoMap.types' - -export type UseKakaoPlaceSearchOptions = { - /** 검색 성공 콜백 */ - onSearchSuccess?: (places: KakaoPlace[]) => void -} - -export function useKakaoPlaceSearch({ onSearchSuccess }: UseKakaoPlaceSearchOptions = {}) { - const [places, setPlaces] = useState([]) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const placesServiceRef = useRef(null) - - // 검색 실행 - const search = (searchKeyword: string) => { - if (!searchKeyword.trim()) { - return false - } - - const kakao = window.kakao - if (!kakao?.maps?.services) { - return false - } - - // Places 서비스 - if (!placesServiceRef.current) { - placesServiceRef.current = new kakao.maps.services.Places() - } - - const ps = placesServiceRef.current - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ps.keywordSearch(searchKeyword, (data: KakaoPlace[], status: any) => { - if (status === kakao.maps.services.Status.OK) { - setPlaces(data) - onSearchSuccess?.(data) - } else if (status === kakao.maps.services.Status.ZERO_RESULT) { - setPlaces([]) - onSearchSuccess?.([]) - } else { - setPlaces([]) - onSearchSuccess?.([]) - alert('검색 중 오류가 발생했습니다.') - } - }) - - return true - } - - // 검색 상태 초기화 - const reset = () => { - setPlaces([]) - placesServiceRef.current = null - } - - return { - places, - search, - reset, - } -} diff --git a/src/features/meetings/hooks/useMeetingApprovals.ts b/src/features/meetings/hooks/useMeetingApprovals.ts index 6c0ce37..7db0abd 100644 --- a/src/features/meetings/hooks/useMeetingApprovals.ts +++ b/src/features/meetings/hooks/useMeetingApprovals.ts @@ -40,13 +40,11 @@ import { meetingQueryKeys } from './meetingQueryKeys' * }) */ export const useMeetingApprovals = (params: GetMeetingApprovalsParams) => { - const isValidGatheringId = !Number.isNaN(params.gatheringId) && params.gatheringId > 0 - return useQuery, ApiError>({ queryKey: meetingQueryKeys.approvalList(params), queryFn: () => getMeetingApprovals(params), // gatheringId가 유효할 때만 쿼리 실행 - enabled: isValidGatheringId, + enabled: params.gatheringId > 0, // 캐시 데이터 10분간 유지 (전역 설정 staleTime: 5분 사용) gcTime: 10 * 60 * 1000, }) diff --git a/src/features/meetings/hooks/useMeetingApprovalsCount.ts b/src/features/meetings/hooks/useMeetingApprovalsCount.ts deleted file mode 100644 index 41ef067..0000000 --- a/src/features/meetings/hooks/useMeetingApprovalsCount.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @file useMeetingApprovalsCount.ts - * @description 약속 승인 카운트 조회 훅 - */ - -import { useQueries } from '@tanstack/react-query' - -import type { PaginatedResponse } from '@/api/types' -import { getMeetingApprovals, type MeetingApprovalItemType } from '@/features/meetings' - -import { meetingQueryKeys } from './meetingQueryKeys' - -/** - * 약속 승인 카운트 일괄 조회 훅 - * - * @description - * PENDING과 CONFIRMED 상태의 카운트를 병렬로 조회합니다. - * size=1로 요청하여 totalCount만 효율적으로 가져옵니다. - * 두 개의 쿼리를 useQueries로 한 번에 처리하여 코드 간결성을 높입니다. - * - * @param gatheringId - 모임 식별자 (유효하지 않은 경우 쿼리 비활성화) - * - * @returns 카운트 및 로딩/에러 상태 객체 - * - pendingCount: PENDING 상태 카운트 - * - confirmedCount: CONFIRMED 상태 카운트 - * - isPendingLoading: PENDING 로딩 상태 - * - isConfirmedLoading: CONFIRMED 로딩 상태 - * - isLoading: 둘 중 하나라도 로딩 중인지 여부 - * - pendingError: PENDING 에러 객체 - * - confirmedError: CONFIRMED 에러 객체 - * - isError: 둘 중 하나라도 에러가 발생했는지 여부 - * - * @example - * const { pendingCount, confirmedCount, isLoading, isError } = useMeetingApprovalsCount(1) - */ -export const useMeetingApprovalsCount = (gatheringId: number) => { - const isValidGatheringId = !Number.isNaN(gatheringId) && gatheringId > 0 - - const results = useQueries({ - queries: [ - { - queryKey: meetingQueryKeys.approvalCount(gatheringId, 'PENDING'), - queryFn: () => - getMeetingApprovals({ - gatheringId, - status: 'PENDING', - page: 0, - size: 1, - }), - enabled: isValidGatheringId, - select: (data: PaginatedResponse) => data.totalCount, - gcTime: 10 * 60 * 1000, - }, - { - queryKey: meetingQueryKeys.approvalCount(gatheringId, 'CONFIRMED'), - queryFn: () => - getMeetingApprovals({ - gatheringId, - status: 'CONFIRMED', - page: 0, - size: 1, - }), - enabled: isValidGatheringId, - select: (data: PaginatedResponse) => data.totalCount, - gcTime: 10 * 60 * 1000, - }, - ], - }) - - return { - pendingCount: results[0].data, - confirmedCount: results[1].data, - isPendingLoading: results[0].isLoading, - isConfirmedLoading: results[1].isLoading, - isLoading: results[0].isLoading || results[1].isLoading, - pendingError: results[0].error, - confirmedError: results[1].error, - isError: results[0].isError || results[1].isError, - } -} diff --git a/src/features/meetings/hooks/useMeetingForm.ts b/src/features/meetings/hooks/useMeetingForm.ts index fcc8b10..a1ec808 100644 --- a/src/features/meetings/hooks/useMeetingForm.ts +++ b/src/features/meetings/hooks/useMeetingForm.ts @@ -1,14 +1,19 @@ -import { useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { type SearchBookItem } from '@/features/book' import { combineDateAndTime, + extractTime, formatScheduleRange, generateTimeOptions, + type GetMeetingDetailResponse, isStartBeforeEnd, } from '@/features/meetings' type UseMeetingFormParams = { gatheringMaxCount: number + /** 수정 모드일 때 초기값 */ + initialData?: GetMeetingDetailResponse | null } type ValidationErrors = { @@ -18,22 +23,82 @@ type ValidationErrors = { location?: string | null } -export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => { - // 폼 상태 - const [locationName, setLocationName] = useState(null) - const [locationAddress, setLocationAddress] = useState(null) - const [latitude, setLatitude] = useState(null) - const [longitude, setLongitude] = useState(null) - const [meetingName, setMeetingName] = useState(null) - const [bookId, setBookId] = useState(null) - const [bookName, setBookName] = useState(null) - const [maxParticipants, setMaxParticipants] = useState(null) - - // 날짜/시간 상태 - const [startDate, setStartDate] = useState(null) - const [startTime, setStartTime] = useState(null) - const [endDate, setEndDate] = useState(null) - const [endTime, setEndTime] = useState(null) +/** + * 폼 데이터 타입 + */ +type FormData = { + locationName: string | null + locationAddress: string | null + latitude: number | null + longitude: number | null + meetingName: string | null + bookId: string | null + bookName: string | null + bookThumbnail: string | null + bookAuthors: string | null + bookPublisher: string | null + maxParticipants: string | null + startDate: Date | null + startTime: string | null + endDate: Date | null + endTime: string | null +} + +/** + * 초기 폼 데이터 생성 + */ +const getInitialFormData = (initialData?: GetMeetingDetailResponse | null): FormData => { + if (!initialData) { + return { + locationName: null, + locationAddress: null, + latitude: null, + longitude: null, + meetingName: null, + bookId: null, + bookName: null, + bookThumbnail: null, + bookAuthors: null, + bookPublisher: null, + maxParticipants: null, + startDate: null, + startTime: null, + endDate: null, + endTime: null, + } + } + + return { + locationName: initialData.location?.name ?? null, + locationAddress: initialData.location?.address ?? null, + latitude: initialData.location?.latitude ?? null, + longitude: initialData.location?.longitude ?? null, + meetingName: initialData.meetingName ?? null, + bookId: initialData.book?.bookId?.toString() ?? null, + bookName: initialData.book?.bookName ?? null, + bookThumbnail: initialData.book?.thumbnail ?? null, + bookAuthors: initialData.book?.authors ?? null, + bookPublisher: initialData.book?.publisher ?? null, + maxParticipants: initialData.participants?.maxCount?.toString() ?? null, + startDate: initialData.schedule?.startDateTime + ? new Date(initialData.schedule.startDateTime) + : null, + startTime: initialData.schedule?.startDateTime + ? extractTime(initialData.schedule.startDateTime) + : null, + endDate: initialData.schedule?.endDateTime ? new Date(initialData.schedule.endDateTime) : null, + endTime: initialData.schedule?.endDateTime + ? extractTime(initialData.schedule.endDateTime) + : null, + } +} + +export const useMeetingForm = ({ gatheringMaxCount, initialData }: UseMeetingFormParams) => { + // 수정 모드 여부 + const isEditMode = !!initialData + + // 폼 상태를 단일 객체로 관리 + const [formData, setFormData] = useState(() => getInitialFormData(initialData)) // 유효성 검사 에러 상태 const [errors, setErrors] = useState(null) @@ -47,27 +112,41 @@ export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => { // 시간 옵션 메모이제이션 (렌더링마다 재생성 방지) const timeOptions = useMemo(() => generateTimeOptions(), []) + // initialData가 로드되면 상태 업데이트 (수정 모드) + useEffect(() => { + if (initialData) { + setFormData(getInitialFormData(initialData)) + } + }, [initialData]) + const validateForm = (): boolean => { const newError: ValidationErrors = {} - if (!bookId || !bookName) { + if ( + !isEditMode && + (!formData.bookId || + !formData.bookName || + !formData.bookThumbnail || + !formData.bookAuthors || + !formData.bookPublisher) + ) { newError.bookId = '* 도서를 선택해주세요.' } - if (!startDate || !startTime || !endDate || !endTime) { + if (!formData.startDate || !formData.startTime || !formData.endDate || !formData.endTime) { newError.schedule = '* 일정을 선택해주세요.' } else { // 시작/종료 일시 비교 (둘 다 있을 때만) - const startDateTime = combineDateAndTime(startDate, startTime) - const endDateTime = combineDateAndTime(endDate, endTime) + const startDateTime = combineDateAndTime(formData.startDate, formData.startTime) + const endDateTime = combineDateAndTime(formData.endDate, formData.endTime) if (!isStartBeforeEnd(startDateTime, endDateTime)) { newError.schedule = '* 종료 일정은 시작 일정보다 늦어야 합니다.' } } - if (maxParticipants) { - const participants = Number(maxParticipants) + if (formData.maxParticipants) { + const participants = Number(formData.maxParticipants) if (isNaN(participants) || participants < 1 || participants > gatheringMaxCount) { newError.maxParticipants = `현재 모임의 전체 멤버 수는 ${gatheringMaxCount}명이에요. 최대 ${gatheringMaxCount}명까지 참가 가능해요.` } @@ -75,10 +154,10 @@ export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => { // 장소 검증: 4개 필드가 모두 있거나 모두 없어야 함 const locationFields = [ - locationName !== null, - locationAddress !== null, - latitude !== null, - longitude !== null, + formData.locationName !== null, + formData.locationAddress !== null, + formData.latitude !== null, + formData.longitude !== null, ] const filledCount = locationFields.filter(Boolean).length @@ -103,50 +182,53 @@ export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => { return true } - /** - * 에러 설정/제거 내부 함수 - */ - const setError = (field: keyof ValidationErrors, message: string | null) => { - setErrors((prev) => { - if (!prev) return message ? { [field]: message } : null - const updated = { ...prev, [field]: message } - return updated - }) - } - /** * 에러 초기화 (특정 필드 또는 전체) - * @param field - 초기화할 필드 (undefined면 전체 초기화) */ const clearError = (field?: keyof ValidationErrors) => { if (field === undefined) { - // 전체 에러 초기화 setErrors(null) } else { - // 특정 필드 에러 초기화 - setError(field, null) + setErrors((prev) => { + if (!prev) return null + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [field]: _, ...rest } = prev + return Object.keys(rest).length > 0 ? rest : null + }) + } + } + + /** + * 폼 필드 업데이트 헬퍼 + */ + const updateField = ( + field: K, + value: FormData[K], + errorField?: keyof ValidationErrors + ) => { + setFormData((prev) => ({ ...prev, [field]: value })) + if (errorField && errors?.[errorField]) { + clearError(errorField) } } /** * 종료 날짜 비활성화 조건 - * 시작 날짜보다 이전 날짜는 선택 불가 */ const getEndDateDisabled = () => { - if (!startDate || !startTime) return undefined - return { before: startDate } + if (!formData.startDate || !formData.startTime) return undefined + return { before: formData.startDate } } /** * 종료 시간 옵션 필터링 - * 같은 날짜인 경우 시작 시간 이후만 선택 가능 */ const getEndTimeOptions = () => { - if (!startDate || !endDate || !startTime) return timeOptions + if (!formData.startDate || !formData.endDate || !formData.startTime) return timeOptions // 같은 날짜인 경우 - if (startDate.toDateString() === endDate.toDateString()) { - return timeOptions.filter((option) => option.value > startTime) + if (formData.startDate.toDateString() === formData.endDate.toDateString()) { + return timeOptions.filter((option) => option.value > formData.startTime!) } return timeOptions @@ -154,7 +236,6 @@ export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => { /** * 시작 날짜 비활성화 조건 - * 오늘은 불가, 내일부터 선택 가능 */ const getStartDateDisabled = () => { const tomorrow = new Date() @@ -165,77 +246,37 @@ export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => { } /** - * 시작 날짜 변경 (에러 초기화 포함) + * 도서 정보 변경 (에러 초기화 포함) */ - const handleStartDateChange = (date: Date | null) => { - setStartDate(date) - if (errors?.schedule) { - clearError('schedule') - } - } - - /** - * 시작 시간 변경 (에러 초기화 포함) - */ - const handleStartTimeChange = (time: string) => { - setStartTime(time) - if (errors?.schedule) { - clearError('schedule') - } - } - - /** - * 종료 날짜 변경 (에러 초기화 포함) - */ - const handleEndDateChange = (date: Date | null) => { - setEndDate(date) - if (errors?.schedule) { - clearError('schedule') - } - } - - /** - * 종료 시간 변경 (에러 초기화 포함) - */ - const handleEndTimeChange = (time: string) => { - setEndTime(time) - if (errors?.schedule) { - clearError('schedule') - } - } - - /** - * 참가 인원 변경 (에러 초기화 포함) - */ - const handleMaxParticipantsChange = (value: string) => { - setMaxParticipants(value) - if (errors?.maxParticipants) { - clearError('maxParticipants') + const handleBookChange = (book: Omit) => { + setFormData((prev) => ({ + ...prev, + bookId: book.isbn, + bookName: book.title, + bookThumbnail: book.thumbnail, + bookAuthors: book.authors.join(', '), + bookPublisher: book.publisher, + })) + if (errors?.bookId) { + clearError('bookId') } } /** * 선택된 일정 텍스트 생성 - * 시작/종료 날짜와 시간이 모두 선택된 경우 포맷된 문자열 반환 */ - const formattedSchedule = formatScheduleRange(startDate, startTime, endDate, endTime) + const formattedSchedule = formatScheduleRange( + formData.startDate, + formData.startTime, + formData.endDate, + formData.endTime + ) return { + // 모드 + isEditMode, // 폼 데이터 - formData: { - meetingName, - bookId, - bookName, - locationName, - locationAddress, - latitude, - longitude, - maxParticipants, - startDate, - startTime, - endDate, - endTime, - }, + formData, // 시간 옵션 timeOptions, // 유효성 검사 @@ -256,18 +297,18 @@ export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => { }, // 상태 업데이트 핸들러 handlers: { - setMeetingName, - setBookId, - setBookName, - setMaxParticipants: handleMaxParticipantsChange, - setStartDate: handleStartDateChange, - setStartTime: handleStartTimeChange, - setEndDate: handleEndDateChange, - setEndTime: handleEndTimeChange, - setLocationAddress, - setLocationName, - setLatitude, - setLongitude, + setMeetingName: (value: string) => updateField('meetingName', value), + setBook: handleBookChange, + setMaxParticipants: (value: string) => + updateField('maxParticipants', value, 'maxParticipants'), + setStartDate: (date: Date | null) => updateField('startDate', date, 'schedule'), + setStartTime: (time: string) => updateField('startTime', time, 'schedule'), + setEndDate: (date: Date | null) => updateField('endDate', date, 'schedule'), + setEndTime: (time: string) => updateField('endTime', time, 'schedule'), + setLocationAddress: (address: string | null) => updateField('locationAddress', address), + setLocationName: (name: string | null) => updateField('locationName', name), + setLatitude: (lat: number | null) => updateField('latitude', lat), + setLongitude: (lng: number | null) => updateField('longitude', lng), }, } } diff --git a/src/features/meetings/hooks/useMyMeetingTabCounts.ts b/src/features/meetings/hooks/useMyMeetingTabCounts.ts new file mode 100644 index 0000000..a402ee0 --- /dev/null +++ b/src/features/meetings/hooks/useMyMeetingTabCounts.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' + +import { getMyMeetingTabCounts } from '../meetings.api' +import { myMeetingQueryKeys } from './myMeetingQueryKeys' + +export function useMyMeetingTabCounts() { + return useQuery({ + queryKey: myMeetingQueryKeys.tabCounts(), + queryFn: getMyMeetingTabCounts, + }) +} diff --git a/src/features/meetings/hooks/useMyMeetings.ts b/src/features/meetings/hooks/useMyMeetings.ts new file mode 100644 index 0000000..269fe68 --- /dev/null +++ b/src/features/meetings/hooks/useMyMeetings.ts @@ -0,0 +1,22 @@ +import { useInfiniteQuery } from '@tanstack/react-query' + +import { PAGE_SIZES } from '@/shared/constants' + +import { getMyMeetings } from '../meetings.api' +import type { MyMeetingFilter, MyMeetingListResponse } from '../meetings.types' +import { myMeetingQueryKeys } from './myMeetingQueryKeys' + +export function useMyMeetings(filter: MyMeetingFilter) { + return useInfiniteQuery({ + queryKey: myMeetingQueryKeys.list(filter), + queryFn: ({ pageParam }) => + getMyMeetings({ + filter, + startDateTime: pageParam?.startDateTime, + meetingId: pageParam?.meetingId, + size: PAGE_SIZES.MY_MEETINGS, + }), + initialPageParam: undefined as MyMeetingListResponse['nextCursor'] | undefined, + getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.nextCursor : undefined), + }) +} diff --git a/src/features/meetings/hooks/usePlaceSearch.ts b/src/features/meetings/hooks/usePlaceSearch.ts new file mode 100644 index 0000000..6386dd2 --- /dev/null +++ b/src/features/meetings/hooks/usePlaceSearch.ts @@ -0,0 +1,168 @@ +/** + * @file usePlaceSearch.ts + * @description 장소 검색 모달의 상태 관리 훅 + * + * SDK 로드, 지도 초기화, 장소 검색 API 등 모든 비동기 로직과 에러를 + * 하나의 searchState로 추상화하여 UI가 선언적으로 상태를 수신할 수 있도록 합니다. + * + * searchState: + * - 'idle' 초기 화면 (검색 전) + * - 'searching' 검색 중 (리스트 스켈레톤 표시) + * - 'hasResults' 검색 결과 있음 (지도 + 리스트 표시) + * - 'noResults' 일치하는 결과 없음 + * - 'error' SDK 로드 실패 또는 검색 API 오류 + */ + +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { KakaoMap, KakaoPlace } from '@/features/kakaomap' +import { useKakaoLoader, useKakaoPlaceSearch } from '@/features/kakaomap' + +export type PlaceSearchState = 'idle' | 'searching' | 'hasResults' | 'noResults' | 'error' + +type SelectedPlace = { + name: string + address: string + latitude: number + longitude: number +} + +export type UsePlaceSearchOptions = { + open: boolean + onOpenChange: (open: boolean) => void + onSelectPlace: (place: SelectedPlace) => void +} + +export function usePlaceSearch({ open, onOpenChange, onSelectPlace }: UsePlaceSearchOptions) { + const [sdkLoading, sdkError] = useKakaoLoader() + + const [searchState, setSearchState] = useState('idle') + const [mapInstance, setMapInstance] = useState(null) + const [hoveredPlaceId, setHoveredPlaceId] = useState(null) + + // 카카오 Map SDK는 마운트 시점의 컨테이너 크기로 지도를 초기화합니다. + // display:none 상태에서 마운트되면 크기가 0으로 계산되어 지도가 깨지므로, + // 첫 검색이 실행되어 지도 영역이 화면에 보이는 시점에 처음 마운트합니다. + const [hasBeenSearched, setHasBeenSearched] = useState(false) + + const keywordRef = useRef(null) + + const { + places, + error: searchError, + search, + reset, + } = useKakaoPlaceSearch({ + onSearchSuccess: (results) => { + setSearchState(results.length > 0 ? 'hasResults' : 'noResults') + }, + onSearchError: () => { + setSearchState('error') + }, + }) + + // SDK 로드 실패 시 error 상태로 전환 + const effectiveSearchState: PlaceSearchState = sdkError ? 'error' : searchState + + // places 또는 mapInstance가 준비되면 지도 범위를 자동 조정 + // (첫 검색 시 places가 먼저 오거나 mapInstance가 먼저 올 수 있으므로 둘 다 dep에 포함) + useEffect(() => { + if (!mapInstance || places.length === 0) return + + const { kakao } = window + const bounds = new kakao.maps.LatLngBounds() + places.forEach((p) => bounds.extend(new kakao.maps.LatLng(Number(p.y), Number(p.x)))) + mapInstance.setBounds(bounds) + }, [mapInstance, places]) + + const resetState = useCallback(() => { + setSearchState('idle') + setHoveredPlaceId(null) + setHasBeenSearched(false) + setMapInstance(null) + reset() + }, [reset]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== 'Enter') return + // 한국어 등 IME 입력 중 composition 이벤트는 무시 + if (e.nativeEvent.isComposing) return + e.preventDefault() + + const keyword = keywordRef.current?.value.trim() ?? '' + if (!keyword) return + + // SDK 로드 실패 상태에서는 검색 불가 + if (sdkError) return + + // SDK 아직 로드 중이라면 무시 + if (sdkLoading) return + + reset() + setHoveredPlaceId(null) + setSearchState('searching') + setHasBeenSearched(true) + search(keyword) + }, + [sdkLoading, sdkError, reset, search] + ) + + const handlePlaceClick = useCallback( + (place: KakaoPlace) => { + if (mapInstance) { + mapInstance.setCenter(new window.kakao.maps.LatLng(Number(place.y), Number(place.x))) + } + onSelectPlace({ + name: place.place_name, + address: place.road_address_name || place.address_name, + latitude: Number(place.y), + longitude: Number(place.x), + }) + onOpenChange(false) + resetState() + }, + [mapInstance, onSelectPlace, onOpenChange, resetState] + ) + + const handlePlaceFocus = useCallback( + (place: KakaoPlace) => { + if (!mapInstance) return + mapInstance.setLevel(4) + mapInstance.setCenter(new window.kakao.maps.LatLng(Number(place.y), Number(place.x))) + }, + [mapInstance] + ) + + const handleClose = useCallback(() => { + onOpenChange(false) + resetState() + }, [onOpenChange, resetState]) + + // error 상태에서 노출할 메시지 — SDK 오류 우선 + const errorMessage = sdkError?.message ?? searchError ?? '오류가 발생했습니다. 다시 시도해주세요.' + + // Map 컴포넌트를 DOM에 마운트할지 여부 + // error 상태는 인스턴스 보존이 불필요하므로 unmount (다음 검색 시 새로 초기화) + const isMapMounted = open && hasBeenSearched && effectiveSearchState !== 'error' + + // 지도 영역을 화면에 표시할지 여부 (isMapMounted가 true일 때만 유의미) + // noResults에서는 Map 인스턴스를 유지한 채 CSS로만 숨김 → 재검색 시 재초기화 없이 재사용 + const isMapVisible = effectiveSearchState === 'searching' || effectiveSearchState === 'hasResults' + + return { + searchState: effectiveSearchState, + errorMessage, + places, + isMapMounted, + isMapVisible, + hoveredPlaceId, + keywordRef, + setMapInstance, + setHoveredPlaceId, + handleKeyDown, + handlePlaceClick, + handlePlaceFocus, + handleClose, + } +} diff --git a/src/features/meetings/hooks/useRejectMeeting.ts b/src/features/meetings/hooks/useRejectMeeting.ts index a1bf449..b0e81f9 100644 --- a/src/features/meetings/hooks/useRejectMeeting.ts +++ b/src/features/meetings/hooks/useRejectMeeting.ts @@ -6,6 +6,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import type { ApiError, ApiResponse } from '@/api' +import { gatheringQueryKeys } from '@/features/gatherings' import { rejectMeeting, type RejectMeetingResponse } from '@/features/meetings' import { meetingQueryKeys } from './meetingQueryKeys' @@ -17,12 +18,8 @@ import { meetingQueryKeys } from './meetingQueryKeys' * 약속을 거부하고 관련 쿼리 캐시를 무효화합니다. * - 약속 승인 리스트 캐시 무효화 * - 약속 승인 카운트 캐시 무효화 - * - * @example - * const rejectMutation = useRejectMeeting() - * rejectMutation.mutate(meetingId) */ -export const useRejectMeeting = () => { +export const useRejectMeeting = (gatheringId: number) => { const queryClient = useQueryClient() return useMutation, ApiError, number>({ @@ -32,6 +29,8 @@ export const useRejectMeeting = () => { queryClient.invalidateQueries({ queryKey: meetingQueryKeys.approvals(), }) + // 모임 약속 리스트 캐시 무효화 + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.meetings(gatheringId) }) }, }) } diff --git a/src/features/meetings/hooks/useUpdateMeeting.ts b/src/features/meetings/hooks/useUpdateMeeting.ts new file mode 100644 index 0000000..7199fc8 --- /dev/null +++ b/src/features/meetings/hooks/useUpdateMeeting.ts @@ -0,0 +1,46 @@ +/** + * @file useUpdateMeeting.ts + * @description 약속 수정 mutation 훅 + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError } from '@/api/errors' +import type { ApiResponse } from '@/api/types' +import { + updateMeeting, + type UpdateMeetingRequest, + type UpdateMeetingResponse, +} from '@/features/meetings' + +import { meetingQueryKeys } from './meetingQueryKeys' + +type UpdateMeetingVariables = { + meetingId: number + data: UpdateMeetingRequest +} + +/** + * 약속 수정 mutation 훅 + * + * @description + * 약속 정보를 수정하고 관련 쿼리 캐시를 무효화합니다. + */ +export const useUpdateMeeting = () => { + const queryClient = useQueryClient() + + return useMutation, ApiError, UpdateMeetingVariables>({ + mutationFn: ({ meetingId, data }: UpdateMeetingVariables) => updateMeeting(meetingId, data), + onSuccess: (_, variables) => { + // 수정된 약속의 상세 정보 캐시 무효화 + queryClient.invalidateQueries({ + queryKey: meetingQueryKeys.detail(variables.meetingId), + }) + + // 약속 승인 관련 모든 캐시 무효화 + queryClient.invalidateQueries({ + queryKey: meetingQueryKeys.approvals(), + }) + }, + }) +} diff --git a/src/features/meetings/index.ts b/src/features/meetings/index.ts index e0d6b0e..3e59a6f 100644 --- a/src/features/meetings/index.ts +++ b/src/features/meetings/index.ts @@ -11,21 +11,25 @@ export * from './lib' export * from './meetings.api' // Types -export type { - KakaoPlace, - KakaoSearchMeta, - KakaoSearchParams, - KakaoSearchResponse, -} from './kakaoMap.types' export type { ConfirmMeetingResponse, CreateMeetingRequest, CreateMeetingResponse, GetMeetingApprovalsParams, GetMeetingDetailResponse, + GetMyMeetingsParams, MeetingApprovalItem as MeetingApprovalItemType, MeetingDetailActionStateType, MeetingLocation, MeetingStatus, + MyMeetingCursor, + MyMeetingFilter, + MyMeetingListItem, + MyMeetingListResponse, + MyMeetingProgressStatus, + MyMeetingRole, + MyMeetingTabCountsResponse, RejectMeetingResponse, + UpdateMeetingRequest, + UpdateMeetingResponse, } from './meetings.types' diff --git a/src/features/meetings/kakaoMap.types.ts b/src/features/meetings/kakaoMap.types.ts deleted file mode 100644 index 2f13f71..0000000 --- a/src/features/meetings/kakaoMap.types.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @file kakao.types.ts - * @description 카카오 로컬 API 관련 타입 정의 - * @note 외부 API 응답 스펙을 따르기 위해 snake_case 사용 - */ - -/* eslint-disable @typescript-eslint/naming-convention */ - -/** - * 카카오 장소 검색 응답 문서 타입 - */ -export type KakaoPlace = { - /** 장소명, 업체명 */ - place_name: string - /** 전체 지번 주소 */ - address_name: string - /** 전체 도로명 주소 */ - road_address_name: string - /** X 좌표값, 경도(longitude) */ - x: string - /** Y 좌표값, 위도(latitude) */ - y: string - /** 장소 ID */ - id: string - /** 카테고리 그룹 코드 */ - category_group_code: string - /** 카테고리 그룹명 */ - category_group_name: string - /** 카테고리 이름 */ - category_name: string - /** 전화번호 */ - phone: string - /** 장소 상세페이지 URL */ - place_url: string - /** 중심좌표까지의 거리 (단, x,y 파라미터를 준 경우에만 존재) */ - distance?: string -} - -/** - * 카카오 장소 검색 API 응답 메타 정보 - */ -export type KakaoSearchMeta = { - /** 검색된 문서 수 */ - total_count: number - /** total_count 중 노출 가능 문서 수 */ - pageable_count: number - /** 현재 페이지가 마지막 페이지인지 여부 */ - is_end: boolean - /** 질의어의 지역 및 키워드 분석 정보 */ - same_name?: { - /** 질의어에서 인식된 지역의 리스트 */ - region: string[] - /** 질의어에서 지역 정보를 제외한 키워드 */ - keyword: string - /** 인식된 지역 리스트 중, 현재 검색에 사용된 지역 정보 */ - selected_region: string - } -} - -/** - * 카카오 장소 검색 API 응답 타입 - */ -export type KakaoSearchResponse = { - /** 검색 결과 문서 리스트 */ - documents: KakaoPlace[] - /** 응답 관련 정보 */ - meta: KakaoSearchMeta -} - -/** - * 카카오 장소 검색 API 요청 파라미터 - */ -export type KakaoSearchParams = { - /** 검색을 원하는 질의어 (필수) */ - query: string - /** 카테고리 그룹 코드 (선택) */ - category_group_code?: string - /** 중심 좌표의 X 혹은 경도(longitude) */ - x?: string - /** 중심 좌표의 Y 혹은 위도(latitude) */ - y?: string - /** 중심 좌표부터의 반경거리. 미터(m) 단위 */ - radius?: number - /** 결과 페이지 번호 (1~45, 기본값: 1) */ - page?: number - /** 한 페이지에 보여질 문서의 개수 (1~15, 기본값: 15) */ - size?: number - /** 결과 정렬 순서 (distance: 거리순, accuracy: 정확도순) */ - sort?: 'distance' | 'accuracy' -} diff --git a/src/features/meetings/meetings.api.ts b/src/features/meetings/meetings.api.ts index a1989f4..e21ab9b 100644 --- a/src/features/meetings/meetings.api.ts +++ b/src/features/meetings/meetings.api.ts @@ -13,17 +13,18 @@ import type { CreateMeetingResponse, GetMeetingApprovalsParams, GetMeetingDetailResponse, + GetMyMeetingsParams, MeetingApprovalItem, + MyMeetingListResponse, + MyMeetingTabCountsResponse, RejectMeetingResponse, + UpdateMeetingRequest, + UpdateMeetingResponse, } from '@/features/meetings/meetings.types' import { PAGE_SIZES } from '@/shared/constants' -/** - * 목데이터 사용 여부 - * @description 로그인 기능 개발 전까지 true로 설정하여 목데이터 사용 - * TODO: 로그인 기능 완료 후 false로 변경하여 실제 API 호출 - */ -const USE_MOCK_DATA = true +/** 목데이터 사용 여부 플래그 */ +const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' /** * 약속 승인 리스트 조회 @@ -48,7 +49,7 @@ export const getMeetingApprovals = async ( // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 - if (USE_MOCK_DATA) { + if (USE_MOCK) { // 실제 API 호출을 시뮬레이션하기 위한 지연 await new Promise((resolve) => setTimeout(resolve, 500)) return getMockMeetingApprovals(status, page, size) @@ -137,7 +138,7 @@ export const deleteMeeting = async (meetingId: number) => { export const getMeetingDetail = async (meetingId: number): Promise => { // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 - if (USE_MOCK_DATA) { + if (USE_MOCK) { // 실제 API 호출을 시뮬레이션하기 위한 지연 await new Promise((resolve) => setTimeout(resolve, 500)) return getMockMeetingDetail(meetingId) @@ -194,6 +195,54 @@ export const cancelJoinMeeting = async (meetingId: number) => { * - B001: 책을 찾을 수 없습니다. */ export const createMeeting = async (data: CreateMeetingRequest) => { - const response = await apiClient.post>('/api/meetings', data) + const response = await apiClient.post>( + MEETINGS_ENDPOINTS.CREATE, + data + ) return response.data } + +/** + * 약속 수정 + * + * @description + * 약속 정보를 수정합니다. + * 책 정보는 수정할 수 없습니다. + * + * @param meetingId - 약속 ID + * @param data - 약속 수정 요청 데이터 + * + * @returns 수정된 약속 정보 + * + * @throws + * - M001: 약속을 찾을 수 없습니다. + * - M013: 최대 참가 인원이 유효하지 않습니다. + */ +export const updateMeeting = async (meetingId: number, data: UpdateMeetingRequest) => { + const response = await apiClient.patch>( + MEETINGS_ENDPOINTS.UPDATE(meetingId), + data + ) + return response.data +} + +/** + * 메인페이지 내 약속 리스트 조회 + * + * @param params - 조회 파라미터 (filter, cursor, size) + * @returns 내 약속 리스트 (커서 기반 페이지네이션) + */ +export const getMyMeetings = async ( + params: GetMyMeetingsParams +): Promise => { + return api.get(MEETINGS_ENDPOINTS.MY_MEETINGS, { params }) +} + +/** + * 메인페이지 내 약속 탭 카운트 조회 + * + * @returns 탭별 약속 카운트 (all, upcoming, done) + */ +export const getMyMeetingTabCounts = async (): Promise => { + return api.get(MEETINGS_ENDPOINTS.MY_MEETING_TAB_COUNTS) +} diff --git a/src/features/meetings/meetings.endpoints.ts b/src/features/meetings/meetings.endpoints.ts index e5fc740..e153a18 100644 --- a/src/features/meetings/meetings.endpoints.ts +++ b/src/features/meetings/meetings.endpoints.ts @@ -7,6 +7,9 @@ export const MEETINGS_ENDPOINTS = { // 약속 상세 조회 (GET /api/meetings/{meetingId}) DETAIL: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}`, + // 약속 생성 (POST /api/meetings) + CREATE: `${API_PATHS.MEETINGS}`, + // 약속 거부 (POST /api/meetings/{meetingId}/reject) REJECT: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}/reject`, @@ -21,4 +24,13 @@ export const MEETINGS_ENDPOINTS = { // 약속 참가취소 (DELETE /api/meetings/{meetingId}/join) CANCEL_JOIN: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}/join`, + + // 약속 수정 (PATCH /api/meetings/{meetingId}) + UPDATE: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}`, + + // 메인페이지 내 약속 리스트 조회 (GET /api/meetings/me) + MY_MEETINGS: `${API_PATHS.MEETINGS}/me`, + + // 메인페이지 내 약속 탭 카운트 조회 (GET /api/meetings/me/tab-counts) + MY_MEETING_TAB_COUNTS: `${API_PATHS.MEETINGS}/me/tab-counts`, } as const diff --git a/src/features/meetings/meetings.mock.ts b/src/features/meetings/meetings.mock.ts index 60863c1..c6f7be4 100644 --- a/src/features/meetings/meetings.mock.ts +++ b/src/features/meetings/meetings.mock.ts @@ -218,6 +218,8 @@ const mockMeetingDetails: Record = { bookId: 1001, bookName: '클린 코드', thumbnail: 'https://picsum.photos/seed/cleancode/200/300', + authors: '로버트 C. 마틴', + publisher: '인사이트', }, schedule: { startDateTime: '2026-02-01T14:00:00', @@ -259,7 +261,7 @@ const mockMeetingDetails: Record = { buttonLabel: '약속이 끝났어요', enabled: false, }, - confirmedTopicExpand: true, + confirmedTopic: true, confirmedTopicDate: '2026-01-20T14:00:00', }, 11: { @@ -267,7 +269,7 @@ const mockMeetingDetails: Record = { progressStatus: 'PRE', meetingName: '킥오프 모임', meetingStatus: 'CONFIRMED', - confirmedTopicExpand: false, + confirmedTopic: false, confirmedTopicDate: null, gathering: { gatheringId: 102, @@ -277,6 +279,8 @@ const mockMeetingDetails: Record = { bookId: 1002, bookName: '실용주의 프로그래머', thumbnail: 'https://picsum.photos/seed/pragmatic/200/300', + authors: '데이비드 토머스, 앤드류 헌트', + publisher: '인사이트', }, schedule: { startDateTime: '2026-02-11T14:00:00', diff --git a/src/features/meetings/meetings.types.ts b/src/features/meetings/meetings.types.ts index a2333bf..35381f5 100644 --- a/src/features/meetings/meetings.types.ts +++ b/src/features/meetings/meetings.types.ts @@ -3,6 +3,8 @@ * @description Meeting API 관련 타입 정의 */ +import type { CreateBookBody } from '@/features/book' + /** * 약속 상태 타입 */ @@ -84,8 +86,8 @@ export type MeetingLocation = { export type CreateMeetingRequest = { /** 모임 ID */ gatheringId: number - /** 책 ID */ - bookId: number + /** 책 정보 */ + book: CreateBookBody /** 약속 이름 */ meetingName: string /** 약속 시작 일시 (ISO 8601 형식) */ @@ -99,7 +101,7 @@ export type CreateMeetingRequest = { } /** - * 약속 생성 응답 타입 + * 약속 생성 응답 타입 Todo:실제 응답값이랑 비교해봐야 함 */ export type CreateMeetingResponse = { /** 약속 ID */ @@ -117,6 +119,9 @@ export type CreateMeetingResponse = { book: { bookId: number bookName: string + thumbnail: string + authors: string + publisher: string } /** 일정 정보 */ schedule: { @@ -139,6 +144,40 @@ export type CreateMeetingResponse = { } } +/** + * 약속 수정 요청 타입 + */ +export type UpdateMeetingRequest = { + /** 약속 이름 */ + meetingName: string + /** 약속 시작 일시 (ISO 8601 형식) */ + startDate: string + /** 약속 종료 일시 (ISO 8601 형식) */ + endDate: string + /** 장소 (선택 사항) */ + location: MeetingLocation | null + /** 최대 참가 인원 */ + maxParticipants: number +} + +/** + * 약속 수정 응답 타입 + */ +export type UpdateMeetingResponse = { + /** 약속 ID */ + meetingId: number + /** 약속 이름 */ + meetingName: string + /** 약속 시작 일시 (ISO 8601 형식) */ + startDate: string + /** 약속 종료 일시 (ISO 8601 형식) */ + endDate: string + /** 장소 (선택 사항) */ + location: MeetingLocation | null + /** 최대 참가 인원 */ + maxParticipants: number +} + /** * 약속 일정 타입 */ @@ -176,7 +215,7 @@ export type GetMeetingDetailResponse = { /** 약속 진행 상태 */ progressStatus: MeetingProgressStatus /** 주제 확정 여부 */ - confirmedTopicExpand: boolean + confirmedTopic: boolean /** 주제 확정 일시 */ confirmedTopicDate: string | null /** 모임 정보 */ @@ -189,6 +228,8 @@ export type GetMeetingDetailResponse = { bookId: number bookName: string thumbnail: string + authors: string + publisher: string } /** 일정 정보 */ schedule: MeetingSchedule @@ -212,3 +253,61 @@ export type GetMeetingDetailResponse = { enabled: boolean } } + +// ============================================================ +// 메인페이지 내 약속 리스트 관련 타입 +// ============================================================ + +/** 메인페이지 약속 진행 상태 (시간 기준) */ +export type MyMeetingProgressStatus = 'UPCOMING' | 'ONGOING' | 'DONE' | 'UNKNOWN' + +/** 메인페이지 내 역할 */ +export type MyMeetingRole = 'LEADER' | 'GATHERING_LEADER' | 'MEMBER' | 'NONE' + +/** 메인페이지 약속 필터 */ +export type MyMeetingFilter = 'ALL' | 'UPCOMING' | 'DONE' + +/** 메인페이지 내 약속 아이템 */ +export interface MyMeetingListItem { + meetingId: number + meetingName: string + gatheringId: number + gatheringName: string + meetingLeaderName: string + bookName: string + startDateTime: string + endDateTime: string + meetingStatus: MeetingStatus | 'REJECTED' | 'DONE' + myRole: MyMeetingRole + progressStatus: MyMeetingProgressStatus +} + +/** 메인페이지 내 약속 커서 */ +export interface MyMeetingCursor { + startDateTime: string + meetingId: number +} + +/** 메인페이지 내 약속 리스트 응답 */ +export interface MyMeetingListResponse { + items: MyMeetingListItem[] + totalCount: number + pageSize: number + hasNext: boolean + nextCursor: MyMeetingCursor | null +} + +/** 메인페이지 내 약속 조회 파라미터 */ +export interface GetMyMeetingsParams { + filter: MyMeetingFilter + startDateTime?: string + meetingId?: number + size?: number +} + +/** 메인페이지 내 약속 탭 카운트 응답 */ +export interface MyMeetingTabCountsResponse { + all: number + upcoming: number + done: number +} diff --git a/src/features/pre-opinion/components/BookReviewSection.tsx b/src/features/pre-opinion/components/BookReviewSection.tsx new file mode 100644 index 0000000..8be7ef3 --- /dev/null +++ b/src/features/pre-opinion/components/BookReviewSection.tsx @@ -0,0 +1,59 @@ +import { + BookReviewForm, + type BookReviewFormValues, +} from '@/features/book/components/BookReviewForm' +import type { PreOpinionReview } from '@/features/pre-opinion/preOpinion.types' +import { Container } from '@/shared/ui' + +/** + * 사전 의견 작성 페이지의 책 평가 섹션 + * + * @description 기존 평가가 있으면 해당 데이터를 채워서 보여주고, + * 없으면 빈 폼을 보여줘서 사용자가 평가를 남길 수 있게 합니다. + * 제출은 상위 페이지에서 일괄 처리합니다. + * + * @example + * ```tsx + * setReviewValues(values)} /> + * ``` + */ +interface BookReviewSectionProps { + review: PreOpinionReview | null + onChange?: (values: BookReviewFormValues) => void +} + +const BookReviewSection = ({ review, onChange }: BookReviewSectionProps) => { + const hasReview = review !== null + + return ( + + + 이 책은 어떠셨나요? + + + k.id) + .sort() + .join(',')}` + : 'empty' + } + initialRating={review?.rating ?? 0} + initialKeywordIds={review?.keywords.map((k) => k.id) ?? []} + onChange={onChange} + /> + + + ) +} + +export default BookReviewSection diff --git a/src/features/pre-opinion/components/PreOpinionWriteHeader.tsx b/src/features/pre-opinion/components/PreOpinionWriteHeader.tsx new file mode 100644 index 0000000..9975621 --- /dev/null +++ b/src/features/pre-opinion/components/PreOpinionWriteHeader.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from 'react' + +import type { PreOpinionBook } from '@/features/pre-opinion/preOpinion.types' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/ui' + +import { formatUpdatedAt } from '../lib/date' + +interface PreOpinionWriteHeaderProps { + book: PreOpinionBook + updatedAt: string | null + onSave: () => void + onSubmit: () => void + isSaving?: boolean + isSubmitting?: boolean + isReviewValid?: boolean +} + +/** + * 사전 의견 작성 페이지 헤더 + * + * @description + * 책 제목, 저자, 마지막 저장 시각을 표시하고 + * 스크롤 시 sticky로 고정되며 하단 그림자가 생깁니다. + * + * @example + * ```tsx + * + * ``` + */ +const PreOpinionWriteHeader = ({ + book, + updatedAt, + onSave, + onSubmit, + isSaving, + isSubmitting, + isReviewValid = false, +}: PreOpinionWriteHeaderProps) => { + const sentinelRef = useRef(null) + const [isStuck, setIsStuck] = useState(false) + + useEffect(() => { + const sentinel = sentinelRef.current + if (!sentinel) return + + const observer = new IntersectionObserver( + ([entry]) => { + setIsStuck(!entry.isIntersecting) + }, + { threshold: 0 } + ) + + observer.observe(sentinel) + return () => observer.disconnect() + }, []) + + return ( + <> +
+
+
+
+
+

사전 의견 작성하기

+

+ {book.title} · {book.author} +

+
+
+ {updatedAt && ( +

{formatUpdatedAt(updatedAt)}

+ )} + + +
+
+
+
+ + ) +} + +export default PreOpinionWriteHeader diff --git a/src/features/pre-opinion/components/TopicItem.tsx b/src/features/pre-opinion/components/TopicItem.tsx new file mode 100644 index 0000000..f3bfee5 --- /dev/null +++ b/src/features/pre-opinion/components/TopicItem.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react' + +import type { PreOpinionTopic } from '@/features/pre-opinion/preOpinion.types' +import { Badge, Container, Textarea } from '@/shared/ui' + +interface TopicItemProps { + topic: PreOpinionTopic + onChange?: (topicId: number, content: string) => void +} + +function TopicItem({ topic, onChange }: TopicItemProps) { + const [value, setValue] = useState(topic.content ?? '') + const [prevContent, setPrevContent] = useState(topic.content) + + if (topic.content !== prevContent) { + setPrevContent(topic.content) + setValue(topic.content ?? '') + } + + return ( + + {topic.topicTypeLabel}} + > + {`주제 ${topic.confirmOrder}. ${topic.topicTitle}`} + + +
+

{topic.topicDescription}

+