diff --git a/package-lock.json b/package-lock.json index cd5a1b9..4ea03ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "0.0.0", "dependencies": { "@iconify/react": "^6.0.2", + "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.101.4", "axios": "^1.18.1", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router-dom": "^7.18.0", + "tailwind-merge": "^3.6.0", "zustand": "^5.0.14" }, "devDependencies": { @@ -23,6 +25,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", "autoprefixer": "^10.5.2", + "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-react": "^7.37.5", @@ -104,6 +107,47 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@eslint/js": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", @@ -125,6 +169,96 @@ } } }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@iconify/react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@iconify/react/-/react-6.0.2.tgz", @@ -875,6 +1009,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stomp/stompjs": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz", + "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", + "license": "Apache-2.0" + }, "node_modules/@tanstack/query-core": { "version": "5.101.4", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", @@ -912,6 +1052,27 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", @@ -1211,6 +1372,29 @@ } } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -1223,6 +1407,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -1814,6 +2015,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1905,6 +2121,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -2233,6 +2456,78 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, "node_modules/eslint-config-prettier": { "version": "10.1.8", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", @@ -2368,6 +2663,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -2381,6 +2695,50 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -2408,6 +2766,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-diff": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", @@ -2445,6 +2810,20 @@ "node": ">= 6" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2473,6 +2852,19 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2486,6 +2878,44 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -2853,6 +3283,26 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -3312,6 +3762,13 @@ "dev": true, "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -3347,6 +3804,27 @@ "dev": true, "license": "MIT" }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -3363,6 +3841,30 @@ "node": ">=4.0" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3686,6 +4188,22 @@ "node": ">=22.13.0" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -4085,6 +4603,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/own-keys": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", @@ -4153,6 +4689,58 @@ } } }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -4373,6 +4961,16 @@ "dev": true, "license": "MIT" }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { "version": "3.9.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.1.tgz", @@ -4423,6 +5021,16 @@ "node": ">=10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4831,6 +5439,29 @@ "node": ">= 0.4" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -5155,6 +5786,16 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -5284,6 +5925,19 @@ "license": "0BSD", "optional": true }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -5457,6 +6111,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5542,6 +6206,22 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-boxed-primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", @@ -5631,6 +6311,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", @@ -5666,6 +6356,19 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zustand": { "version": "5.0.14", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", diff --git a/package.json b/package.json index f53b019..3bbc17f 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,13 @@ }, "dependencies": { "@iconify/react": "^6.0.2", + "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.101.4", "axios": "^1.18.1", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router-dom": "^7.18.0", + "tailwind-merge": "^3.6.0", "zustand": "^5.0.14" }, "devDependencies": { @@ -26,6 +28,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", "autoprefixer": "^10.5.2", + "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-react": "^7.37.5", diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..8814767 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,53 @@ +// public/sw.js +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +// 서버(FCM/Web Push)가 보낸 푸시를 받아서 브라우저 알림으로 표시 +self.addEventListener('push', (event) => { + let payload = {}; + try { + payload = event.data ? event.data.json() : {}; + } catch { + payload = { title: '알림', body: event.data ? event.data.text() : '' }; + } + + const title = payload.title || '알림'; + const options = { + body: payload.body || '', + icon: '/icons/icon-192.png', // 👈 실제 아이콘 경로로 교체 + badge: '/icons/badge-72.png', // 👈 실제 배지 아이콘으로 교체 + data: { + // 💡 요청대로 딥링크는 항상 /alert로 고정 + url: '/alert', + notificationId: payload.notificationId ?? null, + }, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +// 알림 클릭 시 /alert로 이동 (이미 열린 탭 있으면 그쪽 포커스) +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const targetUrl = event.notification.data?.url || '/alert'; + + event.waitUntil( + self.clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + for (const client of clientList) { + if (client.url.includes('/alert') && 'focus' in client) { + return client.focus(); + } + } + if (self.clients.openWindow) { + return self.clients.openWindow(targetUrl); + } + }), + ); +}); diff --git a/src/api/alert/push.js b/src/api/alert/push.js new file mode 100644 index 0000000..69f2517 --- /dev/null +++ b/src/api/alert/push.js @@ -0,0 +1,83 @@ +// src/api/push.js +const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ''; +const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY ?? ''; +// 👆 VAPID 공개키는 백엔드가 발급해줘야 해요. .env에 VITE_VAPID_PUBLIC_KEY=... 로 넣어두세요. + +const urlBase64ToUint8Array = (base64String) => { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = atob(base64); + return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0))); +}; + +export const registerServiceWorker = async () => { + if (!('serviceWorker' in navigator)) { + console.warn('이 브라우저는 Service Worker를 지원하지 않습니다.'); + return null; + } + try { + const registration = await navigator.serviceWorker.register('/sw.js'); + return registration; + } catch (err) { + console.error('Service Worker 등록 실패:', err); + return null; + } +}; + +export const subscribeToPush = async () => { + if (!('Notification' in window)) { + return { success: false, reason: 'unsupported' }; + } + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + return { success: false, reason: 'denied' }; + } + + const registration = + (await navigator.serviceWorker.getRegistration()) || + (await registerServiceWorker()); + if (!registration) return { success: false, reason: 'no-sw' }; + + const existing = await registration.pushManager.getSubscription(); + const subscription = + existing || + (await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), + })); + + const subJson = subscription.toJSON(); + + // 서버에 구독 정보 등록 (API 문서의 "펌푸시 구독 등록" 엔드포인트) + const res = await fetch(`${API_BASE}/api/notifications/push-subscription`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + endpoint: subJson.endpoint, + p256dh: subJson.keys?.p256dh, + auth: subJson.keys?.auth, + }), + }); + + return { success: res.ok, subscription }; +}; + +export const unsubscribeFromPush = async () => { + const registration = await navigator.serviceWorker.getRegistration(); + const subscription = await registration?.pushManager.getSubscription(); + if (!subscription) return { success: true }; + + const endpoint = subscription.endpoint; + await subscription.unsubscribe(); + + const res = await fetch(`${API_BASE}/api/notifications/push-subscription`, { + method: 'DELETE', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint }), + }); + + return { success: res.ok }; +}; diff --git a/src/api/auth/authApi.js b/src/api/auth/authApi.js new file mode 100644 index 0000000..ba0abf1 --- /dev/null +++ b/src/api/auth/authApi.js @@ -0,0 +1,115 @@ +import { apiFetch, ApiError } from './client'; +import { getTokens } from '../../store/tokenStorage'; + +export { ApiError }; + +/** + * 이메일 인증코드 발송 + * POST /api/auth/email/code + * body: { email } + */ +export function sendEmailCode(email) { + return apiFetch('/api/auth/email/code', { method: 'POST', body: { email } }); +} + +/** + * 이메일 인증코드 검증 + * POST /api/auth/email/verify + * body: { email, code } + */ +export function verifyEmailCode(email, code) { + return apiFetch('/api/auth/email/verify', { + method: 'POST', + body: { email, code }, + }); +} + +/** + * 이메일(아이디) 중복 확인 + * GET /api/auth/email/exists?email=... + * ⚠ 쿼리 파라미터 이름 미확인 상태 (email로 가정). 실제 응답 확인 후 조정 필요. + */ +export function checkEmailExists(email) { + return apiFetch(`/api/auth/email/exists?email=${encodeURIComponent(email)}`); +} + +/** + * 회원가입 + * POST /api/auth/signup + * body: { email, password, passwordConfirm } + */ +export function signupRequest({ email, password, passwordConfirm }) { + return apiFetch('/api/auth/signup', { + method: 'POST', + body: { email, password, passwordConfirm }, + }); +} + +/** + * 로그인 + * POST /api/auth/login + * body: { email, password } + * response.data: { accessToken, refreshToken } + */ +export function loginRequest({ email, password }) { + return apiFetch('/api/auth/login', { + method: 'POST', + body: { email, password }, + }); +} + +/** + * 토큰 재발급 + * POST /api/auth/token/refresh + * body: { refreshToken } + * response.data: { accessToken, refreshToken } + */ +export function refreshTokenRequest(refreshToken) { + return apiFetch('/api/auth/token/refresh', { + method: 'POST', + body: { refreshToken }, + }); +} + +/** + * 로그아웃 + * POST /api/auth/logout + * Authorization: Bearer {accessToken} + */ +export function logoutRequest() { + const tokens = getTokens(); + return apiFetch('/api/auth/logout', { + method: 'POST', + token: tokens?.accessToken, + }); +} + +/** + * 비밀번호 재설정용 인증코드 발송 (findpw 전용, 미가입 이메일이면 404) + * POST /api/auth/password/email/code + * body: { email } + */ +export function sendPasswordResetCode(email) { + return apiFetch('/api/auth/password/email/code', { + method: 'POST', + body: { email }, + }); +} + +/** + * 비밀번호 재설정 (인증 완료 후 새 비밀번호로 교체) + * POST /api/auth/password/reset + * body: { email, newPassword, newPasswordConfirm } + * + * ※ 인증코드 확인 자체는 기존 verifyEmailCode(/api/auth/email/verify)를 재사용 + */ +export function resetPasswordRequest({ + email, + newPassword, + newPasswordConfirm, +}) { + return apiFetch('/api/auth/password/reset', { + method: 'POST', + body: { email, newPassword, newPasswordConfirm }, + }); +} diff --git a/src/api/auth/client.js b/src/api/auth/client.js new file mode 100644 index 0000000..416cca5 --- /dev/null +++ b/src/api/auth/client.js @@ -0,0 +1,37 @@ +const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ''; + +export class ApiError extends Error { + constructor(status, message, data) { + super(message); + this.status = status; + this.data = data; + } +} + +export async function apiFetch(path, { method = 'GET', body, token } = {}) { + const headers = { 'Content-Type': 'application/json' }; + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(`${API_BASE}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + let payload = null; + try { + payload = await res.json(); + } catch { + // body 없는 응답 대비 + } + + if (!res.ok || payload?.success === false) { + throw new ApiError( + res.status, + payload?.message ?? '요청에 실패했습니다.', + payload, + ); + } + + return payload; // { success, data, message } +} diff --git a/src/api/chat/README.md b/src/api/chat/README.md new file mode 100644 index 0000000..549309e --- /dev/null +++ b/src/api/chat/README.md @@ -0,0 +1,75 @@ +# API 연동 가이드 + +## 지금 (mock 모드) +`src/api/config.ts` 의 `USE_MOCK = true` 상태입니다. +모든 데이터는 `localStorage`에 저장되고, `BroadcastChannel`로 브라우저 탭 간에 +동기화됩니다 (탭을 2개 열면 한쪽은 나, 한쪽은 상대방처럼 테스트 가능). + +## 백엔드 연결할 때 +`src/api/config.ts` 파일을 열어서 아래 한 줄만 바꾸면 됩니다. + +```ts +export const USE_MOCK = false; +``` + +그리고 `.env`에 실제 서버 주소를 설정하세요. + +``` +VITE_API_BASE_URL=https://your-api-server.com +``` + +이게 전부입니다. `exchangeApi.ts` / `chatApi.ts` 안의 모든 함수가 이미 +명세서에 맞는 실제 fetch 호출 코드를 가지고 있어서, 페이지 컴포넌트 +(ChatRoomPage, ScheduleDecisionPage, TerminateDealPage)는 손댈 필요가 없습니다. + +## 필요한 패키지 +실시간 채팅(WebSocket STOMP)을 위해 아래 패키지가 필요합니다. + +``` +npm i @stomp/stompjs +``` + +(mock 모드에서는 이 패키지가 로드는 되지만 실제로 연결하지 않습니다. +USE_MOCK=true인 동안은 설치 안 해도 당장 에러는 안 나지만, import 자체는 +있으므로 빌드 전에 설치해두는 걸 권장합니다.) + +## 파일별 역할 + +| 파일 | 설명 | +|---|---| +| `config.ts` | USE_MOCK 스위치, API_BASE, WS_URL, 로그인 유저 id 등 공통 설정 | +| `http.ts` | 실제 API 호출용 공통 fetch 래퍼 (에러 처리 포함) | +| `mockDb.ts` | mock 전용 - localStorage + BroadcastChannel 기반 가짜 DB | +| `exchangeApi.ts` | API 1, 2, 5, 7, 8 (QR생성/결과선택/시간확정/파기/캡처업로드) | +| `chatApi.ts` | API 3, 9, 10, 11 (방조회/메시지목록/전송/실시간소켓) | + +## 백엔드와 반드시 확인해야 할 것 (명세서 대비 gap) + +1. **API 11(채팅방 조회) 응답에 아래 필드가 명세에 없지만 화면에 필요합니다.** + - `scheduledAt` (교환 확정 시각) + - `myVerified` / `counterpartVerified` (5분전 인증 완료 여부) + - `myDisputeVerified` (사후 인증 완료 여부) + - `qrToken` (QR 표시용) + → 이 필드들을 room 응답에 포함해달라고 요청하거나, 별도 엔드포인트로 + 받아오도록 협의가 필요합니다. 지금은 `chatApi.ts`의 `RoomInfo` 타입에 + optional로 선언해뒀고, 없으면 그냥 undefined로 처리됩니다. + +2. **상태 실시간 반영 방식이 명세에 없습니다.** + 상대방이 인증을 완료했다든지, 카운트다운이 시작됐다든지 하는 변화를 + 실시간으로 받을 방법이 명세서(웹소켓은 채팅 메시지만 다룸)에 없어서 + `chatApi.ts`의 `subscribeRoomStatus()`가 3초 간격 폴링으로 대체 + 구현되어 있습니다. 웹소켓 상태 이벤트가 추가되면 이 폴링을 제거하고 + 이벤트 구독으로 바꾸는 게 좋습니다. + +3. **원상복구 API가 명세서 1~11번에 없습니다.** + `exchangeApi.ts`의 `restoreDeal()` 안에 TODO로 표시해뒀습니다. + 엔드포인트가 정해지면 그 부분만 채우면 됩니다. + +4. **거래 파기 사유 enum 중 `MONEY_DEMAND`가 화면(TerminateDealPage) UI에 + 대응하는 선택지가 없습니다.** 필요하면 디자인팀과 항목 추가를 논의해야 + 합니다. + +5. **API 8(캡처 업로드-QR검증) 완료 후 바로 COUNTDOWN으로 넘어가는지, + 아니면 화면에 있는 "상대방 강의 확인 → 카운트다운 시작" 중간 단계 + (READY)를 거치는지 명세서와 디자인이 서로 다릅니다.** 지금은 디자인 + 기준(중간 단계 있음)으로 구현했습니다. diff --git a/src/api/chat/chatApi.ts b/src/api/chat/chatApi.ts new file mode 100644 index 0000000..fd063db --- /dev/null +++ b/src/api/chat/chatApi.ts @@ -0,0 +1,215 @@ +// src/api/chatApi.ts +// +// API 명세서 3(메시지 전송), 9(실시간 채팅/이벤트), 10(메시지 목록 조회), +// 11(채팅방 조회)에 대응합니다. +// USE_MOCK일 때는 localStorage + BroadcastChannel로 흉내내고, +// 실제 연결 시에는 STOMP(WebSocket) + REST로 동작합니다. + +import { Client, type IMessage } from '@stomp/stompjs'; +import { API_BASE, USE_MOCK, WS_URL, getCurrentUserId, sleep, MOCK_DELAY_MS } from './config'; +import { apiFetch } from './http'; +import { mockDb, type ExchangeStatus, type MockChatMessage } from './mockDb'; + +export interface ChatMessage { + id: number; + senderId: number; + content: string; + createdAt: string; + system?: boolean; +} + +export interface RoomInfo { + id: number; + status: ExchangeStatus; + exchangeId: number; + // ⚠️ 아래 3개 필드는 API 11 명세서에는 없지만 화면 렌더링(VERIFY 단계)에 필요합니다. + // 백엔드에 추가 요청하거나 별도 엔드포인트 협의가 필요합니다. + scheduledAt?: string; + myVerified?: boolean; + counterpartVerified?: boolean; + myDisputeVerified?: boolean; + qrToken?: string; +} + +// ── 11. 채팅방 조회 (+메시지 내역) ────────────────────────────────────── +export async function fetchRoom( + roomId: string, + params: { before?: string; size?: number } = {}, +): Promise<{ room: RoomInfo; messages: ChatMessage[] }> { + if (USE_MOCK) { + await sleep(MOCK_DELAY_MS); + const state = mockDb.get(roomId); + return { + room: { + id: state.id, + exchangeId: state.exchangeId, + status: state.status, + scheduledAt: state.scheduledAt, + myVerified: state.myVerified, + counterpartVerified: state.counterpartVerified, + myDisputeVerified: state.myDisputeVerified, + qrToken: state.qrToken, + }, + messages: state.messages, + }; + } + + const query = new URLSearchParams(); + if (params.before) query.set('before', params.before); + query.set('size', String(params.size ?? 50)); + + const res = await apiFetch<{ room: RoomInfo; messages: ChatMessage[] }>( + `${API_BASE}/api/chat/rooms/${roomId}?${query.toString()}`, + ); + return res.data!; +} + +// ── 10. 메시지 목록 조회 (커서 페이징) ────────────────────────────────── +export async function fetchMessages( + roomId: string, + params: { before?: string; size: number }, +): Promise { + if (USE_MOCK) { + await sleep(MOCK_DELAY_MS); + const state = mockDb.get(roomId); + let msgs = state.messages; + if (params.before) { + msgs = msgs.filter((m) => new Date(m.createdAt).getTime() < new Date(params.before!).getTime()); + } + return msgs.slice(-params.size); + } + + const query = new URLSearchParams(); + if (params.before) query.set('before', params.before); + query.set('size', String(params.size)); + + const res = await apiFetch<{ messages: ChatMessage[] }>( + `${API_BASE}/api/chat/rooms/${roomId}/messages?${query.toString()}`, + ); + return res.data!.messages; +} + +// ── 3. 메시지 전송 (REST 폴백 - 소켓 미연결 시 사용) ──────────────────── +export async function sendMessageRest(roomId: string, content: string): Promise { + if (USE_MOCK) { + await sleep(150); + const message = mockDb.addMessage(roomId, { + senderId: getCurrentUserId(), + content, + createdAt: new Date().toISOString(), + }); + mockDb.broadcastMessage(roomId, message); + return; + } + + await apiFetch(`${API_BASE}/api/chat/rooms/${roomId}/send`, { + method: 'POST', + body: JSON.stringify({ content }), + }); +} + +// ── 9. 실시간 채팅/이벤트 ──────────────────────────────────────────────── +// connect()는 구독 해제 함수를 반환합니다. +export interface ChatSocket { + send: (content: string) => void; + disconnect: () => void; +} + +export function connectChatSocket( + roomId: string, + onMessage: (message: ChatMessage) => void, +): ChatSocket { + if (USE_MOCK) { + // mock: BroadcastChannel로 같은 브라우저의 다른 탭과 실시간 동기화됩니다. + const unsubscribe = mockDb.subscribeMessage(roomId, (msg: MockChatMessage) => { + onMessage(msg); + }); + + return { + send: (content: string) => { + const message = mockDb.addMessage(roomId, { + senderId: getCurrentUserId(), + content, + createdAt: new Date().toISOString(), + }); + mockDb.broadcastMessage(roomId, message); + }, + disconnect: unsubscribe, + }; + } + + const client = new Client({ + brokerURL: WS_URL, + reconnectDelay: 3000, + onConnect: () => { + client.subscribe(`/topic/chat/${roomId}`, (message: IMessage) => { + try { + onMessage(JSON.parse(message.body) as ChatMessage); + } catch { + // 파싱 실패 이벤트는 무시 + } + }); + }, + }); + client.activate(); + + return { + send: (content: string) => { + if (!client.connected) return; + client.publish({ + destination: `/app/chat/${roomId}/send`, + body: JSON.stringify({ content }), + }); + }, + disconnect: () => client.deactivate(), + }; +} + +// ── 개발용 헬퍼 (mock 전용) ───────────────────────────────────────────── +// CHAT 화면의 "테스트 발신자 전환" 버튼처럼, 상대방인 척 메시지를 보내는 테스트용 함수. +// 실서버 연결 시(USE_MOCK=false)에는 아무 동작도 하지 않습니다. +export function mockSendMessageAs(roomId: string, content: string, senderId: number) { + if (!USE_MOCK) return; + const message = mockDb.addMessage(roomId, { + senderId, + content, + createdAt: new Date().toISOString(), + }); + mockDb.broadcastMessage(roomId, message); +} + +// 채팅방 상태 변화 구독 (상대방 인증 완료, 카운트다운 시작 등 반영용) +// mock: BroadcastChannel 기반. real: 별도 이벤트가 없어 폴링으로 대체합니다. +export function subscribeRoomStatus( + roomId: string, + onChange: (room: RoomInfo) => void, + pollIntervalMs = 3000, +): () => void { + if (USE_MOCK) { + return mockDb.subscribe(roomId, (state) => { + onChange({ + id: state.id, + exchangeId: state.exchangeId, + status: state.status, + scheduledAt: state.scheduledAt, + myVerified: state.myVerified, + counterpartVerified: state.counterpartVerified, + myDisputeVerified: state.myDisputeVerified, + qrToken: state.qrToken, + }); + }); + } + + // ⚠️ 명세서에 실시간 상태 push 이벤트가 없어 폴링으로 대체합니다. + // 웹소켓 상태 이벤트가 추가되면 이 폴링을 제거하고 이벤트 구독으로 바꾸는 게 좋습니다. + const timer = setInterval(async () => { + try { + const { room } = await fetchRoom(roomId, { size: 1 }); + onChange(room); + } catch { + // 폴링 실패는 조용히 무시하고 다음 주기에 재시도 + } + }, pollIntervalMs); + + return () => clearInterval(timer); +} diff --git a/src/api/chat/config.ts b/src/api/chat/config.ts new file mode 100644 index 0000000..90226dd --- /dev/null +++ b/src/api/chat/config.ts @@ -0,0 +1,27 @@ +// src/api/config.ts +// +// ============================================================================ +// 🔧 백엔드 연결 스위치 +// ============================================================================ +// 지금은 true (mock) 상태입니다. +// 백엔드 API가 준비되면 아래 값을 false로 바꾸기만 하면 됩니다. +// exchangeApi.ts / chatApi.ts 안의 모든 함수가 이 값을 보고 +// mock 구현 / 실제 fetch 구현을 자동으로 분기합니다. (호출부 코드는 수정할 필요 없음) +// ============================================================================ +export const USE_MOCK = true; + +// 실제 API 베이스 URL. .env 파일에 VITE_API_BASE_URL 설정해두면 됩니다. +export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ''; + +// WebSocket(STOMP) 연결 URL. 명세서 기준 /ws +export const WS_URL = `${API_BASE.replace(/^http/, 'ws')}/ws`; + +// mock 모드에서 네트워크 지연을 흉내내기 위한 딜레이 (실제 API 느낌 테스트용) +export const MOCK_DELAY_MS = 350; + +// mock 로그인 유저 id. 실제 auth 붙으면 이 부분만 교체하면 됩니다. +// (localStorage에 'userId'가 없으면 1번 유저로 동작) +export const getCurrentUserId = (): number => + Number(localStorage.getItem('userId')) || 1; + +export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); diff --git a/src/api/chat/exchangeApi.ts b/src/api/chat/exchangeApi.ts new file mode 100644 index 0000000..9621f43 --- /dev/null +++ b/src/api/chat/exchangeApi.ts @@ -0,0 +1,211 @@ +// src/api/exchangeApi.ts +// +// API 명세서 1, 2, 5, 7, 8번에 대응하는 함수들입니다. +// USE_MOCK 값에 따라 mock 구현 / 실제 fetch 구현으로 자동 분기됩니다. +// 응답 형태(status/success/data 필드명)는 명세서와 동일하게 맞춰서, +// 나중에 USE_MOCK=false로 바꿨을 때 호출부(페이지 컴포넌트) 코드는 손댈 필요가 없습니다. + +import { API_BASE, MOCK_DELAY_MS, USE_MOCK, getCurrentUserId, sleep } from './config'; +import { apiFetch } from './http'; +import { mockDb, type ExchangeStatus } from './mockDb'; + +// ── 1. 강의 보유 인증 QR 생성 ─────────────────────────────────────────── +export interface GenerateQrResponse { + qrToken: string; + qrImageUrl: string; + expiresAt: string; +} + +export async function generateVerifyQr(roomId: string): Promise { + if (USE_MOCK) { + await sleep(MOCK_DELAY_MS); + const state = mockDb.get(roomId); + // 명세: VERIFYING 상태에서만 호출 가능 + if (!['MATCHED', 'SCHEDULED', 'VERIFYING'].includes(state.status)) { + throw new Error('지금은 인증 QR을 생성할 수 없는 상태입니다.'); + } + const qrToken = `mock-qr-${roomId}-${Date.now()}`; + const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + mockDb.update(roomId, { + status: 'VERIFYING', + qrToken, + qrExpiresAt: expiresAt, + myVerified: false, + }); + return { qrToken, qrImageUrl: '', expiresAt }; + } + + const res = await apiFetch( + `${API_BASE}/api/exchange/${roomId}/verify/qr`, + { method: 'POST' }, + ); + return res.data!; +} + +// ── 2. 교환 결과 선택 (성공/실패) ─────────────────────────────────────── +export interface ExchangeResultResponse { + exchangeStatus: ExchangeStatus; +} + +export async function submitExchangeResult( + roomId: string, + result: 'SUCCESS' | 'FAIL', +): Promise { + if (USE_MOCK) { + await sleep(MOCK_DELAY_MS); + // mock: 상대방도 즉시 같은 값을 선택한다고 가정 (단, FAIL을 고르면 결과는 항상 DISPUTE) + const exchangeStatus: ExchangeStatus = result === 'SUCCESS' ? 'COMPLETED' : 'DISPUTE'; + mockDb.update(roomId, { status: exchangeStatus, myResult: result }); + return { exchangeStatus }; + } + + const res = await apiFetch( + `${API_BASE}/api/exchange/${roomId}/result`, + { method: 'POST', body: JSON.stringify({ result }) }, + ); + return res.data!; +} + +// ── 5. 교환 시간 확정 ─────────────────────────────────────────────────── +export interface ConfirmScheduleResponse { + scheduledAt: string; + autoConfirmAt: string; +} + +export async function confirmSchedule( + roomId: string, + scheduledAt: string, +): Promise { + if (new Date(scheduledAt).getTime() <= Date.now()) { + throw new Error('과거 시간으로는 교환 시간을 설정할 수 없습니다.'); + } + + if (USE_MOCK) { + await sleep(MOCK_DELAY_MS); + const autoConfirmAt = new Date( + new Date(scheduledAt).getTime() + 72 * 60 * 60 * 1000, + ).toISOString(); + + mockDb.update(roomId, { scheduledAt, autoConfirmAt, status: 'SCHEDULED' }); + + // 명세: 확정 시 시스템 메시지 발송 + const d = new Date(scheduledAt); + const hour = d.getHours(); + const isPM = hour >= 12; + const displayHour = hour % 12 === 0 ? 12 : hour % 12; + mockDb.addMessage(roomId, { + senderId: 0, + system: true, + content: `${isPM ? '오후' : '오전'} ${displayHour}시 ${d.getMinutes()}분으로 교환 시간이 확정되었습니다.`, + createdAt: new Date().toISOString(), + }); + + return { scheduledAt, autoConfirmAt }; + } + + const res = await apiFetch( + `${API_BASE}/api/exchange/${roomId}/schedule`, + { method: 'POST', body: JSON.stringify({ scheduledAt }) }, + ); + return res.data!; +} + +// ── 7. 거래 파기 ──────────────────────────────────────────────────────── +export type TerminateReason = 'MUTUAL' | 'FRAUD' | 'MONEY_DEMAND' | 'ABUSE' | 'OTHER'; + +export async function terminateDeal( + roomId: string, + reason: TerminateReason, + detail?: string, +): Promise<{ message: string }> { + if (USE_MOCK) { + await sleep(MOCK_DELAY_MS); + const state = mockDb.get(roomId); + mockDb.update(roomId, { + status: 'TERMINATED', + statusBeforeTerminate: state.status, + }); + return { message: '거래가 파기되었습니다.' }; + } + + const res = await apiFetch(`${API_BASE}/api/exchange/${roomId}/terminate`, { + method: 'POST', + body: JSON.stringify({ reason, detail }), + }); + return { message: res.message ?? '거래가 파기되었습니다.' }; +} + +// ⚠️ 원상복구는 명세서(1~11번)에 없는 기능입니다. +// 백엔드 엔드포인트가 확정되면 아래 URL만 채우면 됩니다. +export async function restoreDeal(roomId: string): Promise { + if (USE_MOCK) { + await sleep(MOCK_DELAY_MS); + const state = mockDb.get(roomId); + mockDb.update(roomId, { + status: state.statusBeforeTerminate ?? 'MATCHED', + statusBeforeTerminate: null, + }); + return; + } + + // TODO: 실제 엔드포인트 확정되면 교체 + await apiFetch(`${API_BASE}/api/exchange/${roomId}/terminate/restore`, { + method: 'POST', + }); +} + +// ── 8. 화면 캡처 업로드 - QR 검증 ─────────────────────────────────────── +export interface CaptureUploadResponse { + qrValid: boolean; + status: 'PASSED' | 'FAILED'; +} + +export async function uploadVerifyCapture( + roomId: string, + image: Blob, + mode: 'VERIFY' | 'DISPUTE' = 'VERIFY', +): Promise { + if (USE_MOCK) { + await sleep(MOCK_DELAY_MS + 600); // 업로드+검증 느낌으로 조금 더 대기 + const state = mockDb.get(roomId); + const passed = state.mockCaptureSucceeds; + + if (mode === 'VERIFY') { + mockDb.update(roomId, { myVerified: passed || state.myVerified }); + } else { + mockDb.update(roomId, { myDisputeVerified: passed || state.myDisputeVerified }); + } + + return { qrValid: passed, status: passed ? 'PASSED' : 'FAILED' }; + } + + const formData = new FormData(); + formData.append('image', image, 'capture.png'); + + // ⚠️ 명세서엔 사후 인증(분쟁조정) 전용 업로드 엔드포인트가 별도로 없어서 + // 일단 동일한 verify-capture 엔드포인트를 재사용한다고 가정했습니다. + // 백엔드에서 분쟁조정용 엔드포인트를 따로 준다면 mode에 따라 URL을 분기하면 됩니다. + const res = await apiFetch( + `${API_BASE}/api/exchange/${roomId}/verify-capture`, + { method: 'POST', body: formData }, + ); + return res.data!; +} + +// ── 개발용 헬퍼 (mock 전용) ───────────────────────────────────────────── +// 실배포/실서버 연결 시 아무 의미 없어지는 함수들입니다. UI의 [TEST] 버튼들이 사용합니다. +export const mockDevHelpers = { + setStatus(roomId: string, status: ExchangeStatus) { + if (!USE_MOCK) return; + mockDb.update(roomId, { status }); + }, + setCounterpartVerified(roomId: string, verified: boolean) { + if (!USE_MOCK) return; + mockDb.update(roomId, { counterpartVerified: verified }); + }, + setCaptureSucceeds(roomId: string, succeeds: boolean) { + if (!USE_MOCK) return; + mockDb.update(roomId, { mockCaptureSucceeds: succeeds }); + }, + getCurrentUserId, +}; diff --git a/src/api/chat/http.ts b/src/api/chat/http.ts new file mode 100644 index 0000000..dd4aff1 --- /dev/null +++ b/src/api/chat/http.ts @@ -0,0 +1,46 @@ +// src/api/http.ts +// +// USE_MOCK = false일 때 실제 서버와 통신하는 공통 fetch 래퍼입니다. +// 명세서 공통 응답 형식 { status, success, message?, data? } 을 기준으로 +// success가 false이거나 http status가 실패면 예외를 던집니다. + +export interface ApiEnvelope { + status: number; + success: boolean; + message?: string; + data?: T; +} + +export class ApiError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + this.name = 'ApiError'; + } +} + +export async function apiFetch( + url: string, + options: RequestInit = {}, +): Promise> { + const isFormData = options.body instanceof FormData; + + const res = await fetch(url, { + credentials: 'include', + ...options, + headers: isFormData + ? options.headers + : { 'Content-Type': 'application/json', ...(options.headers ?? {}) }, + }); + + const json = (await res.json().catch(() => null)) as ApiEnvelope | null; + + if (!res.ok || json?.success === false) { + throw new ApiError(json?.message ?? `요청 실패 (${res.status})`, res.status); + } + if (!json) { + throw new ApiError('서버 응답을 해석할 수 없습니다.', res.status); + } + return json; +} diff --git a/src/api/chat/mockDb.ts b/src/api/chat/mockDb.ts new file mode 100644 index 0000000..7fb6e29 --- /dev/null +++ b/src/api/chat/mockDb.ts @@ -0,0 +1,166 @@ +// src/api/mockDb.ts +// +// USE_MOCK = true일 때 사용하는 가짜 DB입니다. +// - roomId별 교환 상태를 localStorage에 저장해서 새로고침해도 유지됩니다. +// - BroadcastChannel로 같은 브라우저의 다른 탭(=상대방 역할)과 상태를 동기화합니다. +// 즉, 탭을 2개 열어서 한쪽은 나, 한쪽은 상대방처럼 테스트할 수 있습니다. +// +// 실제 백엔드 연결 시(USE_MOCK=false) 이 파일은 전혀 사용되지 않습니다. + +export type ExchangeStatus = + | 'MATCHED' + | 'SCHEDULED' + | 'VERIFYING' + | 'READY' + | 'COUNTDOWN' + | 'RESULT_SELECT' + | 'DISPUTE' + | 'DISPUTE_SUBMITTED' + | 'COMPLETED' + | 'TERMINATED'; + +export interface MockChatMessage { + id: number; + senderId: number; + content: string; + createdAt: string; + system?: boolean; +} + +export interface MockRoomState { + id: number; + exchangeId: number; + status: ExchangeStatus; + statusBeforeTerminate: ExchangeStatus | null; + scheduledAt?: string; + autoConfirmAt?: string; + qrToken?: string; + qrExpiresAt?: string; + myVerified: boolean; + counterpartVerified: boolean; + myDisputeVerified: boolean; + counterpartDisputeVerified: boolean; + myResult?: 'SUCCESS' | 'FAIL'; + counterpartResult?: 'SUCCESS' | 'FAIL'; + messages: MockChatMessage[]; + // 개발용: 다음 캡처 업로드가 성공할지 실패할지 (실제로는 서버 QR 검증 결과) + mockCaptureSucceeds: boolean; +} + +const STORAGE_PREFIX = 'mock_exchange_room_'; +const CHANNEL_PREFIX = 'mock_exchange_channel_'; + +const defaultState = (roomId: string): MockRoomState => ({ + id: Number(roomId), + exchangeId: Number(roomId), + status: 'MATCHED', + statusBeforeTerminate: null, + myVerified: false, + counterpartVerified: false, + myDisputeVerified: false, + counterpartDisputeVerified: false, + mockCaptureSucceeds: true, + messages: [ + { + id: 1, + senderId: 1, + content: '내일 오전 4시에 교환해요', + createdAt: new Date().toISOString(), + }, + { + id: 2, + senderId: 2, + content: '좋아요', + createdAt: new Date().toISOString(), + }, + ], +}); + +const storageKey = (roomId: string) => `${STORAGE_PREFIX}${roomId}`; + +function readState(roomId: string): MockRoomState { + try { + const raw = localStorage.getItem(storageKey(roomId)); + if (!raw) { + const initial = defaultState(roomId); + localStorage.setItem(storageKey(roomId), JSON.stringify(initial)); + return initial; + } + return JSON.parse(raw) as MockRoomState; + } catch { + return defaultState(roomId); + } +} + +function writeState(roomId: string, state: MockRoomState) { + localStorage.setItem(storageKey(roomId), JSON.stringify(state)); + getChannel(roomId).postMessage({ type: 'state', state }); +} + +const channels = new Map(); +function getChannel(roomId: string): BroadcastChannel { + if (!channels.has(roomId)) { + channels.set(roomId, new BroadcastChannel(`${CHANNEL_PREFIX}${roomId}`)); + } + return channels.get(roomId)!; +} + +export const mockDb = { + get(roomId: string): MockRoomState { + return readState(roomId); + }, + + update(roomId: string, patch: Partial): MockRoomState { + const next = { ...readState(roomId), ...patch }; + writeState(roomId, next); + return next; + }, + + addMessage(roomId: string, message: Omit): MockChatMessage { + const state = readState(roomId); + const newMessage: MockChatMessage = { ...message, id: Date.now() + Math.random() }; + const next = { ...state, messages: [...state.messages, newMessage] }; + writeState(roomId, next); + return newMessage; + }, + + // roomId의 상태 변화를 구독 (다른 탭에서의 변경 포함) + subscribe(roomId: string, callback: (state: MockRoomState) => void): () => void { + const channel = getChannel(roomId); + const handler = (e: MessageEvent) => { + if (e.data?.type === 'state') callback(e.data.state as MockRoomState); + }; + channel.addEventListener('message', handler); + + // storage 이벤트도 함께 구독 (다른 탭에서 직접 localStorage를 바꾼 경우 대비) + const storageHandler = (e: StorageEvent) => { + if (e.key === storageKey(roomId) && e.newValue) { + callback(JSON.parse(e.newValue) as MockRoomState); + } + }; + window.addEventListener('storage', storageHandler); + + return () => { + channel.removeEventListener('message', handler); + window.removeEventListener('storage', storageHandler); + }; + }, + + // 새 메시지 브로드캐스트 구독 (채팅 실시간 수신 mock) + subscribeMessage(roomId: string, callback: (message: MockChatMessage) => void): () => void { + const channel = getChannel(roomId); + const handler = (e: MessageEvent) => { + if (e.data?.type === 'message') callback(e.data.message as MockChatMessage); + }; + channel.addEventListener('message', handler); + return () => channel.removeEventListener('message', handler); + }, + + broadcastMessage(roomId: string, message: MockChatMessage) { + getChannel(roomId).postMessage({ type: 'message', message }); + }, + + reset(roomId: string) { + localStorage.removeItem(storageKey(roomId)); + }, +}; diff --git a/src/assets/icons/back.svg b/src/assets/icons/back.svg new file mode 100644 index 0000000..e79e89c --- /dev/null +++ b/src/assets/icons/back.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/check.svg b/src/assets/icons/check.svg new file mode 100644 index 0000000..987707f --- /dev/null +++ b/src/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/clock.svg b/src/assets/icons/clock.svg new file mode 100644 index 0000000..71e0310 --- /dev/null +++ b/src/assets/icons/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/dibs.svg b/src/assets/icons/dibs.svg new file mode 100644 index 0000000..c1f3a5d --- /dev/null +++ b/src/assets/icons/dibs.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/dispute.svg b/src/assets/icons/dispute.svg new file mode 100644 index 0000000..59c15df --- /dev/null +++ b/src/assets/icons/dispute.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/email.svg b/src/assets/icons/email.svg new file mode 100644 index 0000000..a83027b --- /dev/null +++ b/src/assets/icons/email.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/eye.svg b/src/assets/icons/eye.svg new file mode 100644 index 0000000..c80a01c --- /dev/null +++ b/src/assets/icons/eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/hands.svg b/src/assets/icons/hands.svg new file mode 100644 index 0000000..19008bc --- /dev/null +++ b/src/assets/icons/hands.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/join.svg b/src/assets/icons/join.svg new file mode 100644 index 0000000..be760db --- /dev/null +++ b/src/assets/icons/join.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/lock.svg b/src/assets/icons/lock.svg new file mode 100644 index 0000000..a5208c7 --- /dev/null +++ b/src/assets/icons/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/logo.png b/src/assets/icons/logo.png new file mode 100644 index 0000000..d7b553a Binary files /dev/null and b/src/assets/icons/logo.png differ diff --git a/src/assets/icons/matched.svg b/src/assets/icons/matched.svg new file mode 100644 index 0000000..82002d6 --- /dev/null +++ b/src/assets/icons/matched.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/matchoffer.svg b/src/assets/icons/matchoffer.svg new file mode 100644 index 0000000..28f1655 --- /dev/null +++ b/src/assets/icons/matchoffer.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/pwlock.svg b/src/assets/icons/pwlock.svg new file mode 100644 index 0000000..2c879cc --- /dev/null +++ b/src/assets/icons/pwlock.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/send.svg b/src/assets/icons/send.svg new file mode 100644 index 0000000..e3a6703 --- /dev/null +++ b/src/assets/icons/send.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main.tsx b/src/main.tsx index 6358fe6..b530452 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,12 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import './index.css'; // Tailwind CSS 등 전역 스타일 적용 +import { registerServiceWorker } from './api/alert/push'; ReactDOM.createRoot(document.getElementById('root')!).render( , ); + +registerServiceWorker(); diff --git a/src/pages/alert/alert.jsx b/src/pages/alert/alert.jsx new file mode 100644 index 0000000..96dc29c --- /dev/null +++ b/src/pages/alert/alert.jsx @@ -0,0 +1,286 @@ +import React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Header from '@/components/layout/Header'; +import { IconButton } from '@/components/common/IconButton'; +import { Toast } from '@/components/common/Toast'; +import { ICONS } from '@/constants/icons'; +import { apiFetch } from '@/api/auth/client'; +import { getTokens } from '@/store/tokenStorage'; + +// 💡 알림 타입별 아이콘 - src/assets/icons 안의 실제 파일명으로 교체하세요. +import matchIcon from '@/assets/icons/matchoffer.svg'; +import likeIcon from '@/assets/icons/dibs.svg'; +import checkIcon from '@/assets/icons/matched.svg'; +import clockIcon from '@/assets/icons/hands.svg'; +import cancelIcon from '@/assets/icons/matched.svg'; +import systemIcon from '@/assets/icons/dibs.svg'; + +// apiFetch가 API_BASE를 이미 붙이므로 여기는 path만 관리 +const API = { + UNREAD_COUNT: '/api/notifications/unread-count', + LIST: '/api/notifications', + READ_ONE: (id) => `/api/notifications/${id}/read`, + READ_ALL: '/api/notifications/read-all', +}; + +const PAGE_SIZE = 20; + +const USE_MOCK = false; + +const TYPE_ICON_MAP = { + MATCH_PROPOSAL: { icon: matchIcon, bg: 'bg-blue-100' }, + MATCH_REQUESTED: { icon: matchIcon, bg: 'bg-blue-100' }, + MATCH_ACCEPTED: { icon: checkIcon, bg: 'bg-blue-100' }, + MATCH_REJECTED: { icon: cancelIcon, bg: 'bg-gray-100' }, + MATCH_TIMEOUT: { icon: cancelIcon, bg: 'bg-gray-100' }, + EXCHANGE_SCHEDULED: { icon: clockIcon, bg: 'bg-blue-100' }, + VERIFY_30MIN: { icon: clockIcon, bg: 'bg-blue-100' }, + VERIFY_10MIN: { icon: clockIcon, bg: 'bg-blue-100' }, + VERIFY_5MIN: { icon: clockIcon, bg: 'bg-blue-100' }, + SWAP_RESULT: { icon: checkIcon, bg: 'bg-blue-100' }, + CANCEL: { icon: cancelIcon, bg: 'bg-gray-100' }, + LIKE: { icon: likeIcon, bg: 'bg-pink-50' }, + PENALTY: { icon: systemIcon, bg: 'bg-gray-100' }, + SYSTEM: { icon: systemIcon, bg: 'bg-gray-100' }, +}; + +const TOAST_BY_TYPE = { + EXPIRED_POST: '존재하지 않거나 삭제된 게시글입니다.', + EXPIRED_MATCH: '만료된 매칭 제안입니다.', + LOAD_FAILED: '알림을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.', +}; + +const formatRelativeTime = (iso) => { + const diffMs = Date.now() - new Date(iso).getTime(); + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return '방금 전'; + if (diffMin < 60) return `${diffMin}분 전`; + const diffHour = Math.floor(diffMin / 60); + if (diffHour < 24) return `${diffHour}시간 전`; + return `${Math.floor(diffHour / 24)}일 전`; +}; + +export default function AlertPage() { + const navigate = useNavigate(); + const [notifications, setNotifications] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [toastMessage, setToastMessage] = useState(''); + const [isToastVisible, setIsToastVisible] = useState(false); + + const pageRef = useRef(0); + const listRef = useRef(null); + + useEffect(() => { + fetchNotifications({ reset: true }); + }, []); + + const showToast = (message) => { + setToastMessage(message); + setIsToastVisible(true); + }; + + // page: 알림 목록 조회 (page/size 파라미터로 페이지네이션) + const fetchNotifications = async ({ reset = false } = {}) => { + const page = reset ? 0 : pageRef.current; + + if (reset) { + setIsLoading(true); + } else { + setIsLoadingMore(true); + } + + try { + const token = getTokens()?.accessToken; + const payload = await apiFetch( + `${API.LIST}?page=${page}&size=${PAGE_SIZE}`, + { token }, + ); + const fetched = payload?.data?.notifications ?? []; + setNotifications((prev) => (reset ? fetched : [...prev, ...fetched])); + setHasMore(fetched.length === PAGE_SIZE); + pageRef.current = page + 1; + } catch { + // apiFetch는 401/403/네트워크 오류 시 ApiError를 던짐 (여기서는 메시지 구분 없이 안내) + showToast(TOAST_BY_TYPE.LOAD_FAILED); + } finally { + setIsLoading(false); + setIsLoadingMore(false); + } + }; + + // API 1. 안 읽은 알림 개수 조회 + // TODO: 헤더/탭바 뱃지 UI 나오면 마운트 시(or 폴링)로 연결 + // const fetchUnreadCount = async () => { + // try { + // const token = getTokens()?.accessToken; + // const payload = await apiFetch(API.UNREAD_COUNT, { token }); + // return payload?.data?.unreadCount ?? null; + // } catch { + // return null; + // } + // }; + + // // API 5. 알림 전체 읽음 처리 + // // TODO: '모두 읽음' 버튼 UI 나오면 onClick에 연결하고 아래처럼 로컬 상태도 갱신 + // const markAllAsRead = async () => { + // try { + // const token = getTokens()?.accessToken; + // await apiFetch(API.READ_ALL, { method: 'PATCH', token }); + // setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); + // return true; + // } catch { + // return false; + // } + // }; + + // 리스트를 끝까지 스크롤하면 다음 페이지 로드 (기존 "불러오는 중..." 표시를 그대로 재사용) + const handleScroll = () => { + if (USE_MOCK || isLoading || isLoadingMore || !hasMore) return; + const el = listRef.current; + if (!el) return; + const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80; + if (nearBottom) { + fetchNotifications({ reset: false }); + } + }; + + const handleBack = () => navigate(-1); + + const handleNotificationClick = async (item) => { + if (!item.isRead) { + if (USE_MOCK) { + setNotifications((prev) => + prev.map((n) => (n.id === item.id ? { ...n, isRead: true } : n)), + ); + } else { + try { + const token = getTokens()?.accessToken; + await apiFetch(API.READ_ONE(item.id), { method: 'PATCH', token }); + setNotifications((prev) => + prev.map((n) => (n.id === item.id ? { ...n, isRead: true } : n)), + ); + } catch { + // 읽음 처리 실패는 조용히 무시 (다음 진입 시 재시도됨) + } + } + } + + if (!item.deepLink) return; + + // 3번 API(알림 목록 조회) 명세에 정의된 두 케이스만 만료/삭제로 취급한다. + // 그 외 네트워크 오류 등은 "삭제됨"으로 단정할 근거가 없으므로 일단 이동시킨다. + try { + const res = await fetch(item.deepLink, { credentials: 'include' }); + if (res.status === 404) { + showToast(TOAST_BY_TYPE.EXPIRED_POST); + return; + } + if (res.status === 410) { + showToast(TOAST_BY_TYPE.EXPIRED_MATCH); + return; + } + navigate(item.deepLink); + } catch { + navigate(item.deepLink); + } + }; + + return ( +
+
+
+ +
+ } + title={알림} + /> +
+ +
+ {isLoading && ( +
+ 불러오는 중... +
+ )} + {!isLoading && notifications.length === 0 && ( +
+ 새로운 알림이 없습니다. +
+ )} + + {notifications.map((item) => { + const { icon, bg } = TYPE_ICON_MAP[item.type] ?? TYPE_ICON_MAP.SYSTEM; + const [mainLine, subLine] = item.body.split('\n'); + + return ( + + ); + })} + + {!isLoading && isLoadingMore && ( +
+ 불러오는 중... +
+ )} +
+ + setIsToastVisible(false)} + icon={ICONS.CLOSE} + /> + + ); +} diff --git a/src/pages/auth/findPW.jsx b/src/pages/auth/findPW.jsx new file mode 100644 index 0000000..bc6e945 --- /dev/null +++ b/src/pages/auth/findPW.jsx @@ -0,0 +1,473 @@ +import React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Button from '@/components/common/Button'; +import { Input } from '@/components/common/Input'; +import { Modal } from '@/components/common/Modal'; +import { IconButton } from '@/components/common/IconButton'; +import { ICONS } from '@/constants/icons'; +import eyeIcon from '@/assets/icons/eye.svg'; +import { + sendPasswordResetCode, + verifyEmailCode, + resetPasswordRequest, +} from '@/api/auth/authApi'; + +const EMAIL_DOMAIN = 'sookmyung.ac.kr'; +const CODE_TIMER_SECONDS = 5 * 60; +const PASSWORD_REGEX = + /^(?=.*[A-Za-z])(?=.*\d)(?=.*[!@#$%^&*(),.?":{}|<>]).{8,12}$/; + +// 프레임 고정 크기 (Figma 기준) +const FRAME_W = 402; +const FRAME_H = 874; + +export default function FindPasswordPage() { + const navigate = useNavigate(); + const [step, setStep] = useState('email'); + const [email, setEmail] = useState(''); + const [emailError, setEmailError] = useState(''); + const [isSending, setIsSending] = useState(false); + const [isCodeSent, setIsCodeSent] = useState(false); + const [code, setCode] = useState(''); + const [codeError, setCodeError] = useState(''); + const [isVerifying, setIsVerifying] = useState(false); + const [secondsLeft, setSecondsLeft] = useState(CODE_TIMER_SECONDS); + const timerRef = useRef(null); + const [password, setPassword] = useState(''); + const [passwordConfirm, setPasswordConfirm] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [confirmError, setConfirmError] = useState(''); + const [resetError, setResetError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showCompleteModal, setShowCompleteModal] = useState(false); + const [showPw, setShowPw] = useState(false); + const [showPwConfirm, setShowPwConfirm] = useState(false); + + // 화면 크기에 맞춰 402x874 프레임을 통째로 스케일링 + const wrapRef = useRef(null); + const [scale, setScale] = useState(1); + + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const w = entries[0].contentRect.width; + setScale(w / FRAME_W); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + if (isCodeSent) { + timerRef.current = setInterval(() => { + setSecondsLeft((prev) => { + if (prev <= 1) { + if (timerRef.current) clearInterval(timerRef.current); + return 0; + } + return prev - 1; + }); + }, 1000); + } + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [isCodeSent]); + + const formatTime = (sec) => { + const m = Math.floor(sec / 60) + .toString() + .padStart(2, '0'); + const s = (sec % 60).toString().padStart(2, '0'); + return `${m}:${s}`; + }; + + const isValidDomainEmail = (value) => + /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value) && + value.toLowerCase().endsWith(`@${EMAIL_DOMAIN}`); + + const handleBack = () => { + if (step === 'password') { + setStep('email'); + return; + } + navigate('/login'); + }; + + const handleSendCode = async () => { + if (!isValidDomainEmail(email)) { + setEmailError('숙명 이메일만 입력 가능합니다.'); + return; + } + setEmailError(''); + setIsSending(true); + try { + await sendPasswordResetCode(email); + setIsCodeSent(true); + setSecondsLeft(CODE_TIMER_SECONDS); + setCode(''); + setCodeError(''); + } catch (err) { + // 404: 가입되지 않은 이메일 + setEmailError( + err.status === 404 + ? '가입되지 않은 이메일입니다.' + : err.message || '인증코드 발송에 실패했습니다.', + ); + } finally { + setIsSending(false); + } + }; + + const handleVerifyCode = async () => { + if (!isCodeSent || secondsLeft === 0 || !code.trim()) return; + setCodeError(''); + setIsVerifying(true); + try { + await verifyEmailCode(email, code); + setStep('password'); + } catch (err) { + setCodeError(err.message || '잘못된 입력값입니다.'); + } finally { + setIsVerifying(false); + } + }; + + const validatePassword = () => { + let valid = true; + if (!PASSWORD_REGEX.test(password)) { + setPasswordError('영문, 숫자, 특수문자를 포함하여 8~12자로 작성해주세요'); + valid = false; + } else { + setPasswordError(''); + } + if (password !== passwordConfirm) { + setConfirmError('비밀번호가 일치하지 않습니다.'); + valid = false; + } else { + setConfirmError(''); + } + return valid; + }; + + const handleResetPassword = async () => { + setResetError(''); + if (!validatePassword()) return; + setIsSubmitting(true); + try { + await resetPasswordRequest({ + email, + newPassword: password, + newPasswordConfirm: passwordConfirm, + }); + setShowCompleteModal(true); + } catch (err) { + setResetError(err.message || '비밀번호 변경에 실패했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + const isSendDisabled = + email.length === 0 || isSending || (isCodeSent && secondsLeft > 0); + + return ( + // 바깥 wrapRef가 실제 사용 가능한 너비를 재고, 그 비율만큼 402x874 프레임을 scale +
+
+ {/* 뒤로가기 버튼 — 확대 (44px 탭 영역, 아이콘 28x16) */} +
+ +
+ + {step === 'email' ? ( + <> +

+ 비밀번호 찾기 +

+

+ 인증번호를 확인하여 비밀번호를 변경합니다. +

+ + +
+ { + setEmail(e.target.value); + if (emailError) setEmailError(''); + }} + disabled={isCodeSent} + isError={!!emailError} + errorMessage={emailError} + rightNode={ + + } + /> +
+ + +
+ { + setCode(e.target.value); + if (codeError) setCodeError(''); + }} + disabled={!isCodeSent || secondsLeft === 0} + isError={!!codeError} + errorMessage={codeError} + className={isCodeSent ? '!border-brand-lightBlue' : ''} + rightNode={ + isCodeSent ? ( + + {formatTime(secondsLeft)} + + ) : null + } + /> +
+ + {isCodeSent && !codeError && ( +

+ 메일이 발송되었습니다 +

+ )} + +
+ +
+ + ) : ( + <> + {/* 비밀번호 재설정 (비밀번호 찾기 단계와 동일한 위치) */} +

+ 비밀번호 재설정 +

+ + {/* 새로운 비밀번호로 재설정합니다 */} +

+ 새로운 비밀번호로 재설정합니다 +

+ + {resetError && ( +

+ {resetError} +

+ )} + + {/* 새 비밀번호 (비밀번호 찾기 단계와 동일한 위치) */} + + +
+ { + setPassword(e.target.value); + if (passwordError) setPasswordError(''); + }} + isError={!!passwordError} + errorMessage={passwordError} + className={`placeholder:text-neutral-400 placeholder:text-s placeholder:font-light ${ + passwordError ? '' : 'border-zinc-400' + }`} + rightNode={ + + } + /> +
+ + {/* 비밀번호 확인 */} + + +
+ { + setPasswordConfirm(e.target.value); + if (confirmError) setConfirmError(''); + }} + isError={!!confirmError} + errorMessage={confirmError} + className={confirmError ? '' : 'border-zinc-400'} + rightNode={ + + } + /> +
+ + {/* 비밀번호 변경 버튼 */} +
+ +
+ + )} +
+ + setShowCompleteModal(false)} + title={ +
+ 비밀번호가 변경되었습니다. +
+ } + footer={ + + } + > +
+ +
+ ); +} diff --git a/src/pages/auth/login.jsx b/src/pages/auth/login.jsx new file mode 100644 index 0000000..7bd5370 --- /dev/null +++ b/src/pages/auth/login.jsx @@ -0,0 +1,271 @@ +import React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; +import { useNavigate } from 'react-router-dom'; +import { loginRequest, ApiError } from '@/api/auth/authApi'; +import { saveTokens } from '@/store/tokenStorage'; +import lockIcon from '../../assets/icons/lock.svg'; +import joinIcon from '../../assets/icons/join.svg'; +import checkIcon from '../../assets/icons/check.svg'; +import emailIcon from '../../assets/icons/email.svg'; +import logo from '../../assets/icons/logo.png'; +import eyeIcon from '../../assets/icons/eye.svg'; +import pwLockIcon from '../../assets/icons/pwlock.svg'; +import Button from '../../components/common/Button'; +import { Input } from '../../components/common/Input'; +import { Spinner } from '../../components/common/Spinner'; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export default function LoginPage() { + const navigate = useNavigate(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [autoLogin, setAutoLogin] = useState(true); + const [fieldErrors, setFieldErrors] = useState({ email: '', password: '' }); + const [banner, setBanner] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const refreshTimerRef = useRef(null); + + useEffect(() => { + return () => clearTimeout(refreshTimerRef.current); + }, []); + + function validate() { + const errors = { email: '', password: '' }; + if (!email.trim()) { + errors.email = '이메일을 입력해주세요.'; + } else if (!EMAIL_REGEX.test(email.trim())) { + errors.email = '올바른 이메일 형식이 아닙니다.'; + } + if (!password) { + errors.password = '비밀번호를 입력해주세요.'; + } + setFieldErrors(errors); + return !errors.email && !errors.password; + } + + async function handleSubmit(e) { + e.preventDefault(); + setBanner(null); + if (!validate()) return; + + setIsSubmitting(true); + try { + const { data } = await loginRequest({ email: email.trim(), password }); + saveTokens(data, autoLogin); + setBanner({ + type: 'success', + message: `로그인에 성공했습니다. ${autoLogin ? '자동 로그인이 설정됨' : ''}`, + }); + navigate('/'); // TODO: 실제 로그인 후 이동할 경로로 교체 + } catch (err) { + if (err instanceof ApiError) { + // 400: 비밀번호 불일치, 403: 정지된 계정 등 서버 메시지 그대로 노출 + setBanner({ type: 'error', message: err.message }); + } else { + setBanner({ + type: 'error', + message: '네트워크 오류가 발생했습니다. 다시 시도해주세요.', + }); + } + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+
+ SOO +
+

+ 눈송이들의 수강신청을 구조해줄 간편하고 +
+ 안전한 강의 교환 매칭 제안 서비스 +

+
+ +
+ {banner?.message || ''} +
+ +
+ {/* ── 이메일 ── */} +
+ +
+ + + + { + setEmail(e.target.value); + if (e.target.value.trim()) + setFieldErrors((prev) => ({ ...prev, email: '' })); + }} + autoComplete="email" + className="flex-1 min-w-0 !border-none !bg-transparent !p-0 !rounded-none !shadow-none placeholder:text-[11px] placeholder:leading-[18.384px] placeholder:tracking-[0.368px] placeholder:text-[#657a88] placeholder:font-['Pretendard'] placeholder:font-normal" + /> +
+

+ {fieldErrors.email} +

+
+ + {/* ── 비밀번호 ── */} +
+ +
+ + + + { + setPassword(e.target.value); + if (e.target.value) + setFieldErrors((prev) => ({ ...prev, password: '' })); + }} + autoComplete="current-password" + className="flex-1 min-w-0 !border-none !bg-transparent !p-0 !rounded-none !shadow-none [&::placeholder]:text-[11px] [&::placeholder]:leading-[18.384px] [&::placeholder]:tracking-[0.368px] placeholder:text-[#657a88] placeholder:font-['Pretendard'] placeholder:font-normal" + /> + +
+

+ {fieldErrors.password} +

+
+ + {/* ── 자동 로그인 ── */} +
setAutoLogin((v) => !v)} + role="checkbox" + aria-checked={autoLogin} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setAutoLogin((v) => !v); + } + }} + > + + {autoLogin && ( + + )} + + + 자동 로그인 + +
+ + {/* ── 로그인 버튼 ── */} + +
+ +
+ + | + +
+
+
+ ); +} diff --git a/src/pages/auth/signup.jsx b/src/pages/auth/signup.jsx new file mode 100644 index 0000000..adb8583 --- /dev/null +++ b/src/pages/auth/signup.jsx @@ -0,0 +1,513 @@ +import React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Button from '@/components/common/Button'; +import { Input } from '@/components/common/Input'; +import { Modal } from '@/components/common/Modal'; +import { IconButton } from '@/components/common/IconButton'; +import { ICONS } from '@/constants/icons'; +import eyeIcon from '@/assets/icons/eye.svg'; +import { + sendEmailCode, + verifyEmailCode, + checkEmailExists, + signupRequest, +} from '@/api/auth/authApi'; + +const EMAIL_DOMAIN = 'sookmyung.ac.kr'; +const CODE_TIMER_SECONDS = 5 * 60; +const PASSWORD_REGEX = + /^(?=.*[A-Za-z])(?=.*\d)(?=.*[!@#$%^&*(),.?":{}|<>]).{8,12}$/; + +// 프레임 고정 크기 (Figma 기준) +const FRAME_W = 402; +const FRAME_H = 874; + +export default function SignupPage() { + const navigate = useNavigate(); + const [step, setStep] = useState('email'); + const [email, setEmail] = useState(''); + const [emailError, setEmailError] = useState(''); + const [isSending, setIsSending] = useState(false); + const [isCodeSent, setIsCodeSent] = useState(false); + const [code, setCode] = useState(''); + const [codeError, setCodeError] = useState(''); + const [isVerifying, setIsVerifying] = useState(false); + const [secondsLeft, setSecondsLeft] = useState(CODE_TIMER_SECONDS); + const timerRef = useRef(null); + const [password, setPassword] = useState(''); + const [passwordConfirm, setPasswordConfirm] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [confirmError, setConfirmError] = useState(''); + const [signupError, setSignupError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showCompleteModal, setShowCompleteModal] = useState(false); + const [showPw, setShowPw] = useState(false); + const [showPwConfirm, setShowPwConfirm] = useState(false); + + // 화면 크기에 맞춰 402x874 프레임을 통째로 스케일링 + const wrapRef = useRef(null); + const [scale, setScale] = useState(1); + + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const w = entries[0].contentRect.width; + setScale(w / FRAME_W); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + if (isCodeSent) { + timerRef.current = setInterval(() => { + setSecondsLeft((prev) => { + if (prev <= 1) { + if (timerRef.current) clearInterval(timerRef.current); + return 0; + } + return prev - 1; + }); + }, 1000); + } + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [isCodeSent]); + + const formatTime = (sec) => { + const m = Math.floor(sec / 60) + .toString() + .padStart(2, '0'); + const s = (sec % 60).toString().padStart(2, '0'); + return `${m}:${s}`; + }; + + const isValidDomainEmail = (value) => + /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value) && + value.toLowerCase().endsWith(`@${EMAIL_DOMAIN}`); + + const handleBack = () => { + if (step === 'password') { + setStep('email'); + return; + } + navigate('/login'); + }; + + const handleSendCode = async () => { + if (!isValidDomainEmail(email)) { + setEmailError('숙명 이메일만 입력 가능합니다.'); + return; + } + setEmailError(''); + setIsSending(true); + try { + await sendEmailCode(email); + setIsCodeSent(true); + setSecondsLeft(CODE_TIMER_SECONDS); + setCode(''); + setCodeError(''); + } catch (err) { + setEmailError(err.message || '인증코드 발송에 실패했습니다.'); + } finally { + setIsSending(false); + } + }; + + const handleVerifyCode = async () => { + if (!isCodeSent || secondsLeft === 0 || !code.trim()) return; + setCodeError(''); + setIsVerifying(true); + try { + await verifyEmailCode(email, code); + const { data } = await checkEmailExists(email); + if (data?.exists) { + setCodeError('이미 가입된 이메일입니다.'); + return; + } + setStep('password'); + } catch (err) { + setCodeError(err.message || '잘못된 입력값입니다.'); + } finally { + setIsVerifying(false); + } + }; + + const validatePassword = () => { + let valid = true; + if (!PASSWORD_REGEX.test(password)) { + setPasswordError('영문, 숫자, 특수문자를 포함하여 8~12자로 작성해주세요'); + valid = false; + } else { + setPasswordError(''); + } + if (password !== passwordConfirm) { + setConfirmError('비밀번호가 일치하지 않습니다.'); + valid = false; + } else { + setConfirmError(''); + } + return valid; + }; + + const handleSignup = async () => { + setSignupError(''); + if (!validatePassword()) return; + setIsSubmitting(true); + try { + await signupRequest({ email, password, passwordConfirm }); + setShowCompleteModal(true); + } catch (err) { + setSignupError(err.message || '회원가입에 실패했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + const isSendDisabled = + email.length === 0 || isSending || (isCodeSent && secondsLeft > 0); + + return ( + // 바깥 wrapRef가 실제 사용 가능한 너비를 재고, 그 비율만큼 402x874 프레임을 scale +
+
+ {/* 뒤로가기 버튼 — 확대 (44px 탭 영역, 아이콘 28x16) */} +
+ +
+ + {step === 'email' ? ( + <> +

+ 회원가입 +

+

+ 숙명 이메일을 인증해주세요. +

+ + +
+ { + setEmail(e.target.value); + if (emailError) setEmailError(''); + }} + disabled={isCodeSent} + isError={!!emailError} + errorMessage={emailError} + rightNode={ + + } + /> +
+ + +
+ { + setCode(e.target.value); + if (codeError) setCodeError(''); + }} + disabled={!isCodeSent || secondsLeft === 0} + isError={!!codeError} + errorMessage={codeError} + className={ + isCodeSent && !codeError ? '!border-brand-lightBlue' : '' + } + rightNode={ + isCodeSent ? ( + + {formatTime(secondsLeft)} + + ) : null + } + /> +
+ + {isCodeSent && !codeError && ( +

+ 메일이 발송되었습니다 +

+ )} + +
+ +
+ + ) : ( + <> + {/* 회원가입 */} +

+ 회원가입 +

+ + {/* 가입을 완료해주세요 */} +

+ 가입을 완료해주세요 +

+ + {signupError && ( +

+ {signupError} +

+ )} + + {/* 숙명 이메일 */} + + + {/* 숙명 이메일 텍스트 박스 */} +
+ +
+ + {/* 비밀번호 입력 */} + + + {/* + 비밀번호 입력 텍스트 박스. + */} +
+ { + setPassword(e.target.value); + if (passwordError) setPasswordError(''); + }} + isError={!!passwordError} + errorMessage={passwordError} + className={`placeholder:text-neutral-400 placeholder:text-s placeholder:font-light ${ + passwordError ? '' : 'border-zinc-400' + }`} + rightNode={ + + } + /> +
+ + {/* 비밀번호 확인 */} + + + {/* 비밀번호 확인 텍스트 박스 (에러 메시지+아이콘은 Input 내부에서 자동 렌더링) */} +
+ { + setPasswordConfirm(e.target.value); + if (confirmError) setConfirmError(''); + }} + isError={!!confirmError} + errorMessage={confirmError} + className={confirmError ? '' : 'border-zinc-400'} + rightNode={ + + } + /> +
+ + {/* 회원가입 완료 버튼 */} +
+ +
+ + )} +
+ + setShowCompleteModal(false)} + title="가입이 완료되었습니다" + footer={ + + } + > + 필요한 강의는 얻고, 가진 강의는 나누며 +
+ 더 넓게 배워보세요. +
+ 성공적인 강의 교환을 응원합니다! +
+
+ ); +} diff --git a/src/pages/chat/CRP.tsx b/src/pages/chat/CRP.tsx new file mode 100644 index 0000000..a5e6125 --- /dev/null +++ b/src/pages/chat/CRP.tsx @@ -0,0 +1,1372 @@ +// pages/chat/ChatRoomPage.tsx (crp.tsx) +import React, { useEffect, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Icon } from '@iconify/react'; +import Header from '@/components/layout/Header'; +import { IconButton } from '@/components/common/IconButton'; +import { Input } from '@/components/common/Input'; +import Button from '@/components/common/Button'; +import { Modal } from '@/components/common/Modal'; +import { ICONS } from '@/constants/icons'; +import { useScheduledAt } from '../mockChat'; +import { mockExchangeStore, useExchangeState } from '../mockExcn'; +import sendIcon from '@/assets/icons/send.svg'; +import disputeIcon from '@/assets/icons/dispute.svg'; + +const CURRENT_USER_ID = 1; // TODO: 실제 로그인 유저 id로 교체 +const COUNTERPART_ID = 2; + +const VERIFY_WINDOW_SECONDS = 5 * 60; +const COUNTDOWN_START = 10; +const COUNTDOWN_RED_THRESHOLD = 3; + +interface ChatMessage { + id: number; + senderId: number; + content: string; + createdAt: string; +} + +const INITIAL_MESSAGES: ChatMessage[] = [ + { + id: 1, + senderId: CURRENT_USER_ID, + content: '내일 오전 4시에 교환해요', + createdAt: new Date().toISOString(), + }, + { + id: 2, + senderId: COUNTERPART_ID, + content: '좋아요', + createdAt: new Date().toISOString(), + }, +]; + +// TODO: 실제 room 상세 API로 대체 (내 과목명 / 상대 과목명) +const MOCK_ROOM_COURSES: Record = { + '1': { my: '마케팅과 소비자 이슈', counterpart: '프로그래밍언어론' }, + '2': { my: '자바프로그래밍', counterpart: '소비자 경제' }, +}; + +const formatTime = (iso: string) => + new Date(iso).toLocaleTimeString('ko-KR', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + +const formatScheduledDate = (iso: string) => { + const d = new Date(iso); + const days = ['일', '월', '화', '수', '목', '금', '토']; + return `${d.getMonth() + 1}월 ${d.getDate()}일 (${days[d.getDay()]})`; +}; + +// 채팅방 내부에서 전환되는 화면 단계 +// CHAT: 평소 채팅 화면 +// GUIDE: 캡쳐 인증 방법 안내 화면 +// VERIFY: 5분 전 강의 보유 인증 (이미지1) +// COUNTDOWN: 카운트다운 + 성공/실패 선택 (이미지2, 전체 오버레이) +// DISPUTE: 분쟁 조정(사후 인증) (이미지3) +// +// ⚠️ 위 단계들은 "페이지 전환"이 아니라 채팅방이라는 하나의 화면 위에 +// 카드 형태로 얹히는 콘텐츠일 뿐이다. 따라서 헤더/푸터는 flowStep과 +// 무관하게 항상 기본으로 노출되어야 한다. (COUNTDOWN도 예외 아님 - +// 다만 카운트다운 중에는 Modal 오버레이가 위에 뜨면서 헤더/푸터는 +// "보이기만" 하고 기능은 동작하지 않아도 된다.) +// +// ⚠️ 단, 채팅 입력(푸터)은 VERIFY(5분 전 인증)가 시작된 순간부터 완전히 잠긴다. +// VERIFY -> COUNTDOWN -> DISPUTE 로 이어지는 동안 계속 잠금 상태이며, +// CHAT/GUIDE 단계에서만 입력창이 노출된다. +// ⚠️ 거래가 파기(TERMINATED)된 경우에도 flowStep과 무관하게 입력창은 잠긴다. +type FlowStep = 'CHAT' | 'GUIDE' | 'VERIFY' | 'COUNTDOWN' | 'DISPUTE'; +type VerifySubStep = + | 'INTRO' + | 'CAPTURING' + | 'WAITING_COUNTERPART' + | 'CONFIRM_COUNTERPART' + | 'READY'; +type CountdownPhase = 'COUNTING' | 'RESULT_SELECT'; +// DISPUTE 화면 내부 단계 - CAPTURE: 인증 시작 카드, SUBMITTED: 인증 제출 완료 카드 +type DisputeSubStep = 'CAPTURE' | 'SUBMITTED'; + +// 채팅 입력창(푸터)이 노출되는 flowStep 목록. VERIFY부터는 잠금. +const CHAT_INPUT_UNLOCKED_STEPS: FlowStep[] = ['CHAT', 'GUIDE']; + +const GUIDE_STEPS = [ + { + title: '1. 수강신청 내역 페이지를 열어주세요.', + desc: '학교 수강신청 시스템에서 현재 신청한 강의 목록이 보이는 화면을 준비해주세요.', + caption: '실제 수강신청 페이지 예시 · 강의 목록이 표시된 상태', + }, + { + title: '2. 인증 QR 코드가 보이도록 화면을 배치해주세요.', + desc: '수강신청 내역과 인증 QR 코드가 한 화면에 함께 보이도록 창 크기를 조정해주세요.', + caption: '왼쪽: 학교 수강신청 페이지 · 오른쪽: 서비스의 인증 QR 코드', + }, + { + title: '3. [인증 시작] 버튼을 눌러주세요.', + desc: '버튼을 누르면 화면 공유 창이 나타납니다.', + caption: '"인증 시작" 버튼이 강조된 화면', + }, + { + title: '4. 전체 화면을 선택해주세요.', + desc: '화면 공유 창에서 "전체 화면"을 선택한 후 [공유] 버튼을 눌러주세요.', + caption: '브라우저의 화면 공유 선택 창 · "전체 화면" 선택 부분 강조 표시', + }, + { + title: '5. 자동 인증이 진행됩니다.', + desc: '공유가 시작되면 시스템이 현재 화면을 자동으로 촬영하여 인증을 진행합니다.\n※ 촬영 후 화면 공유는 자동으로 종료됩니다.', + caption: '"인증 진행 중..." 로딩 화면', + }, + { + title: '6. 촬영된 이미지를 확인해주세요.', + desc: '촬영된 이미지에서 교환하려는 강의가 정상적으로 보이는지 확인해주세요.', + caption: '실제 촬영된 결과 예시 · 강의 목록과 QR 코드가 함께 보이는 화면', + }, + { + title: '7. 인증 완료', + desc: '인증이 완료되면 다음 단계로 이동할 수 있습니다.', + caption: '"인증이 완료되었습니다" 완료 화면 · "카운트다운 시작" 버튼', + }, +]; + +export default function ChatRoomPage() { + const navigate = useNavigate(); + const { roomId = '' } = useParams(); + const scheduledAt = useScheduledAt(roomId); + const exchange = useExchangeState(roomId); + + const myCourseName = MOCK_ROOM_COURSES[roomId]?.my ?? '알 수 없음'; + const counterpartCourseName = + MOCK_ROOM_COURSES[roomId]?.counterpart ?? '알 수 없음'; + + const [messages, setMessages] = useState( + scheduledAt ? INITIAL_MESSAGES : [], + ); + const [inputValue, setInputValue] = useState(''); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const scrollRef = useRef(null); + const isComposingRef = useRef(false); + + // 💡 왼쪽(상대)/오른쪽(나) 채팅을 둘 다 테스트할 수 있도록 하는 개발용 토글 + const [senderRole, setSenderRole] = useState<'ME' | 'OTHER'>('ME'); + + // ===== 화면 전환 state ===== + const [flowStep, setFlowStep] = useState('CHAT'); + + // 카드(GUIDE/VERIFY/DISPUTE)가 대화 타임라인의 어느 시점에 끼어들었는지 기록. + // 이 인덱스 "이전" 메시지들만 접혀서 "이전 채팅 보기" 안에 들어가고, + // 이 인덱스 "이후"에 보낸 새 메시지는 카드 아래에 항상 펼쳐진 채로 보인다. + const [cardInsertIndex, setCardInsertIndex] = useState(0); + + // "교환 시간 확정" 카드가 대화 타임라인의 어느 시점에 끼어들었는지 기록. + // 이 인덱스 이전 메시지 -> 확정 카드 -> 이후에 보낸 새 메시지 순으로 CHAT 화면에 렌더링한다. + const [scheduleInsertIndex, setScheduleInsertIndex] = useState(() => + scheduledAt ? INITIAL_MESSAGES.length : 0, + ); + const prevScheduledAtRef = useRef(scheduledAt); + + // ----- 이전 채팅 보기 토글 (GUIDE / VERIFY / DISPUTE 화면 공통) ----- + const [showPreviousChat, setShowPreviousChat] = useState(false); + + // ----- VERIFY 관련 state ----- + const [verifyStep, setVerifyStep] = useState('INTRO'); + const [verifySecondsLeft, setVerifySecondsLeft] = useState( + VERIFY_WINDOW_SECONDS, + ); + const [isCaptureFailModalOpen, setIsCaptureFailModalOpen] = useState(false); + const [isCounterpartConfirmedChecked, setIsCounterpartConfirmedChecked] = + useState(false); + const [mockCaptureSucceeds, setMockCaptureSucceeds] = useState(true); // 💡 개발용 + + // ----- COUNTDOWN 관련 state ----- + const [countdownPhase, setCountdownPhase] = + useState('COUNTING'); + const [countdownSecondsLeft, setCountdownSecondsLeft] = + useState(COUNTDOWN_START); + + // ----- DISPUTE 관련 state ----- + const [isDisputeSubmitting, setIsDisputeSubmitting] = useState(false); + const [disputeStep, setDisputeStep] = useState('CAPTURE'); + + // ----- 거래 파기/원상복구 관련 state ----- + // 파기 직전의 exchange.status를 기억해뒀다가, 원상복구 시 그 상태로 되돌리기 위함. + // (예: READY 상태에서 파기했다면 원상복구 시 다시 READY로 복귀) + const statusBeforeTerminateRef = useRef(null); + const prevExchangeStatusRef = useRef(exchange.status); + const [isRestoring, setIsRestoring] = useState(false); + void setIsRestoring; // 연동하면 지우기 + + // 교환 시간이 방금 확정된 시점(false -> true 전환)의 메시지 개수를 기록해 + // "확정 카드" 위/아래로 메시지를 나눠 보여줄 수 있게 한다. + useEffect(() => { + if (!prevScheduledAtRef.current && scheduledAt) { + setScheduleInsertIndex(messages.length); + } + prevScheduledAtRef.current = scheduledAt; + }, [scheduledAt]); + + // exchange.status가 TERMINATED로 "막 바뀐 시점"의 이전 상태를 기억해둔다. + // /chat/:roomId/terminate 페이지에서 사유를 골라 파기를 확정하면 + // mockExchangeStore.setStatus(roomId, 'TERMINATED')가 호출되고, + // 그 결과 exchange.status가 바뀌면서 이 effect가 이전 상태를 저장한다. + // TODO: 실제 API 연동 시에는 서버가 파기 이전 status를 함께 내려주거나, + // "원상복구" 시 서버가 알아서 이전 상태로 되돌려주는 API를 호출하면 되므로 + // 이 ref 로직 자체는 제거하고 서버 응답값을 그대로 반영하면 된다. + useEffect(() => { + if ( + exchange.status === 'TERMINATED' && + prevExchangeStatusRef.current !== 'TERMINATED' + ) { + statusBeforeTerminateRef.current = prevExchangeStatusRef.current; + } + prevExchangeStatusRef.current = exchange.status; + }, [exchange.status]); + + // 새 카드/메시지가 생기면 맨 아래로 자동 스크롤한다. + // 단, GUIDE(강의 보유 인증 안내)로 "막 진입"한 순간에는 안내문을 위에서부터 + // 볼 수 있도록 예외적으로 맨 위로 스크롤한다. + const prevFlowStepRef = useRef(flowStep); + useEffect(() => { + const justEnteredGuide = + flowStep === 'GUIDE' && prevFlowStepRef.current !== 'GUIDE'; + if (justEnteredGuide) { + scrollRef.current?.scrollTo({ top: 0 }); + } else { + scrollRef.current?.scrollTo({ top: scrollRef.current?.scrollHeight }); + } + prevFlowStepRef.current = flowStep; + }, [ + messages, + scheduledAt, + flowStep, + verifyStep, + disputeStep, + countdownPhase, + cardInsertIndex, + showPreviousChat, + scheduleInsertIndex, + ]); + + const handleBack = () => { + // VERIFY/GUIDE/COUNTDOWN/DISPUTE 등 어떤 화면이든 뒤로가기는 + // 항상 채팅방을 나가서 목록 화면으로 이동 + navigate(-1); + }; + + const handleSend = () => { + const content = inputValue.trim(); + if (!content) return; + setMessages((prev) => [ + ...prev, + { + id: Date.now(), + senderId: senderRole === 'ME' ? CURRENT_USER_ID : COUNTERPART_ID, + content, + createdAt: new Date().toISOString(), + }, + ]); + setInputValue(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !isComposingRef.current && e.keyCode !== 229) { + handleSend(); + } + }; + + const handleCloseGuide = () => { + setFlowStep('CHAT'); + }; + + const handleGoSchedule = () => { + setIsMenuOpen(false); + navigate(`/chat/${roomId}/schedule`); + }; + + // 거래 파기 페이지로 이동. 실제 파기 확정(사유 선택 후 제출)은 + // /chat/:roomId/terminate 페이지에서 이루어지고, 그 페이지가 + // mockExchangeStore.setStatus(roomId, 'TERMINATED')를 호출한다. + // TODO: 실제 API 연동 시 terminate 페이지에서 + // await fetch(`${API_BASE}/api/exchange/${roomId}/terminate`, { + // method: 'POST', + // body: JSON.stringify({ reason }), + // credentials: 'include', + // }); + // 성공 응답을 받은 뒤 status를 TERMINATED로 반영하도록 구현하면 된다. + const handleGoTerminate = () => { + setIsMenuOpen(false); + navigate(`/chat/${roomId}/terminate`); + }; + + // 거래 파기 상태를 원상복구(취소)한다. + // 채팅방을 나가지 않고, 파기 이전 상태로 되돌려 마치 파기가 없었던 것처럼 만든다. + const handleRestoreDeal = async () => { + setIsMenuOpen(false); + + // 💡 실제 API 연동 로직 - 백엔드 원상복구 API 붙으면 아래 주석 블록 해제 + /* + setIsRestoring(true); + try { + const res = await fetch( + `${API_BASE}/api/exchange/${roomId}/terminate/restore`, + { + method: 'POST', + credentials: 'include', + }, + ); + if (!res.ok) throw new Error('원상복구에 실패했습니다.'); + const data = await res.json(); + // 서버가 내려주는 복구된 status를 그대로 반영 + mockExchangeStore.setStatus(roomId, data.data.status); + } catch (err) { + // TODO: 실패 시 에러 토스트/모달 처리 + console.error(err); + } finally { + setIsRestoring(false); + } + return; + */ + + // 💡 목업: 파기 직전에 기억해둔 상태로 되돌린다. (없으면 기본값 READY) + mockExchangeStore.setStatus( + roomId, + statusBeforeTerminateRef.current ?? 'READY', + ); + statusBeforeTerminateRef.current = null; + }; + + const handleReport = () => { + setIsMenuOpen(false); + // TODO: 신고하기 - 다음 단계에서 구현 + }; + + // 메시지 리스트 렌더링 (카드 삽입 전/후 구간을 나눠서 재사용) + const renderMessages = (msgs: ChatMessage[] = messages) => + msgs.map((msg) => { + const isMine = msg.senderId === CURRENT_USER_ID; + const timeNode = ( + + {formatTime(msg.createdAt)} + + ); + return ( +
+ {isMine && timeNode} +
+ {msg.content} +
+ {!isMine && timeNode} +
+ ); + }); + + // 교환 시간 확정 노란 박스 (CHAT 화면 + VERIFY/DISPUTE의 "이전 채팅 보기"에서 재사용) + const renderScheduledBox = () => + scheduledAt && ( +
+
+ + + +
+

+ 교환 시간 확정 +

+

+ 날짜 : {formatScheduledDate(scheduledAt)} +

+

+ 시간 : {formatTime(scheduledAt)} +

+
+
+ +
    + {[ + '교환 5분 전 강의 보유 인증이 진행됩니다', + 'PC에서 수강신청(내역) 페이지를 미리 열어주세요', + '모바일 인증은 지원되지 않습니다', + ].map((text) => ( +
  • + + {text} +
  • + ))} +
+ + +
+ ); + + // VERIFY/DISPUTE의 "이전 채팅 보기"에서 쓰는 히스토리 렌더링. + // 단순히 메시지 다 보여주고 확정 카드를 맨 뒤에 붙이는 게 아니라, + // 확정 카드가 실제로 끼어든 시점(scheduleInsertIndex)을 기준으로 + // 메시지 사이 정확한 위치에 카드를 끼워 넣는다. + const renderPreviousChatHistory = (uptoIndex: number) => { + const boxIndex = Math.min(scheduleInsertIndex, uptoIndex); + return ( + <> + {renderMessages(messages.slice(0, boxIndex))} + {boxIndex === scheduleInsertIndex && renderScheduledBox()} + {renderMessages(messages.slice(boxIndex, uptoIndex))} + + ); + }; + + // ============ GUIDE 로직 (캡쳐 인증 방법 안내) ============ + const handleShowGuide = () => { + setCardInsertIndex(messages.length); + setShowPreviousChat(false); + setFlowStep('GUIDE'); + }; + + // ============ VERIFY 로직 (이미지1) ============ + const handleEnterVerify = () => { + setCardInsertIndex(messages.length); + setShowPreviousChat(false); + setFlowStep('VERIFY'); + setVerifyStep('INTRO'); + setVerifySecondsLeft(VERIFY_WINDOW_SECONDS); + mockExchangeStore.generateQrToken(roomId); + mockExchangeStore.setStatus(roomId, 'VERIFYING'); + }; + + useEffect(() => { + if (flowStep !== 'VERIFY' || verifyStep !== 'INTRO') return; + const timer = setInterval( + () => setVerifySecondsLeft((prev) => (prev > 0 ? prev - 1 : 0)), + 1000, + ); + return () => clearInterval(timer); + }, [flowStep, verifyStep]); + + useEffect(() => { + if (flowStep !== 'VERIFY') return; + if (exchange.myVerified && exchange.counterpartVerified) { + if (verifyStep === 'WAITING_COUNTERPART') + setVerifyStep('CONFIRM_COUNTERPART'); + } else if (exchange.myVerified) { + if (verifyStep === 'INTRO' || verifyStep === 'CAPTURING') + setVerifyStep('WAITING_COUNTERPART'); + } + }, [flowStep, exchange.myVerified, exchange.counterpartVerified, verifyStep]); + + const formatVerifyTimer = (sec: number) => { + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + }; + + // 💡 실제 화면 캡처 로직 - 백엔드 업로드 API 붙으면 아래 주석 블록 해제 + const startRealCapture = async (): Promise => { + /* + const stream = await navigator.mediaDevices.getDisplayMedia({ video: true }); + const video = document.createElement('video'); + video.srcObject = stream; + await video.play(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext('2d')?.drawImage(video, 0, 0); + const blob: Blob | null = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + stream.getTracks().forEach((track) => track.stop()); + if (!blob) return false; + + const formData = new FormData(); + formData.append('image', blob); + const res = await fetch(`${API_BASE}/api/exchange/${roomId}/verify-capture`, { + method: 'POST', + body: formData, + credentials: 'include', + }); + const data = await res.json(); + return Boolean(data?.data?.qrValid); + */ + return mockCaptureSucceeds; + }; + + const handleStartCapture = async () => { + setVerifyStep('CAPTURING'); + await new Promise((r) => setTimeout(r, 1200)); + const ok = await startRealCapture(); + if (!ok) { + setIsCaptureFailModalOpen(true); + setVerifyStep('INTRO'); + return; + } + mockExchangeStore.setMyVerified(roomId, true); + }; + + const handleConfirmCounterpart = () => { + mockExchangeStore.setStatus(roomId, 'READY'); + setVerifyStep('READY'); + }; + + const handleToggleCounterpartVerified = () => { + mockExchangeStore.setCounterpartVerified( + roomId, + !exchange.counterpartVerified, + ); + }; + + // ============ COUNTDOWN 로직 (이미지2) ============ + const handleEnterCountdown = () => { + setFlowStep('COUNTDOWN'); + setCountdownPhase('COUNTING'); + setCountdownSecondsLeft(COUNTDOWN_START); + mockExchangeStore.setStatus(roomId, 'COUNTDOWN'); + }; + + useEffect(() => { + if (flowStep !== 'COUNTDOWN' || countdownPhase !== 'COUNTING') return; + if (countdownSecondsLeft <= 0) { + setCountdownPhase('RESULT_SELECT'); + mockExchangeStore.setStatus(roomId, 'RESULT_SELECT'); + return; + } + const timer = setTimeout( + () => setCountdownSecondsLeft((prev) => prev - 1), + 1000, + ); + return () => clearTimeout(timer); + }, [flowStep, countdownPhase, countdownSecondsLeft, roomId]); + + const handleExchangeResult = (result: 'SUCCESS' | 'FAIL') => { + // TODO: 실제로는 5번 API(교환 결과 선택) 호출 { result } + if (result === 'SUCCESS') { + mockExchangeStore.setStatus(roomId, 'COMPLETED'); + setFlowStep('CHAT'); + } else { + mockExchangeStore.setStatus(roomId, 'DISPUTE'); + setCardInsertIndex(messages.length); + setShowPreviousChat(false); + setFlowStep('DISPUTE'); + } + }; + + // ============ DISPUTE 로직 (이미지3) ============ + const handleEnterDispute = () => { + setCardInsertIndex(messages.length); + setShowPreviousChat(false); + setFlowStep('DISPUTE'); + setDisputeStep('CAPTURE'); + }; + + const handleStartDisputeCapture = async () => { + setIsDisputeSubmitting(true); + // TODO: 실제로는 getDisplayMedia 캡처 + 1번 API(캡처 업로드-QR 검증) 호출 + await new Promise((r) => setTimeout(r, 1000)); + mockExchangeStore.setMyDisputeVerified(roomId, true); + setIsDisputeSubmitting(false); + setDisputeStep('SUBMITTED'); + }; + + const handleConfirmDisputeSubmitted = () => { + mockExchangeStore.setStatus(roomId, 'DISPUTE_SUBMITTED'); + setFlowStep('CHAT'); + }; + + const isTerminated = exchange.status === 'TERMINATED'; + + return ( +
+ {/* ============ 헤더 (모든 flowStep 공통 디폴트) ============ */} +
+
+ +
+ + {myCourseName} + + + ↔ {counterpartCourseName} + +
+
+ } + rightNode={ + setIsMenuOpen((prev) => !prev)} + className="!opacity-60" + /> + } + /> +
+ + {/* 햄버거 드롭다운 메뉴 - 평소 채팅 화면에서만 노출 */} + {isMenuOpen && flowStep === 'CHAT' && ( + <> +
setIsMenuOpen(false)} + /> +
+ + +
+ + )} + + {/* ============ CHAT 화면 ============ */} + {flowStep === 'CHAT' && ( +
+

+ {myCourseName.replace(' ', '')} 교환 준비방이 생성되었습니다 +

+ + {!scheduledAt && ( +
+

+ 강의를 교환할 시간을 정해 +
+ 교환 시간 결정 버튼을 눌러 확정해주세요 +

+

+ 다른 학우들이 수강 정정을 +
+ 활발히 하지 않는 새벽 시간대를 추천해요. +

+ +
+ )} + + {renderMessages(messages.slice(0, scheduleInsertIndex))} + + {renderScheduledBox()} + + {renderMessages(messages.slice(scheduleInsertIndex))} + + {/* 거래 파기 안내 텍스트 - 버튼 없이 텍스트만 노출 */} + {isTerminated && ( +
+

+ 거래가 파기되었습니다 +

+

+ 파기 사유는 관리자에게 전달되었습니다 +
+ 검토 후 귀책 여부에 따라 페널티가 부여됩니다 +

+
+ )} + + {/* 개발용: 발신자 전환 + 단계 바로가기 - 실배포 전 삭제 */} +
+ + + + + +
+
+ )} + + {/* ============ GUIDE 화면 (캡쳐 인증 방법 안내) ============ */} + {/* 인증 안내 카드는 처음 보는 안내문이므로 이전 채팅을 숨기지 않고 그냥 노출하지 않는다 (요청: 이전 채팅 안 보이게) */} + {flowStep === 'GUIDE' && ( +
+
+

+ 강의 보유 인증 안내 +

+

+ 안전한 강의 교환을 위해 본인이 실제로 해당 강의를 보유하고 있는지 + 확인하는 절차를 진행합니다. +

+

+ 인증은 교환 예정 시간 5분 전부터 가능합니다. +

+

+ 인증 시작 후 5분 이내에 절차를 완료하지 않으면 거래가 자동 + 취소되며 페널티가 부여될 수 있습니다. 교환 시간을 반드시 준수해 + 주세요. +

+
+ +
+ +
+

인증 방법

+ + {GUIDE_STEPS.map((step, i) => ( +
+

+ {step.title} +

+

+ {step.desc} +

+ + {i === 5 ? ( +
+
+ +
+ +
+ + +
+
+ ) : ( +
+ +
+ )} + + + {step.caption} + +
+ ))} +
+ + + + {/* 인증 안내가 뜬 이후에 보낸 새 메시지 - 카드 아래에 계속 이어짐 */} + {messages.slice(cardInsertIndex).length > 0 && ( +
+ {renderMessages(messages.slice(cardInsertIndex))} +
+ )} +
+ )} + + {/* ============ VERIFY 화면 (이미지1) ============ */} + {flowStep === 'VERIFY' && ( + <> +
+ + +
+ +
+ {!showPreviousChat && ( + + )} + {showPreviousChat && ( + <> +
+ {renderPreviousChatHistory(cardInsertIndex)} +
+ + + )} + + {(verifyStep === 'INTRO' || verifyStep === 'CAPTURING') && ( +
+
+ + + +

+ 교환 5분 전! 강의 보유 인증 시작 +

+
+

+ 안전한 교환을 위해 현재 해당 강의를 보유하고 있는지 + 확인합니다. 5분 이내에 진행해주세요. +

+ +
+ +
    + {[ + 'PC에 수강신청(내역) 페이지를 띄워주세요.', + '[인증 시작] 버튼을 누른 후 화면 공유를 허용해주세요.', + '수강신청(내역) 페이지와 아래 QR 코드가 함께 보이도록 전체 화면을 공유해주세요.', + ].map((text) => ( +
  • + + {text} +
  • + ))} +
+ +

+ ※ 수강신청(내역) 페이지와 QR 코드가 동시에 확인되어야 인증이 + 완료됩니다. +

+ + {exchange.qrToken && ( +
+
+ +
+
+ )} + + +
+ )} + + {verifyStep === 'WAITING_COUNTERPART' && ( +
+ +

+ QR 코드 인증이 완료되었습니다. +

+

+ 상대방의 인증을 기다리고 있어요. +

+
+ )} + + {verifyStep === 'CONFIRM_COUNTERPART' && ( +
+
+ + + +

+ 교환 대상 강의 확인 +

+
+ +
+ + 상대방의 공유 화면 +
+ +

+ QR 코드 인증이 완료되었습니다. +
+ 상대방이 보유한 강의 정보를 확인해주세요. +

+

+ 교환을 진행할 준비가 되었다면 아래 버튼을 눌러주세요. +

+ + + + +
+ )} + + {verifyStep === 'READY' && ( +
+
+ + + +

+ 교환 준비 완료 +

+
+ +

+ 양측이 모두 [카운트다운 시작] 버튼을 누르면 +
+ 10초 후 강의 교환이 시작됩니다 +

+

+ 카운트다운이 종료되면 현재 강의를 버리고 +
+ 상대방의 강의를 신청해 주세요! +

+

+ ※ 카운트다운이 시작되면 취소할 수 없습니다 +

+ + + + +
+ )} + + {/* 5분 전 인증이 뜬 이후에 보낸 새 메시지 - 카드 아래에 계속 이어짐 */} + {messages.slice(cardInsertIndex).length > 0 && ( +
+ {renderMessages(messages.slice(cardInsertIndex))} +
+ )} +
+ + setIsCaptureFailModalOpen(false)} + icon={ + + + + } + title="인증에 실패했습니다" + footer={ + + } + > + {'\n'}인증 QR 코드를 확인할 수 없습니다.{'\n\n'} + 수강신청(내역) 페이지와 인증 QR 코드가{'\n'}한 화면에 모두 보이도록 + 한 뒤{'\n'} + 다시 인증을 진행해주세요. + + + )} + + {/* ============ DISPUTE 화면 (이미지3) ============ */} + {flowStep === 'DISPUTE' && ( +
+ {!showPreviousChat && ( + + )} + {showPreviousChat && ( + <> +
+ {renderPreviousChatHistory(cardInsertIndex)} +
+ + + )} + + {/* ---- 1단계: 인증 시작 카드 ---- */} + {disputeStep === 'CAPTURE' && ( +
+
+ + + +

+ 교환 실패로 인한 분쟁 조정 진행 +

+
+ +

+ 교환 실패로 인해 분쟁 조정 절차가 시작되었습니다. 양측 모두 수강 + 취소 여부를 확인하기 위해 수강 취소 내역 인증을 진행합니다. +

+ +
+ +
    + {[ + 'PC에 수강신청(내역) 페이지를 띄워주세요.', + '[인증 시작] 버튼을 누른 후 화면 공유를 허용해주세요.', + '수강신청(내역) 페이지와 아래 QR 코드가 함께 보이도록 전체 화면을 공유해주세요.', + ].map((text) => ( +
  • + + {text} +
  • + ))} +
+ +

+ ※ 수강신청(내역) 페이지와 QR 코드가 동시에 확인되어야 인증이 + 완료됩니다. +
※ 5분 이내에 인증을 완료하지 않을 경우 거래 결과 판정에 + 불이익이 발생할 수 있습니다 +

+ +
+
+ +
+
+ + +
+ )} + + {/* ---- 2단계: 인증 제출 완료 카드 (모달 아님, 인라인) ---- */} + {disputeStep === 'SUBMITTED' && ( +
+
+ + + +

+ 인증 제출 완료 +

+
+ +

+ 분쟁 조정 결과는 양측의 인증을 관리자가 확인한 후 알림을 통해 + 확인할 수 있습니다. +

+ +
+ +
    + {[ + '양측 모두 정상적으로 수강 취소를 진행한 것으로 확인될 경우, 해당 거래는 사기가 아닌 교환 실패로 처리됩니다.', + '수강신청 결과는 학교 시스템 및 제3자의 신청 상황에 따라 달라질 수 있으며, 이로 인해 발생한 교환 실패에 대해서는 결과를 보장할 수 없습니다.', + '교환 실패로 판정된 경우 양측 모두 페널티 없이 거래가 종료됩니다.', + ].map((text) => ( +
  • + + {text} +
  • + ))} +
+ + +
+ )} + + {/* 분쟁 조정이 뜬 이후에 보낸 새 메시지 - 카드 아래에 계속 이어짐 */} + {messages.slice(cardInsertIndex).length > 0 && ( +
+ {renderMessages(messages.slice(cardInsertIndex))} +
+ )} +
+ )} + + {/* ============ COUNTDOWN 배경 (헤더/푸터 사이 flex-1을 채워 레이아웃 유지) ============ */} + {/* 이 컨테이너가 없으면 헤더-푸터 사이가 비어 푸터가 헤더 바로 아래로 붙어버린다. */} + {flowStep === 'COUNTDOWN' && ( +
+ {renderPreviousChatHistory(messages.length)} +
+ )} + + {/* ============ COUNTDOWN 오버레이 (이미지2) - Modal 컴포넌트 재사용 ============ */} + {/* Modal은 헤더/푸터 위에 오버레이로 뜨므로, 아래 기본 헤더/푸터는 "보이기만" 하고 + 이 동안은 기능(전송 등)이 굳이 동작하지 않아도 된다. */} + {flowStep === 'COUNTDOWN' && countdownPhase === 'COUNTING' && ( + +
+

+ {countdownSecondsLeft}초 +

+

+ 상대방도 준비를 완료했습니다. +
+ 강의를 버리고 잡을 준비를 해주세요. +

+
+
+ )} + + {flowStep === 'COUNTDOWN' && countdownPhase === 'RESULT_SELECT' && ( + 지금 교환하세요} + footer={ +
+ + +
+ } + > +
+
+

1. 현재 강의를 취소하세요.

+

2. 상대방의 강의를 신청하세요.

+
+

+ 강의 신청이 완료되면 +
+ 결과에 따라 아래 버튼을 선택해주세요. +

+
+
+ )} + + {/* ============ 푸터 (CHAT/GUIDE 단계에서만 노출) ============ */} + {/* VERIFY(5분 전 인증)가 시작된 순간부터 채팅이 완전히 잠긴다. + COUNTDOWN, DISPUTE 단계에서도 계속 잠금 상태를 유지한다. + 거래가 파기(TERMINATED)된 경우에도 flowStep과 무관하게 잠긴다. */} + {CHAT_INPUT_UNLOCKED_STEPS.includes(flowStep) && !isTerminated && ( +
+ setInputValue(e.target.value)} + onCompositionStart={() => (isComposingRef.current = true)} + onCompositionEnd={() => (isComposingRef.current = false)} + onKeyDown={handleKeyDown} + rightNode={ + + } + /> +
+ )} +
+ ); +} diff --git a/src/pages/chat/EPR.tsx b/src/pages/chat/EPR.tsx new file mode 100644 index 0000000..cbb2e10 --- /dev/null +++ b/src/pages/chat/EPR.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Header from '@/components/layout/Header'; +import { IconButton } from '@/components/common/IconButton'; +import { ICONS } from '@/constants/icons'; +import clockIcon from '@/assets/icons/clock.svg'; + +interface ExchangeRoom { + id: number; + myCourseName: string; + counterpartCourseName: string; + remainingMinutes: number | null; // null이면 '미정' + isRead: boolean; +} + +const MOCK_ROOMS: ExchangeRoom[] = [ + { + id: 1, + myCourseName: '마케팅과 소비자이슈', + counterpartCourseName: '프로그래밍언어론', + remainingMinutes: 30, + isRead: false, + }, + { + id: 2, + myCourseName: '자바프로그래밍', + counterpartCourseName: '소비자 경제', + remainingMinutes: null, + isRead: false, + }, +]; + +const formatRemaining = (min: number | null) => { + if (min === null) return '미정'; + if (min < 60) return `${min}m`; + const h = Math.floor(min / 60); + const m = min % 60; + return m === 0 ? `${h}h` : `${h}h ${m}m`; +}; + +export default function ExchangeRoomListPage() { + const navigate = useNavigate(); + const [rooms, setRooms] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetchRooms(); + }, []); + + const fetchRooms = async () => { + setIsLoading(true); + try { + // TODO: 채팅방 목록 조회 API 붙이기 (지금은 mock) + await new Promise((r) => setTimeout(r, 200)); + setRooms(MOCK_ROOMS); + } finally { + setIsLoading(false); + } + }; + + const handleBack = () => navigate(-1); + const handleGoAlert = () => navigate('/alert'); + const handleRoomClick = (roomId: number) => navigate(`/chat/${roomId}`); + + return ( +
+
+
+ +
+ } + title={교환준비방} + rightNode={} + /> +
+ +
+ {isLoading && ( +
+ 불러오는 중... +
+ )} + {!isLoading && rooms.length === 0 && ( +
+ 교환 준비방이 없습니다. +
+ )} + + {rooms.map((room) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/chat/SDP.tsx b/src/pages/chat/SDP.tsx new file mode 100644 index 0000000..01beede --- /dev/null +++ b/src/pages/chat/SDP.tsx @@ -0,0 +1,219 @@ +// pages/chat/ScheduleDecisionPage.tsx +import React, { useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Icon } from '@iconify/react'; +import Header from '@/components/layout/Header'; +import { IconButton } from '@/components/common/IconButton'; +import Button from '@/components/common/Button'; +import { Modal } from '@/components/common/Modal'; +import { ICONS } from '@/constants/icons'; +import { mockScheduleStore } from '../mockChat'; + +// TODO: 실제로는 수강신청 가능 날짜/시간 범위 API로 대체 +const DATE_OPTIONS = [ + '2026년 6월 05일 화요일', + '2026년 6월 06일 수요일', + '2026년 6월 07일 목요일', +]; +const TIME_OPTIONS = ['오전 4:00', '오전 4:30', '오전 5:00', '오전 5:30']; + +export default function ScheduleDecisionPage() { + const navigate = useNavigate(); + const { roomId = '' } = useParams(); + + const [date, setDate] = useState(DATE_OPTIONS[0]); + const [time, setTime] = useState(TIME_OPTIONS[0]); + const [isErrorModalOpen, setIsErrorModalOpen] = useState(false); + + // 💡 인증 API 연동 전 임시 토글. 실제 API 붙으면 이 state + 우측 상단 버튼은 삭제. + const [isMockVerified, setIsMockVerified] = useState(true); + + const handleBack = () => navigate(-1); + + const parseToIso = () => { + const [, y, m, d] = date.match(/(\d+)년 (\d+)월 (\d+)일/) ?? []; + const isPM = time.includes('오후'); + const [, hRaw, min] = time.match(/(\d+):(\d+)/) ?? []; + let hour = Number(hRaw ?? 0); + if (isPM && hour !== 12) hour += 12; + if (!isPM && hour === 12) hour = 0; + return new Date( + Number(y), + Number(m) - 1, + Number(d), + hour, + Number(min ?? 0), + ).toISOString(); + }; + + const handleRegister = () => { + if (!isMockVerified) { + setIsErrorModalOpen(true); + return; + } + // TODO: 실제로는 POST 교환 시간 확정 API 호출 (scheduledAt 전송) + mockScheduleStore.set(roomId, parseToIso()); + navigate(-1); + }; + + return ( +
+ {/* 개발용 인증 성공/실패 토글 - 실배포 전 삭제 */} + + +
+
+ +
+ } + title={교환시간 결정} + rightNode={ +
+ +
+ } + /> +
+ +
+ {/* 날짜/시간 블럭 */} +
+ + + +
+ +
+
+

+ 교환 시간 안내 +

+

+ 교환 시간은 양측이 동시에 강의를 취소하고 신청하는 시점입니다. + 원활한 교환을 위해 상대방과 충분히 협의한 후 시간을 결정해 주세요. +

+
+ +
+

유의사항

+
    + {[ + '교환 시간은 수강신청이 가능한 시간대로 설정해 주세요.', + '교환 30분 전, 10분 전에 알림이 발송되며, 교환 5분 전부터 강의 보유 인증이 시작됩니다.', + '인증을 완료하지 않거나 교환 시간에 참여하지 않을 경우 거래가 취소될 수 있으며 페널티가 부여될 수 있습니다.', + '강의 취소 및 수강신청 결과는 학교 시스템 상황에 따라 달라질 수 있습니다.', + ].map((text) => ( +
  • + + {text} +
  • + ))} +
+
+ +
    +
  • + • 상대방과 협의하여 결정한 시간입니다. +
  • +
  • + • 선택한 교환 시간을 확인했으며, 해당 시간에 참여할 수 있습니다. +
  • +
+
+
+ +
+ +
+ + setIsErrorModalOpen(false)} + icon={ + + + + } + title="인증에 실패했습니다" + footer={ + + } + > + {'\n'}인증 QR 코드를 확인할 수 없습니다.{'\n\n'} + 수강신청(내역) 페이지와 인증 QR 코드가{'\n'}한 화면에 모두 보이도록 한 + 뒤{'\n'} + 다시 인증을 진행해주세요. + +
+ ); +} diff --git a/src/pages/chat/TDP.tsx b/src/pages/chat/TDP.tsx new file mode 100644 index 0000000..866ed54 --- /dev/null +++ b/src/pages/chat/TDP.tsx @@ -0,0 +1,257 @@ +// pages/chat/TerminateDealPage.tsx +import React, { useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Icon } from '@iconify/react'; +import Header from '@/components/layout/Header'; +import { IconButton } from '@/components/common/IconButton'; +import Button from '@/components/common/Button'; +import { Input } from '@/components/common/Input'; +import { Modal } from '@/components/common/Modal'; +import { ICONS } from '@/constants/icons'; +import { mockExchangeStore } from '../mockExcn'; + +interface ReasonGroup { + key: string; + label: string; + subReasons?: string[]; + freeText?: boolean; + blocked?: boolean; +} + +const REASON_GROUPS: ReasonGroup[] = [ + { + key: 'MUTUAL', + label: '상호 합의로 거래 취소', + subReasons: ['시간 조율 실패', '서로 다른 과목으로 진행하기로 함'], + }, + { + key: 'FRAUD', + label: '인증 정보가 의심됨', + subReasons: ['보유 과목 인증 사진이 의심됨', '다른 과목 사진 제출'], + }, + { + key: 'ABUSE', + label: '상대방이 거래를 진행하지 않음', + subReasons: ['과목을 버리지 않음', '거래를 일방적으로 중단함'], + }, + { key: 'NO_RESPONSE', label: '상대방과 연락이 원활하지 않음', blocked: true }, + { key: 'OTHER', label: '기타', freeText: true }, +]; + +export default function TerminateDealPage() { + const navigate = useNavigate(); + const { roomId = '' } = useParams(); + + const [openKey, setOpenKey] = useState(null); + const [selectedGroupKey, setSelectedGroupKey] = useState(null); + const [selectedSubReason, setSelectedSubReason] = useState( + null, + ); + const [otherDetail, setOtherDetail] = useState(''); + const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); + + const handleBack = () => navigate(-1); + + const handleToggleGroup = (group: ReasonGroup) => { + if (group.blocked) { + setIsBlockedModalOpen(true); + return; + } + setOpenKey((prev) => (prev === group.key ? null : group.key)); + setSelectedGroupKey(group.key); + setSelectedSubReason(null); + }; + + const handleSelectSubReason = (groupKey: string, sub: string) => { + setSelectedGroupKey(groupKey); + setSelectedSubReason(sub); + }; + + const canSubmit = + selectedGroupKey !== null && + (selectedGroupKey === 'OTHER' + ? otherDetail.trim().length > 0 + : selectedSubReason !== null); + + const handleSubmit = () => { + if (!canSubmit) return; + // TODO: 실제로는 2번 API(거래 파기) 호출 + // reason: selectedGroupKey를 서버 enum(MUTUAL|FRAUD|MONEY_DEMAND|ABUSE|OTHER)에 맞게 매핑 + // detail: selectedGroupKey === 'OTHER' ? otherDetail : selectedSubReason + // + // 💡 실제 API 연동 로직 - 백엔드 거래 파기 API 붙으면 아래 주석 블록 해제 + /* + const reason = selectedGroupKey; + const detail = + selectedGroupKey === 'OTHER' ? otherDetail : selectedSubReason; + const res = await fetch(`${API_BASE}/api/exchange/${roomId}/terminate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason, detail }), + credentials: 'include', + }); + if (!res.ok) { + // TODO: 실패 시 에러 토스트/모달 처리 + return; + } + */ + + // 💡 목업: 파기 상태로 전환 → ChatRoomPage에서 이 status를 보고 + // 안내 문구 노출 + 입력창 잠금 처리를 한다. + mockExchangeStore.setStatus(roomId, 'TERMINATED'); + navigate(`/chat/${roomId}`); + }; + + return ( +
+
+
+ +
+ } + title={거래 파기/원상복구} + rightNode={ +
+ +
+ } + /> +
+ +
+
+ +

+ 정당한 사유 없이 거래를 일방적으로 파기하는 경우 +
+ 서비스 이용이 제한될 수 있습니다. +

+
    +
  • • 1회 위반: 1일 이용 정지
  • +
  • • 2회 위반: 3일 이용 정지
  • +
  • • 3회 위반: 해당 학기 수강정정기간 전체 정지
  • +
+

+ 파기된 사유가 일방적인 것으로 확인되면 상대방의 반박 신고를 통해 + 귀책사유가 인정됩니다. +

+
+ +
+ {REASON_GROUPS.map((group) => { + const isOpen = openKey === group.key; + return ( +
+ + + {isOpen && group.subReasons && ( +
+ {group.subReasons.map((sub) => ( + + ))} +
+ )} + + {isOpen && group.freeText && ( +
+ setOtherDetail(e.target.value)} + /> +
+ )} +
+ ); + })} +
+ +

+ 상대방의 악의적인 행동이 의심된다면 +
+ 거래 파기 후{' '} + 사용자 신고로 + 관리자에게 접수하세요 +

+
+ +
+ +
+ + setIsBlockedModalOpen(false)} + icon={ + + + + } + title="해당 사유로는 직접 파기할 수 없습니다" + footer={ + + } + > + + 상대방이 30분 이상 응답하지 않을 경우{'\n'}거래는 자동으로 종료됩니다. + + {'\n\n'} + 자동 종료 전 거래를 일방적으로 파기하면{'\n'} + 파기한 사용자에게 귀책사유가 인정되어{'\n'} + 페널티가 부여될 수 있습니다. + +
+ ); +} diff --git a/src/pages/global.d.ts b/src/pages/global.d.ts new file mode 100644 index 0000000..a66e54f --- /dev/null +++ b/src/pages/global.d.ts @@ -0,0 +1,11 @@ +declare module '*.jsx' { + import React from 'react'; + + const Component: React.ComponentType; + export default Component; +} + +declare module '*.js' { + const content: unknown; + export default content; +} diff --git a/src/pages/mockChat.ts b/src/pages/mockChat.ts new file mode 100644 index 0000000..32c8d7c --- /dev/null +++ b/src/pages/mockChat.ts @@ -0,0 +1,29 @@ +// store/mockScheduleStore.ts +import { useEffect, useState } from 'react'; + +type ScheduleMap = Record; // roomId -> ISO string | null + +let scheduleMap: ScheduleMap = {}; +const listeners = new Set<() => void>(); +const notify = () => listeners.forEach((l) => l()); + +export const mockScheduleStore = { + get: (roomId: string) => scheduleMap[roomId] ?? null, + set: (roomId: string, iso: string) => { + scheduleMap = { ...scheduleMap, [roomId]: iso }; + notify(); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => void listeners.delete(listener); + }, +}; + +export function useScheduledAt(roomId: string) { + const [, forceRender] = useState(0); + useEffect( + () => mockScheduleStore.subscribe(() => forceRender((n) => n + 1)), + [], + ); + return mockScheduleStore.get(roomId); +} diff --git a/src/pages/mockExcn.ts b/src/pages/mockExcn.ts new file mode 100644 index 0000000..75dfb32 --- /dev/null +++ b/src/pages/mockExcn.ts @@ -0,0 +1,101 @@ +// store/mockExchangeStore.ts +import { useEffect, useState } from 'react'; + +export type ExchangeStatus = + | 'IDLE' + | 'VERIFYING' + | 'READY' + | 'COUNTDOWN' + | 'RESULT_SELECT' + | 'DISPUTE' + | 'DISPUTE_SUBMITTED' + | 'COMPLETED' + | 'TERMINATED'; + +interface ExchangeState { + status: ExchangeStatus; + myVerified: boolean; + counterpartVerified: boolean; + myDisputeVerified: boolean; + counterpartDisputeVerified: boolean; + qrToken: string | null; +} + +const defaultState: ExchangeState = { + status: 'IDLE', + myVerified: false, + counterpartVerified: false, + myDisputeVerified: false, + counterpartDisputeVerified: false, + qrToken: null, +}; + +let stateMap: Record = {}; +const listeners = new Set<() => void>(); +const notify = () => listeners.forEach((l) => l()); +const getState = (roomId: string) => stateMap[roomId] ?? defaultState; + +export const mockExchangeStore = { + get: getState, + setStatus: (roomId: string, status: ExchangeStatus) => { + stateMap = { ...stateMap, [roomId]: { ...getState(roomId), status } }; + notify(); + }, + // TODO: 실제로는 6번 API(강의 보유 인증 QR 생성) 응답의 qrToken/qrImageUrl 사용 + generateQrToken: (roomId: string) => { + const token = `MOCK-QR-${roomId}-${Date.now()}`; + stateMap = { + ...stateMap, + [roomId]: { ...getState(roomId), qrToken: token }, + }; + notify(); + return token; + }, + setMyVerified: (roomId: string, v: boolean) => { + stateMap = { + ...stateMap, + [roomId]: { ...getState(roomId), myVerified: v }, + }; + notify(); + }, + setCounterpartVerified: (roomId: string, v: boolean) => { + stateMap = { + ...stateMap, + [roomId]: { ...getState(roomId), counterpartVerified: v }, + }; + notify(); + }, + setMyDisputeVerified: (roomId: string, v: boolean) => { + stateMap = { + ...stateMap, + [roomId]: { ...getState(roomId), myDisputeVerified: v }, + }; + notify(); + }, + setCounterpartDisputeVerified: (roomId: string, v: boolean) => { + stateMap = { + ...stateMap, + [roomId]: { ...getState(roomId), counterpartDisputeVerified: v }, + }; + notify(); + }, + reset: (roomId: string) => { + stateMap = { ...stateMap, [roomId]: defaultState }; + notify(); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, +}; + +export function useExchangeState(roomId: string) { + const [, forceRender] = useState(0); + useEffect( + () => mockExchangeStore.subscribe(() => forceRender((n) => n + 1)), + [], + ); + return mockExchangeStore.get(roomId); +} diff --git a/src/routes/router.tsx b/src/routes/router.tsx index eceae44..cc6efce 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { createBrowserRouter } from 'react-router-dom'; // --- 1. 레이아웃 불러오기 --- @@ -5,8 +6,17 @@ import RootLayout from '@/components/layout/RootLayout'; import DefaultLayout from '@/components/layout/DefaultLayout'; import FullScreenLayout from '@/components/layout/FullScreenLayout'; -// --- 2. 페이지 불러오기 --- +// --- 2. 페이지 불러오기 (임시 예시) --- +import LoginPage from '../pages/auth/login'; +import SignupPage from '../pages/auth/signup'; +import FindPWPage from '../pages/auth/findPW'; +import AlertPage from '../pages/alert/alert'; +import EPRPage from '../pages/chat/EPR'; +import CRPPage from '../pages/chat/CRP'; +import SDPPage from '../pages/chat/SDP'; +import TDPPage from '../pages/chat/TDP'; +// --- 2. 페이지 불러오기 --- export const router = createBrowserRouter([ { element: , @@ -17,6 +27,9 @@ export const router = createBrowserRouter([ // ========================================== element: , children: [ + // 예시: { path: 'board', element: }, // /board (교환게시판) + { path: '/alert', element: }, + { path: '/chat', element: }, // 예시: { path: '/board', element: }, // /board (교환게시판) ], }, @@ -27,6 +40,13 @@ export const router = createBrowserRouter([ element: , children: [ // :id 나 :roomId 는 동적 라우팅 기법입니다. (ex. /board/123) + // 예시: { path: 'board/:id', element: }, // 상세 게시글 + { path: '/login', element: }, + { path: '/signup', element: }, + { path: '/findPW', element: }, + { path: '/chat/:roomId', element: }, + { path: '/chat/:roomId/schedule', element: }, + { path: '/chat/:roomId/terminate', element: }, // 예시: { path: '/board/:id', element: }, // 상세 게시글 ], }, diff --git a/src/store/tokenStorage.js b/src/store/tokenStorage.js new file mode 100644 index 0000000..4f4af93 --- /dev/null +++ b/src/store/tokenStorage.js @@ -0,0 +1,59 @@ +const ACCESS_TOKEN_KEY = 'soo_access_token'; +const REFRESH_TOKEN_KEY = 'soo_refresh_token'; +const EXPIRES_AT_KEY = 'soo_token_expires_at'; + +function getStorage(persist) { + return persist ? window.localStorage : window.sessionStorage; +} + +function decodeJwtExpiresAt(token) { + try { + const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + const payload = JSON.parse(atob(base64)); + return payload.exp ? payload.exp * 1000 : null; + } catch { + return null; + } +} + +export function saveTokens({ accessToken, refreshToken }, persist) { + const storage = getStorage(persist); + const expiresAt = + decodeJwtExpiresAt(accessToken) ?? Date.now() + 30 * 60 * 1000; + + storage.setItem(ACCESS_TOKEN_KEY, accessToken); + storage.setItem(REFRESH_TOKEN_KEY, refreshToken); + storage.setItem(EXPIRES_AT_KEY, String(expiresAt)); + + const other = getStorage(!persist); + other.removeItem(ACCESS_TOKEN_KEY); + other.removeItem(REFRESH_TOKEN_KEY); + other.removeItem(EXPIRES_AT_KEY); +} + +export function getTokens() { + const storage = window.localStorage.getItem(ACCESS_TOKEN_KEY) + ? window.localStorage + : window.sessionStorage; + + const accessToken = storage.getItem(ACCESS_TOKEN_KEY); + const refreshToken = storage.getItem(REFRESH_TOKEN_KEY); + const expiresAt = Number(storage.getItem(EXPIRES_AT_KEY)) || 0; + + if (!accessToken || !refreshToken) return null; + return { accessToken, refreshToken, expiresAt, storage }; +} + +export function isTokenExpired() { + const tokens = getTokens(); + if (!tokens) return true; + return Date.now() >= tokens.expiresAt; +} + +export function clearTokens() { + [window.localStorage, window.sessionStorage].forEach((storage) => { + storage.removeItem(ACCESS_TOKEN_KEY); + storage.removeItem(REFRESH_TOKEN_KEY); + storage.removeItem(EXPIRES_AT_KEY); + }); +}