diff --git a/.gitignore b/.gitignore index cb468b1..e420099 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,5 @@ dist-ssr *.sln *.sw? -.env \ No newline at end of file +.env +.vercel diff --git a/package.json b/package.json index e8cecc8..24c6a60 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ ] }, "dependencies": { + "@emailjs/browser": "^4.4.1", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mui/icons-material": "^7.1.2", @@ -57,6 +58,7 @@ "@types/react-router-dom": "^5.3.3", "@typescript-eslint/eslint-plugin": "^8.34.1", "@typescript-eslint/parser": "^8.34.1", + "@vercel/node": "^5.3.0", "@vitejs/plugin-react": "^4.4.1", "babel-plugin-import": "^1.13.8", "eslint": "^8.57.1", @@ -79,5 +81,8 @@ "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", "vite": "^6.3.5" + }, + "engines": { + "node": "18.x" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c6a0e8..c8e6901 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@emailjs/browser': + specifier: ^4.4.1 + version: 4.4.1 '@emotion/react': specifier: ^11.14.0 version: 11.14.0(@types/react@19.1.8)(react@19.1.0) @@ -69,6 +72,9 @@ importers: '@typescript-eslint/parser': specifier: ^8.34.1 version: 8.35.0(eslint@8.57.1)(typescript@5.8.3) + '@vercel/node': + specifier: ^5.3.0 + version: 5.3.0(rollup@4.44.0) '@vitejs/plugin-react': specifier: ^4.4.1 version: 4.6.0(vite@6.3.5(@types/node@24.0.3)(jiti@2.4.2)(tsx@4.20.3)(yaml@2.8.0)) @@ -294,6 +300,34 @@ packages: resolution: {integrity: sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==} engines: {node: '>=v18'} + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@edge-runtime/format@2.2.1': + resolution: {integrity: sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g==} + engines: {node: '>=16'} + + '@edge-runtime/node-utils@2.3.0': + resolution: {integrity: sha512-uUtx8BFoO1hNxtHjp3eqVPC/mWImGb2exOfGjMLUoipuWgjej+f4o/VP4bUI8U40gu7Teogd5VTeZUkGvJSPOQ==} + engines: {node: '>=16'} + + '@edge-runtime/ponyfill@2.4.2': + resolution: {integrity: sha512-oN17GjFr69chu6sDLvXxdhg0Qe8EZviGSuqzR9qOiKh4MhFYGdBBcqRNzdmYeAdeRzOW2mM9yil4RftUQ7sUOA==} + engines: {node: '>=16'} + + '@edge-runtime/primitives@4.1.0': + resolution: {integrity: sha512-Vw0lbJ2lvRUqc7/soqygUX216Xb8T3WBZ987oywz6aJqRxcwSVWwr9e+Nqo2m9bxobA9mdbWNNoRY6S9eko1EQ==} + engines: {node: '>=16'} + + '@edge-runtime/vm@3.2.0': + resolution: {integrity: sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw==} + engines: {node: '>=16'} + + '@emailjs/browser@4.4.1': + resolution: {integrity: sha512-DGSlP9sPvyFba3to2A50kDtZ+pXVp/0rhmqs2LmbMS3I5J8FSOgLwzY2Xb4qfKlOVHh29EAutLYwe5yuEZmEFg==} + engines: {node: '>=14.0.0'} + '@emnapi/core@1.4.3': resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==} @@ -529,6 +563,10 @@ packages: resolution: {integrity: sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@feature-sliced/eslint-config@0.1.1': resolution: {integrity: sha512-6g2xgY3TVv24N1828U5gnfT7/R2aNiRghhCs45j6mbltLQik0CR7puKJhZSmhLqczMDM8TRqLVNhaCdvdgZpWw==} peerDependencies: @@ -767,6 +805,14 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -785,6 +831,14 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@mapbox/node-pre-gyp@2.0.0': + resolution: {integrity: sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg==} + engines: {node: '>=18'} + hasBin: true + '@mui/core-downloads-tracker@7.1.2': resolution: {integrity: sha512-0gLO1PvbJwSYe5ji021tGj6HFqrtEPMGKK4L1zWwRbhzrWWUumUJvMvJUsIgWQIYQsgOnhq9k2Fc1BxLGHDsAg==} @@ -891,6 +945,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -927,6 +985,15 @@ packages: '@rolldown/pluginutils@1.0.0-beta.19': resolution: {integrity: sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==} + '@rollup/pluginutils@5.2.0': + resolution: {integrity: sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.44.0': resolution: {integrity: sha512-xEiEE5oDW6tK4jXCAyliuntGR+amEMO7HLtdSshVuhFnKTYoeYMyXQK7pLouAJJj5KHdwdn87bfHAR2nSdNAUA==} cpu: [arm] @@ -1046,6 +1113,21 @@ packages: peerDependencies: react: ^18 || ^19 + '@ts-morph/common@0.11.1': + resolution: {integrity: sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==} + + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -1070,6 +1152,9 @@ packages: '@types/history@4.7.11': resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} @@ -1079,6 +1164,9 @@ packages: '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} + '@types/node@16.18.11': + resolution: {integrity: sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==} + '@types/node@24.0.3': resolution: {integrity: sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==} @@ -1270,6 +1358,23 @@ packages: cpu: [x64] os: [win32] + '@vercel/build-utils@10.6.1': + resolution: {integrity: sha512-E6O45bInBcKFDtliPADlNpIMutPjzGepYVfV2GyXdxf+00k6wMAlTQ/HbgWhvErOvy7TkZxFxrkRghAWnGK+UA==} + + '@vercel/error-utils@2.0.3': + resolution: {integrity: sha512-CqC01WZxbLUxoiVdh9B/poPbNpY9U+tO1N9oWHwTl5YAZxcqXmmWJ8KNMFItJCUUWdY3J3xv8LvAuQv2KZ5YdQ==} + + '@vercel/nft@0.29.2': + resolution: {integrity: sha512-A/Si4mrTkQqJ6EXJKv5EYCDQ3NL6nJXxG8VGXePsaiQigsomHYQC9xSpX8qGk7AEZk4b1ssbYIqJ0ISQQ7bfcA==} + engines: {node: '>=18'} + hasBin: true + + '@vercel/node@5.3.0': + resolution: {integrity: sha512-NeE5c7dRt9PXUzq7zUA+rj94l7AoXBw2cE+xK0hIoYDcWbIJVYBhbkBtzNdZx8CGncUJ2wMq01gn8pCwoQ0xYA==} + + '@vercel/static-config@3.1.1': + resolution: {integrity: sha512-IRtKnm9N1Uqd2ayIbLPjRtdwcl1GTWvqF1PuEVNm9O43kmoI+m9VpGlW8oga+5LQq1LmJ2Y67zHr7NbjrH1rrw==} + '@vitejs/plugin-react@4.6.0': resolution: {integrity: sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1280,22 +1385,42 @@ packages: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true + abbrev@3.0.1: + resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} + engines: {node: ^18.17.0 || >=20.5.0} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.6.3: + resolution: {integrity: sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==} + ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -1323,6 +1448,9 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1376,6 +1504,17 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-listen@3.0.0: + resolution: {integrity: sha512-V+SsTpDqkrWTimiotsyl33ePSjA5/KrithwupuvJ6ztsqPvGv6ge4OredFhPffVXiLN/QUWvE0XcqJaYgt6fOg==} + engines: {node: '>= 14'} + + async-listen@3.0.1: + resolution: {integrity: sha512-cWMaNwUJnf37C/S5TfCkk/15MwbPRwVYALA2jtjkbHjCmAPiDXyNJy2q3p1KAZzDLHAWyarUWSujUoHR4pEgrA==} + engines: {node: '>= 14'} + + async-sema@3.1.1: + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -1405,6 +1544,9 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -1484,6 +1626,10 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + chrome-launcher@0.14.2: resolution: {integrity: sha512-Nk8DUCIfPR6p9WClPPFeP2ztpAdkT8xueoiDS03csea1uoJjm4w0p5Oy1hjykyjT1EQ0MMrEshLD3C8gHXyiZw==} engines: {node: '>=12.13.0'} @@ -1491,6 +1637,9 @@ packages: ci-info@2.0.0: resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + cjs-module-lexer@1.2.3: + resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + cli-boxes@2.2.1: resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} engines: {node: '>=6'} @@ -1529,6 +1678,9 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + code-block-writer@10.1.1: + resolution: {integrity: sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1553,6 +1705,10 @@ packages: resolution: {integrity: sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==} engines: {node: '>=8'} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + conventional-changelog-angular@7.0.0: resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==} engines: {node: '>=16'} @@ -1566,6 +1722,10 @@ packages: engines: {node: '>=16'} hasBin: true + convert-hrtime@3.0.0: + resolution: {integrity: sha512-7V+KqSvMiHp8yWDuwfww06XleMWVVB9b9tURBx+G7UTADuo5hYPuowKloz4OzOqbPezxgo+fdQ1522WzPG4OeA==} + engines: {node: '>=8'} + convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} @@ -1601,6 +1761,9 @@ packages: typescript: optional: true + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1705,6 +1868,14 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + detect-libc@2.0.4: + resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} + engines: {node: '>=8'} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1727,6 +1898,14 @@ packages: duplexer3@0.1.5: resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + edge-runtime@2.5.9: + resolution: {integrity: sha512-pk+k0oK0PVXdlT4oRp4lwh+unuKB7Ng4iZ2HB+EZ7QCEQizX360Rp/F4aRpgpRgdP2ufB35N+1KppHmYjqIGSg==} + engines: {node: '>=16'} + hasBin: true + electron-to-chromium@1.5.172: resolution: {integrity: sha512-fnKW9dGgmBfsebbYognQSv0CGGLFH1a5iV9EDYTBwmAQn+whbzHbLFlC+3XbHc8xaNtpO0etm8LOcRXs1qMRkQ==} @@ -1773,6 +1952,9 @@ packages: resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} engines: {node: '>= 0.4'} + es-module-lexer@1.4.1: + resolution: {integrity: sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1789,6 +1971,131 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild-android-64@0.14.47: + resolution: {integrity: sha512-R13Bd9+tqLVFndncMHssZrPWe6/0Kpv2/dt4aA69soX4PRxlzsVpCvoJeFE8sOEoeVEiBkI0myjlkDodXlHa0g==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + esbuild-android-arm64@0.14.47: + resolution: {integrity: sha512-OkwOjj7ts4lBp/TL6hdd8HftIzOy/pdtbrNA4+0oVWgGG64HrdVzAF5gxtJufAPOsEjkyh1oIYvKAUinKKQRSQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + esbuild-darwin-64@0.14.47: + resolution: {integrity: sha512-R6oaW0y5/u6Eccti/TS6c/2c1xYTb1izwK3gajJwi4vIfNs1s8B1dQzI1UiC9T61YovOQVuePDcfqHLT3mUZJA==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + esbuild-darwin-arm64@0.14.47: + resolution: {integrity: sha512-seCmearlQyvdvM/noz1L9+qblC5vcBrhUaOoLEDDoLInF/VQ9IkobGiLlyTPYP5dW1YD4LXhtBgOyevoIHGGnw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + esbuild-freebsd-64@0.14.47: + resolution: {integrity: sha512-ZH8K2Q8/Ux5kXXvQMDsJcxvkIwut69KVrYQhza/ptkW50DC089bCVrJZZ3sKzIoOx+YPTrmsZvqeZERjyYrlvQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + esbuild-freebsd-arm64@0.14.47: + resolution: {integrity: sha512-ZJMQAJQsIOhn3XTm7MPQfCzEu5b9STNC+s90zMWe2afy9EwnHV7Ov7ohEMv2lyWlc2pjqLW8QJnz2r0KZmeAEQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + esbuild-linux-32@0.14.47: + resolution: {integrity: sha512-FxZOCKoEDPRYvq300lsWCTv1kcHgiiZfNrPtEhFAiqD7QZaXrad8LxyJ8fXGcWzIFzRiYZVtB3ttvITBvAFhKw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + esbuild-linux-64@0.14.47: + resolution: {integrity: sha512-nFNOk9vWVfvWYF9YNYksZptgQAdstnDCMtR6m42l5Wfugbzu11VpMCY9XrD4yFxvPo9zmzcoUL/88y0lfJZJJw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + esbuild-linux-arm64@0.14.47: + resolution: {integrity: sha512-ywfme6HVrhWcevzmsufjd4iT3PxTfCX9HOdxA7Hd+/ZM23Y9nXeb+vG6AyA6jgq/JovkcqRHcL9XwRNpWG6XRw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + esbuild-linux-arm@0.14.47: + resolution: {integrity: sha512-ZGE1Bqg/gPRXrBpgpvH81tQHpiaGxa8c9Rx/XOylkIl2ypLuOcawXEAo8ls+5DFCcRGt/o3sV+PzpAFZobOsmA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + esbuild-linux-mips64le@0.14.47: + resolution: {integrity: sha512-mg3D8YndZ1LvUiEdDYR3OsmeyAew4MA/dvaEJxvyygahWmpv1SlEEnhEZlhPokjsUMfRagzsEF/d/2XF+kTQGg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + esbuild-linux-ppc64le@0.14.47: + resolution: {integrity: sha512-WER+f3+szmnZiWoK6AsrTKGoJoErG2LlauSmk73LEZFQ/iWC+KhhDsOkn1xBUpzXWsxN9THmQFltLoaFEH8F8w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + esbuild-linux-riscv64@0.14.47: + resolution: {integrity: sha512-1fI6bP3A3rvI9BsaaXbMoaOjLE3lVkJtLxsgLHqlBhLlBVY7UqffWBvkrX/9zfPhhVMd9ZRFiaqXnB1T7BsL2g==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + esbuild-linux-s390x@0.14.47: + resolution: {integrity: sha512-eZrWzy0xFAhki1CWRGnhsHVz7IlSKX6yT2tj2Eg8lhAwlRE5E96Hsb0M1mPSE1dHGpt1QVwwVivXIAacF/G6mw==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + esbuild-netbsd-64@0.14.47: + resolution: {integrity: sha512-Qjdjr+KQQVH5Q2Q1r6HBYswFTToPpss3gqCiSw2Fpq/ua8+eXSQyAMG+UvULPqXceOwpnPo4smyZyHdlkcPppQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + esbuild-openbsd-64@0.14.47: + resolution: {integrity: sha512-QpgN8ofL7B9z8g5zZqJE+eFvD1LehRlxr25PBkjyyasakm4599iroUpaj96rdqRlO2ShuyqwJdr+oNqWwTUmQw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + esbuild-sunos-64@0.14.47: + resolution: {integrity: sha512-uOeSgLUwukLioAJOiGYm3kNl+1wJjgJA8R671GYgcPgCx7QR73zfvYqXFFcIO93/nBdIbt5hd8RItqbbf3HtAQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + esbuild-windows-32@0.14.47: + resolution: {integrity: sha512-H0fWsLTp2WBfKLBgwYT4OTfFly4Im/8B5f3ojDv1Kx//kiubVY0IQunP2Koc/fr/0wI7hj3IiBDbSrmKlrNgLQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + esbuild-windows-64@0.14.47: + resolution: {integrity: sha512-/Pk5jIEH34T68r8PweKRi77W49KwanZ8X6lr3vDAtOlH5EumPE4pBHqkCUdELanvsT14yMXLQ/C/8XPi1pAtkQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + esbuild-windows-arm64@0.14.47: + resolution: {integrity: sha512-HFSW2lnp62fl86/qPQlqw6asIwCnEsEoNIL1h2uVMgakddf+vUuMcCbtUY1i8sst7KkgHrVKCJQB33YhhOweCQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + esbuild@0.14.47: + resolution: {integrity: sha512-wI4ZiIfFxpkuxB8ju4MHrGwGLyp1+awEHAHVpx6w7a+1pmYIq8T9FGEVVwFo0iFierDoMj++Xq69GXWYn2EiwA==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.5: resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} engines: {node: '>=18'} @@ -1952,10 +2259,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -1994,6 +2308,9 @@ packages: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2027,6 +2344,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -2093,6 +2414,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -2193,6 +2518,10 @@ packages: http-parser-js@0.5.10: resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -2460,6 +2789,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.4.2: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true @@ -2492,6 +2824,9 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@1.6.4: + resolution: {integrity: sha512-pR4yQ9DHz6itqswtHCm26mw45FSNfQ9rEQjosaZErhn5J3J2sIViQiz8rDaezjKAhFGpmsoczYVBgGHzFw/stA==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -2654,6 +2989,9 @@ packages: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2665,6 +3003,9 @@ packages: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + map-obj@1.0.1: resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} engines: {node: '>=0.10.0'} @@ -2732,11 +3073,28 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.0.2: + resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} + engines: {node: '>= 18'} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} hasBin: true + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -2760,9 +3118,27 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-fetch@2.6.9: + resolution: {integrity: sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + nopt@8.1.0: + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -2873,6 +3249,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-json@6.5.0: resolution: {integrity: sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==} engines: {node: '>=8'} @@ -2888,6 +3267,13 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-ms@2.1.0: + resolution: {integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==} + engines: {node: '>=6'} + + 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'} @@ -2907,10 +3293,23 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@6.1.0: + resolution: {integrity: sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2948,6 +3347,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-ms@7.0.1: + resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} + engines: {node: '>=10'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3213,6 +3616,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.0.2: + resolution: {integrity: sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==} + engines: {node: '>=14'} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3276,6 +3683,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -3341,6 +3752,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tar@7.4.3: + resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} + engines: {node: '>=18'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -3358,6 +3773,10 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + time-span@4.0.0: + resolution: {integrity: sha512-MyqZCTGLDZ77u4k+jqg4UlrzPTPZ49NDlaekU6uuFaJLzPIN1woaRXCbGeqOfxwc3Y37ZROGAJ614Rdv7Olt+g==} + engines: {node: '>=10'} + timed-out@4.0.1: resolution: {integrity: sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA==} engines: {node: '>=0.10.0'} @@ -3377,6 +3796,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + trim-newlines@3.0.1: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} @@ -3387,6 +3809,26 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-morph@12.0.0: + resolution: {integrity: sha512-VHC8XgU2fFW7yO1f/b3mxKDje1vmyzFXHWzOYmKEkCEwcLjDtbdLgBQviqj4ZwP4MJkQtRo6Ha2I29lq/B+VxA==} + + ts-node@10.9.1: + resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + ts-toolbelt@6.15.5: + resolution: {integrity: sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==} + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -3444,6 +3886,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' + typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} @@ -3456,6 +3903,10 @@ packages: undici-types@7.8.0: resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + undici@5.28.4: + resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} + engines: {node: '>=14.0'} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -3496,6 +3947,9 @@ packages: deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. hasBin: true + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -3545,6 +3999,9 @@ packages: web-vitals@4.2.4: resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + websocket-driver@0.7.4: resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} engines: {node: '>=0.8.0'} @@ -3553,6 +4010,9 @@ packages: resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} engines: {node: '>=0.8.0'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3590,6 +4050,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.0: resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} engines: {node: '>=18'} @@ -3626,6 +4090,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} @@ -3651,6 +4119,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3906,6 +4378,24 @@ snapshots: '@types/conventional-commits-parser': 5.0.1 chalk: 5.4.1 + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@edge-runtime/format@2.2.1': {} + + '@edge-runtime/node-utils@2.3.0': {} + + '@edge-runtime/ponyfill@2.4.2': {} + + '@edge-runtime/primitives@4.1.0': {} + + '@edge-runtime/vm@3.2.0': + dependencies: + '@edge-runtime/primitives': 4.1.0 + + '@emailjs/browser@4.4.1': {} + '@emnapi/core@1.4.3': dependencies: '@emnapi/wasi-threads': 1.0.2 @@ -4105,6 +4595,8 @@ snapshots: '@eslint/js@9.29.0': {} + '@fastify/busboy@2.1.1': {} + '@feature-sliced/eslint-config@0.1.1(eslint-plugin-boundaries@5.0.1(@typescript-eslint/parser@8.35.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@4.4.3)(eslint@8.57.1))(eslint-plugin-import@2.32.0)': dependencies: eslint-plugin-boundaries: 5.0.1(@typescript-eslint/parser@8.35.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@4.4.3)(eslint@8.57.1) @@ -4452,6 +4944,19 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 @@ -4469,6 +4974,24 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@mapbox/node-pre-gyp@2.0.0': + dependencies: + consola: 3.4.2 + detect-libc: 2.0.4 + https-proxy-agent: 7.0.6 + node-fetch: 2.6.9 + nopt: 8.1.0 + semver: 7.7.2 + tar: 7.4.3 + transitivePeerDependencies: + - encoding + - supports-color + '@mui/core-downloads-tracker@7.1.2': {} '@mui/icons-material@7.1.2(@mui/material@7.1.2(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.8)(react@19.1.0)': @@ -4575,6 +5098,9 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@pkgjs/parseargs@0.11.0': + optional: true + '@popperjs/core@2.11.8': {} '@protobufjs/aspromise@1.1.2': {} @@ -4602,6 +5128,14 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.19': {} + '@rollup/pluginutils@5.2.0(rollup@4.44.0)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.2 + optionalDependencies: + rollup: 4.44.0 + '@rollup/rollup-android-arm-eabi@4.44.0': optional: true @@ -4677,6 +5211,21 @@ snapshots: '@tanstack/query-core': 5.81.2 react: 19.1.0 + '@ts-morph/common@0.11.1': + dependencies: + fast-glob: 3.3.3 + minimatch: 3.1.2 + mkdirp: 1.0.4 + path-browserify: 1.0.1 + + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -4711,6 +5260,8 @@ snapshots: '@types/history@4.7.11': {} + '@types/json-schema@7.0.15': {} + '@types/json5@0.0.29': {} '@types/keyv@3.1.4': @@ -4719,6 +5270,8 @@ snapshots: '@types/minimist@1.2.5': {} + '@types/node@16.18.11': {} + '@types/node@24.0.3': dependencies: undici-types: 7.8.0 @@ -4909,6 +5462,65 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.9.1': optional: true + '@vercel/build-utils@10.6.1': {} + + '@vercel/error-utils@2.0.3': {} + + '@vercel/nft@0.29.2(rollup@4.44.0)': + dependencies: + '@mapbox/node-pre-gyp': 2.0.0 + '@rollup/pluginutils': 5.2.0(rollup@4.44.0) + acorn: 8.15.0 + acorn-import-attributes: 1.9.5(acorn@8.15.0) + async-sema: 3.1.1 + bindings: 1.5.0 + estree-walker: 2.0.2 + glob: 10.4.5 + graceful-fs: 4.2.11 + node-gyp-build: 4.8.4 + picomatch: 4.0.2 + resolve-from: 5.0.0 + transitivePeerDependencies: + - encoding + - rollup + - supports-color + + '@vercel/node@5.3.0(rollup@4.44.0)': + dependencies: + '@edge-runtime/node-utils': 2.3.0 + '@edge-runtime/primitives': 4.1.0 + '@edge-runtime/vm': 3.2.0 + '@types/node': 16.18.11 + '@vercel/build-utils': 10.6.1 + '@vercel/error-utils': 2.0.3 + '@vercel/nft': 0.29.2(rollup@4.44.0) + '@vercel/static-config': 3.1.1 + async-listen: 3.0.0 + cjs-module-lexer: 1.2.3 + edge-runtime: 2.5.9 + es-module-lexer: 1.4.1 + esbuild: 0.14.47 + etag: 1.8.1 + node-fetch: 2.6.9 + path-to-regexp: 6.1.0 + path-to-regexp-updated: path-to-regexp@6.3.0 + ts-morph: 12.0.0 + ts-node: 10.9.1(@types/node@16.18.11)(typescript@4.9.5) + typescript: 4.9.5 + undici: 5.28.4 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - encoding + - rollup + - supports-color + + '@vercel/static-config@3.1.1': + dependencies: + ajv: 8.6.3 + json-schema-to-ts: 1.6.4 + ts-morph: 12.0.0 + '@vitejs/plugin-react@4.6.0(vite@6.3.5(@types/node@24.0.3)(jiti@2.4.2)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@babel/core': 7.27.4 @@ -4926,12 +5538,24 @@ snapshots: jsonparse: 1.3.1 through: 2.3.8 + abbrev@3.0.1: {} + + acorn-import-attributes@1.9.5(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + acorn@8.15.0: {} + agent-base@7.1.3: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -4946,6 +5570,13 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ajv@8.6.3: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + ansi-align@3.0.1: dependencies: string-width: 4.2.3 @@ -4966,6 +5597,8 @@ snapshots: ansi-styles@6.2.1: {} + arg@4.1.3: {} + argparse@2.0.1: {} aria-query@5.3.2: {} @@ -5045,6 +5678,12 @@ snapshots: async-function@1.0.0: {} + async-listen@3.0.0: {} + + async-listen@3.0.1: {} + + async-sema@3.1.1: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -5071,6 +5710,10 @@ snapshots: base64-js@1.5.1: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -5179,6 +5822,8 @@ snapshots: charenc@0.0.2: {} + chownr@3.0.0: {} + chrome-launcher@0.14.2: dependencies: '@types/node': 24.0.3 @@ -5190,6 +5835,8 @@ snapshots: ci-info@2.0.0: {} + cjs-module-lexer@1.2.3: {} + cli-boxes@2.2.1: {} cli-cursor@3.1.0: @@ -5227,6 +5874,8 @@ snapshots: clsx@2.1.1: {} + code-block-writer@10.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5253,6 +5902,8 @@ snapshots: write-file-atomic: 3.0.3 xdg-basedir: 4.0.0 + consola@3.4.2: {} + conventional-changelog-angular@7.0.0: dependencies: compare-func: 2.0.0 @@ -5268,6 +5919,8 @@ snapshots: meow: 12.1.1 split2: 4.2.0 + convert-hrtime@3.0.0: {} + convert-source-map@1.9.0: {} convert-source-map@2.0.0: {} @@ -5300,6 +5953,8 @@ snapshots: optionalDependencies: typescript: 5.8.3 + create-require@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5389,6 +6044,10 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + detect-libc@2.0.4: {} + + diff@4.0.2: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -5414,6 +6073,20 @@ snapshots: duplexer3@0.1.5: {} + eastasianwidth@0.2.0: {} + + edge-runtime@2.5.9: + dependencies: + '@edge-runtime/format': 2.2.1 + '@edge-runtime/ponyfill': 2.4.2 + '@edge-runtime/vm': 3.2.0 + async-listen: 3.0.1 + mri: 1.2.0 + picocolors: 1.0.0 + pretty-ms: 7.0.1 + signal-exit: 4.0.2 + time-span: 4.0.0 + electron-to-chromium@1.5.172: {} emoji-regex@10.4.0: {} @@ -5519,6 +6192,8 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 + es-module-lexer@1.4.1: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -5540,6 +6215,89 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild-android-64@0.14.47: + optional: true + + esbuild-android-arm64@0.14.47: + optional: true + + esbuild-darwin-64@0.14.47: + optional: true + + esbuild-darwin-arm64@0.14.47: + optional: true + + esbuild-freebsd-64@0.14.47: + optional: true + + esbuild-freebsd-arm64@0.14.47: + optional: true + + esbuild-linux-32@0.14.47: + optional: true + + esbuild-linux-64@0.14.47: + optional: true + + esbuild-linux-arm64@0.14.47: + optional: true + + esbuild-linux-arm@0.14.47: + optional: true + + esbuild-linux-mips64le@0.14.47: + optional: true + + esbuild-linux-ppc64le@0.14.47: + optional: true + + esbuild-linux-riscv64@0.14.47: + optional: true + + esbuild-linux-s390x@0.14.47: + optional: true + + esbuild-netbsd-64@0.14.47: + optional: true + + esbuild-openbsd-64@0.14.47: + optional: true + + esbuild-sunos-64@0.14.47: + optional: true + + esbuild-windows-32@0.14.47: + optional: true + + esbuild-windows-64@0.14.47: + optional: true + + esbuild-windows-arm64@0.14.47: + optional: true + + esbuild@0.14.47: + optionalDependencies: + esbuild-android-64: 0.14.47 + esbuild-android-arm64: 0.14.47 + esbuild-darwin-64: 0.14.47 + esbuild-darwin-arm64: 0.14.47 + esbuild-freebsd-64: 0.14.47 + esbuild-freebsd-arm64: 0.14.47 + esbuild-linux-32: 0.14.47 + esbuild-linux-64: 0.14.47 + esbuild-linux-arm: 0.14.47 + esbuild-linux-arm64: 0.14.47 + esbuild-linux-mips64le: 0.14.47 + esbuild-linux-ppc64le: 0.14.47 + esbuild-linux-riscv64: 0.14.47 + esbuild-linux-s390x: 0.14.47 + esbuild-netbsd-64: 0.14.47 + esbuild-openbsd-64: 0.14.47 + esbuild-sunos-64: 0.14.47 + esbuild-windows-32: 0.14.47 + esbuild-windows-64: 0.14.47 + esbuild-windows-arm64: 0.14.47 + esbuild@0.25.5: optionalDependencies: '@esbuild/aix-ppc64': 0.25.5 @@ -5789,8 +6547,12 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + esutils@2.0.3: {} + etag@1.8.1: {} + eventemitter3@5.0.1: {} fast-deep-equal@3.1.3: {} @@ -5825,6 +6587,8 @@ snapshots: dependencies: flat-cache: 3.2.0 + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -5892,6 +6656,11 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -5966,6 +6735,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -6064,6 +6842,13 @@ snapshots: http-parser-js@0.5.10: {} + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + husky@9.1.7: {} idb@7.1.1: {} @@ -6296,6 +7081,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jiti@2.4.2: {} jpeg-js@0.4.4: {} @@ -6316,6 +7107,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@1.6.4: + dependencies: + '@types/json-schema': 7.0.15 + ts-toolbelt: 6.15.5 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -6513,6 +7309,8 @@ snapshots: lowercase-keys@2.0.0: {} + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -6525,6 +7323,8 @@ snapshots: dependencies: semver: 6.3.1 + make-error@1.3.6: {} + map-obj@1.0.1: {} map-obj@4.3.0: {} @@ -6589,8 +7389,18 @@ snapshots: minimist@1.2.8: {} + minipass@7.1.2: {} + + minizlib@3.0.2: + dependencies: + minipass: 7.1.2 + mkdirp@1.0.4: {} + mkdirp@3.0.1: {} + + mri@1.2.0: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -6603,8 +7413,18 @@ snapshots: natural-compare@1.4.0: {} + node-fetch@2.6.9: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build@4.8.4: {} + node-releases@2.0.19: {} + nopt@8.1.0: + dependencies: + abbrev: 3.0.1 + normalize-package-data@2.5.0: dependencies: hosted-git-info: 2.8.9 @@ -6744,6 +7564,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + package-json@6.5.0: dependencies: got: 9.6.0 @@ -6764,6 +7586,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-ms@2.1.0: {} + + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-exists@5.0.0: {} @@ -6774,8 +7600,19 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-to-regexp@6.1.0: {} + + path-to-regexp@6.3.0: {} + path-type@4.0.0: {} + picocolors@1.0.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -6798,6 +7635,10 @@ snapshots: prettier@3.6.0: {} + pretty-ms@7.0.1: + dependencies: + parse-ms: 2.1.0 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -7117,6 +7958,8 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.0.2: {} + signal-exit@4.1.0: {} slice-ansi@5.0.0: @@ -7174,6 +8017,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + string-width@7.2.0: dependencies: emoji-regex: 10.4.0 @@ -7260,6 +8109,15 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tar@7.4.3: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.0.2 + mkdirp: 3.0.1 + yallist: 5.0.0 + term-size@2.2.1: {} text-extensions@2.4.0: {} @@ -7270,6 +8128,10 @@ snapshots: through@2.3.8: {} + time-span@4.0.0: + dependencies: + convert-hrtime: 3.0.0 + timed-out@4.0.1: {} tinyexec@1.0.1: {} @@ -7285,12 +8147,39 @@ snapshots: dependencies: is-number: 7.0.0 + tr46@0.0.3: {} + trim-newlines@3.0.1: {} ts-api-utils@2.1.0(typescript@5.8.3): dependencies: typescript: 5.8.3 + ts-morph@12.0.0: + dependencies: + '@ts-morph/common': 0.11.1 + code-block-writer: 10.1.1 + + ts-node@10.9.1(@types/node@16.18.11)(typescript@4.9.5): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 16.18.11 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 4.9.5 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + ts-toolbelt@6.15.5: {} + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -7366,6 +8255,8 @@ snapshots: transitivePeerDependencies: - supports-color + typescript@4.9.5: {} + typescript@5.8.3: {} unbox-primitive@1.1.0: @@ -7377,6 +8268,10 @@ snapshots: undici-types@7.8.0: {} + undici@5.28.4: + dependencies: + '@fastify/busboy': 2.1.1 + unicorn-magic@0.1.0: {} unique-string@2.0.0: @@ -7458,6 +8353,8 @@ snapshots: uuid@3.3.2: {} + v8-compile-cache-lib@3.0.1: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -7484,6 +8381,8 @@ snapshots: web-vitals@4.2.4: {} + webidl-conversions@3.0.1: {} + websocket-driver@0.7.4: dependencies: http-parser-js: 0.5.10 @@ -7492,6 +8391,11 @@ snapshots: websocket-extensions@0.1.4: {} + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -7551,6 +8455,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + wrap-ansi@9.0.0: dependencies: ansi-styles: 6.2.1 @@ -7576,6 +8486,8 @@ snapshots: yallist@4.0.0: {} + yallist@5.0.0: {} + yaml@1.10.2: {} yaml@2.8.0: {} @@ -7604,6 +8516,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yn@3.1.1: {} + yocto-queue@0.1.0: {} yocto-queue@1.2.1: {} diff --git a/src/entities/projects/api/getProjectLikeApi.ts b/src/entities/projects/api/getProjectLikeApi.ts index 48a0e7e..23414ca 100644 --- a/src/entities/projects/api/getProjectLikeApi.ts +++ b/src/entities/projects/api/getProjectLikeApi.ts @@ -143,14 +143,11 @@ export const deleteUserLikes = async ( ): Promise => { if (!userId || !projectIds.length) return; - const q = query( - collection(db, "likes"), - where("userId", "==", userId), - where("projectId", "in", projectIds) - ); - const snapshot = await getDocs(q); - const deletePromises = snapshot.docs.map((docSnap) => - deleteDoc(doc(db, "likes", docSnap.id)) - ); + // 문서 ID를 직접 생성하여 삭제 (더 효율적) + const deletePromises = projectIds.map((projectId) => { + const likeId = `${userId}_${projectId}`; + return deleteDoc(doc(db, "likes", likeId)); + }); + await Promise.all(deletePromises); }; diff --git a/src/entities/projects/api/projectsApi.ts b/src/entities/projects/api/projectsApi.ts index dba74cd..4446b70 100644 --- a/src/entities/projects/api/projectsApi.ts +++ b/src/entities/projects/api/projectsApi.ts @@ -11,6 +11,9 @@ import { doc, getDoc, where, + deleteDoc, + updateDoc, + arrayRemove, } from "firebase/firestore"; import { db } from "@shared/firebase/firebase"; @@ -111,3 +114,63 @@ export const getProjectsByIds = async ( ); return results; }; + +/** 여러 프로젝트를 완전히 삭제 (likes, applications, projects, users 컬렉션 모두) */ +export const deleteProjectsEverywhere = async ( + projectIds: string[], + userId: string +): Promise<{ success: boolean; error?: string }> => { + try { + // 모든 삭제 작업을 병렬로 실행하기 위한 함수들 + + // 좋아요 삭제 - 프로젝트 ID로 모든 좋아요 찾아서 삭제 + const deleteLikesForProject = async ( + projectId: string + ): Promise => { + const likesSnap = await getDocs( + query(collection(db, "likes"), where("projectId", "==", projectId)) + ); + return Promise.all(likesSnap.docs.map((doc) => deleteDoc(doc.ref))); + }; + + const deleteApplicationsForProject = async ( + projectId: string + ): Promise => { + const appsSnap = await getDocs( + query( + collection(db, "applications"), + where("projectId", "==", projectId) + ) + ); + return Promise.all(appsSnap.docs.map((doc) => deleteDoc(doc.ref))); + }; + + const deleteProject = async (projectId: string): Promise => { + return deleteDoc(doc(db, "projects", projectId)); + }; + + // 모든 작업을 병렬로 실행 + await Promise.all([ + // 1. likes 컬렉션에서 모든 프로젝트의 likes 삭제 (병렬) + ...projectIds.map(deleteLikesForProject), + + // 2. applications 컬렉션에서 모든 프로젝트의 applications 삭제 (병렬) + ...projectIds.map(deleteApplicationsForProject), + + // 3. projects 컬렉션에서 모든 프로젝트 삭제 (병렬) + ...projectIds.map(deleteProject), + + // 4. users 컬렉션에서 myProjects, likeProjects, appliedProjects에서 제거 + updateDoc(doc(db, "users", userId), { + myProjects: arrayRemove(...projectIds), + likeProjects: arrayRemove(...projectIds), + appliedProjects: arrayRemove(...projectIds), + }), + ]); + + return { success: true }; + } catch (err) { + console.error(err); + return { success: false, error: "프로젝트 완전 삭제 실패" }; + } +}; diff --git a/src/entities/projects/hooks/useDeleteProjectsMutation.ts b/src/entities/projects/hooks/useDeleteProjectsMutation.ts new file mode 100644 index 0000000..8aa358a --- /dev/null +++ b/src/entities/projects/hooks/useDeleteProjectsMutation.ts @@ -0,0 +1,204 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { UseMutationResult } from "@tanstack/react-query"; + +import { deleteApplication } from "@entities/projects/api/getProjectApplicationsApi"; +import { deleteUserLikes } from "@entities/projects/api/getProjectLikeApi"; +import { deleteProjectsEverywhere } from "@entities/projects/api/projectsApi"; + +import queryKeys from "@shared/react-query/queryKey"; +import { useLikeStore } from "@shared/stores/likeStore"; +import { useProjectStore } from "@shared/stores/projectStore"; +import { useSnackbarStore } from "@shared/stores/snackbarStore"; +import { ProjectCollectionTabType } from "@shared/types/project"; +import type { ProjectListRes } from "@shared/types/project"; + +interface DeleteProjectsParams { + type: ProjectCollectionTabType; + ids: string[]; + user: { uid: string } | null; + appliedProjectsData?: ProjectListRes[]; + myLikedProjectsData?: ProjectListRes[]; +} + +const ERROR_MSG = "프로젝트 삭제에 실패했습니다."; + +export const useDeleteProjectsMutation = (): UseMutationResult< + void, + unknown, + DeleteProjectsParams +> => { + const queryClient = useQueryClient(); + const { removeLikeProjects } = useLikeStore(); + const { setAppliedProjects, setLikeProjects } = useProjectStore(); + const { showSuccess, showError } = useSnackbarStore(); + + // 관심 프로젝트 삭제 + const deleteLikes = async ( + userUid: string, + ids: string[], + myLikedProjectsData?: ProjectListRes[] + ): Promise => { + await deleteUserLikes(userUid, ids); + + // 전역 상태 동기화 + removeLikeProjects(ids); + setLikeProjects( + myLikedProjectsData?.filter((p: ProjectListRes) => !ids.includes(p.id)) || + [] + ); + + showSuccess("관심 프로젝트가 삭제되었습니다."); + }; + + // 지원한 프로젝트 삭제 + const deleteApplied = async ( + userUid: string, + ids: string[], + appliedProjectsData?: ProjectListRes[] + ): Promise => { + // applications 컬렉션에서 제거 (병렬 처리) + const deletePromises = ids.map((projectId) => + deleteApplication(userUid, projectId) + ); + await Promise.all(deletePromises); + + // 전역 상태 동기화 + setAppliedProjects( + appliedProjectsData?.filter((p: ProjectListRes) => !ids.includes(p.id)) || + [] + ); + + showSuccess("지원한 프로젝트가 삭제되었습니다."); + }; + + // 만든 프로젝트 삭제 + const deleteCreated = async ( + userUid: string, + ids: string[], + appliedProjectsData?: ProjectListRes[], + myLikedProjectsData?: ProjectListRes[] + ): Promise => { + const res = await deleteProjectsEverywhere(ids, userUid); + + if (!res.success) { + showError(res.error || ERROR_MSG); + throw new Error(res.error || ERROR_MSG); + } + + // 전역 상태 동기화 + setAppliedProjects( + appliedProjectsData?.filter((p: ProjectListRes) => !ids.includes(p.id)) || + [] + ); + setLikeProjects( + myLikedProjectsData?.filter((p: ProjectListRes) => !ids.includes(p.id)) || + [] + ); + removeLikeProjects(ids); + + showSuccess("만든 프로젝트가 삭제되었습니다."); + }; + + // 쿼리 무효화 함수들 + const invalidateLikeQueries = async (): Promise => { + const queries = [ + [queryKeys.myLikedProjects, "details"], + [queryKeys.myLikedProjects, "ids"], + [queryKeys.projectLike], + [queryKeys.projectLikedUser], + [queryKeys.projects], // 홈페이지, 프로젝트 찾기 페이지 동기화 + ]; + + await Promise.all( + queries.map((queryKey) => queryClient.invalidateQueries({ queryKey })) + ); + }; + + const invalidateAppliedQueries = async (): Promise => { + const queries = [ + [queryKeys.myAppliedProjects, "details"], + [queryKeys.myAppliedProjects, "ids"], + [queryKeys.projectAppliedUser], + ]; + + await Promise.all( + queries.map((queryKey) => queryClient.invalidateQueries({ queryKey })) + ); + }; + + const invalidateCreatedQueries = async (userUid: string): Promise => { + const queries = [ + [queryKeys.myLikedProjects, "details"], + [queryKeys.myAppliedProjects, "details"], + [queryKeys.projects], + ["userProfile", userUid], + [queryKeys.projectLike], + [queryKeys.projectLikedUser], + [queryKeys.projectAppliedUser], + ]; + + await Promise.all( + queries.map((queryKey) => queryClient.invalidateQueries({ queryKey })) + ); + }; + + // 타입별 쿼리 무효화 + const invalidateQueries = async ( + type: ProjectCollectionTabType, + user?: { uid: string } | null + ): Promise => { + switch (type) { + case ProjectCollectionTabType.Likes: + await invalidateLikeQueries(); + break; + case ProjectCollectionTabType.Applied: + await invalidateAppliedQueries(); + break; + case ProjectCollectionTabType.Created: + if (user) { + await invalidateCreatedQueries(user.uid); + } + break; + } + }; + + // 메인 삭제 로직 + const handleDelete = async ({ + type, + ids, + user, + appliedProjectsData, + myLikedProjectsData, + }: DeleteProjectsParams): Promise => { + if (!user) { + throw new Error("로그인이 필요합니다."); + } + + switch (type) { + case ProjectCollectionTabType.Likes: + await deleteLikes(user.uid, ids, myLikedProjectsData); + break; + case ProjectCollectionTabType.Applied: + await deleteApplied(user.uid, ids, appliedProjectsData); + break; + case ProjectCollectionTabType.Created: + await deleteCreated( + user.uid, + ids, + appliedProjectsData, + myLikedProjectsData + ); + break; + } + }; + + return useMutation({ + mutationFn: handleDelete, + onSuccess: async (_data, variables): Promise => { + await invalidateQueries(variables.type, variables.user); + }, + onError: (error: any): void => { + showError(error?.message || ERROR_MSG); + }, + }); +}; diff --git a/src/entities/projects/queries/useGetProjectLike.ts b/src/entities/projects/queries/useGetProjectLike.ts index 01d9e93..0127111 100644 --- a/src/entities/projects/queries/useGetProjectLike.ts +++ b/src/entities/projects/queries/useGetProjectLike.ts @@ -7,6 +7,7 @@ import { getProjectLikedUsers, getProjectLikeStatus, } from "@entities/projects/api/getProjectLikeApi"; +import { getProjectsByIds } from "@entities/projects/api/projectsApi"; import queryKeys from "@shared/react-query/queryKey"; import { useAuthStore } from "@shared/stores/authStore"; @@ -62,3 +63,13 @@ export const useGetMyLikedProjectsWithDetails = (): UseQueryResult< enabled: !!user, }); }; + +export const useGetMyCreatedProjectsWithDetails = ( + myProjectsIds?: string[] +): UseQueryResult => { + return useQuery({ + queryKey: [queryKeys.myCreatedProjects, "details", myProjectsIds], + queryFn: () => getProjectsByIds(myProjectsIds || []), + enabled: !!myProjectsIds && myProjectsIds.length > 0, + }); +}; diff --git a/src/entities/projects/ui/post-info/ProjectLeader.tsx b/src/entities/projects/ui/post-info/ProjectLeader.tsx index 0adff85..e242086 100644 --- a/src/entities/projects/ui/post-info/ProjectLeader.tsx +++ b/src/entities/projects/ui/post-info/ProjectLeader.tsx @@ -2,15 +2,22 @@ import MailOutlineIcon from "@mui/icons-material/MailOutline"; import ThumbUpOffAltIcon from "@mui/icons-material/ThumbUpOffAlt"; import { Box, styled, Typography } from "@mui/material"; import type { JSX } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useAuthStore } from "@shared/stores/authStore"; import type { User } from "@shared/types/user"; import TitleWithIcon from "@shared/ui/project-detail/TitleWithIcon"; const ProjectLeader = ({ projectOwner, + onEmailClick, }: { projectOwner: User | undefined; + onEmailClick?: () => void; }): JSX.Element | null => { + const navigate = useNavigate(); + const location = useLocation(); + const { user } = useAuthStore(); if (!projectOwner) return null; return ( @@ -40,8 +47,18 @@ const ProjectLeader = ({ {projectOwner.introduceMyself || "아직 등록한 소개가 없어요! 🚀"} - + { + if (!user) { + navigate( + `/login?redirect=${encodeURIComponent(location.pathname)}` + ); + } + onEmailClick?.(); + }} + > + {} 연락하기 diff --git a/src/entities/projects/ui/project-insert/ProjectCategoryCard.tsx b/src/entities/projects/ui/project-insert/ProjectCategoryCard.tsx index 16f18c7..e9f71ed 100644 --- a/src/entities/projects/ui/project-insert/ProjectCategoryCard.tsx +++ b/src/entities/projects/ui/project-insert/ProjectCategoryCard.tsx @@ -3,11 +3,12 @@ import type { SelectChangeEvent } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import type { CSSProperties, JSX } from "react"; +import { ProjectCategory } from "@shared/types/project"; import SimpleFormCard from "@shared/ui/project-insert/SimpleFormCard"; interface ProjectCategoryCardProps { - value: string; - onChange: (event: SelectChangeEvent) => void; + value: ProjectCategory | ""; + onChange: (event: SelectChangeEvent) => void; large?: boolean; style?: CSSProperties; } @@ -53,14 +54,31 @@ const ProjectCategoryCard = ({ - 웹 서비스 - 모바일 앱 - AI/머신러닝 - 블록체인 - 게임 - 디자인 - IoT/하드웨어 - 기타 + + {ProjectCategory.webDevelopment} + + + {ProjectCategory.mobileDevelopment} + + + {ProjectCategory.aiMl} + + + {ProjectCategory.blockchain} + + + {ProjectCategory.gameDevelopment} + + + {ProjectCategory.dataScience} + + + {ProjectCategory.iotHardware} + + + {ProjectCategory.webDesign} + + {ProjectCategory.etc} diff --git a/src/entities/projects/ui/project-insert/ProjectPreferentialCard.tsx b/src/entities/projects/ui/project-insert/ProjectPreferentialCard.tsx index 747765c..4ad5d2f 100644 --- a/src/entities/projects/ui/project-insert/ProjectPreferentialCard.tsx +++ b/src/entities/projects/ui/project-insert/ProjectPreferentialCard.tsx @@ -7,6 +7,7 @@ import type { JSX, FocusEvent, MouseEvent, + KeyboardEvent, } from "react"; import { useState } from "react"; @@ -35,6 +36,13 @@ const ProjectPreferentialCard = ({ } }; + const handleKeyPress = (e: KeyboardEvent): void => { + if (e.key === "Enter") { + e.preventDefault(); + addPreferential(); + } + }; + const removePreferential = (preferentialToRemove: string): void => { onChange(value.filter((pref) => pref !== preferentialToRemove)); }; @@ -55,6 +63,7 @@ const ProjectPreferentialCard = ({ onChange={(e: ChangeEvent) => setNewPreferential(e.target.value) } + onKeyUp={handleKeyPress} placeholder="예: AWS, Docker, 스타트업 경험..." style={{ flex: 1, diff --git a/src/entities/projects/ui/project-insert/ProjectScheduleManagementCard.tsx b/src/entities/projects/ui/project-insert/ProjectScheduleManagementCard.tsx index 24c52c1..c7edad4 100644 --- a/src/entities/projects/ui/project-insert/ProjectScheduleManagementCard.tsx +++ b/src/entities/projects/ui/project-insert/ProjectScheduleManagementCard.tsx @@ -15,8 +15,10 @@ import { import type { SelectChangeEvent } from "@mui/material"; import type { CSSProperties, JSX, ChangeEvent } from "react"; -import type { ProjectSchedule } from "@shared/types/schedule"; -import { ExpectedPeriod } from "@shared/types/schedule"; +import { + NewSchedulePeriod, + type ProjectSchedule, +} from "@shared/types/schedule"; import SimpleFormCard from "@shared/ui/project-insert/SimpleFormCard"; interface ProjectScheduleManagementCardProps { @@ -27,17 +29,19 @@ interface ProjectScheduleManagementCardProps { } const PERIOD_OPTIONS = [ - { value: ExpectedPeriod.oneMonth, label: "1개월" }, - { value: ExpectedPeriod.twoMonths, label: "2개월" }, - { value: ExpectedPeriod.threeMonths, label: "3개월" }, - { value: ExpectedPeriod.fourMonths, label: "4개월" }, - { value: ExpectedPeriod.sixMonths, label: "6개월" }, - { value: ExpectedPeriod.moreThanSixMonths, label: "6개월 이상" }, + { value: NewSchedulePeriod.oneWeek, label: "1주" }, + { value: NewSchedulePeriod.twoWeeks, label: "2주" }, + { value: NewSchedulePeriod.threeWeeks, label: "3주" }, + { value: NewSchedulePeriod.fourWeeks, label: "4주" }, + { value: NewSchedulePeriod.twoMonths, label: "2개월" }, + { value: NewSchedulePeriod.threeMonths, label: "3개월" }, + { value: NewSchedulePeriod.lessThanSixMonths, label: "6개월 미만" }, + { value: NewSchedulePeriod.moreThanSixMonths, label: "6개월 이상" }, ]; const INIT_SCHEDULE: ProjectSchedule = { stageName: "", - period: ExpectedPeriod.oneMonth, + period: NewSchedulePeriod.oneWeek, description: "", }; @@ -70,7 +74,7 @@ const ProjectScheduleManagementCard = ({ const updateSchedule = ( index: number, field: keyof ProjectSchedule, - newValue: string | ExpectedPeriod + newValue: string | NewSchedulePeriod ): void => { const currentSchedules = value.length === 0 ? [INIT_SCHEDULE] : value; const newSchedules = [...currentSchedules]; @@ -129,13 +133,13 @@ const ProjectScheduleManagementCard = ({ {/* 기간 선택 */} 기간 - + value={schedule.period} - onChange={(e: SelectChangeEvent) => + onChange={(e: SelectChangeEvent) => updateSchedule( index, "period", - e.target.value as ExpectedPeriod + e.target.value as NewSchedulePeriod ) } label="기간" diff --git a/src/entities/projects/ui/projects-card/ProjectCard.tsx b/src/entities/projects/ui/projects-card/ProjectCard.tsx index 733f02c..6ca6268 100644 --- a/src/entities/projects/ui/projects-card/ProjectCard.tsx +++ b/src/entities/projects/ui/projects-card/ProjectCard.tsx @@ -1,3 +1,4 @@ +import { Favorite as FavoriteIcon } from "@mui/icons-material"; import { Box, Button, @@ -14,7 +15,10 @@ import { memo } from "react"; import { useNavigate } from "react-router-dom"; import { useGetProjectApplicationUsers } from "@entities/projects/queries/useGetProjectApplications"; -import { useGetProjectLikedUsers } from "@entities/projects/queries/useGetProjectLike"; +import { + useGetProjectLikedUsers, + useGetMyLikedProjectsIds, +} from "@entities/projects/queries/useGetProjectLike"; import { RecruitmentStatus, type ProjectListRes } from "@shared/types/project"; import DragScrollContainer from "@shared/ui/DragScrollContainer"; @@ -39,10 +43,13 @@ const ProjectCard = ({ const isRecruiting = project.status === RecruitmentStatus.recruiting; const { data: likedUsers } = useGetProjectLikedUsers(project.id); const { data: appliedUsers } = useGetProjectApplicationUsers(project.id); + const { data: myLikedProjectIds } = useGetMyLikedProjectsIds(); const likedUserCnt = likedUsers?.length || 0; const appliedUsersCnt = appliedUsers?.length || 0; + const isLikedByCurrentUser = myLikedProjectIds?.includes(project.id) || false; + return ( - + {isLikedByCurrentUser ? ( + + ) : ( + + )} {likedUserCnt} diff --git a/src/entities/projects/ui/projects-detail/ProjectDescription.tsx b/src/entities/projects/ui/projects-detail/ProjectDescription.tsx index a983be1..98647e8 100644 --- a/src/entities/projects/ui/projects-detail/ProjectDescription.tsx +++ b/src/entities/projects/ui/projects-detail/ProjectDescription.tsx @@ -13,7 +13,9 @@ const ProjectDescription = ({ return ( <> - {description} + + {description} + ); }; diff --git a/src/entities/search/hooks/useProjectSearch.ts b/src/entities/search/hooks/useProjectSearch.ts index 37c6fe2..1f58eae 100644 --- a/src/entities/search/hooks/useProjectSearch.ts +++ b/src/entities/search/hooks/useProjectSearch.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, type RefObject } from "react"; +import { useState, useCallback, useEffect, type RefObject } from "react"; import { useProjectsByPage, @@ -40,6 +40,8 @@ const useProjectSearch = ( } ); + const [shouldScrollAfterLoad, setShouldScrollAfterLoad] = useState(false); + const { data: totalCount = 0, isLoading: isCountLoading, @@ -61,6 +63,17 @@ const useProjectSearch = ( const isLoading = isProjectsLoading || isCountLoading; const isError = isProjectsError || isCountError; + useEffect(() => { + if (!isLoading && shouldScrollAfterLoad && resultsRef?.current) { + setTimeout(() => { + if (resultsRef?.current) { + scrollToElement(resultsRef.current, "smooth", 80); + } + }, 200); + setShouldScrollAfterLoad(false); + } + }, [isLoading, shouldScrollAfterLoad, resultsRef]); + const handleSearch = useCallback( (filter: ProjectSearchFilterOption): void => { setCurrentFilter(filter); @@ -70,15 +83,14 @@ const useProjectSearch = ( ); const handlePageChange = (page: number): void => { - const isSamePage = page === currentPage; - - if (isSamePage || isLoading) return; - - setPage(page); - if (resultsRef?.current) { scrollToElement(resultsRef.current, "smooth", 80); } + + if (page !== currentPage) { + setPage(page); + setShouldScrollAfterLoad(true); + } }; return { diff --git a/src/entities/search/ui/SearchForm.tsx b/src/entities/search/ui/SearchForm.tsx index 63372d2..e0b2e91 100644 --- a/src/entities/search/ui/SearchForm.tsx +++ b/src/entities/search/ui/SearchForm.tsx @@ -125,6 +125,7 @@ const HeaderSection = styled(Box)(({ theme }) => ({ flexDirection: "column", gap: theme.spacing(3), alignItems: "stretch", + padding: theme.spacing(2), }, })); @@ -143,6 +144,10 @@ const StatusArea = styled(Box, { const SearchSection = styled(Box)(({ theme }) => ({ padding: `${theme.spacing(2)} ${theme.spacing(4)}`, backgroundColor: theme.palette.background.paper, + + [theme.breakpoints.down("md")]: { + padding: theme.spacing(1, 2), + }, })); const SearchContainer = styled(Box)(() => ({ @@ -152,6 +157,9 @@ const SearchContainer = styled(Box)(() => ({ const FiltersSection = styled(Box)(({ theme }) => ({ padding: `0 ${theme.spacing(4)} ${theme.spacing(3)}`, backgroundColor: theme.palette.background.paper, + [theme.breakpoints.down("md")]: { + padding: theme.spacing(1, 2), + }, })); const ActionSection = styled(Box)(({ theme }) => ({ diff --git a/src/entities/search/ui/SearchInputHistory.tsx b/src/entities/search/ui/SearchInputHistory.tsx index b492e23..dda91fc 100644 --- a/src/entities/search/ui/SearchInputHistory.tsx +++ b/src/entities/search/ui/SearchInputHistory.tsx @@ -34,18 +34,18 @@ interface SearchInputHistoryProps { const HistoryDisabledMessage = (): JSX.Element => ( - + 검색 히스토리가 비활성화되어 있습니다 - + ); const HistoryEmptyMessage = (): JSX.Element => ( - + 검색 히스토리가 없습니다 - + ); @@ -79,7 +79,7 @@ const HistoryListContent = ({ - - + 검색 히스토리 - + ({ "&:hover": { color: theme.palette.primary.main, }, + [theme.breakpoints.down("sm")]: { + fontSize: "0.875rem", + }, })); const StyledDeleteButton = styled(IconButton)(({ theme }) => ({ @@ -341,4 +344,30 @@ const StyledDeleteButton = styled(IconButton)(({ theme }) => ({ }, })); +const DisabledMessageText = styled(Typography)(({ theme }) => ({ + [theme.breakpoints.down("sm")]: { + fontSize: "1.3rem", + }, +})); + +const EmptyMessageText = styled(Typography)(({ theme }) => ({ + [theme.breakpoints.down("sm")]: { + fontSize: "1.3rem", + }, +})); + +const StyledListItemText = styled(ListItemText)(({ theme }) => ({ + "& .MuiListItemText-primary": { + [theme.breakpoints.down("sm")]: { + fontSize: "1.4rem", + }, + }, +})); + +const HeaderTitle = styled(Typography)(({ theme }) => ({ + [theme.breakpoints.down("sm")]: { + fontSize: "1.3rem", + }, +})); + export default SearchInputHistory; diff --git a/src/features/email/api/emailApi.ts b/src/features/email/api/emailApi.ts new file mode 100644 index 0000000..7733797 --- /dev/null +++ b/src/features/email/api/emailApi.ts @@ -0,0 +1,66 @@ +import emailjs from "@emailjs/browser"; + +import type { + SendEmailRequest, + SendEmailResponse, +} from "@features/email/types/email"; + +const EMAIL_SERVICE_ID = import.meta.env.VITE_EMAIL_SERVICE_ID || ""; +const EMAIL_TEMPLATE_ID = import.meta.env.VITE_EMAIL_TEMPLATE_ID || ""; +const EMAIL_PUBLIC_KEY = import.meta.env.VITE_EMAIL_PUBLIC_KEY || ""; + +emailjs.init(EMAIL_PUBLIC_KEY); + +export const sendEmailApi = async ({ + actualSenderEmail, + receiverEmail, + projectId, + projectTitle, + emailData, +}: SendEmailRequest): Promise => { + try { + const templateParams = { + to_name: receiverEmail.split("@")[0], + to_email: receiverEmail, + + from_name: actualSenderEmail.split("@")[0], + from_email: actualSenderEmail, + + subject: emailData.subject, + message: emailData.message, + + project_title: projectTitle, + project_id: projectId, + + reply_to: actualSenderEmail, + }; + + const result = await emailjs.send( + EMAIL_SERVICE_ID, + EMAIL_TEMPLATE_ID, + templateParams + ); + + return { + success: true, + message: result.text, + }; + } catch (error) { + let errorMessage = "이메일 전송에 실패했습니다. 다시 시도해주세요."; + + if (error instanceof Error) { + if (error.message.includes("template")) { + errorMessage = "이메일 템플릿 설정에 문제가 있습니다."; + } else if (error.message.includes("service")) { + errorMessage = "이메일 서비스 설정에 문제가 있습니다."; + } else if (error.message.includes("user")) { + errorMessage = "이메일 서비스 인증에 실패했습니다."; + } + } + + return { + success: false, + message: errorMessage, + }; + } +}; diff --git a/src/features/email/hooks/useEmailForm.ts b/src/features/email/hooks/useEmailForm.ts new file mode 100644 index 0000000..da9ddd1 --- /dev/null +++ b/src/features/email/hooks/useEmailForm.ts @@ -0,0 +1,98 @@ +import { useState, useCallback, type ChangeEvent } from "react"; + +import useSendEmail from "@features/email/queries/useSendEmail"; +import type { + UseEmailFormProps, + UseEmailFormReturn, +} from "@features/email/types/email"; + +const useEmailForm = ({ + senderEmail, + receiverEmail, + project, + onClose, +}: UseEmailFormProps): UseEmailFormReturn => { + const [isOpen, setIsOpen] = useState(false); + const [subject, setSubject] = useState(""); + const [message, setMessage] = useState(""); + + const sendEmailMutation = useSendEmail(); + + const projectId = project?.id || ""; + const projectTitle = project?.title || ""; + + const openModal = useCallback((): void => { + setIsOpen(true); + }, []); + + const closeModal = useCallback((): void => { + setIsOpen(false); + onClose?.(); + }, [onClose]); + + const handleSubjectChange = useCallback( + (e: ChangeEvent): void => { + setSubject(e.target.value); + }, + [] + ); + + const handleMessageChange = useCallback( + (e: ChangeEvent): void => { + setMessage(e.target.value); + }, + [] + ); + + const resetForm = useCallback((): void => { + setSubject(""); + setMessage(""); + }, []); + + const handleSend = async (): Promise => { + const isEmpty = !subject.trim() || !message.trim(); + if (isEmpty) return; + + sendEmailMutation.mutate( + { + actualSenderEmail: senderEmail, + receiverEmail, + projectId, + projectTitle, + emailData: { + subject, + message, + }, + }, + { + onSuccess: (data) => { + if (data.success) { + resetForm(); + closeModal(); + } + }, + } + ); + }; + + const handleCancel = useCallback((): void => { + resetForm(); + closeModal(); + }, [resetForm, closeModal]); + + return { + isOpen, + isLoading: sendEmailMutation.isPending, + subject, + message, + openModal, + closeModal, + handleSubjectChange, + handleMessageChange, + handleSend, + handleCancel, + resetForm, + }; +}; + +export default useEmailForm; diff --git a/src/features/email/queries/useSendEmail.ts b/src/features/email/queries/useSendEmail.ts new file mode 100644 index 0000000..770f6b6 --- /dev/null +++ b/src/features/email/queries/useSendEmail.ts @@ -0,0 +1,33 @@ +import { useMutation, type UseMutationResult } from "@tanstack/react-query"; + +import { sendEmailApi } from "@features/email/api/emailApi"; +import type { + SendEmailRequest, + SendEmailResponse, +} from "@features/email/types/email"; + +import { useSnackbarStore } from "@shared/stores/snackbarStore"; + +const useSendEmail = (): UseMutationResult< + SendEmailResponse, + Error, + SendEmailRequest +> => { + const { showSuccess, showError } = useSnackbarStore(); + + return useMutation({ + mutationFn: sendEmailApi, + onSuccess: (data) => { + if (data.success) { + showSuccess("이메일이 성공적으로 전송되었습니다."); + } else { + showError(data.message); + } + }, + onError: (error) => { + showError(error.message || "이메일 전송 중 오류가 발생했습니다."); + }, + }); +}; + +export default useSendEmail; diff --git a/src/features/email/types/email.ts b/src/features/email/types/email.ts new file mode 100644 index 0000000..2455bc3 --- /dev/null +++ b/src/features/email/types/email.ts @@ -0,0 +1,42 @@ +import type { ChangeEvent } from "react"; + +import type { ProjectListRes } from "@shared/types/project"; + +export interface EmailData { + subject: string; + message: string; +} + +export interface UseEmailFormProps { + senderEmail: string; + receiverEmail: string; + project: ProjectListRes | null; + onClose?: () => void; +} + +export interface UseEmailFormReturn { + isOpen: boolean; + isLoading: boolean; + subject: string; + message: string; + openModal: () => void; + closeModal: () => void; + handleSubjectChange: (e: ChangeEvent) => void; + handleMessageChange: (e: ChangeEvent) => void; + handleSend: () => Promise; + handleCancel: () => void; + resetForm: () => void; +} + +export interface SendEmailRequest { + actualSenderEmail: string; + receiverEmail: string; + projectId: string; + projectTitle: string; + emailData: EmailData; +} + +export interface SendEmailResponse { + success: boolean; + message: string; +} diff --git a/src/features/email/ui/EmailField.tsx b/src/features/email/ui/EmailField.tsx new file mode 100644 index 0000000..fa04a61 --- /dev/null +++ b/src/features/email/ui/EmailField.tsx @@ -0,0 +1,25 @@ +import { TextField } from "@mui/material"; +import { memo, type JSX } from "react"; + +interface EmailFieldProps { + label: string; + value: string; +} + +const EmailFieldComponent = ({ + label, + value, +}: EmailFieldProps): JSX.Element => ( + +); + +const EmailField = memo(EmailFieldComponent); + +export default EmailField; diff --git a/src/features/email/ui/EmailModal.tsx b/src/features/email/ui/EmailModal.tsx new file mode 100644 index 0000000..6df88de --- /dev/null +++ b/src/features/email/ui/EmailModal.tsx @@ -0,0 +1,143 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, +} from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { type JSX } from "react"; + +import useEmailForm from "@features/email/hooks/useEmailForm"; +import EmailField from "@features/email/ui/EmailField"; +import MessageField from "@features/email/ui/MessageField"; +import SubjectField from "@features/email/ui/SubjectField"; + +import type { ProjectListRes } from "@shared/types/project"; + +interface EmailModalProps { + open: boolean; + onClose: () => void; + senderEmail: string; + receiverEmail: string; + project: ProjectListRes | null; +} + +const EmailModal = ({ + open, + onClose, + senderEmail, + receiverEmail, + project, +}: EmailModalProps): JSX.Element => { + const { + isLoading, + subject, + message, + handleSubjectChange, + handleMessageChange, + handleSend, + handleCancel, + } = useEmailForm({ + senderEmail, + receiverEmail, + project, + onClose, + }); + + const isFormValid = subject.trim().length > 0 && message.trim().length > 0; + + return ( + + + 📧 이메일 보내기 + + + + + + + + + + + + + 취소 + + + {isLoading ? "전송 중..." : "전송"} + + + + + ); +}; + +export default EmailModal; + +const StyledDialog = styled(Dialog)({ + zIndex: 9999, + "& .MuiDialog-paper": { + width: "600px", + maxWidth: "90vw", + }, +}); + +const Title = styled(Typography)({ + textAlign: "center", + fontSize: "1.8rem", + fontWeight: 600, + marginBottom: "8px", +}); + +const FormContainer = styled(Box)({ + display: "flex", + flexDirection: "column", + gap: "20px", + paddingTop: "16px", +}); + +const ButtonContainer = styled(Box)({ + display: "flex", + gap: "12px", + justifyContent: "flex-end", + width: "100%", +}); + +const CancelButton = styled(Button)({ + padding: "12px 24px", + border: "1px solid #ccc", + backgroundColor: "#fff", + color: "#666", + fontSize: "1.4rem", + fontWeight: 500, + borderRadius: "8px", + transition: "all 0.2s ease", + "&:hover": { + backgroundColor: "#f5f5f5", + borderColor: "#999", + }, +}); + +const SendButton = styled(Button)({ + padding: "12px 24px", + border: "1px solid #1976d2", + backgroundColor: "#1976d2", + color: "#fff", + fontSize: "1.4rem", + fontWeight: 500, + borderRadius: "8px", + transition: "all 0.2s ease", + "&:hover": { + backgroundColor: "#1565c0", + borderColor: "#1565c0", + }, + "&:disabled": { + backgroundColor: "#e0e0e0", + borderColor: "#e0e0e0", + color: "#9e9e9e", + }, +}); diff --git a/src/features/email/ui/MessageField.tsx b/src/features/email/ui/MessageField.tsx new file mode 100644 index 0000000..f1107d6 --- /dev/null +++ b/src/features/email/ui/MessageField.tsx @@ -0,0 +1,31 @@ +import { TextField } from "@mui/material"; +import { memo, type JSX, type ChangeEvent } from "react"; + +interface MessageFieldProps { + value: string; + onChange: (e: ChangeEvent) => void; +} + +const MessageFieldComponent = ({ + value, + onChange, +}: MessageFieldProps): JSX.Element => ( + +); + +const MessageField = memo(MessageFieldComponent); + +export default MessageField; diff --git a/src/features/email/ui/PositionSelect.tsx b/src/features/email/ui/PositionSelect.tsx new file mode 100644 index 0000000..3b61ea7 --- /dev/null +++ b/src/features/email/ui/PositionSelect.tsx @@ -0,0 +1,76 @@ +import { FormControl, InputLabel, Select, MenuItem } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { memo, type JSX } from "react"; + +import { RecruitmentStatus, type Positions } from "@shared/types/project"; +import type { UserRole } from "@shared/types/user"; + +interface PositionSelectProps { + value: string; + onChange: (value: string) => void; + projectPositions: Positions[]; +} + +// UserRole에 따른 한글 라벨 매핑 +const POSITION_LABELS: Record = { + frontend: "프론트엔드 개발자", + backend: "백엔드 개발자", + fullstack: "풀스택 개발자", + designer: "UI/UX 디자이너", + pm: "프로젝트 매니저", +}; + +const PositionSelectComponent = ({ + value, + onChange, + projectPositions, +}: PositionSelectProps): JSX.Element => { + // 모집중인 포지션들만 필터링 (status가 undefined이거나 recruiting인 것들) + const availablePositions = (projectPositions || []).filter( + (pos) => !pos.status || pos.status === RecruitmentStatus.recruiting + ); + + return ( + + 💼 지원 포지션 + onChange(e.target.value as UserRole | "")} + label="💼 지원 포지션" + disabled={availablePositions.length === 0} + > + {availablePositions.length === 0 ? ( + + 현재 모집중인 포지션이 없습니다 + + ) : ( + availablePositions.map((position, index) => { + const uniqueValue = `${position.position}-${index}`; + return ( + + {POSITION_LABELS[position.position]} ({position.count}명 모집) + {position.experience && ` - ${position.experience}`} + + ); + }) + )} + + + ); +}; + +const PositionSelect = memo(PositionSelectComponent); + +export default PositionSelect; + +const StyledSelect = styled(Select)({ + fontSize: "1.4rem", + fontFamily: "inherit", + "&:hover .MuiOutlinedInput-notchedOutline": { + borderColor: "#222", + }, + "&.Mui-focused .MuiOutlinedInput-notchedOutline": { + borderColor: "#1976d2", + borderWidth: "2px", + }, +}); diff --git a/src/features/email/ui/SubjectField.tsx b/src/features/email/ui/SubjectField.tsx new file mode 100644 index 0000000..4f811a2 --- /dev/null +++ b/src/features/email/ui/SubjectField.tsx @@ -0,0 +1,27 @@ +import { TextField } from "@mui/material"; +import { memo, type JSX, type ChangeEvent } from "react"; + +interface SubjectFieldProps { + value: string; + onChange: (e: ChangeEvent) => void; +} + +const SubjectFieldComponent = ({ + value, + onChange, +}: SubjectFieldProps): JSX.Element => ( + +); + +const SubjectField = memo(SubjectFieldComponent); + +export default SubjectField; diff --git a/src/features/projects/api/projectsApi.ts b/src/features/projects/api/projectsApi.ts index 3abb876..ca9b2c3 100644 --- a/src/features/projects/api/projectsApi.ts +++ b/src/features/projects/api/projectsApi.ts @@ -1,15 +1,10 @@ import { addDoc, - arrayRemove, arrayUnion, collection, doc, serverTimestamp, updateDoc, - getDocs, - where, - deleteDoc, - query, } from "firebase/firestore"; import type { ApiResMessage } from "@entities/projects/types/firebase"; @@ -104,61 +99,3 @@ export const updateApplyOrLike = async ( }; } }; - -/** 여러 프로젝트를 완전히 삭제 (likes, applications, projects, users 컬렉션 모두) */ -export const deleteProjectsEverywhere = async ( - projectIds: string[], - userId: string -): Promise<{ success: boolean; error?: string }> => { - try { - // 모든 삭제 작업을 병렬로 실행하기 위한 함수들 - const deleteLikesForProject = async ( - projectId: string - ): Promise => { - const likesSnap = await getDocs( - query(collection(db, "likes"), where("projectId", "==", projectId)) - ); - return Promise.all(likesSnap.docs.map((doc) => deleteDoc(doc.ref))); - }; - - const deleteApplicationsForProject = async ( - projectId: string - ): Promise => { - const appsSnap = await getDocs( - query( - collection(db, "applications"), - where("projectId", "==", projectId) - ) - ); - return Promise.all(appsSnap.docs.map((doc) => deleteDoc(doc.ref))); - }; - - const deleteProject = async (projectId: string): Promise => { - return deleteDoc(doc(db, "projects", projectId)); - }; - - // 모든 작업을 병렬로 실행 - await Promise.all([ - // 1. likes 컬렉션에서 모든 프로젝트의 likes 삭제 (병렬) - ...projectIds.map(deleteLikesForProject), - - // 2. applications 컬렉션에서 모든 프로젝트의 applications 삭제 (병렬) - ...projectIds.map(deleteApplicationsForProject), - - // 3. projects 컬렉션에서 모든 프로젝트 삭제 (병렬) - ...projectIds.map(deleteProject), - - // 4. users 컬렉션에서 myProjects, likeProjects, appliedProjects에서 제거 - updateDoc(doc(db, "users", userId), { - myProjects: arrayRemove(...projectIds), - likeProjects: arrayRemove(...projectIds), - appliedProjects: arrayRemove(...projectIds), - }), - ]); - - return { success: true }; - } catch (err) { - console.error(err); - return { success: false, error: "프로젝트 완전 삭제 실패" }; - } -}; diff --git a/src/features/projects/hooks/useApplyForm.ts b/src/features/projects/hooks/useApplyForm.ts index 91adaa7..dffc377 100644 --- a/src/features/projects/hooks/useApplyForm.ts +++ b/src/features/projects/hooks/useApplyForm.ts @@ -6,6 +6,7 @@ import { useCreateProjectApplications } from "@features/projects/queries/useCrea import { useGetProjectApplicationStatus } from "@entities/projects/queries/useGetProjectApplications"; +import { useAuthStore } from "@shared/stores/authStore"; import { useSnackbarStore } from "@shared/stores/snackbarStore"; interface ApplyFormResult { @@ -27,6 +28,7 @@ interface ApplyFormResult { const useApplyForm = (): ApplyFormResult => { const { id: projectId } = useParams(); + const { user } = useAuthStore(); const { showError } = useSnackbarStore(); const { data: isApplied = false, isLoading: dataLoading } = @@ -41,6 +43,10 @@ const useApplyForm = (): ApplyFormResult => { const openForm = (): void => { if (dataLoading) return; + if (!user) { + showError("로그인을 해주세요"); + return; + } setIsFormOpen(true); }; const closeForm = (): void => setIsFormOpen(false); diff --git a/src/features/projects/hooks/useInsertStep1.ts b/src/features/projects/hooks/useInsertStep1.ts index 2435d67..472a22a 100644 --- a/src/features/projects/hooks/useInsertStep1.ts +++ b/src/features/projects/hooks/useInsertStep1.ts @@ -5,6 +5,7 @@ import { useState } from "react"; import type { Step1Type } from "@features/projects/types/project-update"; +import { useSnackbarStore } from "@shared/stores/snackbarStore"; import { ProjectCategory } from "@shared/types/project"; interface ApplyFormResult { @@ -23,6 +24,7 @@ interface ApplyFormResult { const useInsertStep1 = ({ state }: { state?: Step1Type }): ApplyFormResult => { const isModify = !!state; // 추후에 수정을 위해서 + const { showError } = useSnackbarStore(); const [form1, setForm1] = useState(isModify ? state : initForm1); const [hasUserSelected, setHasUserSelected] = useState(false); @@ -77,23 +79,23 @@ const useInsertStep1 = ({ state }: { state?: Step1Type }): ApplyFormResult => { const validateForm = (): boolean => { if (!form1.title.trim()) { - alert("프로젝트 이름을 입력해주세요"); + showError("프로젝트 이름을 입력해주세요"); return false; } if (!form1.oneLineInfo.trim()) { - alert("한 줄 소개를 입력해주세요."); + showError("한 줄 소개를 입력해주세요"); return false; } if (!form1.simpleInfo.trim()) { - alert("프로젝트 간단 설명을 입력해주세요."); + showError("프로젝트 간단 설명을 입력해주세요"); return false; } if (!hasUserSelected) { - alert("프로젝트 분야를 선택해주세요."); + showError("프로젝트 분야를 선택해주세요"); return false; } if (!form1.closedDate) { - alert("모집 마감일을 선택해주세요."); + showError("모집 마감일을 선택해주세요"); return false; } return true; @@ -116,7 +118,7 @@ export default useInsertStep1; const initForm1 = { title: "", - category: ProjectCategory.webDevelopment, + category: "" as ProjectCategory, simpleInfo: "", closedDate: Timestamp.now(), oneLineInfo: "", diff --git a/src/features/projects/hooks/useInsertStep2.ts b/src/features/projects/hooks/useInsertStep2.ts index 2eaeb37..b107f96 100644 --- a/src/features/projects/hooks/useInsertStep2.ts +++ b/src/features/projects/hooks/useInsertStep2.ts @@ -2,6 +2,7 @@ import { useState } from "react"; import type { Step2Type } from "@features/projects/types/project-update"; +import { useSnackbarStore } from "@shared/stores/snackbarStore"; import { ExpectedPeriod } from "@shared/types/schedule"; interface ApplyFormResult { @@ -12,6 +13,7 @@ interface ApplyFormResult { const useInsertStep2 = ({ state }: { state?: Step2Type }): ApplyFormResult => { const isModify = !!state; // 추후에 수정을 위해서 + const { showError } = useSnackbarStore(); const [formStep2, setFormStep2] = useState(isModify ? state : initForm2); @@ -21,37 +23,37 @@ const useInsertStep2 = ({ state }: { state?: Step2Type }): ApplyFormResult => { const validateForm = (): boolean => { if (formStep2.teamSize === 0) { - alert("팀 규모를 선택해주세요."); + showError("팀 규모를 선택해주세요"); return false; } if (!formStep2.expectedPeriod) { - alert("예상 일정을 선택해주세요."); + showError("예상 일정을 선택해주세요"); return false; } if (formStep2.techStack.length === 0) { - alert("기술 스택을 추가해주세요"); + showError("기술 스택을 추가해주세요"); return false; } if (formStep2.positions.length === 0) { - alert("최소 1개 이상의 모집 포지션을 추가해주세요."); + showError("최소 1개 이상의 모집 포지션을 추가해주세요"); return false; } for (let i = 0; i < formStep2.positions.length; i++) { const position = formStep2.positions[i]; if (!position.position) { - alert(`${i + 1}번째 포지션을 선택해주세요.`); + showError(`${i + 1}번째 포지션을 선택해주세요`); return false; } if (!position.count || position.count === 0) { - alert(`${i + 1}번째 포지션의 모집 인원을 선택해주세요.`); + showError(`${i + 1}번째 포지션의 모집 인원을 선택해주세요`); return false; } if (!position.experience) { - alert(`${i + 1}번째 포지션의 경력 요구사항을 선택해주세요.`); + showError(`${i + 1}번째 포지션의 경력 요구사항을 선택해주세요`); return false; } } diff --git a/src/features/projects/hooks/useInsertStep3.ts b/src/features/projects/hooks/useInsertStep3.ts index e8d01b3..2667717 100644 --- a/src/features/projects/hooks/useInsertStep3.ts +++ b/src/features/projects/hooks/useInsertStep3.ts @@ -1,14 +1,11 @@ import { useState } from "react"; +import { useSnackbarStore } from "@shared/stores/snackbarStore"; import { type ProjectItemInsertReq } from "@shared/types/project"; -import { ExpectedPeriod } from "@shared/types/schedule"; - -// Schedule 타입 정의 (원본 코드 참고) -interface Schedule { - stageName: string; - period: ExpectedPeriod; - description: string; -} +import { + NewSchedulePeriod, + type ProjectSchedule, +} from "@shared/types/schedule"; type Step3Type = Pick; @@ -23,6 +20,7 @@ interface ApplyFormResult { const useInsertStep3 = ({ state }: { state?: Step3Type }): ApplyFormResult => { const isModify = !!state; // 추후에 수정을 위해서 + const { showError } = useSnackbarStore(); const [formStep3, setFormStep3] = useState(isModify ? state : initForm3); @@ -35,11 +33,11 @@ const useInsertStep3 = ({ state }: { state?: Step3Type }): ApplyFormResult => { const validateForm = (): boolean => { if (!formStep3.description.trim()) { - alert("프로젝트 상세 설명을 작성해주세요"); + showError("프로젝트 상세 설명을 작성해주세요"); return false; } if (formStep3.schedules.length === 0) { - alert("최소 1개 이상의 프로젝트 일정을 추가해주세요."); + showError("최소 1개 이상의 프로젝트 일정을 추가해주세요"); return false; } @@ -48,17 +46,17 @@ const useInsertStep3 = ({ state }: { state?: Step3Type }): ApplyFormResult => { const scheduleNum = i + 1; if (!schedule.stageName.trim()) { - alert(`${scheduleNum}번째 일정의 단계명을 입력해주세요.`); + showError(`${scheduleNum}번째 일정의 단계명을 입력해주세요`); return false; } if (!schedule.description.trim()) { - alert(`${scheduleNum}번째 일정의 설명을 입력해주세요.`); + showError(`${scheduleNum}번째 일정의 설명을 입력해주세요`); return false; } if (!schedule.period) { - alert(`${scheduleNum}번째 일정의 예상 기간을 선택해주세요.`); + showError(`${scheduleNum}번째 일정의 예상 기간을 선택해주세요`); return false; } } @@ -74,9 +72,9 @@ const useInsertStep3 = ({ state }: { state?: Step3Type }): ApplyFormResult => { export default useInsertStep3; -const initSchedule: Schedule = { +const initSchedule: ProjectSchedule = { stageName: "", - period: ExpectedPeriod.oneMonth, + period: NewSchedulePeriod.oneWeek, description: "", }; diff --git a/src/features/projects/hooks/useInsertStep4.ts b/src/features/projects/hooks/useInsertStep4.ts index d3aef91..cd35b95 100644 --- a/src/features/projects/hooks/useInsertStep4.ts +++ b/src/features/projects/hooks/useInsertStep4.ts @@ -1,5 +1,6 @@ import { useState } from "react"; +import { useSnackbarStore } from "@shared/stores/snackbarStore"; import { Workflow, type ProjectItemInsertReq } from "@shared/types/project"; type Step4Type = Pick< @@ -18,6 +19,7 @@ interface ApplyFormResult { const useInsertStep4 = ({ state }: { state?: Step4Type }): ApplyFormResult => { const isModify = !!state; // 추후에 수정을 위해서 + const { showError } = useSnackbarStore(); const [formStep4, setFormStep4] = useState(isModify ? state : initForm4); @@ -30,26 +32,26 @@ const useInsertStep4 = ({ state }: { state?: Step4Type }): ApplyFormResult => { const validateForm = (): boolean => { if (!formStep4.workflow) { - alert("진행 방식을 선택해주세요."); + showError("진행 방식을 선택해주세요"); return false; } if (formStep4.requirements.length === 0) { - alert("최소 1개 이상의 지원 요구사항을 입력해주세요."); + showError("최소 1개 이상의 지원 요구사항을 입력해주세요"); return false; } for (let i = 0; i < formStep4.requirements.length; i++) { if (!formStep4.requirements[i].trim()) { - alert(`${i + 1}번째 지원 요구사항을 입력해주세요.`); + showError(`${i + 1}번째 지원 요구사항을 입력해주세요`); return false; } } if (formStep4.preferentialTreatment.length === 0) { - alert("최소 1개 이상의 우대사항을 입력해주세요."); + showError("최소 1개 이상의 우대사항을 입력해주세요"); return false; } for (let i = 0; i < formStep4.preferentialTreatment.length; i++) { if (!formStep4.preferentialTreatment[i].trim()) { - alert(`${i + 1}번째 우대사항을 입력해주세요.`); + showError(`${i + 1}번째 우대사항을 입력해주세요`); return false; } } diff --git a/src/features/projects/hooks/useProjectInsertForm.ts b/src/features/projects/hooks/useProjectInsertForm.ts index e7603a5..403f17b 100644 --- a/src/features/projects/hooks/useProjectInsertForm.ts +++ b/src/features/projects/hooks/useProjectInsertForm.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import useProjectInsert from "@features/projects/queries/useProjectInsert"; import type { @@ -31,6 +31,7 @@ const useProjectInsertForm = (): InsertFormResult => { const { mutate: insertProject, isPending } = useProjectInsert(); const [currentStep, setCurrentStep] = useState(1); + const [shouldSubmit, setShouldSubmit] = useState(false); const [allForm, setAllForm] = useState({ form1: {} as Step1Type, @@ -39,6 +40,50 @@ const useProjectInsertForm = (): InsertFormResult => { form4: {} as Step4Type, }); + // useEffect로 제출 감지 + useEffect(() => { + const handleSubmit = (): void => { + if (!userProfile) { + return; + } + + if (isPending) { + return; + } + + const isRealInsert = window.confirm("등록을 완료 하시겠습니까?"); + if (!isRealInsert) { + setShouldSubmit(false); + return; + } + + const finalData: ProjectItemInsertReq = { + ...projectOwnerData(userProfile), + ...allForm.form1, + ...allForm.form2, + ...allForm.form3, + ...allForm.form4, + status: RecruitmentStatus.recruiting, + applicants: [], + likedUsers: [], + }; + + insertProject(finalData); + }; + + if (shouldSubmit && currentStep === 4) { + handleSubmit(); + setShouldSubmit(false); + } + }, [ + allForm, + shouldSubmit, + currentStep, + userProfile, + isPending, + insertProject, + ]); + /** allForm에 알맞은 form에 데이터 넣기 */ const updateCorrectForm: UpdateAllFormType = (formKey, data): void => { setAllForm((prev) => ({ ...prev, [formKey]: data })); @@ -51,46 +96,14 @@ const useProjectInsertForm = (): InsertFormResult => { }; /** allForm에 알맞은 form에 데이터 넣기 */ - const handleNext = async (): Promise => { + const handleNext = (): void => { if (currentStep !== 4) { setCurrentStep((prev) => prev + 1); scrollToTop(); return; } - - // step4 데이터까지 받기 위해 상태 업데이트 기다린 후 submit - setTimeout(async () => { - await submit(); - }, 100); - }; - - const submit = async (): Promise => { - if (!userProfile) { - console.log("비로그인"); - return; - } - - if (isPending) { - console.log("로딩중"); - return; - } - - const isRealInsert = window.confirm("등록을 완료 하시겠습니까?"); - if (!isRealInsert) return; - - const finalData: ProjectItemInsertReq = { - ...projectOwnerData(userProfile), - ...allForm.form1, - ...allForm.form2, - ...allForm.form3, - ...allForm.form4, - status: RecruitmentStatus.recruiting, - applicants: [], // 추후 삭제 - likedUsers: [], // 추후 삭제 - }; - - // projects에 insert - insertProject(finalData); // 삭제 + // step4에서는 제출 플래그만 설정 + setShouldSubmit(true); }; return { diff --git a/src/features/projects/ui/ProjectDelete.tsx b/src/features/projects/ui/ProjectDelete.tsx index f8d57c2..069ff00 100644 --- a/src/features/projects/ui/ProjectDelete.tsx +++ b/src/features/projects/ui/ProjectDelete.tsx @@ -26,24 +26,10 @@ export const ProjectDones = ({ } }; - const handleModify = (): void => { - // Navigate '/project/insert로 이동' - // state로 폼 넘김 - // 이푸 state 존재 여부에 따라 등록, 수정 나눌 예정 - // form을 나눈다면 여기서 나눠서 보낼 수 있도록 ... - alert("아직없어염.."); - }; - return ( - - - 수정하기 - - - - 모집 마감 하기 - - + + 모집 마감 하기 + ); }; diff --git a/src/features/projects/ui/ProjectLike.tsx b/src/features/projects/ui/ProjectLike.tsx index 098926f..4712a96 100644 --- a/src/features/projects/ui/ProjectLike.tsx +++ b/src/features/projects/ui/ProjectLike.tsx @@ -7,6 +7,7 @@ import { getStatusClassname, shareProjectUrl, } from "@shared/libs/utils/projectDetail"; +import { useSnackbarStore } from "@shared/stores/snackbarStore"; import type { ProjectListRes } from "@shared/types/project"; import { FavoriteBorderIcon, @@ -22,6 +23,13 @@ interface ProjectLikeProps { const ProjectLike = ({ values }: ProjectLikeProps): JSX.Element => { const { isLiked, toggleLike } = useOptimisticProjectLike(); + const { showError, showSuccess } = useSnackbarStore(); + + const sharelink = (): void => { + shareProjectUrl() + .then(() => showSuccess("UPL이 복사되었습니다.")) + .catch(() => showError("복사 실패")); + }; return ( @@ -37,7 +45,7 @@ const ProjectLike = ({ values }: ProjectLikeProps): JSX.Element => { )} - + diff --git a/src/features/projects/ui/project-insert/Step3.tsx b/src/features/projects/ui/project-insert/Step3.tsx index fb5e8fb..3226c25 100644 --- a/src/features/projects/ui/project-insert/Step3.tsx +++ b/src/features/projects/ui/project-insert/Step3.tsx @@ -7,15 +7,9 @@ import type { UpdateAllFormType } from "@features/projects/types/project-update" import ProjectDetailDescriptionCard from "@entities/projects/ui/project-insert/ProjectDetailDescriptionCard"; import ProjectScheduleManagementCard from "@entities/projects/ui/project-insert/ProjectScheduleManagementCard"; -import type { ExpectedPeriod } from "@shared/types/schedule"; +import type { ProjectSchedule } from "@shared/types/schedule"; import StepWhiteBox from "@shared/ui/project-insert/StepWhiteBox"; -interface Schedule { - stageName: string; - period: ExpectedPeriod; // ExpectedPeriod enum 값 - description: string; -} - const Step3 = ({ updateForm, }: { @@ -43,7 +37,9 @@ const Step3 = ({ onChangeForm("schedules", value)} + onChange={(value: ProjectSchedule[]) => + onChangeForm("schedules", value) + } large style={{ gridColumn: isMdDown ? "span 1" : "1 / -1" }} /> diff --git a/src/pages/project-detail/ui/ProjectDetailPage.tsx b/src/pages/project-detail/ui/ProjectDetailPage.tsx index a0a986b..35ec95f 100644 --- a/src/pages/project-detail/ui/ProjectDetailPage.tsx +++ b/src/pages/project-detail/ui/ProjectDetailPage.tsx @@ -2,6 +2,8 @@ import { Box, Card, Container, styled } from "@mui/material"; import { type JSX } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import useEmailForm from "@features/email/hooks/useEmailForm"; +import EmailModal from "@features/email/ui/EmailModal"; import ProjectApplyForm from "@features/projects/ui/ProjectApplyForm"; import { ProjectDone, ProjectDones } from "@features/projects/ui/ProjectDelete"; import ProjectLike from "@features/projects/ui/ProjectLike"; @@ -32,6 +34,13 @@ const ProjectDetailPage = (): JSX.Element | null => { isError, } = useProjectsItem({ id: id || null }); + const { isOpen, openModal, closeModal } = useEmailForm({ + senderEmail: user?.email || "", + receiverEmail: project?.projectOwner.email || "", + project: project || null, + onClose: () => {}, + }); + const projectInfoValues = !project ? null : { @@ -90,6 +99,15 @@ const ProjectDetailPage = (): JSX.Element | null => { } return ( + {project && ( + + )} @@ -117,7 +135,10 @@ const ProjectDetailPage = (): JSX.Element | null => { - + diff --git a/src/pages/user-profile/ui/UserProfilePage.tsx b/src/pages/user-profile/ui/UserProfilePage.tsx index 6ebf0ab..cda1329 100644 --- a/src/pages/user-profile/ui/UserProfilePage.tsx +++ b/src/pages/user-profile/ui/UserProfilePage.tsx @@ -1,24 +1,20 @@ import { Box, Container, Chip as MuiChip } from "@mui/material"; import { styled as muiStyled } from "@mui/material/styles"; -import { useQueryClient } from "@tanstack/react-query"; import type { JSX } from "react"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; -import { deleteProjectsEverywhere } from "@features/projects/api/projectsApi"; - -import { deleteApplication } from "@entities/projects/api/getProjectApplicationsApi"; -import { deleteUserLikes } from "@entities/projects/api/getProjectLikeApi"; -import { useProjectsByIds } from "@entities/projects/hooks/useProjectsByIds"; +import { useDeleteProjectsMutation } from "@entities/projects/hooks/useDeleteProjectsMutation"; import { useGetMyAppliedProjectsWithDetails } from "@entities/projects/queries/useGetProjectApplications"; -import { useGetMyLikedProjectsWithDetails } from "@entities/projects/queries/useGetProjectLike"; +import { + useGetMyLikedProjectsWithDetails, + useGetMyCreatedProjectsWithDetails, +} from "@entities/projects/queries/useGetProjectLike"; import ProjectCollectionContainer from "@entities/projects/ui/project-collection-tab/ProjectCollectionContainer"; import UserProfileCard from "@entities/user/ui/user-profile/UserProfileCard"; import UserProfileHeader from "@entities/user/ui/user-profile/UserProfileHeader"; import { useUserProfile } from "@shared/queries/useUserProfile"; -import queryKeys from "@shared/react-query/queryKey"; import { useAuthStore } from "@shared/stores/authStore"; -import { useLikeStore } from "@shared/stores/likeStore"; import { useProjectStore } from "@shared/stores/projectStore"; import { ProjectCollectionTabType } from "@shared/types/project"; import LoadingSpinner from "@shared/ui/loading-spinner/LoadingSpinner"; @@ -51,101 +47,52 @@ const UserProfilePage = (): JSX.Element => { uid ?? "" ); - // zustand store 사용 - const { setLikeProjects, setAppliedProjects } = useProjectStore(); - const { setLikedProjectIds, removeLikeProjects } = useLikeStore(); - const queryClient = useQueryClient(); - - // 만든 프로젝트 id 배열 - const createdIds = userProfile?.myProjects ?? []; - // 지원한 프로젝트 데이터 가져오기 (applications 컬렉션 기반) const { data: appliedProjectsData, isLoading: appliedProjectsLoading } = useGetMyAppliedProjectsWithDetails(); - // 만든 프로젝트는 기존대로 id 배열로 fetch - const { data: createdProjectsData, isLoading: createdProjectsLoading } = - useProjectsByIds(createdIds); const { data: myLikedProjectsData, isLoading: myLikedProjectsLoading } = useGetMyLikedProjectsWithDetails(); - // zustand store에 동기화 - useEffect(() => { - if (myLikedProjectsData) { - setLikeProjects(myLikedProjectsData); - // 좋아요 프로젝트 ID들도 likeStore에 동기화 - const likedIds = myLikedProjectsData.map((project) => project.id); - setLikedProjectIds(likedIds); - } - }, [myLikedProjectsData, setLikeProjects, setLikedProjectIds]); - useEffect(() => { - if (appliedProjectsData) setAppliedProjects(appliedProjectsData); - }, [appliedProjectsData, setAppliedProjects]); + + // 만든 프로젝트 데이터 가져오기 (userProfile.myProjects 기반) + const { data: myCreatedProjectsData, isLoading: myCreatedProjectsLoading } = + useGetMyCreatedProjectsWithDetails(userProfile?.myProjects); const [tab, setTab] = useState( ProjectCollectionTabType.Likes ); - // 프로젝트 삭제 핸들러 - const handleDeleteProjects = useCallback( - async (type: ProjectCollectionTabType, ids: string[]) => { - if (type === ProjectCollectionTabType.Likes && user) { - await deleteUserLikes(user.uid, ids); - removeLikeProjects(ids); - await queryClient.invalidateQueries({ - queryKey: [queryKeys.myLikedProjects, "details"], - }); - } - if (type === ProjectCollectionTabType.Applied && user) { - for (const projectId of ids) { - await deleteApplication(user.uid, projectId); - } - await queryClient.invalidateQueries({ - queryKey: [queryKeys.myAppliedProjects, "details"], - }); - } - if (type === ProjectCollectionTabType.Created && user) { - // 만든 프로젝트 완전 삭제 - const res = await deleteProjectsEverywhere(ids, user.uid); - if (res.success) { - // zustand store 동기화 - setAppliedProjects( - appliedProjectsData - ? appliedProjectsData.filter((p) => !ids.includes(p.id)) - : [] - ); - setLikeProjects( - myLikedProjectsData - ? myLikedProjectsData.filter((p) => !ids.includes(p.id)) - : [] - ); - // 쿼리 invalidate - await queryClient.invalidateQueries({ - queryKey: [queryKeys.myLikedProjects, "details"], - }); - await queryClient.invalidateQueries({ - queryKey: [queryKeys.myAppliedProjects, "details"], - }); - await queryClient.invalidateQueries({ - queryKey: [queryKeys.projects], - }); - await queryClient.invalidateQueries({ - queryKey: ["userProfile", user.uid], - }); - } else { - alert(res.error || "프로젝트 삭제에 실패했습니다."); - } - } - }, - [ + const deleteProjectsMutation = useDeleteProjectsMutation(); + + // projectStore 동기화 + const { setAppliedProjects, setLikeProjects } = useProjectStore(); + + // 지원한 프로젝트 데이터를 store에 동기화 + useEffect(() => { + if (appliedProjectsData) { + setAppliedProjects(appliedProjectsData); + } + }, [appliedProjectsData, setAppliedProjects]); + + // 좋아요한 프로젝트 데이터를 store에 동기화 + useEffect(() => { + if (myLikedProjectsData) { + setLikeProjects(myLikedProjectsData); + } + }, [myLikedProjectsData, setLikeProjects]); + + const handleDeleteProjects = async ( + type: ProjectCollectionTabType, + ids: string[] + ): Promise => { + await deleteProjectsMutation.mutateAsync({ + type, + ids, user, - removeLikeProjects, - queryClient, - setAppliedProjects, - setLikeProjects, appliedProjectsData, myLikedProjectsData, - ] - ); + }); + }; // 사용자 프로필이 로딩 중이거나 없으면 early return if (userProfileLoading) { @@ -178,11 +125,11 @@ const UserProfilePage = (): JSX.Element => { { try { // 토큰이 갱신되면 새로운 토큰을 가져옴 await user.getIdToken(true); - console.log("토큰이 갱신되었습니다."); } catch (error) { console.error("토큰 갱신 실패:", error); showError("인증이 만료되었습니다. 다시 로그인해주세요."); diff --git a/src/shared/libs/utils/projectDetail.ts b/src/shared/libs/utils/projectDetail.ts index cdb913f..7a30bfb 100644 --- a/src/shared/libs/utils/projectDetail.ts +++ b/src/shared/libs/utils/projectDetail.ts @@ -17,9 +17,6 @@ export const formatDate = (date?: string | Timestamp): string => { return date; }; -export const shareProjectUrl = (): void => { - navigator.clipboard - .writeText(window.location.href) - .then(() => alert("URL이 복사되었습니다.")) - .catch(() => alert("복사 실패")); +export const shareProjectUrl = async (): Promise => { + navigator.clipboard.writeText(window.location.href); }; diff --git a/src/shared/libs/utils/projectInsert.ts b/src/shared/libs/utils/projectInsert.ts index f0ce63b..2fca559 100644 --- a/src/shared/libs/utils/projectInsert.ts +++ b/src/shared/libs/utils/projectInsert.ts @@ -6,7 +6,7 @@ import { Workflow, type ProjectItemInsertReq, } from "@shared/types/project"; -import { ExpectedPeriod } from "@shared/types/schedule"; +import { ExpectedPeriod, NewSchedulePeriod } from "@shared/types/schedule"; import type { User } from "@shared/types/user"; export const projectOwnerData = ( @@ -23,12 +23,13 @@ export const projectOwnerData = ( email: user.email, experience: user.experience, avatar: user.avatar, + introduceMyself: user.introduceMyself, }, }); // 테스트용 form 입니다. export const TestData = (user: User): ProjectItemInsertReq => ({ - projectOwnerID: user.id, // 요거 추가!! + projectOwnerID: user.id, projectOwner: { id: user.id, name: user.name, @@ -36,6 +37,7 @@ export const TestData = (user: User): ProjectItemInsertReq => ({ email: user.email, experience: user.experience, avatar: user.avatar, + introduceMyself: user.introduceMyself, }, applicants: [], status: RecruitmentStatus.recruiting, @@ -81,7 +83,7 @@ export const TestData = (user: User): ProjectItemInsertReq => ({ schedules: [ { stageName: "기획", - period: ExpectedPeriod.oneMonth, + period: NewSchedulePeriod.oneWeek, description: "기획 단계", }, ], diff --git a/src/shared/react-query/queryKey.ts b/src/shared/react-query/queryKey.ts index 7825c65..315a1e3 100644 --- a/src/shared/react-query/queryKey.ts +++ b/src/shared/react-query/queryKey.ts @@ -6,6 +6,7 @@ const queryKeys = { projectStats: "project-stats", myLikedProjects: "my-liked-projects", myAppliedProjects: "my-applied-projects", + myCreatedProjects: "my-created-projects", projectAppliedUser: "project-applied-users", projectApply: "project-apply", }; diff --git a/src/shared/types/schedule.ts b/src/shared/types/schedule.ts index 092508d..9271599 100644 --- a/src/shared/types/schedule.ts +++ b/src/shared/types/schedule.ts @@ -1,9 +1,10 @@ export interface ProjectSchedule { stageName: string; - period: ExpectedPeriod; + period: NewSchedulePeriod; description: string; } +// 전체 프로젝트 기간 export enum ExpectedPeriod { oneMonth = "1개월 이내(빠르게)", twoMonths = "2개월(적당히)", @@ -12,3 +13,15 @@ export enum ExpectedPeriod { sixMonths = "6개월(장기프로젝트)", moreThanSixMonths = "6개월 이상(대장정)", } + +// 항목별 단계 기간 +export enum NewSchedulePeriod { + oneWeek = "1주", + twoWeeks = "2주", + threeWeeks = "3주", + fourWeeks = "4주", + twoMonths = "2개월", + threeMonths = "3개월", + lessThanSixMonths = "6개월 미만", + moreThanSixMonths = "6개월 이상", +} diff --git a/src/shared/ui/LogoBox.tsx b/src/shared/ui/LogoBox.tsx index 58bbd7f..4a679a8 100644 --- a/src/shared/ui/LogoBox.tsx +++ b/src/shared/ui/LogoBox.tsx @@ -3,6 +3,8 @@ import type { SxProps, Theme } from "@mui/material"; import type { JSX } from "react"; import { useNavigate } from "react-router-dom"; +import Logo from "./icons/logo.svg"; + interface LogoBoxProps { size?: "small" | "medium" | "large"; onClick?: () => void; @@ -40,7 +42,7 @@ const LogoBox = ({ $disableHover={disableHover} sx={sx} > - + {showText && {text}} ); diff --git a/src/app/public/logo.svg b/src/shared/ui/icons/logo.svg similarity index 100% rename from src/app/public/logo.svg rename to src/shared/ui/icons/logo.svg diff --git a/src/shared/ui/pagination/Pagination.tsx b/src/shared/ui/pagination/Pagination.tsx index 9468faa..84f95d2 100644 --- a/src/shared/ui/pagination/Pagination.tsx +++ b/src/shared/ui/pagination/Pagination.tsx @@ -43,6 +43,20 @@ const Pagination = ({ return null; } + const handlePrevPage = (): void => { + const newPage = currentPage - 1; + if (newPage >= 1) { + onPageChange(newPage); + } + }; + + const handleNextPage = (): void => { + const newPage = currentPage + 1; + if (newPage <= totalPages) { + onPageChange(newPage); + } + }; + const handleFastPrev = (): void => { const currentBlock = Math.floor((currentPage - 1) / 5); const prevBlockLastPage = currentBlock * 5; @@ -74,7 +88,7 @@ const Pagination = ({ )} onPageChange(currentPage - 1)} + onClick={handlePrevPage} disabled={!canGoPrev || disabled} size={isMobile ? "large" : "medium"} title="이전 페이지" @@ -106,7 +120,7 @@ const Pagination = ({ onPageChange(currentPage + 1)} + onClick={handleNextPage} disabled={!canGoNext || disabled} size={isMobile ? "large" : "medium"} title="다음 페이지" diff --git a/src/widgets/Header/Header.tsx b/src/widgets/Header/Header.tsx index e97dae1..1447051 100644 --- a/src/widgets/Header/Header.tsx +++ b/src/widgets/Header/Header.tsx @@ -55,8 +55,6 @@ const Header = (): JSX.Element => { { label: "프로젝트 등록", onClick: () => navigate("/project/insert") }, ]; - console.log("Header user:", user); - return (