diff --git a/jest.config.js b/jest.config.js index 679e6597..2d756f45 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,8 +10,7 @@ const createJestConfig = nextJest({ const config = { coverageProvider: 'v8', testEnvironment: 'jsdom', - // Add more setup options before each test is run - // setupFilesAfterEnv: ['/jest.setup.ts'], + // setupFilesAfterEnv: ['/src/setupTests.ts'], }; // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async diff --git a/next.config.ts b/next.config.ts index 0ccb0755..51ca252f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,7 +2,13 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { images: { - domains: ['sprint-fe-project.s3.ap-northeast-2.amazonaws.com'], + domains: [ + 'sprint-fe-project.s3.ap-northeast-2.amazonaws.com', + 'codeit-bookco.s3.ap-northeast-2.amazonaws.com', + ], + }, + instrumentation: { + enabled: true, }, }; diff --git a/package-lock.json b/package-lock.json index 36ce19e7..f6efef56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,15 +10,20 @@ "dependencies": { "@headlessui/react": "^2.2.0", "@hookform/resolvers": "^3.9.1", + "@lukemorales/query-key-factory": "^1.3.4", + "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.61.3", "@tanstack/react-query-devtools": "^5.61.3", "@types/react-datepicker": "^6.2.0", + "@types/sockjs-client": "^1.5.4", "axios": "^1.7.8", "next": "15.0.3", "react": "^18.3.1", "react-datepicker": "^7.5.0", "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", + "react-toastify": "^11.0.2", + "sockjs-client": "^1.6.1", "tailwind-merge": "^2.5.5", "zod": "^3.23.8", "zustand": "^5.0.1" @@ -56,6 +61,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "lint-staged": "^15.2.10", + "msw": "^2.7.0", "postcss": "^8.4.49", "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.9", @@ -2124,6 +2130,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@bundled-es-modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cookie": "^0.7.2" + } + }, + "node_modules/@bundled-es-modules/statuses": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", + "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", + "dev": true, + "license": "ISC", + "dependencies": { + "statuses": "^2.0.1" + } + }, + "node_modules/@bundled-es-modules/tough-cookie": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz", + "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@types/tough-cookie": "^4.0.5", + "tough-cookie": "^4.1.4" + } + }, "node_modules/@chromatic-com/storybook": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-3.2.2.tgz", @@ -3187,6 +3224,104 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@inquirer/confirm": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.0.tgz", + "integrity": "sha512-osaBbIMEqVFjTX5exoqPXs6PilWQdjaLhGtMDXMXg/yxkHXNq43GlxGyTA35lK2HpzUgDN+Cjh/2AmqCN0QJpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/core": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.1.tgz", + "integrity": "sha512-rmZVXy9iZvO3ZStEe/ayuuwIJ23LSF13aPMlLMTQARX6lGUBDHGV8UB5i9MRrfy0+mZwt5/9bdy8llszSD3NQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/figures": "^1.0.8", + "@inquirer/type": "^3.0.1", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/core/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@inquirer/core/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.8.tgz", + "integrity": "sha512-tKd+jsmhq21AP1LhexC0pPwsCxEhGgAkg28byjJAd+xhmIs8LUX8JbUc3vBf3PhLxWiB5EvyBE5X7JSPAqMAqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.1.tgz", + "integrity": "sha512-+ksJMIy92sOAiAccGpcKZUc3bYO07cADnscIxHBknEm3uNts3movSmBofc1908BNy5edKscxYeAdaX1NXkHS6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3774,6 +3909,37 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lukemorales/query-key-factory": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@lukemorales/query-key-factory/-/query-key-factory-1.3.4.tgz", + "integrity": "sha512-A3frRDdkmaNNQi6mxIshsDk4chRXWoXa05US8fBo4kci/H+lVmujS6QrwQLLGIkNIRFGjMqp2uKjC4XsLdydRw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@tanstack/query-core": ">= 4.0.0", + "@tanstack/react-query": ">= 4.0.0" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.37.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.3.tgz", + "integrity": "sha512-USvgCL/uOGFtVa6SVyRrC8kIAedzRohxIXN5LISlg5C5vLZCn7dgMFVSNhSF9cuBEFrm/O2spDWEZeMnw4ZXYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@next/env": { "version": "15.0.3", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.0.3.tgz", @@ -3966,6 +4132,31 @@ "node": ">=12.4.0" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4191,6 +4382,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@stomp/stompjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.0.0.tgz", + "integrity": "sha512-fGdq4wPDnSV/KyOsjq4P+zLc8MFWC3lMmP5FBgLWKPJTYcuCbAIrnRGjB7q2jHZdYCOD5vxLuFoKIYLy5/u8Pw==", + "license": "Apache-2.0" + }, "node_modules/@storybook/addon-actions": { "version": "8.4.5", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.4.5.tgz", @@ -5331,6 +5528,13 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/doctrine": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", @@ -5558,6 +5762,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/sockjs-client": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.4.tgz", + "integrity": "sha512-zk+uFZeWyvJ5ZFkLIwoGA/DfJ+pYzcZ8eH4H/EILCm2OBZyHH6Hkdna1/UWL/CFruh5wj6ES7g75SvUB0VsH5w==", + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -5565,6 +5775,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/statuses": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.5.tgz", + "integrity": "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -8311,6 +8528,16 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -8514,6 +8741,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/core-js-compat": { "version": "3.39.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", @@ -10302,6 +10539,15 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -10452,6 +10698,18 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -11010,6 +11268,16 @@ "dev": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.10.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.10.0.tgz", + "integrity": "sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -11133,6 +11401,13 @@ "he": "bin/he" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -11267,6 +11542,12 @@ "entities": "^2.0.0" } }, + "node_modules/http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -11475,7 +11756,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -11798,6 +12078,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -14278,9 +14565,76 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.7.0.tgz", + "integrity": "sha512-BIodwZ19RWfCbYTxWTUfTXc+sg4OwjCAgxU1ZsgmggX/7S3LdUifsbUPJs61j0rWb19CZRGY5if77duhc0uXzw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@bundled-es-modules/cookie": "^2.0.1", + "@bundled-es-modules/statuses": "^1.0.1", + "@bundled-es-modules/tough-cookie": "^0.1.6", + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.37.0", + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/until": "^2.1.0", + "@types/cookie": "^0.6.0", + "@types/statuses": "^2.0.4", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "strict-event-emitter": "^0.5.1", + "type-fest": "^4.26.1", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "4.30.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.30.2.tgz", + "integrity": "sha512-UJShLPYi1aWqCdq9HycOL/gwsuqda1OISdBO3t8RlXQC4QvtuIz4b5FCfe2dQIWEpmlRExKmcTBfP1r9bhY7ig==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -14294,9 +14648,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -14766,6 +15120,13 @@ "dev": true, "license": "MIT" }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -14981,6 +15342,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -15788,7 +16156,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, "license": "MIT" }, "node_modules/queue": { @@ -15958,6 +16325,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-toastify": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.2.tgz", + "integrity": "sha512-GjHuGaiXMvbls3ywqv8XdWONwrcO4DXCJIY1zVLkHU73gEElKvTTXNI5Vom3s/k/M8hnkrfsqgBSX3OwmlonbA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -16223,7 +16603,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, "license": "MIT" }, "node_modules/resolve": { @@ -16485,7 +16864,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -16887,6 +17265,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sockjs-client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz", + "integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "eventsource": "^2.0.2", + "faye-websocket": "^0.11.4", + "inherits": "^2.0.4", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://tidelift.com/funding/github/npm/sockjs-client" + } + }, + "node_modules/sockjs-client/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -16964,6 +17370,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/storybook": { "version": "8.4.5", "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.4.5.tgz", @@ -17053,6 +17469,13 @@ "node": ">=10.0.0" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -18395,7 +18818,6 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, "license": "MIT", "dependencies": { "querystringify": "^2.1.1", @@ -18668,6 +19090,29 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/whatwg-encoding": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", @@ -19087,6 +19532,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", diff --git a/package.json b/package.json index 8209897d..1cb6f6bf 100644 --- a/package.json +++ b/package.json @@ -28,15 +28,20 @@ "dependencies": { "@headlessui/react": "^2.2.0", "@hookform/resolvers": "^3.9.1", + "@lukemorales/query-key-factory": "^1.3.4", + "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.61.3", "@tanstack/react-query-devtools": "^5.61.3", "@types/react-datepicker": "^6.2.0", + "@types/sockjs-client": "^1.5.4", "axios": "^1.7.8", "next": "15.0.3", "react": "^18.3.1", "react-datepicker": "^7.5.0", "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", + "react-toastify": "^11.0.2", + "sockjs-client": "^1.6.1", "tailwind-merge": "^2.5.5", "zod": "^3.23.8", "zustand": "^5.0.1" @@ -74,6 +79,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "lint-staged": "^15.2.10", + "msw": "^2.7.0", "postcss": "^8.4.49", "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.9", @@ -82,5 +88,10 @@ "ts-jest": "^29.2.5", "typescript": "^5", "typescript-eslint": "^8.15.0" + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/public/assets/Spinner.gif b/public/assets/Spinner.gif new file mode 100644 index 00000000..ffe483f9 Binary files /dev/null and b/public/assets/Spinner.gif differ diff --git a/public/fonts/Pretendard-1.3.8/LICENSE.txt b/public/fonts/Pretendard-1.3.8/LICENSE.txt new file mode 100644 index 00000000..497b88f9 --- /dev/null +++ b/public/fonts/Pretendard-1.3.8/LICENSE.txt @@ -0,0 +1,94 @@ +Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard), +with Reserved Font Name Pretendard. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/Pretendard-1.3.8/web/static/pretendard-subset.css b/public/fonts/Pretendard-1.3.8/web/static/pretendard-subset.css new file mode 100644 index 00000000..e9fc195e --- /dev/null +++ b/public/fonts/Pretendard-1.3.8/web/static/pretendard-subset.css @@ -0,0 +1,71 @@ +/* +Copyright (c) 2021 Kil Hyung-jin, with Reserved Font Name Pretendard. +https://github.com/orioncactus/pretendard + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL +*/ + +@font-face { + font-family: 'Pretendard'; + font-weight: 900; + font-display: swap; + src: local('Pretendard Black'), url('./woff2-subset/Pretendard-Black.subset.woff2') format('woff2'), url('./woff-subset/Pretendard-Black.subset.woff') format('woff'); +} + +@font-face { + font-family: 'Pretendard'; + font-weight: 800; + font-display: swap; + src: local('Pretendard ExtraBold'), url('./woff2-subset/Pretendard-ExtraBold.subset.woff2') format('woff2'), url('./woff-subset/Pretendard-ExtraBold.subset.woff') format('woff'); +} + +@font-face { + font-family: 'Pretendard'; + font-weight: 700; + font-display: swap; + src: local('Pretendard Bold'), url('./woff2-subset/Pretendard-Bold.subset.woff2') format('woff2'), url('./woff-subset/Pretendard-Bold.subset.woff') format('woff'); +} + +@font-face { + font-family: 'Pretendard'; + font-weight: 600; + font-display: swap; + src: local('Pretendard SemiBold'), url('./woff2-subset/Pretendard-SemiBold.subset.woff2') format('woff2'), url('./woff-subset/Pretendard-SemiBold.subset.woff') format('woff'); +} + +@font-face { + font-family: 'Pretendard'; + font-weight: 500; + font-display: swap; + src: local('Pretendard Medium'), url('./woff2-subset/Pretendard-Medium.subset.woff2') format('woff2'), url('./woff-subset/Pretendard-Medium.subset.woff') format('woff'); +} + +@font-face { + font-family: 'Pretendard'; + font-weight: 400; + font-display: swap; + src: local('Pretendard Regular'), url('./woff2-subset/Pretendard-Regular.subset.woff2') format('woff2'), url('./woff-subset/Pretendard-Regular.subset.woff') format('woff'); +} + +@font-face { + font-family: 'Pretendard'; + font-weight: 300; + font-display: swap; + src: local('Pretendard Light'), url('./woff2-subset/Pretendard-Light.subset.woff2') format('woff2'), url('./woff-subset/Pretendard-Light.subset.woff') format('woff'); +} + +@font-face { + font-family: 'Pretendard'; + font-weight: 200; + font-display: swap; + src: local('Pretendard ExtraLight'), url('./woff2-subset/Pretendard-ExtraLight.subset.woff2') format('woff2'), url('./woff-subset/Pretendard-ExtraLight.subset.woff') format('woff'); +} + +@font-face { + font-family: 'Pretendard'; + font-weight: 100; + font-display: swap; + src: local('Pretendard Thin'), url('./woff2-subset/Pretendard-Thin.subset.woff2') format('woff2'), url('./woff-subset/Pretendard-Thin.subset.woff') format('woff'); +} diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Black.subset.woff b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Black.subset.woff new file mode 100644 index 00000000..86edfd4a Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Black.subset.woff differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Bold.subset.woff b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Bold.subset.woff new file mode 100644 index 00000000..960b42a5 Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Bold.subset.woff differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-ExtraBold.subset.woff b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-ExtraBold.subset.woff new file mode 100644 index 00000000..4266a9eb Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-ExtraBold.subset.woff differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-ExtraLight.subset.woff b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-ExtraLight.subset.woff new file mode 100644 index 00000000..3c35cc9f Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-ExtraLight.subset.woff differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Light.subset.woff b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Light.subset.woff new file mode 100644 index 00000000..cffbc51e Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Light.subset.woff differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Medium.subset.woff b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Medium.subset.woff new file mode 100644 index 00000000..31324f0f Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Medium.subset.woff differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Regular.subset.woff b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Regular.subset.woff new file mode 100644 index 00000000..7bea3522 Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Regular.subset.woff differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-SemiBold.subset.woff b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-SemiBold.subset.woff new file mode 100644 index 00000000..53136eba Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-SemiBold.subset.woff differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Thin.subset.woff b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Thin.subset.woff new file mode 100644 index 00000000..25e66794 Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff-subset/Pretendard-Thin.subset.woff differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Black.subset.woff2 b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Black.subset.woff2 new file mode 100644 index 00000000..43f05cdc Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Black.subset.woff2 differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Bold.subset.woff2 b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Bold.subset.woff2 new file mode 100644 index 00000000..1cc5a1c3 Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Bold.subset.woff2 differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-ExtraBold.subset.woff2 b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-ExtraBold.subset.woff2 new file mode 100644 index 00000000..97a15c7f Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-ExtraBold.subset.woff2 differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-ExtraLight.subset.woff2 b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-ExtraLight.subset.woff2 new file mode 100644 index 00000000..3b038631 Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-ExtraLight.subset.woff2 differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Light.subset.woff2 b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Light.subset.woff2 new file mode 100644 index 00000000..8be17b48 Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Light.subset.woff2 differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Medium.subset.woff2 b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Medium.subset.woff2 new file mode 100644 index 00000000..9573c35f Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Medium.subset.woff2 differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Regular.subset.woff2 b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Regular.subset.woff2 new file mode 100644 index 00000000..7e220201 Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Regular.subset.woff2 differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-SemiBold.subset.woff2 b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-SemiBold.subset.woff2 new file mode 100644 index 00000000..a01658bc Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-SemiBold.subset.woff2 differ diff --git a/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Thin.subset.woff2 b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Thin.subset.woff2 new file mode 100644 index 00000000..b8002d28 Binary files /dev/null and b/public/fonts/Pretendard-1.3.8/web/static/woff2-subset/Pretendard-Thin.subset.woff2 differ diff --git a/public/icons/CameraIcon.tsx b/public/icons/CameraIcon.tsx new file mode 100644 index 00000000..3e7dcf88 --- /dev/null +++ b/public/icons/CameraIcon.tsx @@ -0,0 +1,36 @@ +import { SVGProps } from 'react'; + +interface CameraIconProps extends SVGProps { + width?: number; + height?: number; +} + +function CameraIcon({ width = 50, height = 50, ...props }: CameraIconProps) { + return ( + + + + + ); +} + +export default CameraIcon; diff --git a/public/icons/GoBackIcon.tsx b/public/icons/GoBackIcon.tsx new file mode 100644 index 00000000..766f9918 --- /dev/null +++ b/public/icons/GoBackIcon.tsx @@ -0,0 +1,24 @@ +function GoBackIcon({ + width = 14, + height = 14, + strokeColor = '#B4B5B6', + ...props +}) { + return ( + + + + ); +} + +export default GoBackIcon; diff --git a/public/icons/HamburgerMenuIcon.tsx b/public/icons/HamburgerMenuIcon.tsx new file mode 100644 index 00000000..3d5dbb25 --- /dev/null +++ b/public/icons/HamburgerMenuIcon.tsx @@ -0,0 +1,30 @@ +function HamburgerMenuIcon({ + width = 18, + height = 14, + strokeColor = '#B4B5B6', + ...props +}) { + return ( + + + + ); +} + +export default HamburgerMenuIcon; diff --git a/public/icons/IcCheckOnly.tsx b/public/icons/IcCheckOnly.tsx new file mode 100644 index 00000000..05cf0704 --- /dev/null +++ b/public/icons/IcCheckOnly.tsx @@ -0,0 +1,37 @@ +import { SVGProps } from 'react'; + +interface IcCheckOnlyProps extends SVGProps { + width?: number; + height?: number; + stroke?: string; + className?: string; +} + +function IcCheckOnly({ + width = 12, + height = 9, + stroke = '#E6F6F4', + className = '', + ...props +}: IcCheckOnlyProps) { + return ( + + + + ); +} +export default IcCheckOnly; diff --git a/public/icons/ImageIcon.tsx b/public/icons/ImageIcon.tsx new file mode 100644 index 00000000..2f9d4ea7 --- /dev/null +++ b/public/icons/ImageIcon.tsx @@ -0,0 +1,26 @@ +import { SVGProps } from 'react'; + +interface ImageIconProps extends SVGProps { + width?: number; + height?: number; +} + +function ImageIcon({ width = 24, height = 24, ...props }: ImageIconProps) { + return ( + + + + ); +} + +export default ImageIcon; diff --git a/public/icons/LocationIcon.tsx b/public/icons/LocationIcon.tsx index 606ca39b..6c58a208 100644 --- a/public/icons/LocationIcon.tsx +++ b/public/icons/LocationIcon.tsx @@ -3,11 +3,13 @@ import { SVGProps } from 'react'; interface LocationIconProps extends SVGProps { width?: number; height?: number; + isPast?: boolean; } function LocationIcon({ width = 24, height = 24, + isPast = false, ...props }: LocationIconProps) { return ( @@ -21,7 +23,7 @@ function LocationIcon({ > { + size?: number; +} + +export default function MessageIcon({ size = 24, ...props }: IconProps) { + return ( + + + + ); +} diff --git a/public/icons/OnlineIcon.tsx b/public/icons/OnlineIcon.tsx new file mode 100644 index 00000000..a05fa970 --- /dev/null +++ b/public/icons/OnlineIcon.tsx @@ -0,0 +1,35 @@ +import { SVGProps } from 'react'; + +interface OnlineIconProps extends SVGProps { + width?: number; + height?: number; + isPast?: boolean; +} + +function OnlineIcon({ + width = 24, + height = 24, + isPast = false, + ...props +}: OnlineIconProps) { + return ( + + + + ); +} + +export default OnlineIcon; diff --git a/public/icons/PencilIcon.tsx b/public/icons/PencilIcon.tsx new file mode 100644 index 00000000..023c2725 --- /dev/null +++ b/public/icons/PencilIcon.tsx @@ -0,0 +1,26 @@ +import { SVGProps } from 'react'; + +interface PencilIconProps extends SVGProps { + width?: number; + height?: number; +} + +function PencilIcon({ width = 13, height = 14, ...props }: PencilIconProps) { + return ( + + + + ); +} + +export default PencilIcon; diff --git a/public/icons/index.ts b/public/icons/index.ts index a3f11b0e..1a23bc21 100644 --- a/public/icons/index.ts +++ b/public/icons/index.ts @@ -13,3 +13,9 @@ export { default as HostIcon } from './HostIcon'; export { default as IcClose } from './IcClose'; export { default as EditIcon } from './EditIcon'; export { default as IcEdit } from './IcEdit'; +export { default as ImageIcon } from './ImageIcon'; +export { default as CameraIcon } from './CameraIcon'; +export { default as OnlineIcon } from './OnlineIcon'; +export { default as MessageIcon } from './MessageIcon'; +export { default as PencilIcon } from './PencilIcon'; +export { default as IcCheckOnly } from './IcCheckOnly'; diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 00000000..f24f38e2 --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,307 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.7.0'; +const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +self.addEventListener('install', function () { + self.skipWaiting(); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('message', async function (event) { + const clientId = event.source.id; + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }); + break; + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +self.addEventListener('fetch', function (event) { + const { request } = event; + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + // Generate unique request ID. + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId)); +}); + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + (async function () { + const responseClone = response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body], + ); + })(); + } + + return response; +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (activeClientIds.has(event.clientId)) { + return client; + } + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +async function getResponse(event, client, requestId) { + const { request } = event; + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone(); + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers); + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept'); + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()); + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ); + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')); + } else { + headers.delete('accept'); + } + } + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer(); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'PASSTHROUGH': { + return passthrough(); + } + } + + return passthrough(); +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)), + ); + }); +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} diff --git a/public/svg/index.ts b/public/svg/index.ts deleted file mode 100644 index cb0ff5c3..00000000 --- a/public/svg/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/src/api/auth/authClientAPI.ts b/src/api/auth/authClientAPI.ts new file mode 100644 index 00000000..dc17ff46 --- /dev/null +++ b/src/api/auth/authClientAPI.ts @@ -0,0 +1,19 @@ +import { EditInfoParams } from '@/features/profile/types'; +import apiClient from '@/lib/utils/apiClient'; + +export const authClientAPI = { + //회원정보 확인 + + //회원정보 수정 + editInfo: async ({ nickname, image, description }: EditInfoParams) => { + await apiClient.post('auths/user', { + nickname, + image, + description, + }); + }, + + //회원가입 + + //액세스 토큰 재발급 +}; diff --git a/src/api/auth/authServerAPI.ts b/src/api/auth/authServerAPI.ts new file mode 100644 index 00000000..20aa8174 --- /dev/null +++ b/src/api/auth/authServerAPI.ts @@ -0,0 +1,3 @@ +//로그인 + +//로그아웃 diff --git a/src/api/auth/react-query/customHooks.ts b/src/api/auth/react-query/customHooks.ts new file mode 100644 index 00000000..74958a5a --- /dev/null +++ b/src/api/auth/react-query/customHooks.ts @@ -0,0 +1,20 @@ +import { useMutation } from '@tanstack/react-query'; +import { showToast } from '@/components/toast/toast'; +import { authClientAPI } from '../authClientAPI'; +import { getUserInfo } from '@/features/auth/api/auth'; +import { EditInfoParams } from '@/features/profile/types'; + +//프로필 수정하기 +export function useEditInfo() { + return useMutation({ + mutationFn: (data: EditInfoParams) => authClientAPI.editInfo(data), + onSuccess: () => { + getUserInfo(); + showToast({ message: '프로필 수정이 완료되었습니다.', type: 'success' }); + }, + onError: (error) => { + showToast({ message: '프로필 수정을 실패하였습니다', type: 'error' }); + console.error(error); + }, + }); +} diff --git a/src/api/auth/react-query/index.ts b/src/api/auth/react-query/index.ts new file mode 100644 index 00000000..283bba0f --- /dev/null +++ b/src/api/auth/react-query/index.ts @@ -0,0 +1,2 @@ +// export * from './queries'; +export * from './customHooks'; diff --git a/src/api/auth/react-query/queries.ts b/src/api/auth/react-query/queries.ts new file mode 100644 index 00000000..3b409718 --- /dev/null +++ b/src/api/auth/react-query/queries.ts @@ -0,0 +1 @@ +//auth queries diff --git a/src/api/auth/types.ts b/src/api/auth/types.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/api/book-club/bookClubLikeAPI.ts b/src/api/book-club/bookClubLikeAPI.ts new file mode 100644 index 00000000..2d5a9781 --- /dev/null +++ b/src/api/book-club/bookClubLikeAPI.ts @@ -0,0 +1,14 @@ +import apiClient from '@/lib/utils/apiClient'; + +export const bookClubLikeAPI = { + //찜 취소 + unlike: async (id: number) => { + apiClient.delete(`/book-clubs/${id}/likes`); + }, + //찜하기 + like: async (id: number) => { + apiClient.post(`/book-clubs/${id}/likes`); + }, + + //찜 목록 조회 +}; diff --git a/src/api/book-club/bookClubMainAPI.ts b/src/api/book-club/bookClubMainAPI.ts new file mode 100644 index 00000000..12ad39cc --- /dev/null +++ b/src/api/book-club/bookClubMainAPI.ts @@ -0,0 +1,60 @@ +import apiClient from '@/lib/utils/apiClient'; +import { BookClubParams, MyProfileParams } from '@/types/bookclubs'; + +export const bookClubMainAPI = { + //북클럽 삭제 + cancel: async (id: number) => { + const res = await apiClient.delete(`book-clubs/${id}`); + return res; + }, + + //북클럽 목록 조회 + getBookClubs: async (params?: BookClubParams) => { + const response = await apiClient.get('/book-clubs', { params }); + return response.data; + }, + + //단일 북클럽 조회 + getBookClubDetail: async (bookClubId: number) => { + const response = await apiClient.get(`/book-clubs/${bookClubId}`); + return response.data; + }, + + //유저가 참가한 북클럽 조회 + userJoined: async (userId: number, params?: MyProfileParams) => { + const response = await apiClient.get(`/book-clubs/user/${userId}/joined`, { + params, + }); + return response.data; + }, + + //유저가 만든 북클럽 조회 + userCreated: async (userId: number, params?: MyProfileParams) => { + const response = await apiClient.get(`/book-clubs/user/${userId}/created`, { + params, + }); + return response.data; + }, + + //내가 참여한 북클럽 조회 + myJoined: async (params?: MyProfileParams) => { + const response = await apiClient.get('/book-clubs/my-joined', { params }); + return response.data; + }, + + //내가 만든 북클럽 조회 + myCreated: async (params?: MyProfileParams) => { + const response = await apiClient.get('/book-clubs/my-created', { params }); + return response.data; + }, + + //북클럽 생성 + create: async (formData: FormData) => { + const response = await apiClient.post('/book-clubs', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return response.data; + }, +}; diff --git a/src/api/book-club/bookClubMemberAPI.ts b/src/api/book-club/bookClubMemberAPI.ts new file mode 100644 index 00000000..d02e9e44 --- /dev/null +++ b/src/api/book-club/bookClubMemberAPI.ts @@ -0,0 +1,13 @@ +import apiClient from '@/lib/utils/apiClient'; + +export const bookClubMemberAPI = { + //북클럽 참여하기 + join: async (id: number) => { + await apiClient.post(`/book-clubs/${id}/join`); + }, + //북클럽 참여 취소하기 + leave: async (id: number) => { + const res = await apiClient.delete(`/book-clubs/${id}/leave`); + return res; + }, +}; diff --git a/src/api/book-club/bookClubReviewAPI.ts b/src/api/book-club/bookClubReviewAPI.ts new file mode 100644 index 00000000..eca1fffa --- /dev/null +++ b/src/api/book-club/bookClubReviewAPI.ts @@ -0,0 +1,53 @@ +import apiClient from '@/lib/utils/apiClient'; +import { WriteReviewParams, DetailClubReviewParams } from './types'; +import { MyProfileParams } from '@/types/bookclubs'; + +export const bookClubReviewAPI = { + //리뷰 삭제하기 + + //단일 북클럽 리뷰 목록 조회 + getReviews: async ({ + bookClubId, + params = { order: 'DESC' }, + }: { + bookClubId: number; + params?: DetailClubReviewParams; + }) => { + return await apiClient.get(`book-clubs/${bookClubId}/reviews`, { + params, + }); + }, + + //특정 유저의 리뷰 조회 + userReviews: async ({ + userId, + params = { order: 'DESC' }, + }: { + userId: number; + params?: MyProfileParams; + }) => { + const response = await apiClient.get( + `/book-clubs/users/${userId}/reviews`, + { + params, + }, + ); + return response.data; + }, + + //내가 작성한 리뷰 조회 + myReviews: async (params?: MyProfileParams) => { + const response = await apiClient.get('/book-clubs/my-reviews', { + params, + }); + return response.data; + }, + + //리뷰 작성하기 + write: async ({ bookClubId, rating, content }: WriteReviewParams) => { + await apiClient.post(`book-clubs/${bookClubId}/reviews`, { + rating, + content, + }); + }, +}; diff --git a/src/api/book-club/index.ts b/src/api/book-club/index.ts new file mode 100644 index 00000000..240bfeb0 --- /dev/null +++ b/src/api/book-club/index.ts @@ -0,0 +1,4 @@ +export * from './bookClubLikeAPI'; +export * from './bookClubMainAPI'; +export * from './bookClubMemberAPI'; +export * from './bookClubReviewAPI'; diff --git a/src/api/book-club/react-query/customHooks.ts b/src/api/book-club/react-query/customHooks.ts new file mode 100644 index 00000000..defec8ef --- /dev/null +++ b/src/api/book-club/react-query/customHooks.ts @@ -0,0 +1,132 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { bookClubs } from './queries'; +import { showToast } from '@/components/toast/toast'; +import { + bookClubLikeAPI, + bookClubMainAPI, + bookClubMemberAPI, + bookClubReviewAPI, +} from '../index'; +import { WriteReviewParams } from '../types'; +import { AxiosError } from 'axios'; + +export function useBookClubCreateMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (formData: FormData) => bookClubMainAPI.create(formData), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: bookClubs.list().queryKey, + }); + queryClient.invalidateQueries({ + queryKey: bookClubs.my().queryKey, + }); + }, + onError: () => { + showToast({ message: '북클럽 생성에 실패했습니다.', type: 'error' }); + }, + }); +} + +export function useJoinBookClub() { + const queryClient = useQueryClient(); + + return useMutation, number>({ + mutationFn: (id: number) => bookClubMemberAPI.join(id), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: bookClubs._def, + }); + }, + }); +} + +//북클럽 참여 취소하기 +export function useLeaveBookClub() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => bookClubMemberAPI.leave(id), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: bookClubs._def, + }); + }, + }); +} + +//리뷰 작성하기 +export function useWriteReview() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ bookClubId, rating, content }: WriteReviewParams) => + bookClubReviewAPI.write({ bookClubId, rating, content }), + + onSuccess: (data, variables) => { + const { bookClubId } = variables; + + queryClient.invalidateQueries({ + queryKey: bookClubs.detail(bookClubId).queryKey, // bookClubId에 해당하는 모임 상세 무효화 + }); + + queryClient.invalidateQueries({ + queryKey: bookClubs.my()._ctx.reviews().queryKey, + }); + showToast({ message: '리뷰 작성을 완료하였습니다', type: 'success' }); + }, + onError: (error) => { + console.error(error); + + showToast({ message: '리뷰 작성을 실패하였습니다.', type: 'error' }); + }, + }); +} + +//북클럽 취소하기 +export function useCancelBookClub() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => bookClubMainAPI.cancel(id), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: bookClubs.my()._ctx.created().queryKey, + }); + queryClient.invalidateQueries({ + queryKey: bookClubs.my()._ctx.joined().queryKey, + }); + }, + }); +} + +export function useLikeBookClub() { + const queryClient = useQueryClient(); + + return useMutation, number>({ + mutationFn: (id: number) => bookClubLikeAPI.like(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ + queryKey: bookClubs.list().queryKey, + }); + queryClient.invalidateQueries({ + queryKey: bookClubs.detail(id).queryKey, + }); + }, + }); +} + +export function useUnLikeBookClub() { + const queryClient = useQueryClient(); + + return useMutation, number>({ + mutationFn: (id: number) => bookClubLikeAPI.unlike(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ + queryKey: bookClubs.list().queryKey, + }); + queryClient.invalidateQueries({ + queryKey: bookClubs.detail(id).queryKey, + }); + }, + }); +} diff --git a/src/api/book-club/react-query/index.ts b/src/api/book-club/react-query/index.ts new file mode 100644 index 00000000..01e8d3b3 --- /dev/null +++ b/src/api/book-club/react-query/index.ts @@ -0,0 +1,2 @@ +export * from './queries'; +export * from './customHooks'; diff --git a/src/api/book-club/react-query/queries.ts b/src/api/book-club/react-query/queries.ts new file mode 100644 index 00000000..92f84752 --- /dev/null +++ b/src/api/book-club/react-query/queries.ts @@ -0,0 +1,63 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { BookClubParams, MyProfileParams } from '@/types/bookclubs'; +import { ClubDetailReviewFilters } from '@/types/review'; +import { bookClubReviewAPI } from '@/api/book-club/bookClubReviewAPI'; +import { bookClubMainAPI } from '@/api/book-club/bookClubMainAPI'; + +export const bookClubs = createQueryKeys('bookClubs', { + list: (filters?: BookClubParams) => ({ + queryKey: [{ filters: filters || {} }], + queryFn: () => bookClubMainAPI.getBookClubs(filters), + }), + + detail: (bookClubId: number) => ({ + queryKey: [bookClubId], + queryFn: () => bookClubMainAPI.getBookClubDetail(bookClubId), + contextQueries: { + reviews: (filters?: ClubDetailReviewFilters) => ({ + queryKey: [{ filters: filters || {} }], + queryFn: () => + bookClubReviewAPI.getReviews({ bookClubId, params: filters }), + }), + }, + }), + + my: () => ({ + queryKey: [{}], + queryFn: () => ({}), + contextQueries: { + joined: (filters?: MyProfileParams) => ({ + queryKey: [{ filters: filters || {} }], + queryFn: () => bookClubMainAPI.myJoined(filters), + }), + created: (filters?: MyProfileParams) => ({ + queryKey: [{ filters: filters || {} }], + queryFn: () => bookClubMainAPI.myCreated(filters), + }), + reviews: (filters?: MyProfileParams) => ({ + queryKey: [{ filters: filters || {} }], + queryFn: () => bookClubReviewAPI.myReviews(filters), + }), + }, + }), + + user: (userId: number) => ({ + queryKey: [userId], + queryFn: () => ({}), + contextQueries: { + joined: (filters?: MyProfileParams) => ({ + queryKey: [{ filters: filters || {} }], + queryFn: () => bookClubMainAPI.userJoined(userId, filters), + }), + created: (filters?: MyProfileParams) => ({ + queryKey: [{ filters: filters || {} }], + queryFn: () => bookClubMainAPI.userCreated(userId, filters), + }), + reviews: (filters?: MyProfileParams) => ({ + queryKey: [{ filters: filters || {} }], + queryFn: () => + bookClubReviewAPI.userReviews({ userId, params: filters }), + }), + }, + }), +}); diff --git a/src/api/book-club/types.ts b/src/api/book-club/types.ts new file mode 100644 index 00000000..7b89d277 --- /dev/null +++ b/src/api/book-club/types.ts @@ -0,0 +1,12 @@ +export interface DetailClubReviewParams { + // bookClubId: number; + order?: 'DESC' | 'RATE_DESC' | 'RATE_ASC'; + size?: number; + page?: number; +} + +export interface WriteReviewParams { + bookClubId: number; + rating: number; + content: string; +} diff --git a/src/api/chat/index.ts b/src/api/chat/index.ts new file mode 100644 index 00000000..bbab3df9 --- /dev/null +++ b/src/api/chat/index.ts @@ -0,0 +1 @@ +//교환 diff --git a/src/api/user/react-query/queries.ts b/src/api/user/react-query/queries.ts new file mode 100644 index 00000000..b134142f --- /dev/null +++ b/src/api/user/react-query/queries.ts @@ -0,0 +1,11 @@ +import apiClient from '@/lib/utils/apiClient'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +//타유저 프로필 정보 조회 +//TODO: 에러 처리 +export const users = createQueryKeys('users', { + userInfo: (userId: number) => ({ + queryKey: [userId], + queryFn: () => apiClient.get(`auths/user/${userId}`), + }), +}); diff --git a/src/api/user/userAPI.ts b/src/api/user/userAPI.ts new file mode 100644 index 00000000..b50fd60d --- /dev/null +++ b/src/api/user/userAPI.ts @@ -0,0 +1,8 @@ +import apiClient from '@/lib/utils/apiClient'; + +export const userAPI = { + getInfo: async (userId: number) => { + const response = await apiClient.get(`auths/user/${userId}`); + return response.data; + }, +}; diff --git a/src/app/actions.ts b/src/app/actions.ts new file mode 100644 index 00000000..7884b7c3 --- /dev/null +++ b/src/app/actions.ts @@ -0,0 +1,66 @@ +'use server'; + +import { LoginFormData } from '@/features/auth/types/loginFormSchema'; +import { cookies } from 'next/headers'; + +export async function loginServer(data: LoginFormData) { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/auths/signin`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }, + ); + const json = await response.json(); + + if (response.ok && json.accessToken && json.refreshToken) { + const cookieStore = await cookies(); + cookieStore.set('auth_token', json.accessToken, { + maxAge: 60 * 15, + }); + cookieStore.set('refresh_token', json.refreshToken, { + maxAge: 60 * 60 * 24 * 14, + }); + return json; + } + + return { error: json.message || '로그인에 실패했습니다.' }; + } catch (error) { + console.error(error); + return { error: '로그인 처리 중 오류가 발생했습니다.' }; + } +} + +export async function logoutServer( + accessToken: string | null, + refreshToken: string | null, +) { + const cookieStore = await cookies(); + cookieStore.delete('auth_token'); + cookieStore.delete('refresh_token'); + try { + if (accessToken && refreshToken) { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/auths/signout`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ accessToken, refreshToken }), + }, + ); + const json = await response.json(); + return json; + } else { + return { success: true }; + } + } catch (error) { + console.error(error); + return null; + } +} diff --git a/src/app/bookclub/create/page.tsx b/src/app/bookclub/create/page.tsx index fa89244a..1da57578 100644 --- a/src/app/bookclub/create/page.tsx +++ b/src/app/bookclub/create/page.tsx @@ -1,4 +1,4 @@ -import { CreateBookClubPage } from '@/features/club-create/container'; +import CreateBookClubPage from '@/features/club-create/container/CreateBookClubPage'; export default function BookClubCreate() { return ; diff --git a/src/app/bookclub/page.tsx b/src/app/bookclub/page.tsx index 4477773c..91208bd1 100644 --- a/src/app/bookclub/page.tsx +++ b/src/app/bookclub/page.tsx @@ -1,9 +1,5 @@ -import { BookClubMainPage } from '@/features/bookclub/components'; +import BookClubMainPage from '@/features/bookclub/components/BookClubMainPage'; export default function Home() { - return ( - <> - - - ); + return ; } diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx new file mode 100644 index 00000000..f3d7d151 --- /dev/null +++ b/src/app/chat/[id]/page.tsx @@ -0,0 +1,244 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import ChatBubbleList from '@/features/chat-room/container/chat-bubble-list/ChatBubbleList'; +import { usePathname, useRouter } from 'next/navigation'; +import ChatCard from '@/features/chat/components/chat-card/ChatCard'; +import ParticipantCounter from '@/components/participant-counter/ParticipantCounter'; +import IconButton from '@/components/icon-button/IconButton'; +import GoBackIcon from '../../../../public/icons/GoBackIcon'; +import HamburgerMenuIcon from '../../../../public/icons/HamburgerMenuIcon'; +import MessageInput from '@/components/input/message-input/MessageInput'; +import { + ChatHistoryResponse, + ChatMessage, + getChatHistory, + sendMessage, + subscribeToChat, +} from '@/features/chat/utils/socket'; +import { + ChatMessageType, + GroupedMessage, + SystemMessageType, +} from '@/features/chat-room/types/chatBubbleList'; +import { useQuery } from '@tanstack/react-query'; +import { bookClubs } from '@/api/book-club/react-query'; +import { formatDateForUI } from '@/lib/utils/formatDateForUI'; +import { getCookie } from '@/features/auth/utils/cookies'; +import { initializeSocket } from '@/features/chat/utils/socket'; +import MessageIcon from '../../../../public/icons/MessageIcon'; + +function ChatRoomPage() { + const pathname = usePathname(); + const chatId = pathname?.split('/').pop() || ''; + const [message, setMessage] = useState(''); + const [chatHistory, setChatHistory] = useState({ + historyResponses: [], + }); + + const [isConnected, setIsConnected] = useState(false); + + const { data } = useQuery( + bookClubs.my()._ctx.joined({ order: 'DESC', page: 1, size: 10 }), + ); + + const bookClubDetail = data?.bookClubs?.find( + (club: any) => club.id === Number(chatId), + ); + + const router = useRouter(); + + const handleGoBack = () => { + router.push('/chat'); + }; + + useEffect(() => { + const connectSocket = async () => { + const token = getCookie('auth_token'); + if (token) { + try { + const client = await initializeSocket(token); + + let attempts = 0; + const maxAttempts = 50; + + await new Promise((resolve, reject) => { + const checkConnection = setInterval(() => { + console.log(`소켓 연결 시도 ${attempts + 1}회`); + + if (client?.connected) { + console.log('소켓 연결 성공!'); + clearInterval(checkConnection); + resolve(true); + } + + attempts++; + if (attempts >= maxAttempts) { + console.log('소켓 연결 최대 시도 횟수 초과'); + clearInterval(checkConnection); + reject(new Error('소켓 연결 타임아웃')); + } + }, 100); + }); + + setIsConnected(true); + } catch (error) { + console.error('소켓 연결 실패:', error); + } + } + }; + + connectSocket(); + }, []); + + useEffect(() => { + if (!isConnected) return; + + const loadChatHistory = async () => { + try { + const history = await getChatHistory(Number(chatId)); + setChatHistory(history); + } catch (error) { + console.error('채팅 히스토리 로딩 실패:', error); + } + }; + + const handleNewMessage = (message: ChatMessage) => { + setChatHistory((prev: any) => { + if (!prev?.historyResponses?.length) { + return { + historyResponses: [ + { + date: new Date().toISOString(), + messages: [message], + }, + ], + }; + } + + return { + ...prev, + historyResponses: [ + ...prev.historyResponses.slice(0, -1), + { + ...prev.historyResponses[prev.historyResponses.length - 1], + messages: [ + ...prev.historyResponses[prev.historyResponses.length - 1] + .messages, + message, + ], + }, + ], + }; + }); + }; + + loadChatHistory(); + const subscription = subscribeToChat(Number(chatId), handleNewMessage); + + return () => { + subscription?.unsubscribe(); + }; + }, [chatId, isConnected]); + + const handleMessageChange = (e: React.ChangeEvent) => { + setMessage(e.target.value); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (message.trim()) { + sendMessage(Number(chatId), message); + setMessage(''); + } + }; + + const convertToGroupedMessage = ( + history: ChatHistoryResponse, + ): GroupedMessage[] => { + return history.historyResponses.map((response) => ({ + date: response.date, + messages: response.messages.map((msg) => { + if (msg.type === 'CHAT') { + return { + type: 'CHAT', + id: msg.id, + date: msg.date, + userId: msg.userId, + userNickname: msg.userNickname, + content: msg.content, + } as ChatMessageType; + } else { + return { + type: msg.type, + id: msg.id, + date: msg.date, + user: msg.userNickname, + } as SystemMessageType; + } + }), + })); + }; + + return ( +
+
+
+
+
+ } + onClick={handleGoBack} + className="bg-gray-light-02" + /> +

채팅

+ +
+
+ } + onClick={() => console.log('메뉴 열기 버튼 클릭')} + className="bg-gray-light-02" + /> +
+
+ router.push(`/bookclub/${chatId}`), + }} + /> +
+
+
+ {}} + /> +
+
+
+ + + } + aria-label="메시지 전송" + className="h-[52px] w-[52px] bg-green-light-01" + onClick={handleSubmit} + /> +
+
+ ); +} + +export default ChatRoomPage; diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx new file mode 100644 index 00000000..e0f8acbf --- /dev/null +++ b/src/app/chat/page.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import ChatContainer from '@/features/chat/container/ChatContainer'; +import HeaderSection from '@/components/common-layout/HeaderSection'; + +function ChatPage() { + return ( + <> + + 채팅에 활발히 +
+ 참여해 보세요! + + } + /> + + + + ); +} + +export default ChatPage; diff --git a/src/app/exchange/page.tsx b/src/app/exchange/page.tsx index 5b180952..94bae389 100644 --- a/src/app/exchange/page.tsx +++ b/src/app/exchange/page.tsx @@ -1,4 +1,6 @@ +import ExchangePage from '@/features/exchange/container/ExchangePage'; + function Exchange() { - return
Exchange
; + return ; } export default Exchange; diff --git a/src/app/instrumentation.ts b/src/app/instrumentation.ts new file mode 100644 index 00000000..baa2eb9d --- /dev/null +++ b/src/app/instrumentation.ts @@ -0,0 +1,8 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + const { server } = await import('@/mocks/server'); + server.listen({ + onUnhandledRequest: 'bypass', + }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f8380695..a7ff9915 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,11 +2,13 @@ import type { Metadata } from 'next'; import ReactQueryProviders from '@/lib/utils/reactQueryProvider'; import HeaderBar from '@/components/header/HeaderBar'; import Script from 'next/script'; +import { Toast } from '@/components/toast/toast'; +import { MSWComponent } from '@/components/MSWComponent'; import '@/styles/globals.css'; export const metadata: Metadata = { - title: 'Create Next App', + title: 'Bookco', description: 'Generated by create next app', }; @@ -25,9 +27,11 @@ export default function RootLayout({
- {children} + + {children}
+ ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 8abe04c8..91208bd1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,5 @@ -import { BookClubMainPage } from '@/features/bookclub/components'; +import BookClubMainPage from '@/features/bookclub/components/BookClubMainPage'; export default function Home() { - return ( -
- -
- ); + return ; } diff --git a/src/app/profile/[userId]/page.tsx b/src/app/profile/[userId]/page.tsx index 0dad427c..e9f5d0f4 100644 --- a/src/app/profile/[userId]/page.tsx +++ b/src/app/profile/[userId]/page.tsx @@ -1,11 +1,8 @@ +'use client'; + +import { ProfilePage } from '@/features/profile/container'; import React from 'react'; -function Profile() { - return ( -
-
profile page
-
- ); +export default function Profile() { + return ; } - -export default Profile; diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index de681fd0..0cb1b861 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -1,8 +1,8 @@ -import ProfilePage from '@/features/profile/container/ProfilePage'; -import React from 'react'; +'use client'; -const Profile = () => { - return ; -}; +import { ProfilePage } from '@/features/profile/container'; +import React from 'react'; -export default Profile; +export default function MyProfile() { + return ; +} diff --git a/src/app/wish/page.tsx b/src/app/wish/page.tsx index 06587f91..e9774281 100644 --- a/src/app/wish/page.tsx +++ b/src/app/wish/page.tsx @@ -1,4 +1,6 @@ +import WishPage from '@/features/club-wish/components/WishPage'; + function Wish() { - return
Wish
; + return ; } export default Wish; diff --git a/src/components/MSWComponent.tsx b/src/components/MSWComponent.tsx new file mode 100644 index 00000000..598bab53 --- /dev/null +++ b/src/components/MSWComponent.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export const MSWComponent = ({ children }: { children: React.ReactNode }) => { + const [mswReady, setMswReady] = useState(false); + + useEffect(() => { + const init = async () => { + if (process.env.NEXT_PUBLIC_API_MOCKING !== 'enabled') { + setMswReady(true); + return; + } + + const initMsw = await import('@/mocks/index').then((res) => res.initMsw); + await initMsw(); + setMswReady(true); + }; + + if (!mswReady) { + init(); + } + }, [mswReady]); + + return <>{children}; +}; diff --git a/src/components/avatar/Avatar.tsx b/src/components/avatar/Avatar.tsx index 6f2c97dc..01228c3b 100644 --- a/src/components/avatar/Avatar.tsx +++ b/src/components/avatar/Avatar.tsx @@ -3,8 +3,8 @@ import Image from 'next/image'; import { HTMLAttributes } from 'react'; interface AvatarProps extends HTMLAttributes { - src: string; - alt: string; + src?: string; + alt?: string; size?: AvatarSize; isPast?: boolean; onClick?: (e: React.MouseEvent) => void; @@ -26,7 +26,12 @@ function Avatar({ onClick={onClick} {...props} > - {alt} + {alt ); } diff --git a/src/components/badge/Badge.stories.tsx b/src/components/badge/Badge.stories.tsx new file mode 100644 index 00000000..ab0007dc --- /dev/null +++ b/src/components/badge/Badge.stories.tsx @@ -0,0 +1,67 @@ +import Badge from '@/components/badge/Badge'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta = { + title: 'Components/Badge', + component: Badge, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DefaultBadge: Story = { + args: { + count: 1, + size: 'md', + variant: 'default', + }, +}; + +export const DefaultBadgeSizes: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const DefaultBadgeNumbers: Story = { + render: () => ( +
+ + + + +
+ ), +}; + +export const DotBadge: Story = { + args: { + variant: 'dot', + size: 'md', + }, +}; + +export const DotBadgeSizes: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const CustomStyle: Story = { + args: { + count: 5, + className: 'bg-blue-500', + }, +}; diff --git a/src/components/badge/Badge.test.tsx b/src/components/badge/Badge.test.tsx new file mode 100644 index 00000000..9482a049 --- /dev/null +++ b/src/components/badge/Badge.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Badge from './Badge'; + +describe('Badge', () => { + it('숫자가 1000 이상일 때 999+로 표시된다', () => { + render(); + expect(screen.getByText('999+')).toBeInTheDocument(); + }); + + it('숫자가 1000 미만일 때 실제 숫자가 표시된다', () => { + render(); + expect(screen.getByText('999')).toBeInTheDocument(); + }); + + it('variant가 dot일 때는 숫자가 표시되지 않는다', () => { + render(); + expect(screen.queryByText('5')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/badge/Badge.tsx b/src/components/badge/Badge.tsx new file mode 100644 index 00000000..30e37bff --- /dev/null +++ b/src/components/badge/Badge.tsx @@ -0,0 +1,58 @@ +import { twMerge } from 'tailwind-merge'; + +interface BadgeProps extends React.HTMLAttributes { + count?: number; + size?: 'sm' | 'md' | 'lg'; + variant?: 'default' | 'dot'; +} + +const sizeStyles = { + sm: 'h-[20px] min-w-[20px] px-1.5 text-[10px]', + md: 'h-[26px] min-w-[26px] px-2 text-xs', + lg: 'h-[32px] min-w-[32px] px-2.5 text-sm', +} as const; + +const dotSizeStyles = { + sm: 'h-2 w-2', + md: 'h-2.5 w-2.5', + lg: 'h-3 w-3', +} as const; + +// TODO: 추후 위치 정보(vertical, horizontal) 에 따라 뱃지 위치를 조정할 수 있도록 하는 기능이 필요할 수 있음 +function Badge({ + count, + size = 'md', + variant = 'default', + className, + ...props +}: BadgeProps) { + if (variant === 'dot') { + return ( +
+ ); + } + + const displayCount = count && count >= 1000 ? '999+' : count; + + return ( +
+ {displayCount} +
+ ); +} + +export default Badge; diff --git a/src/components/button/Button.tsx b/src/components/button/Button.tsx index 4a1b845d..e49fa5b6 100644 --- a/src/components/button/Button.tsx +++ b/src/components/button/Button.tsx @@ -4,7 +4,7 @@ import { COLOR_SCHEMES, SIZE } from '@/constants'; export interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> { text: string; - size: 'large' | 'medium' | 'small' | 'modal'; + size: 'large' | 'medium' | 'small' | 'modal' | 'custom'; fillType: 'solid' | 'outline' | 'lightSolid'; themeColor: keyof typeof COLOR_SCHEMES; lightColor?: keyof typeof COLOR_SCHEMES; diff --git a/src/components/card/Card.stories.tsx b/src/components/card/Card.stories.tsx index 6965bd21..0afac0fa 100644 --- a/src/components/card/Card.stories.tsx +++ b/src/components/card/Card.stories.tsx @@ -37,7 +37,24 @@ export const Title: Story = { }; export const Location: Story = { - render: () => 서울특별시 강남구, + render: () => ( + <> + 서울특별시 강남구 +
+ 서울특별시 강남구 +
+
+ + 서울특별시 강남구 + +
+
+ + 서울특별시 강남구 + +
+ + ), }; export const DateTime: Story = { diff --git a/src/components/card/Card.tsx b/src/components/card/Card.tsx index 05e28876..cd5dc547 100644 --- a/src/components/card/Card.tsx +++ b/src/components/card/Card.tsx @@ -2,7 +2,6 @@ import { createContext, useContext } from 'react'; import ParticipantCounter from '../participant-counter/ParticipantCounter'; -import AvatarGroup from '../avatar-group/AvatarGroup'; import ProgressBar from '../progress-bar/ProgressBar'; import Avatar from '../avatar/Avatar'; import { @@ -10,6 +9,7 @@ import { HostIcon, HeartIcon, RatingIcon, + OnlineIcon, } from '../../../public/icons'; import Image from 'next/image'; import { twMerge } from 'tailwind-merge'; @@ -97,18 +97,28 @@ function CardLocation({ children, className, textClassName, + meetingType, + isPast, ...props }: CardLocationProps) { + const displayText = + meetingType === 'ONLINE' ? '온라인' : children || '위치 정보 없음'; + return (
- + {meetingType === 'ONLINE' ? ( + + ) : ( + + )} - {children} + {displayText}
); @@ -163,7 +173,7 @@ function CardHost({ avatar, className, isHost, - onClick, + onHostClick, ...props }: CardHostInfo) { return ( @@ -172,12 +182,9 @@ function CardHost({
@@ -207,16 +214,17 @@ function Card(props: CardProps) { location, datetime, isLiked, - onLikeClick, current, max, isPast, isCanceled, // meetingType, bookClubType, + clubStatus, + onLikeClick, onClick, onDelete, - clubStatus, + meetingType, } = props as DefaultClubCard & { variant: 'defaultClub' }; return ( @@ -230,7 +238,7 @@ function Card(props: CardProps) { /> onClick(clubId)} + onClick={() => onClick?.(clubId)} className="justify-between" >
@@ -239,7 +247,9 @@ function Card(props: CardProps) {
- {location} + + {location} + {datetime}
@@ -259,7 +269,7 @@ function Card(props: CardProps) { />
- {isCanceled && onDelete()} />} + {isCanceled && onDelete?.()} />} ); } @@ -269,13 +279,11 @@ function Card(props: CardProps) { clubId, imageUrl, imageAlt, - // isLiked, - // onLikeClick, isCanceled, onClick, onDelete, clubStatus, - // meetingType, + meetingType, bookClubType, title, location, @@ -287,14 +295,9 @@ function Card(props: CardProps) { return (
- + onClick(clubId)} + onClick={() => onClick?.(clubId)} className="justify-between" >
@@ -308,7 +311,9 @@ function Card(props: CardProps) {
{title}
- {location} + + {location} + {datetime}
@@ -321,7 +326,7 @@ function Card(props: CardProps) { themeColor="green-normal-01" onClick={(e) => { e.stopPropagation(); - onWriteReview(); + onWriteReview(clubId); }} className="w-full" /> @@ -354,7 +359,7 @@ function Card(props: CardProps) { imageAlt, onClick, clubStatus, - // meetingType, + meetingType, bookClubType, isPast, title, @@ -368,7 +373,7 @@ function Card(props: CardProps) {
onClick(clubId)} + onClick={() => onClick?.(clubId)} className="justify-between" >
@@ -379,7 +384,9 @@ function Card(props: CardProps) {
{title}
- {location} + + {location} + {datetime}
@@ -393,7 +400,7 @@ function Card(props: CardProps) { lightColor="gray-normal-01" onClick={(e) => { e.stopPropagation(); - onCancel(); + onCancel(clubId); }} className="w-full" /> @@ -414,7 +421,7 @@ function Card(props: CardProps) {
) : ( - 아직 리뷰가 달리지 않았습니다 + 아직 작성된 리뷰가 없습니다 )}
@@ -436,15 +443,15 @@ function Card(props: CardProps) { datetime, isLiked, onLikeClick, - // meetingType, bookClubType, + meetingType, current, max, isPast, host, clubStatus, - participants, onClick, + onHostClick, isHost, isParticipant, hasWrittenReview, @@ -556,10 +563,11 @@ function Card(props: CardProps) { alt: `${host.name}님의 프로필`, }} isHost={isHost} + onClick={onHostClick} /> onClick(clubId)} + onClick={() => onClick?.(clubId)} className="justify-between" >
@@ -568,29 +576,20 @@ function Card(props: CardProps) {
- {location} + + {location} + {datetime}
-
- - - {participants.map((participant, index) => ( - - ))} - -
+
alert('좋아요 클릭!'), current: 5, max: 10, - participants: [ - { - id: '1', - name: '참여자1', - profileImage: 'https://picsum.photos/200/200?random=1', - profileImageAlt: '참여자1 프로필 이미지', - }, - { - id: '2', - name: '참여자2', - profileImage: 'https://picsum.photos/200/200?random=2', - profileImageAlt: '참여자2 프로필 이미지', - }, - { - id: '3', - name: '참여자3', - profileImage: 'https://picsum.photos/200/200?random=3', - profileImageAlt: '참여자3 프로필 이미지', - }, - { - id: '4', - name: '참여자4', - profileImage: 'https://picsum.photos/200/200?random=4', - profileImageAlt: '참여자4 프로필 이미지', - }, - { - id: '5', - name: '참여자5', - profileImage: 'https://picsum.photos/200/200?random=5', - profileImageAlt: '참여자5 프로필 이미지', - }, - ], host: { - id: 'host1', + id: 1, name: '호스트', profileImage: 'https://picsum.photos/200/200?random=host', }, diff --git a/src/components/card/types/card.ts b/src/components/card/types/card.ts index 710fd308..1ed7064b 100644 --- a/src/components/card/types/card.ts +++ b/src/components/card/types/card.ts @@ -18,8 +18,10 @@ interface CardTitleProps extends ComponentPropsWithoutRef<'h3'> { interface CardLocationProps extends ComponentPropsWithoutRef<'div'> { children: React.ReactNode; + meetingType: 'ONLINE' | 'OFFLINE'; className?: string; textClassName?: string; + isPast?: boolean; } interface CardDateTimeProps extends ComponentPropsWithoutRef<'span'> { diff --git a/src/components/card/types/clubCard.ts b/src/components/card/types/clubCard.ts index a71972af..f20afced 100644 --- a/src/components/card/types/clubCard.ts +++ b/src/components/card/types/clubCard.ts @@ -6,15 +6,15 @@ interface ClubCard { // 모임 정보 clubId: number; title: string; - location: string; + location: string | null; datetime: string; meetingType: 'ONLINE' | 'OFFLINE'; bookClubType: 'FREE' | 'FIXED'; isPast: boolean; // 지난 모임인지 아닌지 - clubStatus: 'pending' | 'confirmed' | 'closed'; // 개설 현황 + clubStatus: 'pending' | 'confirmed' | 'closed'; // 개설 현황 TODO: 내가 만든 모임에서 '모임 완료' 상태 추가 // 액션 (카드 클릭시 라우터 처리 등) - onClick: (clubId: number) => void; + onClick?: (clubId: number) => void; } interface DefaultClubCard extends ClubCard { @@ -24,32 +24,27 @@ interface DefaultClubCard extends ClubCard { // 취소 정보 (블러) isCanceled: boolean; - onDelete: () => void; + onDelete?: () => void; // 참가자 현황 current: number; max: number; + + //마이페이지 판별 + isMyPage?: boolean; } interface ParticipatedClubCard extends ClubCard { - // 찜 정보 - // isLiked: boolean; - // onLikeClick: () => void; - - // 취소 정보 (블러) isCanceled: boolean; - onDelete: (clubId: number) => void; - // 버튼 액션 - onWriteReview: () => void; // 리뷰 작성 - onCancel: (clubId: number) => void; // 모임 취소 + onWriteReview: (clubId: number) => void; // 리뷰 작성 + onCancel: (clubId: number) => void; // 모임 참여 취소 + onDelete: (clubId: number) => void; //모임 삭제 } interface HostedClubCard extends ClubCard { - // 모임 취소 액션 - onCancel: () => void; + onCancel: (clubId: number) => void; - // 리뷰 정보 reviewScore?: number; } @@ -61,19 +56,14 @@ interface DetailedClubCard extends ClubCard { // 참가자 정보 current: number; max: number; - participants: ReadonlyArray<{ - readonly id?: string; - readonly name: string; - readonly profileImage?: string; - readonly profileImageAlt?: string; - }>; // 호스트 정보 host: { - id?: string; + id?: number; name: string; profileImage?: string; }; + onHostClick?: (e: React.MouseEvent) => void; // 호스트 여부 isHost: boolean; diff --git a/src/components/common-layout/CategoryTabs.tsx b/src/components/common-layout/CategoryTabs.tsx new file mode 100644 index 00000000..65624889 --- /dev/null +++ b/src/components/common-layout/CategoryTabs.tsx @@ -0,0 +1,45 @@ +import Tab from '@/components/tab/Tab'; +import { BookClubParams } from '../../types/bookclubs'; + +interface CategoryTabsProps { + filters: BookClubParams; + onFilterChange: (newFilters: Partial) => void; +} + +type ValueOf = T[keyof T]; + +const TAB_LABELS = { + ALL: '전체', + FREE: '자유책', + FIXED: '지정책', +} as const; + +type TabLabel = ValueOf; + +function CategoryTabs({ filters, onFilterChange }: CategoryTabsProps) { + const activeTabKey = filters?.bookClubType || 'ALL'; + const activeTabLabel: TabLabel = + TAB_LABELS[activeTabKey as keyof typeof TAB_LABELS]; + + const handleTabChange = (selectedLabel: TabLabel) => { + const selectedKey = ( + Object.keys(TAB_LABELS) as (keyof typeof TAB_LABELS)[] + ).find((key) => TAB_LABELS[key] === selectedLabel); + if (selectedKey) { + onFilterChange({ bookClubType: selectedKey }); + } + }; + + return ( +
+ handleTabChange(item)} + tabType="MAIN_TAB" + /> +
+
+ ); +} +export default CategoryTabs; diff --git a/src/components/common-layout/EmptyState.tsx b/src/components/common-layout/EmptyState.tsx new file mode 100644 index 00000000..b5955f11 --- /dev/null +++ b/src/components/common-layout/EmptyState.tsx @@ -0,0 +1,18 @@ +interface EmptyStateProps { + title: string; + subtitle: string; + className?: string; +} + +function EmptyState({ title, subtitle, className }: EmptyStateProps) { + return ( +
+

{title}

+

{subtitle}

+
+ ); +} + +export default EmptyState; diff --git a/src/components/common-layout/FilterBar.tsx b/src/components/common-layout/FilterBar.tsx new file mode 100644 index 00000000..3f650976 --- /dev/null +++ b/src/components/common-layout/FilterBar.tsx @@ -0,0 +1,42 @@ +import { + CategoryTabs, + SearchSection, + FilterSection, +} from '@/components/common-layout'; +import { BookClub, BookClubParams } from '@/types/bookclubs'; +import { Dispatch, SetStateAction } from 'react'; + +interface FilterBarProps { + filters: BookClubParams; + handleFilterChange: (newFilter: Partial) => void; + bookClubs: BookClub[]; + initialBookClubs: BookClub[]; + setBookClubs: Dispatch>; +} + +function FilterBar({ + filters, + handleFilterChange, + bookClubs, + initialBookClubs, + setBookClubs, +}: FilterBarProps) { + return ( +
+ + + handleFilterChange({ searchKeyword: value }) + } + /> + +
+ ); +} +export default FilterBar; diff --git a/src/components/common-layout/FilterSection.tsx b/src/components/common-layout/FilterSection.tsx new file mode 100644 index 00000000..2dfa8c8c --- /dev/null +++ b/src/components/common-layout/FilterSection.tsx @@ -0,0 +1,104 @@ +'use client'; + +import DropDown from '@/components/drop-down/DropDown'; +import FilterCheckbox from '@/components/filter-checkbox/FilterCheckbox'; +import { ChangeEvent, Dispatch, SetStateAction, useState } from 'react'; +import SortingButton from '@/components/sorting-button/SortingButton'; +import { BookClub, BookClubParams } from '../../types/bookclubs'; +import { getMeetingType, getMemberLimit } from '@/lib/utils/filterUtils'; +import { clubStatus } from '@/lib/utils/clubUtils'; + +interface CategoryTabsProps { + bookClubs: BookClub[]; + initialBookClubs: BookClub[]; + setBookClubs: Dispatch>; + onFilterChange: (newFilters: Partial) => void; +} + +function FilterSection({ + bookClubs, + initialBookClubs, + setBookClubs, + onFilterChange, +}: CategoryTabsProps) { + const [showAvailableOnly, setShowAvailableOnly] = useState(false); // 신청가능 + + const toggleAvailableOnly = (e: ChangeEvent) => { + const isChecked = e.target.checked; + setShowAvailableOnly(isChecked); + + const filteredBookClubs = isChecked + ? bookClubs.filter( + (club) => + club.memberCount < club.memberLimit && + clubStatus( + club.memberCount, + club.memberLimit, + club.endDate, + new Date(), + ) !== 'closed', + ) + : initialBookClubs; + + setBookClubs(filteredBookClubs); + }; + + const updateMemberLimitFilter = (selectedValue: string | undefined) => { + const memberLimit = getMemberLimit(selectedValue); + if (memberLimit) { + onFilterChange({ + memberLimitMin: memberLimit.min, + memberLimitMax: memberLimit.max, + }); + } + }; + + const updateMeetingTypeFilter = (selectedLabel: string | undefined) => { + const meetingType = getMeetingType(selectedLabel); + if (selectedLabel !== undefined) { + onFilterChange({ meetingType }); + } + }; + + const setSortingOrder = (order: string) => { + const isValidOrder = ( + value: string | undefined, + ): value is BookClubParams['order'] => { + return value !== undefined && ['DESC', 'END'].includes(value); + }; + + if (isValidOrder(order)) { + onFilterChange({ order }); + } + }; + + return ( +
+
+ + + +
+ +
+ ); +} + +export default FilterSection; diff --git a/src/components/common-layout/HeaderSection.tsx b/src/components/common-layout/HeaderSection.tsx new file mode 100644 index 00000000..59402fb3 --- /dev/null +++ b/src/components/common-layout/HeaderSection.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { ReactNode } from 'react'; + +interface HeaderSectionProps { + title: ReactNode; + actionElement?: ReactNode; +} + +function HeaderSection({ title, actionElement }: HeaderSectionProps) { + return ( +
+

+ {title} +

+ {actionElement} +
+ ); +} + +export default HeaderSection; diff --git a/src/components/common-layout/SearchSection.tsx b/src/components/common-layout/SearchSection.tsx new file mode 100644 index 00000000..df7e2d55 --- /dev/null +++ b/src/components/common-layout/SearchSection.tsx @@ -0,0 +1,20 @@ +import SearchInput from '@/components/input/search-input/SearchInput'; + +function SearchSection({ + searchValue, + onSearchChange, +}: { + searchValue: string; + onSearchChange: (value: string) => void; +}) { + return ( +
+ onSearchChange(e.target.value)} + aria-label="책 검색" + /> +
+ ); +} +export default SearchSection; diff --git a/src/components/common-layout/index.ts b/src/components/common-layout/index.ts new file mode 100644 index 00000000..3eb36266 --- /dev/null +++ b/src/components/common-layout/index.ts @@ -0,0 +1,5 @@ +export { default as FilterSection } from './FilterSection'; +export { default as SearchSection } from './SearchSection'; +export { default as CategoryTabs } from './CategoryTabs'; +export { default as FilterBar } from './FilterBar'; +export { default as HeaderSection } from './HeaderSection'; diff --git a/src/components/drop-down/DropDown.test.tsx b/src/components/drop-down/DropDown.test.tsx index a57727ec..9c254857 100644 --- a/src/components/drop-down/DropDown.test.tsx +++ b/src/components/drop-down/DropDown.test.tsx @@ -4,16 +4,26 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MENU_ITEMS } from '@/constants'; +beforeAll(() => { + global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; +}); + describe('DropDown variant:navbar 일 때', () => { const mockHandleSelectionChange = jest.fn(); + const items = MENU_ITEMS['navbar']; - it('드롭다운 버튼 클릭 이벤트로 드롭다운 메뉴 아이템 렌더링 확인', () => { + it('드롭다운 버튼 클릭 이벤트로 드롭다운 메뉴 아이템 렌더링 확인', async () => { render( , ); + //드롭다운 버튼 렌더링 확인 const button = screen.getByRole('button'); expect(button).toBeInTheDocument(); @@ -23,8 +33,8 @@ describe('DropDown variant:navbar 일 때', () => { expect(menuItems).not.toBeInTheDocument(); //유저가 드롭다운 클릭 후엔 메뉴 아이템들이 나타남 - userEvent.click(button); - expect(menuItems).toBeInTheDocument; + await userEvent.click(button); + expect(screen.queryByRole('listbox')).toBeInTheDocument(); }); it('드롭다운 메뉴 아이템 클릭 시 해당 아이템 value 값 반환', async () => { @@ -42,16 +52,14 @@ describe('DropDown variant:navbar 일 때', () => { expect(menuItems).not.toBeInTheDocument(); //유저가 드롭다운 클릭 후엔 메뉴 아이템들이 나타남 - userEvent.click(button); - expect(menuItems).toBeInTheDocument; + await userEvent.click(button); + expect(screen.queryByRole('listbox')).toBeInTheDocument(); //유저가 메뉴 아이템의 label을 보고 클릭하면 메뉴 아이템의 value값과 handle 함수를 호출 - const menuItem = screen.getByText(MENU_ITEMS['navbar'][0].label); + const menuItem = screen.getByText(items[0].label); await user.click(menuItem); - expect(mockHandleSelectionChange).toHaveBeenCalledWith( - MENU_ITEMS['navbar'][0].value, - ); + expect(mockHandleSelectionChange).toHaveBeenCalledWith(items[0].value); }); it('드롭다운 외부 영역을 클릭했을 때 드롭다운 메뉴가 닫히는지 확인', async () => { diff --git a/src/components/drop-down/DropDown.tsx b/src/components/drop-down/DropDown.tsx index dfa7e548..01d77229 100644 --- a/src/components/drop-down/DropDown.tsx +++ b/src/components/drop-down/DropDown.tsx @@ -1,8 +1,14 @@ -import React, { useRef, useState } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; +import ReactDOM from 'react-dom'; import { IcDropDown } from '../../../public/icons'; import Avatar from '../avatar/Avatar'; -import useDropDownClose from './hooks/useDropDownClose'; -import { MENU_ITEMS, DROPDOWN_LABELS } from '@/constants/index'; +import { DROPDOWN_LABELS, MENU_ITEMS, MenuItem } from '@/constants/index'; +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from '@headlessui/react'; interface DropDownProps { variant: 'navbar' | 'onOff' | 'memberCount' | 'sortingReview'; @@ -10,32 +16,63 @@ interface DropDownProps { imgSrc?: string; } -interface DropDownItem { - label: string; - value: string; -} +const widthClass = { + navbar: 'w-[36px]', + onOff: 'w-[110.27px]', + memberCount: 'w-[100.54px]', + sortingReview: 'w-[104.53px]', +}; function DropDown({ variant, imgSrc, onChangeSelection }: DropDownProps) { - const dropDownRef = useRef(null); - const [isOpen, setIsOpen] = useDropDownClose(dropDownRef, false); const [isActive, setIsActive] = useState(false); - const [selectedLabel, setSelectedLabel] = useState( + const [dropdownPosition, setDropdownPosition] = useState({ + top: 0, + left: 0, + }); + const buttonRef = useRef(null); + const [isClient, setIsClient] = useState(false); + + const items = MENU_ITEMS[variant]; + const [selectedItem, setSelectedItem] = useState( DROPDOWN_LABELS[variant], ); - const items = MENU_ITEMS[variant]; + const updateDropdownPosition = () => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + window.scrollY + 10, + left: + variant === 'navbar' + ? rect.left + window.scrollX - 52 + : rect.left + window.scrollX, + }); + } + }; - const onClickDropDownItem = (item: DropDownItem): void => { - setIsActive(true); - setSelectedLabel(item.label); + useEffect(() => { + setIsClient(true); + updateDropdownPosition(); + window.addEventListener('resize', updateDropdownPosition); + window.addEventListener('scroll', updateDropdownPosition); + + return () => { + window.removeEventListener('resize', updateDropdownPosition); + window.removeEventListener('scroll', updateDropdownPosition); + }; + }, [isActive, variant]); + const onChange = (item: MenuItem) => { + setIsActive(true); + setSelectedItem(item); if (onChangeSelection) { onChangeSelection(item.value); } - setIsOpen(false); }; - const renderButton = (variant: string, isActive: boolean) => { + const renderButton = ( + variant: 'navbar' | 'onOff' | 'memberCount' | 'sortingReview', + ) => { const colorClass = isActive ? 'border-green-normal-01 text-green-normal-01' : 'border-gray-normal-02 text-gray-dark-02'; @@ -43,60 +80,66 @@ function DropDown({ variant, imgSrc, onChangeSelection }: DropDownProps) { switch (variant) { case 'navbar': return ( - + ); case 'sortingReview': return ( - + ); default: return ( - + ); } }; return ( -
- {renderButton(variant, isActive)} - -
    - {items.map((item) => ( -
  • onClickDropDownItem(item)} - key={item.value} - className={`flex h-[40px] w-full items-center justify-start bg-gray-white px-[16px] py-[10px] text-sm font-medium text-gray-dark-01 first:rounded-t-xl last:rounded-b-xl hover:bg-gray-light-02 hover:font-semibold hover:text-gray-darker`} - > - {item.label} -
  • - ))} -
+
+ + {renderButton(variant)} + {isClient && + ReactDOM.createPortal( + + {items.map((item) => ( + + {item.label} + + ))} + , + document.body, + )} +
); } diff --git a/src/components/filter-checkbox/FilterCheckbox.test.tsx b/src/components/filter-checkbox/FilterCheckbox.test.tsx index 12a65304..3cfe67bf 100644 --- a/src/components/filter-checkbox/FilterCheckbox.test.tsx +++ b/src/components/filter-checkbox/FilterCheckbox.test.tsx @@ -5,7 +5,7 @@ import FilterCheckbox from './FilterCheckbox'; describe('FilterCheckbox 컴포넌트', () => { it('체크박스를 클릭하면 onChange 핸들러가 호출된다', async () => { const mockOnChange = jest.fn(); - const { debug } = render( + render( { await waitFor(() => { expect(mockOnChange).toHaveBeenCalledTimes(1); expect(checkbox).toBeChecked(); - debug(); }); }); }); diff --git a/src/components/filter-checkbox/FilterCheckbox.tsx b/src/components/filter-checkbox/FilterCheckbox.tsx index 327924bb..700d6992 100644 --- a/src/components/filter-checkbox/FilterCheckbox.tsx +++ b/src/components/filter-checkbox/FilterCheckbox.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { IcCheck } from '../../../public/icons'; +import { IcCheckOnly } from '../../../public/icons'; interface FilterCheckboxProps extends React.ComponentPropsWithoutRef<'input'> { label: string; @@ -27,13 +27,10 @@ function FilterCheckbox({ label, checked, ...props }: FilterCheckboxProps) { {...props} className="absolute h-5 w-5 appearance-none rounded-[5px] border border-gray-dark-02 bg-transparent transition duration-200 checked:border-green-normal-01 checked:bg-green-normal-01" /> -
diff --git a/src/components/header/HeaderBar.test.tsx b/src/components/header/HeaderBar.test.tsx index 41efdb7e..a3c0529d 100644 --- a/src/components/header/HeaderBar.test.tsx +++ b/src/components/header/HeaderBar.test.tsx @@ -3,6 +3,7 @@ import HeaderBar from './HeaderBar'; import '@testing-library/jest-dom'; import { NAV_ITEMS } from '@/constants/navigation'; import { useAuthStore } from '@/store/authStore'; +import { mockUser } from '@/mocks/mockDatas'; jest.mock('next/navigation', () => ({ usePathname: jest.fn(() => '/exchange'), @@ -13,6 +14,14 @@ jest.mock('next/navigation', () => ({ })), })); +beforeAll(() => { + global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; +}); + describe('HeaderBar 컴포넌트 테스트', () => { beforeEach(() => { useAuthStore.setState({ isLoggedIn: false, user: null }); @@ -71,16 +80,7 @@ describe('로그인 상태에 따른 버튼 렌더링', () => { it('로그인 상태일 때 드롭다운 버튼이 렌더링되어야 한다', () => { useAuthStore.setState({ isLoggedIn: true, - user: { - image: '/images/profile.png', - teamId: 'team-id', - id: 1, - email: 'user@example.com', - name: 'User Name', - description: 'description', - createdAt: new Date(), - updatedAt: new Date(), - }, + user: mockUser, checkLoginStatus: jest.fn(), }); diff --git a/src/components/icon-button/IconButton.stories.tsx b/src/components/icon-button/IconButton.stories.tsx new file mode 100644 index 00000000..2958f723 --- /dev/null +++ b/src/components/icon-button/IconButton.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import IconButton from './IconButton'; +import { IcEdit, MessageIcon } from '../../../public/icons'; + +const meta = { + title: 'Components/IconButton', + component: IconButton, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const InfoEditButton: Story = { + args: { + icon: , + 'aria-label': '프로필 수정', + }, +}; + +export const SendMessageButton: Story = { + args: { + icon: , + 'aria-label': '메시지 전송', + className: 'h-[52px] w-[52px] bg-green-light-01', + }, +}; diff --git a/src/components/icon-button/IconButton.test.tsx b/src/components/icon-button/IconButton.test.tsx new file mode 100644 index 00000000..731950bf --- /dev/null +++ b/src/components/icon-button/IconButton.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import IconButton from './IconButton'; +import '@testing-library/jest-dom'; + +describe('IconButton', () => { + const mockIcon = ; + const mockClick = jest.fn(); + + it('아이콘이 올바르게 렌더링된다', () => { + render(); + + expect(screen.getByTestId('test-icon')).toBeInTheDocument(); + }); + + it('클릭 이벤트가 발생하면 핸들러가 호출된다', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button')); + expect(mockClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/icon-button/IconButton.tsx b/src/components/icon-button/IconButton.tsx new file mode 100644 index 00000000..8031f403 --- /dev/null +++ b/src/components/icon-button/IconButton.tsx @@ -0,0 +1,24 @@ +import { ComponentProps } from 'react'; +import { twMerge } from 'tailwind-merge'; + +interface IconButtonProps extends ComponentProps<'button'> { + icon: React.ReactNode; +} + +export default function IconButton({ + icon, + className, + ...props +}: IconButtonProps) { + return ( + + ); +} diff --git a/src/components/input/Input.stories.tsx b/src/components/input/Input.stories.tsx new file mode 100644 index 00000000..55a66f60 --- /dev/null +++ b/src/components/input/Input.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Input from './Input'; + +const meta = { + title: 'Components/Input', + component: Input, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + value: '', + onChange: (e) => console.log('Input value:', e.target.value), + }, +}; + +export const WithCustomStyle: Story = { + args: { + value: '', + onChange: (e) => console.log('Input value:', e.target.value), + placeholder: '입력해주세요', + className: 'w-[400px]', + }, +}; diff --git a/src/components/input/Input.test.tsx b/src/components/input/Input.test.tsx new file mode 100644 index 00000000..8c24d01f --- /dev/null +++ b/src/components/input/Input.test.tsx @@ -0,0 +1,48 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Input from './Input'; + +describe('Input', () => { + const mockOnChange = jest.fn(); + const defaultProps = { + value: '', + onChange: mockOnChange, + }; + + beforeEach(() => { + mockOnChange.mockClear(); + }); + + it('placeholder가 올바르게 렌더링되는지 확인', () => { + render(); + expect(screen.getByPlaceholderText('입력해주세요')).toBeInTheDocument(); + }); + + it('입력값이 변경될 때 onChange 핸들러가 호출되는지 확인', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole('textbox'); + + await user.type(input, '테스트 입력값'); + expect(mockOnChange).toHaveBeenCalled(); + }); + + it('초기 value prop이 올바르게 설정되는지 확인', () => { + const initialValue = '초기 입력값'; + render(); + + const input = screen.getByRole('textbox') as HTMLInputElement; + expect(input.value).toBe(initialValue); + }); + + it('아이콘이 전달되면 렌더링되는지 확인', () => { + render( + 아이콘} + />, + ); + expect(screen.getByTestId('test-icon')).toBeInTheDocument(); + }); +}); diff --git a/src/components/input/Input.tsx b/src/components/input/Input.tsx new file mode 100644 index 00000000..6d0aa0ee --- /dev/null +++ b/src/components/input/Input.tsx @@ -0,0 +1,36 @@ +import { ChangeEvent, ReactNode } from 'react'; +import { twMerge } from 'tailwind-merge'; + +interface InputProps { + value: string; + onChange: (e: ChangeEvent) => void; + placeholder?: string; + icon?: ReactNode; + className?: string; +} + +function Input({ value, onChange, placeholder, icon, className }: InputProps) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} + +
+ ); +} + +export default Input; diff --git a/src/components/input/message-input/MessageInput.stories.tsx b/src/components/input/message-input/MessageInput.stories.tsx new file mode 100644 index 00000000..87ab65bf --- /dev/null +++ b/src/components/input/message-input/MessageInput.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import MessageInput from './MessageInput'; + +const meta = { + title: 'Components/Input/MessageInput', + component: MessageInput, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + value: '', + onChange: (e) => console.log('Message:', e.target.value), + }, +}; diff --git a/src/components/input/message-input/MessageInput.tsx b/src/components/input/message-input/MessageInput.tsx new file mode 100644 index 00000000..5aea5847 --- /dev/null +++ b/src/components/input/message-input/MessageInput.tsx @@ -0,0 +1,22 @@ +import { ChangeEvent } from 'react'; +import Input from '../Input'; +import PencilIcon from '../../../../public/icons/PencilIcon'; + +interface MessageInputProps { + value: string; + onChange: (e: ChangeEvent) => void; +} + +function MessageInput({ value, onChange }: MessageInputProps) { + return ( + } + className="border-none bg-gray-light-02" + /> + ); +} + +export default MessageInput; diff --git a/src/components/search-box/SearchBox.stories.tsx b/src/components/input/search-input/SearchInput.stories.tsx similarity index 56% rename from src/components/search-box/SearchBox.stories.tsx rename to src/components/input/search-input/SearchInput.stories.tsx index d4a9446c..ac0b6e9d 100644 --- a/src/components/search-box/SearchBox.stories.tsx +++ b/src/components/input/search-input/SearchInput.stories.tsx @@ -1,22 +1,21 @@ import type { Meta, StoryObj } from '@storybook/react'; -import SearchBox from './SearchBox'; +import SearchInput from './SearchInput'; const meta = { - title: 'Components/SearchBox', - component: SearchBox, + title: 'Components/Input/SearchInput', + component: SearchInput, parameters: { layout: 'centered', }, tags: ['autodocs'], -} satisfies Meta; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: { value: '', onChange: (e) => console.log('Search value:', e.target.value), - placeholder: '검색어를 입력해주세요', }, }; diff --git a/src/components/input/search-input/SearchInput.tsx b/src/components/input/search-input/SearchInput.tsx new file mode 100644 index 00000000..5b47f722 --- /dev/null +++ b/src/components/input/search-input/SearchInput.tsx @@ -0,0 +1,21 @@ +import { ChangeEvent } from 'react'; +import Input from '../Input'; +import SearchIcon from '../../../../public/icons/SearchIcon'; + +interface SearchInputProps { + value: string; + onChange: (e: ChangeEvent) => void; +} + +function SearchInput({ value, onChange }: SearchInputProps) { + return ( + } + /> + ); +} + +export default SearchInput; diff --git a/src/components/loading/Loading.stories.tsx b/src/components/loading/Loading.stories.tsx new file mode 100644 index 00000000..b19b4e30 --- /dev/null +++ b/src/components/loading/Loading.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Loading from './Loading'; + +const meta = { + title: 'Components/Loading', + component: Loading, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithoutFullHeight: Story = { + args: { + fullHeight: false, + }, +}; + +export const CustomSize: Story = { + args: { + className: 'w-12 h-12', + }, +}; + +export const FullHeightContainer: Story = { + render: () => ( +
+ +
+ ), +}; + +export const WithoutFullHeightContainer: Story = { + render: () => ( +
+ +
+ ), +}; + +export const WithTopPadding: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/src/components/loading/Loading.test.tsx b/src/components/loading/Loading.test.tsx new file mode 100644 index 00000000..f749c474 --- /dev/null +++ b/src/components/loading/Loading.test.tsx @@ -0,0 +1,15 @@ +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Loading from './Loading'; + +describe('Loading', () => { + it('로딩 컴포넌트가 정상적으로 렌더링되어야 한다', () => { + const { getByAltText } = render(); + expect(getByAltText('로딩 중...')).toBeInTheDocument(); + }); + + it('fullHeight prop이 정상적으로 전달되어야 한다', () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); +}); diff --git a/src/components/loading/Loading.tsx b/src/components/loading/Loading.tsx new file mode 100644 index 00000000..9eee13df --- /dev/null +++ b/src/components/loading/Loading.tsx @@ -0,0 +1,24 @@ +import Image from 'next/image'; +import Spinner from '../../../public/assets/Spinner.gif'; +import { twMerge } from 'tailwind-merge'; + +interface LoadingProps { + fullHeight?: boolean; + className?: string; +} + +function Loading({ fullHeight = true, className }: LoadingProps) { + return ( +
+ 로딩 중... +
+ ); +} + +export default Loading; diff --git a/src/components/participant-counter/ParticipantCounter.test.tsx b/src/components/participant-counter/ParticipantCounter.test.tsx index 6237d380..380d2fef 100644 --- a/src/components/participant-counter/ParticipantCounter.test.tsx +++ b/src/components/participant-counter/ParticipantCounter.test.tsx @@ -17,4 +17,10 @@ describe('ParticipantCounter', () => { render(); expect(screen.getByRole('participant-count')).toHaveTextContent('15/20'); }); + + it('max 값이 없을 때 current만 표시되는지 확인', () => { + render(); + expect(screen.getByRole('participant-count')).toHaveTextContent('15'); + expect(screen.getByRole('participant-count')).not.toHaveTextContent('/'); + }); }); diff --git a/src/components/participant-counter/ParticipantCounter.tsx b/src/components/participant-counter/ParticipantCounter.tsx index 166ab821..55eb2933 100644 --- a/src/components/participant-counter/ParticipantCounter.tsx +++ b/src/components/participant-counter/ParticipantCounter.tsx @@ -1,7 +1,7 @@ interface ParticipantCounterProps extends React.ComponentPropsWithoutRef<'div'> { current: number; - max: number; + max?: number; isPast?: boolean; } @@ -11,7 +11,7 @@ function ParticipantCounter({ isPast = false, ...props }: ParticipantCounterProps) { - const isFull = current >= max; + const isFull = max ? current >= max : false; const primaryColor = isPast ? 'text-gray-dark-02' : 'text-green-normal-01'; const maxColor = isFull ? primaryColor : 'text-gray-dark-01'; @@ -33,7 +33,7 @@ function ParticipantCounter({ className="text-sm font-medium" > {current} - /{max} + {max && /{max}}
); diff --git a/src/components/pop-up-button/PopUpButton.tsx b/src/components/pop-up-button/PopUpButton.tsx deleted file mode 100644 index e60fb587..00000000 --- a/src/components/pop-up-button/PopUpButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -const THEME_COLOR = { - cancel: 'border border-orange-600 bg-white text-orange-600 mr-[8px]', - confirm: 'bg-orange-600 text-white', -}; - -interface PopUpButtonProps { - isConfirm: boolean; - // onClick: ( - // event: MouseEvent | MouseEvent, - // ) => void; -} - -function PopUpButton({ isConfirm }: PopUpButtonProps) { - return ( - - ); -} - -export default PopUpButton; diff --git a/src/components/pop-up/PopUp.tsx b/src/components/pop-up/PopUp.tsx index eeb59180..5f4ba66e 100644 --- a/src/components/pop-up/PopUp.tsx +++ b/src/components/pop-up/PopUp.tsx @@ -5,7 +5,7 @@ interface PopUpProps { isOpen: boolean; label: string; handlePopUpClose: (result: boolean) => void; - handlePopUpConfirm: (result: boolean) => void; + handlePopUpConfirm?: (result: boolean) => void; isLarge?: boolean; isTwoButton?: boolean; } @@ -31,13 +31,13 @@ function PopUp({ }; return ( <> -
+
+ + ); +}; + +export default ChatComponent; diff --git a/src/features/chat/components/chat-card/ChatCard.stories.tsx b/src/features/chat/components/chat-card/ChatCard.stories.tsx new file mode 100644 index 00000000..e305637b --- /dev/null +++ b/src/features/chat/components/chat-card/ChatCard.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import ChatCard from './ChatCard'; + +const meta = { + title: 'Components/ChatCard/Atoms', + component: ChatCard, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Box: Story = { + render: () => ( + +

ChatCard Box Content

+
+ ), +}; + +export const Title: Story = { + render: () => 채팅방 제목, +}; + +export const Image: Story = { + render: () => ( + + ), +}; + +export const Location: Story = { + render: () => ( + + 서울특별시 강남구 + + ), +}; + +export const DateTime: Story = { + render: () => 2024.03.15 (금) 19:00, +}; + +export const LastMessage: Story = { + render: () => 반갑습니다~!, +}; + +export const LastMessageTime: Story = { + render: () => 10:29, +}; diff --git a/src/features/chat/components/chat-card/ChatCard.test.tsx b/src/features/chat/components/chat-card/ChatCard.test.tsx new file mode 100644 index 00000000..1a53532a --- /dev/null +++ b/src/features/chat/components/chat-card/ChatCard.test.tsx @@ -0,0 +1,109 @@ +import '@testing-library/jest-dom'; +import ChatCard from '@/features/chat/components/chat-card/ChatCard'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const mockOnClick = jest.fn(); + +describe('ChatCard Component', () => { + const user = userEvent.setup(); + const bookClubProps = { + variant: 'bookClub' as const, + props: { + title: '독서 모임 채팅방', + imageUrl: '/test-image.jpg', + isHost: false, + currentParticipants: 5, + lastMessage: '안녕하세요!', + lastMessageTime: '오후 2:30', + unreadCount: 3, + onClick: mockOnClick, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('BookClub variant', () => { + it('채팅방 클릭시 onClick이 호출되어야 함', async () => { + render(); + + const chatCard = screen + .getByRole('heading', { name: '독서 모임 채팅방' }) + .closest('div'); + await user.click(chatCard!); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('unreadCount가 없을 경우 Badge가 렌더링되지 않아야 함', () => { + render( + , + ); + + const badges = screen.queryAllByText('3'); + expect(badges).toHaveLength(0); + }); + + it('unreadCount가 있을 경우 Badge가 렌더링되어야 함', () => { + render(); + + const badges = screen.getAllByText('3'); + expect(badges).toHaveLength(2); + }); + + it('호스트일 경우 호스트 아이콘이 표시되어야 함', () => { + render( + , + ); + + const hostIcon = screen.getByTestId('host-icon'); + expect(hostIcon).toBeInTheDocument(); + }); + }); + + describe('ChatRoomHeader variant', () => { + const chatRoomHeaderProps = { + variant: 'chatRoomHeader' as const, + props: { + title: '독서 모임 채팅방', + imageUrl: '/test-image.jpg', + isHost: false, + location: '서울시 강남구', + datetime: '2024-03-20 14:00', + meetingType: 'OFFLINE' as const, + onClick: mockOnClick, + }, + }; + + it('채팅방 헤더 클릭시 onClick이 호출되어야 함', async () => { + render(); + + const chatHeader = screen + .getByRole('heading', { name: '독서 모임 채팅방' }) + .closest('div'); + await user.click(chatHeader!); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('호스트일 경우 호스트 아이콘이 표시되어야 함', () => { + render( + , + ); + + const hostIcon = screen.getByTestId('host-icon'); + expect(hostIcon).toBeInTheDocument(); + }); + }); +}); diff --git a/src/features/chat/components/chat-card/ChatCard.tsx b/src/features/chat/components/chat-card/ChatCard.tsx new file mode 100644 index 00000000..4d20f415 --- /dev/null +++ b/src/features/chat/components/chat-card/ChatCard.tsx @@ -0,0 +1,282 @@ +import { twMerge } from 'tailwind-merge'; +import Image from 'next/image'; +import { + HostIcon, + OnlineIcon, + LocationIcon, +} from '../../../../../public/icons'; +import { + ChatCardBoxProps, + ChatCardTitleProps, + ChatCardImageProps, + ChatCardLocationProps, + ChatCardDateTimeProps, + ChatCardLastMessageProps, + ChatCardLastMessageTimeProps, + ChatCardComponentProps, + ChatRoomHeaderProps, + BookClubProps, + ChatCardVariant, +} from './types'; +import defaultBookClub from '../../../../../public/images/defaultBookClub.jpg'; +import ParticipantCounter from '@/components/participant-counter/ParticipantCounter'; +import Badge from '@/components/badge/Badge'; + +function ChatCardBox({ + children, + className, + isHost, + onClick, + ...props +}: ChatCardBoxProps) { + return ( +
+ {children} +
+ ); +} + +function ChatCardTitle({ children, className, ...props }: ChatCardTitleProps) { + return ( +

+ {children} +

+ ); +} + +function ChatCardImage({ + url, + alt = '채팅방 이미지', + isHost, + className, + ...props +}: ChatCardImageProps) { + return ( +
+
+ {alt} +
+ {isHost && ( +
+ +
+ )} +
+ ); +} + +function ChatCardLocation({ + meetingType, + children, + className, + textClassName, + ...props +}: ChatCardLocationProps) { + const displayText = + meetingType === 'ONLINE' ? '온라인' : children || '위치 정보 없음'; + + return ( +
+ {meetingType === 'ONLINE' ? : } + + {displayText} + +
+ ); +} + +function ChatCardDateTime({ + children, + className, + ...props +}: ChatCardDateTimeProps) { + return ( + + {children} + + ); +} + +function ChatCardLastMessage({ + children, + className, + ...props +}: ChatCardLastMessageProps) { + return ( +
+ {children} +
+ ); +} + +function ChatCardLastMessageTime({ + children, + className, + ...props +}: ChatCardLastMessageTimeProps) { + return ( + + {children} + + ); +} + +function ChatCard({ + variant, + props, +}: ChatCardComponentProps) { + const renderContent = () => { + switch (variant) { + case 'bookClub': { + const { + imageUrl, + isHost, + title, + currentParticipants, + lastMessage, + lastMessageTime, + unreadCount, + className, + onClick, + } = props as BookClubProps; + + return ( + +
+ + +
+
+
+ {title} + +
+ {unreadCount && unreadCount > 0 && ( + <> + + + + )} +
+ +
+ {lastMessage} + + {lastMessageTime} + +
+
+
+
+ ); + } + + case 'chatRoomHeader': { + const { + imageUrl, + isHost, + title, + location, + datetime, + meetingType, + className, + onClick, + } = props as ChatRoomHeaderProps; + + return ( + +
+ + +
+ {title} +
+ + {location} + + {datetime} +
+
+
+
+ ); + } + + default: + return null; + } + }; + + return renderContent(); +} + +ChatCard.Box = ChatCardBox; +ChatCard.Title = ChatCardTitle; +ChatCard.Image = ChatCardImage; +ChatCard.Location = ChatCardLocation; +ChatCard.DateTime = ChatCardDateTime; +ChatCard.LastMessage = ChatCardLastMessage; +ChatCard.LastMessageTime = ChatCardLastMessageTime; + +export default ChatCard; diff --git a/src/features/chat/components/chat-card/VariantChatCard.stories.tsx b/src/features/chat/components/chat-card/VariantChatCard.stories.tsx new file mode 100644 index 00000000..bb608084 --- /dev/null +++ b/src/features/chat/components/chat-card/VariantChatCard.stories.tsx @@ -0,0 +1,143 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import ChatCard from './ChatCard'; + +const meta = { + title: 'Components/ChatCard/Variants', + component: ChatCard, + parameters: { + layout: 'centered', + }, + argTypes: { + props: { + isHost: { + control: 'boolean', + description: '호스트 여부', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const baseArgs = { + variant: 'bookClub' as const, + props: { + title: '을지로에서 만나는 독서 모임', + imageUrl: 'https://picsum.photos/200', + isHost: true, + currentParticipants: 17, + lastMessage: '반갑습니다~!', + lastMessageTime: '10:29', + unreadCount: 3, + }, +}; + +const chatRoomHeaderArgs = { + variant: 'chatRoomHeader' as const, + props: { + title: '을지로에서 만나는 독서 모임', + imageUrl: 'https://picsum.photos/200', + isHost: false, + location: '을지로 3가', + datetime: '12/14(토) 오전 10:00', + meetingType: 'OFFLINE' as const, + }, +}; + +export const ChatRoomHeaderMobile: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile', + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: chatRoomHeaderArgs, +}; + +export const ChatRoomHeaderTablet: Story = { + parameters: { + viewport: { + defaultViewport: 'tablet', + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: chatRoomHeaderArgs, +}; + +export const ChatRoomHeaderDesktop: Story = { + parameters: { + viewport: { + defaultViewport: 'desktop', + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: chatRoomHeaderArgs, +}; + +export const BookClubMobile: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile', + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: baseArgs, +}; + +export const BookClubTablet: Story = { + parameters: { + viewport: { + defaultViewport: 'tablet', + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: baseArgs, +}; + +export const BookClubDesktop: Story = { + parameters: { + viewport: { + defaultViewport: 'desktop', + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: baseArgs, +}; diff --git a/src/features/chat/components/chat-card/types/chatCard.ts b/src/features/chat/components/chat-card/types/chatCard.ts new file mode 100644 index 00000000..0ff51c94 --- /dev/null +++ b/src/features/chat/components/chat-card/types/chatCard.ts @@ -0,0 +1,42 @@ +export interface ChatCardProps extends React.HTMLAttributes { + children: React.ReactNode; +} + +export interface ChatCardBoxProps extends React.HTMLAttributes { + children: React.ReactNode; + isHost?: boolean; +} + +export interface ChatCardTitleProps + extends React.HTMLAttributes { + children: React.ReactNode; +} + +export interface ChatCardImageProps + extends Omit, 'children'> { + url?: string | undefined; + alt?: string; + isHost?: boolean; +} + +export interface ChatCardLocationProps + extends React.HTMLAttributes { + children: React.ReactNode; + textClassName?: string; + meetingType: 'ONLINE' | 'OFFLINE'; +} + +export interface ChatCardDateTimeProps + extends React.HTMLAttributes { + children: React.ReactNode; +} + +export interface ChatCardLastMessageProps + extends React.HTMLAttributes { + children: React.ReactNode; +} + +export interface ChatCardLastMessageTimeProps + extends React.HTMLAttributes { + children: React.ReactNode; +} diff --git a/src/features/chat/components/chat-card/types/index.ts b/src/features/chat/components/chat-card/types/index.ts new file mode 100644 index 00000000..98d9a9ac --- /dev/null +++ b/src/features/chat/components/chat-card/types/index.ts @@ -0,0 +1,2 @@ +export * from './chatCard'; +export * from './variantChatCard'; diff --git a/src/features/chat/components/chat-card/types/variantChatCard.ts b/src/features/chat/components/chat-card/types/variantChatCard.ts new file mode 100644 index 00000000..f80e7de0 --- /dev/null +++ b/src/features/chat/components/chat-card/types/variantChatCard.ts @@ -0,0 +1,31 @@ +export type ChatCardVariant = 'bookClub' | 'chatRoomHeader'; + +interface CommonProps extends React.HTMLAttributes { + title: string; + imageUrl?: string; + isHost?: boolean; + onClick?: () => void; +} + +export interface BookClubProps extends CommonProps { + currentParticipants: number; + lastMessage?: string; + lastMessageTime?: string; + unreadCount?: number; +} + +export interface ChatRoomHeaderProps extends CommonProps { + location: string; + datetime: string; + meetingType: 'ONLINE' | 'OFFLINE'; +} + +type ChatCardVariantProps = { + bookClub: BookClubProps; + chatRoomHeader: ChatRoomHeaderProps; +}; + +export interface ChatCardComponentProps { + variant: T; + props: ChatCardVariantProps[T]; +} diff --git a/src/features/chat/container/BookClubChatContainer.tsx b/src/features/chat/container/BookClubChatContainer.tsx new file mode 100644 index 00000000..66e7cb2b --- /dev/null +++ b/src/features/chat/container/BookClubChatContainer.tsx @@ -0,0 +1,78 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import ChatCard from '@/features/chat/components/chat-card/ChatCard'; +import { bookClubs } from '@/api/book-club/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { BookClubProps } from '@/features/chat/components/chat-card/types/variantChatCard'; +import Link from 'next/link'; +import { getRecentChats, getStompClient } from '@/features/chat/utils/socket'; +import { findRecentMessage } from '@/features/chat/utils/chatRoom'; +import { formatDateForUI } from '@/lib/utils/formatDateForUI'; + +export default function BookClubChatContainer() { + const [recentMessages, setRecentMessages] = useState< + Array<{ + bookClubId: number; + content: string; + date: string; + }> + >([]); + + const { data, isLoading, error } = useQuery( + bookClubs.my()._ctx.joined({ order: 'DESC', page: 1, size: 10 }), + ); + + useEffect(() => { + const fetchRecentChats = async () => { + try { + const response = await getRecentChats(); + console.log('최근 채팅 내용 조회 성공:', response); + setRecentMessages(response || []); + } catch (error) { + console.error('최근 채팅 내용 조회 실패:', error); + } + }; + + try { + getStompClient(); + fetchRecentChats(); + } catch (error) { + console.error('소켓이 초기화되지 않았습니다:', error); + } + }, []); + + const bookClubChats = data?.bookClubs || []; + console.log('bookClubChats', bookClubChats); + + if (isLoading) return
로딩중...
; + if (error) return
에러가 발생했습니다
; + + return ( +
+
+ {bookClubChats.map((bookClub: BookClubProps, id: number) => { + const recentMessage = findRecentMessage( + recentMessages, + Number(bookClub.id), + ); + + return ( + + + + ); + })} +
+
+ ); +} diff --git a/src/features/chat/container/ChatContainer.tsx b/src/features/chat/container/ChatContainer.tsx new file mode 100644 index 00000000..8400ba97 --- /dev/null +++ b/src/features/chat/container/ChatContainer.tsx @@ -0,0 +1,46 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import Tab from '@/components/tab/Tab'; +import { CONTENT_TABS, contentTab } from '@/constants'; +import BookClubChatContainer from './BookClubChatContainer'; +import ExchangeChatContainer from './ExchangeChatContainer'; +import { initializeSocket } from '../utils/socket'; +import { getCookie } from '@/features/auth/utils/cookies'; + +export default function ChatContainer() { + const [selectedTab, setSelectedTab] = useState(CONTENT_TABS.CLUB); + + useEffect(() => { + const token = getCookie('auth_token'); + if (token) { + const connectSocket = async () => { + try { + await initializeSocket(token); + console.log('채팅 페이지에서 소켓 재연결 성공'); + } catch (error) { + console.error('채팅 페이지에서 소켓 재연결 실패:', error); + } + }; + connectSocket(); + } + }, []); + + return ( + <> +
+ setSelectedTab(item)} + tabType="MAIN_TAB" + /> +
+ {selectedTab === CONTENT_TABS.CLUB ? ( + + ) : ( + + )} + + ); +} diff --git a/src/features/chat/container/ExchangeChatContainer.tsx b/src/features/chat/container/ExchangeChatContainer.tsx new file mode 100644 index 00000000..b330c466 --- /dev/null +++ b/src/features/chat/container/ExchangeChatContainer.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import ChatCard from '../components/chat-card/ChatCard'; + +interface BookClubChatData { + imageUrl: string; + isHost: boolean; + title: string; + currentParticipants: number; + lastMessage: string; + lastMessageTime: string; + unreadCount: number; +} + +export default function ExchangeChatContainer() { + const message = '아직 참여중인 채팅이 없어요.'; + const mockBookClubChats: BookClubChatData[] = []; + + return ( +
+
+ {mockBookClubChats.length === 0 ? ( +
+ {message} +
+ ) : ( + mockBookClubChats.map((chat, index) => ( + console.log('채팅방 클릭'), + }} + /> + )) + )} +
+
+ ); +} diff --git a/src/features/chat/utils/chatRoom.ts b/src/features/chat/utils/chatRoom.ts new file mode 100644 index 00000000..22334fb1 --- /dev/null +++ b/src/features/chat/utils/chatRoom.ts @@ -0,0 +1,10 @@ +export const findRecentMessage = ( + recentMessages: Array<{ + bookClubId: number; + content: string; + date: string; + }>, + chatId: number, +) => { + return recentMessages.find((msg) => msg.bookClubId === chatId); +}; diff --git a/src/features/chat/utils/socket.ts b/src/features/chat/utils/socket.ts new file mode 100644 index 00000000..de1d5cb4 --- /dev/null +++ b/src/features/chat/utils/socket.ts @@ -0,0 +1,197 @@ +import SockJS from 'sockjs-client'; +import { Client } from '@stomp/stompjs'; +import { BookClub } from '@/types/bookclubs'; +import apiClient from '@/lib/utils/apiClient'; + +let stompClient: Client | null = null; + +export interface ChatMessage { + id: number; + bookClubId: number; + date: string; + userId: number; + userNickname: string; + type: 'CHAT' | 'JOIN' | 'LEAVE'; + content: string; + user?: string; +} + +export interface HistoryResponse { + date: string; + messages: ChatMessage[]; +} + +export interface ChatHistoryResponse { + historyResponses: HistoryResponse[]; +} + +export const initializeSocket = async (token: string) => { + if (stompClient?.connected) { + console.log('이미 연결된 소켓이 있습니다.'); + return stompClient; + } + + try { + const response = await apiClient.get('/book-clubs/my-joined', { + params: { + order: 'DESC', + size: 10, + page: 1, + }, + }); + + const { bookClubs } = response.data; + + const client = new Client({ + webSocketFactory: () => + new SockJS(`${process.env.NEXT_PUBLIC_API_URL}/ws?token=${token}`), + reconnectDelay: 5000, + heartbeatIncoming: 4000, + heartbeatOutgoing: 4000, + }); + + client.onConnect = () => { + console.log('소켓 연결 성공'); + + bookClubs.forEach((club: BookClub) => { + console.log('구독 시도:', club.id); + client.subscribe(`/topic/group-chat/${club.id}`, (message) => { + const chatMessage: ChatMessage = JSON.parse(message.body); + console.log(`모임 ${club.title}의 새 메시지 수신:`, chatMessage); + }); + }); + }; + + client.onDisconnect = () => { + console.log('소켓 연결 끊김'); + }; + + client.onStompError = (frame) => { + console.error('Stomp 에러:', frame); + }; + + client.activate(); + stompClient = client; + + return client; + } catch (error) { + console.error('모임 정보 조회 실패:', error); + throw error; + } +}; + +export const disconnectSocket = () => { + if (stompClient) { + stompClient.deactivate(); + stompClient = null; + } +}; + +export const getStompClient = () => { + if (!stompClient) { + throw new Error('소켓이 초기화되지 않았습니다.'); + } + return stompClient; +}; + +export const sendMessage = (roomId: number, content: string) => { + const client = getStompClient(); + + if (content === '') { + return; + } + + console.log(`메시지 전송 시도: roomId=${roomId}, content=${content}`); + + client.publish({ + destination: `/app/group-chat/${roomId}/sendMessage`, + body: JSON.stringify({ content }), + }); + console.log('메시지 전송 성공'); +}; + +export const getChatHistory = (roomId: number) => { + const client = getStompClient(); + + return new Promise((resolve, reject) => { + const subscription = client.subscribe( + '/user/queue/chatHistory', + (message) => { + try { + const historyData: ChatHistoryResponse = JSON.parse(message.body); + console.log('채팅 히스토리 수신:', historyData); + resolve(historyData); + subscription.unsubscribe(); + } catch (error) { + console.error('채팅 히스토리 파싱 실패:', error); + reject(error); + subscription.unsubscribe(); + } + }, + ); + + try { + client.publish({ + destination: `/app/group-chat/history/${roomId}`, + body: JSON.stringify({}), + }); + console.log('채팅 히스토리 요청 전송'); + } catch (error) { + console.error('채팅 히스토리 요청 실패:', error); + subscription.unsubscribe(); + reject(error); + } + }); +}; + +export const getRecentChats = () => { + const client = getStompClient(); + console.log('getRecentChats - 클라이언트 상태:', { + connected: client.connected, + active: client.active, + }); + + return new Promise((resolve, reject) => { + const subscription = client.subscribe('/user/queue/recent', (message) => { + console.log('구독 콜백 실행됨'); + try { + const recentData: ChatMessage[] = JSON.parse(message.body); + console.log('최신 채팅 데이터:', recentData); + resolve(recentData); + subscription.unsubscribe(); + } catch (error) { + console.error('최신 채팅 파싱 실패:', error); + reject(error); + subscription.unsubscribe(); + } + }); + + try { + console.log('서버로 요청 전송 시도'); + client.publish({ + destination: '/app/group-chat/recent', + body: JSON.stringify({}), + }); + console.log('서버로 요청 전송 완료'); + } catch (error) { + console.error('최신 채팅 요청 실패:', error); + subscription.unsubscribe(); + reject(error); + } + }); +}; + +export const subscribeToChat = ( + roomId: number, + callback: (message: ChatMessage) => void, +) => { + const client = getStompClient(); + return client.subscribe(`/topic/group-chat/${roomId}`, (message) => { + const chatMessage: ChatMessage = JSON.parse(message.body); + callback(chatMessage); + }); +}; + +export const isSocketConnected = () => { + return stompClient?.connected ?? false; +}; diff --git a/src/features/club-create/components/CreateClubFormField.tsx b/src/features/club-create/components/CreateClubFormField.tsx index eebe70f2..47ec736a 100644 --- a/src/features/club-create/components/CreateClubFormField.tsx +++ b/src/features/club-create/components/CreateClubFormField.tsx @@ -7,6 +7,7 @@ interface CreateClubFormFieldProps error?: string; currentLength?: number; maxLength?: number; + optional?: boolean; } function CreateClubFormField({ @@ -15,6 +16,7 @@ function CreateClubFormField({ error, currentLength = 0, maxLength, + optional = false, ...props }: CreateClubFormFieldProps) { const isOverMaxLength = maxLength !== undefined && currentLength > maxLength; @@ -22,12 +24,17 @@ function CreateClubFormField({ return (
- + {maxLength !== undefined && ( - {currentLength}/ + {currentLength}/ {maxLength} @@ -35,7 +42,7 @@ function CreateClubFormField({ )}
{children} - {error &&

{error}

} + {error &&

{error}

}
); } diff --git a/src/features/club-create/container/CreateBookClubPage.tsx b/src/features/club-create/container/CreateBookClubPage.tsx index 71ab0c11..1f29e319 100644 --- a/src/features/club-create/container/CreateBookClubPage.tsx +++ b/src/features/club-create/container/CreateBookClubPage.tsx @@ -1,4 +1,4 @@ -import { FormContainer } from '@/features/club-create/container'; +import FormContainer from '@/features/club-create/container/FormContainer'; function CreateBookClubPage() { return ( diff --git a/src/features/club-create/container/DatePickerField.tsx b/src/features/club-create/container/DatePickerField.tsx index 6642b7af..0676607c 100644 --- a/src/features/club-create/container/DatePickerField.tsx +++ b/src/features/club-create/container/DatePickerField.tsx @@ -1,5 +1,8 @@ 'use client'; +import 'react-datepicker/dist/react-datepicker.css'; +import '@/styles/datepicker.css'; +import { useMemo } from 'react'; import DatePicker from 'react-datepicker'; import { Control, Controller } from 'react-hook-form'; import { ko } from 'date-fns/locale'; @@ -15,6 +18,7 @@ interface DatePickerContainerProps { label: string; error?: string; placeholder: string; + targetDate?: Date; } function DatePickerContainer({ @@ -23,7 +27,17 @@ function DatePickerContainer({ label, error, placeholder, + targetDate, }: DatePickerContainerProps) { + const minDate = useMemo(() => new Date(), []); + const maxDate = useMemo( + () => + name === 'endDate' && targetDate + ? new Date(targetDate.getTime() - 24 * 60 * 60 * 1000) + : undefined, + [name, targetDate], + ); + return ( @@ -98,9 +94,12 @@ function FormContainer() { ]} selectedValue={watch('meetingType')} register={register('meetingType')} + addressRegister={register('detailAddress')} setValue={setValue} name="meetingType" town={watch('town')} + watch={watch} + errors={errors} /> @@ -118,6 +117,7 @@ function FormContainer() { label="언제 모임을 마감할까요?" error={errors.endDate?.message} placeholder="모임의 모집 마감 날짜를 선택해 주세요!" + targetDate={watch('targetDate')} /> diff --git a/src/features/club-create/container/ImageField.tsx b/src/features/club-create/container/ImageField.tsx index 01325f0e..64a7568a 100644 --- a/src/features/club-create/container/ImageField.tsx +++ b/src/features/club-create/container/ImageField.tsx @@ -2,8 +2,9 @@ import { UseFormRegister, UseFormSetValue } from 'react-hook-form'; import { BookClubForm } from '../types'; -import { CreateClubFormField, InputField } from '../components'; +import { CreateClubFormField } from '../components'; import { useImageField } from '@/features/club-create/hooks'; +import { CameraIcon, ImageIcon } from '../../../../public/icons'; interface ImageUploadContainerProps { register: UseFormRegister; @@ -12,42 +13,37 @@ interface ImageUploadContainerProps { } function ImageField({ register, setValue, error }: ImageUploadContainerProps) { - const { selectedFileName, handleFileChange, clearFile } = - useImageField(setValue); + const { selectedFileName, handleFileChange } = useImageField( + setValue, + register, + ); return ( - -
- - +
+ {selectedFileName ? ( + <> +
+ + + {selectedFileName} + +
+ + ) : ( +
+ + + 이미지를 첨부해 주세요 (jpg, jpeg) + +
+ )} + - - {selectedFileName && ( - - )}
); diff --git a/src/features/club-create/container/RadioButtonGroup.tsx b/src/features/club-create/container/RadioButtonGroup.tsx index bf32029f..a52556d1 100644 --- a/src/features/club-create/container/RadioButtonGroup.tsx +++ b/src/features/club-create/container/RadioButtonGroup.tsx @@ -1,98 +1,132 @@ import Card from '@/components/card/Card'; import { useSelectAddress } from '@/features/club-create/hooks'; import { BookClubForm } from '@/features/club-create/types'; -import { UseFormSetValue } from 'react-hook-form'; +import { UseFormSetValue, UseFormWatch } from 'react-hook-form'; +import InputField from '../components/InputField'; +import CreateClubFormField from '../components/CreateClubFormField'; interface RadioButtonGroupProps { options: { label: string; value: string; description?: string }[]; selectedValue: string; register: any; + addressRegister?: any; setValue?: UseFormSetValue; name: keyof BookClubForm; town?: string; + watch?: UseFormWatch; + errors?: any; } function RadioButtonGroup({ options, selectedValue, register, + addressRegister, setValue, name, town, + watch, + errors, }: RadioButtonGroupProps) { const { handleRadioChange } = useSelectAddress({ setValue, name }); + const address = watch ? watch('address') : ''; return ( -
+
{options.map((option) => ( -