diff --git a/package-lock.json b/package-lock.json index bd7729b..d2e5cc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "msw": "^2.14.5", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4" @@ -935,6 +936,93 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.13.tgz", + "integrity": "sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.10", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.10", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.10.tgz", + "integrity": "sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -980,6 +1068,56 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.8", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.8.tgz", + "integrity": "sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", @@ -2132,6 +2270,23 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.51.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz", @@ -2486,6 +2641,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2668,6 +2833,31 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2855,6 +3045,13 @@ "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -3208,6 +3405,33 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3344,6 +3568,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3425,6 +3659,16 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphql": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", + "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3474,6 +3718,24 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/headers-polyfill/node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -3552,6 +3814,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3565,6 +3837,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4049,6 +4328,61 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msw": { + "version": "2.14.5", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.5.tgz", + "integrity": "sha512-X6G05oX4x0e+CNI55KMdhMmwHCBKf2iwazGr+iwsdoJ94JA1ED7wSXb6V+lLPdqFkmIlPiGYvayqnaNcOzobDA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -4108,6 +4442,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4190,6 +4531,13 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -4407,6 +4755,16 @@ "react-dom": "^18 || ^19" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4416,6 +4774,13 @@ "node": ">=4" } }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.54.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", @@ -4507,6 +4872,19 @@ "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -4526,6 +4904,51 @@ "node": ">=0.10.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4558,6 +4981,19 @@ "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwind-merge": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", @@ -4603,6 +5039,39 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/ts-api-utils": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz", @@ -4635,6 +5104,22 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4680,6 +5165,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4834,12 +5329,69 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 29647ca..4535629 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,14 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "msw": "^2.14.5", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4" + }, + "msw": { + "workerDirectory": [ + "public" + ] } -} +} \ No newline at end of file diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000..9cd8401 --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.14.5' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/src/app/routes.tsx b/src/app/routes.tsx index 8135a47..2f30f35 100644 --- a/src/app/routes.tsx +++ b/src/app/routes.tsx @@ -1,6 +1,8 @@ +import { NotFoundPage } from "@/pages/NotFound"; import { OnboardingLayout } from "@/widgets/layout/OnboardingLayout"; import { PrivateRoute } from "@/widgets/layout/PrivateRoiute"; import { SystemLayout } from "@/widgets/layout/SystemLayout"; +import RouteErrorElement from "@/app/ui/RouteErrorElement"; import { lazy } from "react"; import { createBrowserRouter } from "react-router-dom"; @@ -10,20 +12,15 @@ const HomePage = lazy(() => import("@/pages/home/HomePage")); const LoginPage = lazy(() => import("@/pages/login/LoginPage")); const KakaoLogin = lazy(() => import("@/pages/login/KakaoLogin")); const Onboarding = lazy(() => import("@/pages/onboarding/Onboarding")); -const OnboardingTag = lazy( - () => import("@/pages/onboarding/OnboardingTag"), -); -const EditInterestPage = lazy( - () => import("@/pages/mypage/EditInterestPage"), -); -const MyIntersListPage = lazy( - () => import("@/pages/mypage/MyInterstListPage"), -); +const OnboardingTag = lazy(() => import("@/pages/onboarding/OnboardingTag")); +const EditInterestPage = lazy(() => import("@/pages/mypage/EditInterestPage")); +const MyIntersListPage = lazy(() => import("@/pages/mypage/MyInterstListPage")); const SettingPage = lazy(() => import("@/pages/mypage/SettingPage")); const AskPage = lazy(() => import("@/pages/mypage/AskPage")); const router = createBrowserRouter([ { element: , + errorElement: , children: [ { index: true, element: }, { @@ -37,6 +34,10 @@ const router = createBrowserRouter([ }, ], }, + { + path: "*", + element: , + }, { element: , diff --git a/src/app/styles/color.css b/src/app/styles/color.css index 3a65985..fad152b 100644 --- a/src/app/styles/color.css +++ b/src/app/styles/color.css @@ -38,6 +38,7 @@ --color-light-text-normal: #14181f; --color-light-text-disabled: #d4d8dd; --color-light-text-strong: #14181f; + --color-light-text-alert: #f33326; /* 라인 */ --color-light-strong: #000000; @@ -81,6 +82,7 @@ --color-dark-text-assistive: #4e5968; --color-dark-text-normal: #e0e4eb; --color-dark-text-disabled: #333; + --color-dark-text-alert: #e5484d; /* 라인 border */ --color-dark-normal: #1f2428; /**/ @@ -130,6 +132,7 @@ --color-text-alternative: var(--color-light-text-alternative); --color-text-assistive: var(--color-light-text-assistive); --color-text-disabled: var(--color-light-text-disabled); + --color-text-alert: var(--color-light-text-alert); --color-bgNormal: var(--color-light-bgNormal); --color-bgPrimary: var(--color-light-bgPrimary); diff --git a/src/app/styles/index.css b/src/app/styles/index.css index ea1ec3d..5b63af3 100644 --- a/src/app/styles/index.css +++ b/src/app/styles/index.css @@ -256,7 +256,7 @@ } .text-alert { - color: var(--color-alert); + color: var(--color-text-alert); } .text-caution { color: var(--color-caution); diff --git a/src/app/styles/theme/dark.css b/src/app/styles/theme/dark.css index 6d15c38..866a5fa 100644 --- a/src/app/styles/theme/dark.css +++ b/src/app/styles/theme/dark.css @@ -32,6 +32,7 @@ --color-text-alternative: var(--color-dark-text-alternative); --color-text-assistive: var(--color-dark-text-assistive); --color-text-disabled: var(--color-dark-text-disabled); + --color-text-alert: var(--color-dark-text-alert); --color-bgNormal: var(--color-dark-bgNormal); --color-bgPrimary: var(--color-dark-bgPrimary); diff --git a/src/app/ui/RouteErrorElement.tsx b/src/app/ui/RouteErrorElement.tsx new file mode 100644 index 0000000..0b1cb40 --- /dev/null +++ b/src/app/ui/RouteErrorElement.tsx @@ -0,0 +1,15 @@ +import { useRouteError } from "react-router-dom"; +import axios from "axios"; +import { NetworkErrorPage } from "@/pages/NetworkError"; +import { DefaultErrorFallback } from "@/shared/ui/ErrorBoundary"; + +const RouteErrorElement = () => { + const error = useRouteError(); + const isNetworkError = axios.isAxiosError(error) && !error.response; + + if (isNetworkError) return window.location.reload()} />; + + return {}} />; +}; + +export default RouteErrorElement; diff --git a/src/features/mypage/model/useInfiniteActivityPosts.ts b/src/features/mypage/model/useInfiniteActivityPosts.ts index 0388580..13e51a3 100644 --- a/src/features/mypage/model/useInfiniteActivityPosts.ts +++ b/src/features/mypage/model/useInfiniteActivityPosts.ts @@ -3,7 +3,6 @@ import { type ActivityPostType, } from "@/shared/api/activity"; import { QUERY_CACHE_TIME } from "@/shared/consts/cacheTimes"; -import { MYPAGE_QUERY_KEY } from "../consts/queryKeys"; import { SHARED_QUERY_KEY } from "@/shared/consts/queryKeys"; import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; @@ -11,7 +10,7 @@ export const useInfiniteActivityPosts = (type: ActivityPostType, size = 20) => { return useSuspenseInfiniteQuery({ queryKey: [ SHARED_QUERY_KEY.POSTS, - MYPAGE_QUERY_KEY.POSTS_ACTIVITY, + SHARED_QUERY_KEY.ACTIVITY, type, ] as const, @@ -32,7 +31,7 @@ export const useInfiniteActivityPosts = (type: ActivityPostType, size = 20) => { }, select: res => res.pages, - staleTime: QUERY_CACHE_TIME.POSTS.staleTime, + staleTime: 0, gcTime: QUERY_CACHE_TIME.POSTS.gcTime, }); }; diff --git a/src/features/onboarding/consts/onboardingFields.ts b/src/features/onboarding/consts/onboardingFields.ts deleted file mode 100644 index 5732b1a..0000000 --- a/src/features/onboarding/consts/onboardingFields.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const onboardingFields = [ - { - name: "nickname", - label: "닉네임", - placeholder: "닉네임을 입력해주세요", - }, - { - name: "email", - label: "이메일", - placeholder: "이메일을 입력해주세요", - }, - { - name: "intro", - label: "한 줄 소개", - placeholder: "당신을 한 줄로 소개해보세요.", - }, -]; diff --git a/src/main.tsx b/src/main.tsx index 59f1f17..d52e33d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,22 +2,45 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "@/app/styles/index.css"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import axios from "axios"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ThemeProvider } from "@/app/providers/ThemProvider.tsx"; import { HelmetProvider } from "react-helmet-async"; import App from "@/app/App"; +import router from "@/app/routes"; +import { initGlobalNavigate } from "@/shared/lib/globalNavigate"; -const queryClient = new QueryClient(); +initGlobalNavigate((path) => router.navigate(path)); -createRoot(document.getElementById("root")!).render( - - - - - - - - - - , -); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: (failureCount, error) => { + if (axios.isAxiosError(error) && !error.response) return false; + return failureCount < 3; + }, + }, + }, +}); + +async function prepare() { + if (import.meta.env.DEV) { + const { worker } = await import("@/mocks/browser"); + return worker.start({ onUnhandledRequest: "bypass" }); + } +} + +prepare().then(() => { + createRoot(document.getElementById("root")!).render( + + + + + + + + + + , + ); +}); diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts new file mode 100644 index 0000000..bcd82e4 --- /dev/null +++ b/src/mocks/browser.ts @@ -0,0 +1,4 @@ +import { setupWorker } from "msw/browser"; +import { handlers } from "./handlers"; + +export const worker = setupWorker(...handlers); diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts new file mode 100644 index 0000000..a658a01 --- /dev/null +++ b/src/mocks/handlers.ts @@ -0,0 +1,132 @@ +import { http, HttpResponse } from "msw"; +import { + AUTH_ERROR, + ACTIVITY_ERROR, + USER_ERROR, + POST_ERROR, + COMMON_ERROR, +} from "@/shared/consts/errorCodes"; + +const BASE = import.meta.env.VITE_SERVER_API_URL; +const url = (path: string) => `${BASE}${path}`; + +const err = (code: string, message: string, status: number) => + HttpResponse.json( + { isSuccess: false, code, message, data: null }, + { status }, + ); + +/** 정상 동작 (mock 없음 — 실제 서버로 패스) */ +export const scenarioNone: never[] = []; + +/** 인증 에러 시나리오 */ +export const scenarioAuth = { + /** 만료된 토큰 => 인터셉터의 refresh 로직 동작 확인 */ + expired: http.get(url("/api/v1/*"), () => + err(AUTH_ERROR.EXPIRED, "만료된 토큰입니다.", 401), + ), + + /** refresh 토큰 불일치 => 즉시 로그아웃 확인 */ + refreshMismatch: http.post(url("/api/v1/auth/refresh"), () => + err(AUTH_ERROR.REFRESH_MISMATCH, "리프레시 토큰이 일치하지 않습니다.", 401), + ), + + /** 탈퇴 회원 접근 => 즉시 로그아웃 확인 */ + withdrawn: http.get(url("/api/v1/*"), () => + err(AUTH_ERROR.WITHDRAWN, "탈퇴한 회원입니다.", 403), + ), + + /** 카카오 토큰 오류 */ + kakaoToken: http.get(url("/api/v1/*"), () => + err(AUTH_ERROR.KAKAO_TOKEN, "유효하지 않은 카카오 액세스 토큰입니다.", 401), + ), +}; + +/** 북마크 에러 시나리오 */ +export const scenarioBookmark = { + /** 북마크 추가 => 이미 북마크한 게시글 */ + alreadyBookmarked: http.post(url("/api/v1/activities/bookmarks"), () => + err(ACTIVITY_ERROR.ALREADY_BOOKMARKED, "이미 북마크한 게시글입니다.", 409), + ), + + /** 북마크 삭제 => 북마크를 찾을 수 없음 */ + bookmarkNotFound: http.delete(url("/api/v1/activities/bookmarks"), () => + err(ACTIVITY_ERROR.BOOKMARK_NOT_FOUND, "북마크를 찾을 수 없습니다.", 404), + ), +}; + +/** 게시글 에러 시나리오 */ +export const scenarioPost = { + /** 게시글 조회 => 찾을 수 없음 */ + notFound: http.get(url("/api/v2/posts/*"), () => + err(POST_ERROR.NOT_FOUND, "게시글을 찾을 수 없습니다.", 404), + ), +}; + +/** 유저 에러 시나리오 */ +export const scenarioUser = { + /** 관심사 수정 => 유효하지 않은 키워드 */ + invalidInterest: http.put(url("/api/v1/users/me/interests"), () => + err(USER_ERROR.INVALID_INTEREST, "유효하지 않은 관심사 키워드입니다.", 400), + ), + + /** 회원 탈퇴 => 이미 탈퇴한 회원 */ + alreadyWithdrawn: http.patch(url("/api/v1/users/me/withdrawal"), () => + err(USER_ERROR.ALREADY_WITHDRAWN, "이미 탈퇴한 회원입니다.", 400), + ), + + /** 사용자 프로필 => 찾을 수 없음 */ + notFound: http.get(url("/api/v1/users/me/profile"), () => + err(USER_ERROR.NOT_FOUND, "사용자를 찾을 수 없습니다.", 404), + ), +}; + +/** 공통 에러 시나리오 */ +export const scenarioCommon = { + /** 서버 에러 */ + internalServer: http.get(url("/api/*"), () => + err( + COMMON_ERROR.INTERNAL_SERVER, + "서버 에러, 관리자에게 문의 바랍니다.", + 500, + ), + ), + + /** 서비스 점검 */ + serviceUnavailable: http.get(url("/api/*"), () => + err( + COMMON_ERROR.SERVICE_UNAVAILABLE, + "서버가 일시적으로 사용중지 되었습니다.", + 503, + ), + ), +}; + +/** 네트워크 에러 시나리오 */ +export const scenarioNetwork = { + /** 연결 끊김 — axios에서 ERR_NETWORK로 잡힘 */ + offline: http.all(url("/api/*"), () => HttpResponse.error()), + + /** 특정 엔드포인트만 끊김 — 북마크 API만 네트워크 에러 */ + bookmarkOffline: http.post(url("/api/v1/activities/bookmarks"), () => + HttpResponse.error(), + ), +}; + +export const handlers = [ + // 인증 + // scenarioAuth.refreshMismatch, // 401: refresh 불일치 => 즉시 로그아웃 확인 + // scenarioAuth.withdrawn, // 403: 탈퇴 회원 => 즉시 로그아웃 확인 + // 북마크 + // scenarioBookmark.alreadyBookmarked, // 409: 중복 북마크 + // scenarioBookmark.bookmarkNotFound, // 404: 북마크 없음 + // 유저 + // scenarioUser.invalidInterest, // 400: 유효하지 않은 관심사 + // scenarioUser.alreadyWithdrawn, // 400: 이미 탈퇴한 회원 + // 공통 + // scenarioCommon.internalServer, // 500: 서버 에러 + // scenarioCommon.serviceUnavailable, // 503: 서비스 점검 + // 네트워크 + // scenarioNetwork.offline, // 연결 끊김 (ERR_NETWORK) + // scenarioNetwork.bookmarkOffline,// 북마크 API만 연결 끊김 +]; diff --git a/src/pages/NetworkError.tsx b/src/pages/NetworkError.tsx new file mode 100644 index 0000000..9945788 --- /dev/null +++ b/src/pages/NetworkError.tsx @@ -0,0 +1,27 @@ +import { Button } from "@/shared/ui/button/Button"; +import { WifiOff } from "lucide-react"; + +interface NetworkErrorPageProps { + onRetry?: () => void; +} + +export const NetworkErrorPage = ({ onRetry }: NetworkErrorPageProps) => { + const handleRetry = onRetry ?? (() => window.location.reload()); + + return ( +
+
+ +

네트워크에 접속할 수 없습니다.

+

네트워크 연결 상태를 확인해주세요

+ +
+
+ ); +}; diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx new file mode 100644 index 0000000..f3ea1cf --- /dev/null +++ b/src/pages/NotFound.tsx @@ -0,0 +1,36 @@ +import { Button } from "@/shared/ui/button/Button"; +import { useNavigate } from "react-router-dom"; +import { CircleAlert } from "lucide-react"; + +export const NotFoundPage = () => { + const navigate = useNavigate(); + return ( +
+
+ +

요청하신 페이지를 찾을 수 없습니다.

+

+ 요청하신 주소가 잘못되었거나, 사용이 일시 중단되어 요청하신 페이지를 + 찾을 수 없습니다. +
서비스 이용에 불편을 드려 죄송합니다. +

+
+ + +
+
+
+ ); +}; diff --git a/src/shared/api/api.ts b/src/shared/api/api.ts index 55a75db..4c48ce3 100644 --- a/src/shared/api/api.ts +++ b/src/shared/api/api.ts @@ -1,6 +1,7 @@ import axios, { type InternalAxiosRequestConfig } from "axios"; import useUserStore from "@/shared/model/useUserStore"; import { postRefreshToken } from "./auth"; +import { AUTH_ERROR } from "@/shared/consts/errorCodes"; // const TEMP_TOKEN = import.meta.env.VITE_APP_DEV_TOKEN; interface CustomInternalAxiosRequestConfig extends InternalAxiosRequestConfig { @@ -35,11 +36,23 @@ api.interceptors.response.use( async error => { const originalRequest = error.config as CustomInternalAxiosRequestConfig; + // 서버 응답 없음 => 네트워크 에러 + if (!error.response) { + return Promise.reject(error); + } + + const code = (error.response?.data as { code?: string })?.code; + + // 세션 무효화 또는 탈퇴 회원=> refresh 시도 없이 즉시 로그아웃 + if (code === AUTH_ERROR.REFRESH_MISMATCH || code === AUTH_ERROR.WITHDRAWN) { + useUserStore.getState().logout(); + return Promise.reject(error); + } + if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; try { - // 이미 refresh 중이면 if (!refreshPromise) { refreshPromise = postRefreshToken(); } @@ -54,7 +67,6 @@ api.interceptors.response.use( } catch (e) { refreshPromise = null; useUserStore.getState().logout(); - // window.location.href = "/login"; return Promise.reject(e); } } diff --git a/src/shared/consts/errorCodes.ts b/src/shared/consts/errorCodes.ts new file mode 100644 index 0000000..fc8d18b --- /dev/null +++ b/src/shared/consts/errorCodes.ts @@ -0,0 +1,61 @@ +// 공통 error + +export const COMMON_ERROR = { + BAD_REQUEST: "COMMON400", + VALIDATION: "COMMON400_VALIDATION", + TYPE: "COMMON400_TYPE", + FORMAT: "COMMON400_FORMAT", + PARAM: "COMMON400_PARAM", + UNAUTHORIZED: "COMMON401", + FORBIDDEN: "COMMON403", + NOT_FOUND: "COMMON404", + INTERNAL_SERVER: "COMMON500", + SERVICE_UNAVAILABLE: "COMMON503", +} as const; + +export const AUTH_ERROR = { + TYPE_MISMATCH: "AUTH400_TYPE_MISMATCH", + INVALID: "AUTH401_INVALID", + EXPIRED: "AUTH401_EXPIRED", + MALFORMED: "AUTH401_MALFORMED", + SIGNATURE: "AUTH401_SIGNATURE", + UNSUPPORTED: "AUTH401_UNSUPPORTED", + EMPTY_CLAIMS: "AUTH401_EMPTY_CLAIMS", + INVALID_REFRESH: "AUTH401_INVALID_REFRESH", + REFRESH_MISSING: "AUTH401_REFRESH_MISSING", + REFRESH_MISMATCH: "AUTH401_REFRESH_MISMATCH", + KAKAO_TOKEN: "AUTH401_KAKAO_TOKEN", + WITHDRAWN: "AUTH403_WITHDRAWN", + FORBIDDEN: "AUTH403_FORBIDDEN", + USER_NOT_FOUND: "AUTH404_USER", + KAKAO_API: "AUTH500_KAKAO_API", +} as const; + +export const POST_ERROR = { + NOT_FOUND: "POST404_1", +} as const; + +export const USER_ERROR = { + INVALID_INTEREST: "USER400_1", + ALREADY_WITHDRAWN: "USER400_2", + NOT_FOUND: "USER404_1", +} as const; + +export const ACTIVITY_ERROR = { + BOOKMARK_NOT_FOUND: "ACTIVITY404_1", + ALREADY_BOOKMARKED: "ACTIVITY409_1", +} as const; + +export type CommonErrorCode = (typeof COMMON_ERROR)[keyof typeof COMMON_ERROR]; +export type AuthErrorCode = (typeof AUTH_ERROR)[keyof typeof AUTH_ERROR]; +export type PostErrorCode = (typeof POST_ERROR)[keyof typeof POST_ERROR]; +export type UserErrorCode = (typeof USER_ERROR)[keyof typeof USER_ERROR]; +export type ActivityErrorCode = + (typeof ACTIVITY_ERROR)[keyof typeof ACTIVITY_ERROR]; + +export type ErrorCode = + | CommonErrorCode + | AuthErrorCode + | PostErrorCode + | UserErrorCode + | ActivityErrorCode; diff --git a/src/shared/consts/queryKeys.ts b/src/shared/consts/queryKeys.ts index 94030ad..8a30ee7 100644 --- a/src/shared/consts/queryKeys.ts +++ b/src/shared/consts/queryKeys.ts @@ -3,4 +3,5 @@ export const SHARED_QUERY_KEY = { MY: "my", MY_PROFILE: "profile", MY_INTEREST: "interest", + ACTIVITY: "activity", } as const; diff --git a/src/shared/lib/globalNavigate.ts b/src/shared/lib/globalNavigate.ts new file mode 100644 index 0000000..d657c19 --- /dev/null +++ b/src/shared/lib/globalNavigate.ts @@ -0,0 +1,11 @@ +type NavigateFn = (path: string) => void; + +let _navigate: NavigateFn = (path) => { + window.location.replace(path); +}; + +export const initGlobalNavigate = (navigate: NavigateFn) => { + _navigate = navigate; +}; + +export const globalNavigate = (path: string) => _navigate(path); diff --git a/src/shared/lib/updateBookmarkState.ts b/src/shared/lib/updateBookmarkState.ts index 40a7f38..17813e0 100644 --- a/src/shared/lib/updateBookmarkState.ts +++ b/src/shared/lib/updateBookmarkState.ts @@ -1,79 +1,83 @@ +type AnyRecord = Record; + export const updateBookmarkState = ( - old: any, + old: unknown, postId: number, isBookmarked: boolean, -) => { +): unknown => { if (!old) return old; + const data = old as AnyRecord; - if (old.pages && Array.isArray(old.pages)) { - return { - ...old, - pages: old.pages.map((page: any) => { - const dataKey = page.data?.posts - ? "posts" - : page.data?.readPosts - ? "readPosts" - : page.data?.bookmarks - ? "bookmarks" - : null; - - if (!dataKey || !page.data[dataKey]) return page; + if (Array.isArray((data as AnyRecord).pages)) { + const pages = (data.pages as AnyRecord[]).map((page: AnyRecord) => { + const pageData = page.data as AnyRecord; + const dataKey = pageData?.posts + ? "posts" + : pageData?.readPosts + ? "readPosts" + : pageData?.bookmarks + ? "bookmarks" + : null; - if (dataKey === "bookmarks" && !isBookmarked) { - return { - ...page, - data: { - ...page.data, - [dataKey]: page.data[dataKey].filter( - (post: any) => post.id !== postId && post.postId !== postId, - ), - }, - }; - } + if (!dataKey || !pageData[dataKey]) return page; + if (dataKey === "bookmarks" && !isBookmarked) { return { ...page, data: { - ...page.data, - [dataKey]: page.data[dataKey].map((post: any) => - post.id === postId || post.postId === postId - ? { ...post, isBookmarked } - : post, + ...pageData, + [dataKey]: (pageData[dataKey] as AnyRecord[]).filter( + (post: AnyRecord) => post.id !== postId && post.postId !== postId, ), }, }; - }), - }; + } + + return { + ...page, + data: { + ...pageData, + [dataKey]: (pageData[dataKey] as AnyRecord[]).map((post: AnyRecord) => + post.id === postId || post.postId === postId + ? { ...post, isBookmarked } + : post, + ), + }, + }; + }); + return { ...data, pages }; } - const isRec = old.recommendations || (old.data && old.data.recommendations); + const isRec = + data.recommendations || + ((data.data as AnyRecord)?.recommendations); if (isRec) { - const isRoot = !!old.recommendations; - const target = isRoot ? old : old.data; - + const isRoot = !!data.recommendations; + const target = (isRoot ? data : data.data) as AnyRecord; const updated = { ...target, - recommendations: target.recommendations.map((post: any) => - post.postId === postId || post.id === postId - ? { ...post, isBookmarked } - : post, + recommendations: (target.recommendations as AnyRecord[]).map( + (post: AnyRecord) => + post.postId === postId || post.id === postId + ? { ...post, isBookmarked } + : post, ), }; - return isRoot ? updated : { ...old, data: updated }; + return isRoot ? updated : { ...data, data: updated }; } if (Array.isArray(old)) { - return old.map((post: any) => + return (old as AnyRecord[]).map((post: AnyRecord) => post.id === postId || post.postId === postId ? { ...post, isBookmarked } : post, ); } - if (old.data && Array.isArray(old.data)) { + if (data.data && Array.isArray(data.data)) { return { - ...old, - data: old.data.map((post: any) => + ...data, + data: (data.data as AnyRecord[]).map((post: AnyRecord) => post.id === postId || post.postId === postId ? { ...post, isBookmarked } : post, diff --git a/src/shared/ui/CardItem.tsx b/src/shared/ui/CardItem.tsx index 03f44ad..6b237f8 100644 --- a/src/shared/ui/CardItem.tsx +++ b/src/shared/ui/CardItem.tsx @@ -1,6 +1,6 @@ import BookOn from "@/assets/icons/book-on.svg"; import BookOff from "@/assets/icons/book-off.svg"; -import Eye from "@/assets/icons/eye.svg"; +import { Eye } from "lucide-react"; import { forwardRef } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; @@ -128,7 +128,7 @@ export const CardItem = forwardRef(
- 본 횟수 +

{viewCount}

{publishedAt?.split("T")[0]}

diff --git a/src/shared/ui/ErrorBoundary.tsx b/src/shared/ui/ErrorBoundary.tsx index 5a76058..ff301e3 100644 --- a/src/shared/ui/ErrorBoundary.tsx +++ b/src/shared/ui/ErrorBoundary.tsx @@ -1,18 +1,38 @@ +import { NetworkErrorPage } from "@/pages/NetworkError"; +import { Button } from "@/shared/ui/button/Button"; +import axios from "axios"; import { ErrorBoundary as ReactErrorBoundary, type FallbackProps, } from "react-error-boundary"; +import { CircleAlert } from "lucide-react"; -export const DefaultErrorFallback = ({ error }: FallbackProps) => { +const isNetworkError = (error: unknown) => { + return axios.isAxiosError(error) && !error.response; +}; +export const DefaultErrorFallback = ({ + error, + resetErrorBoundary, +}: FallbackProps) => { const err = error as Error; + if (isNetworkError(err)) + return ; + return ( -
-

- 문제가 발생했습니다 -

-

- {err?.message || "알 수 없는 에러가 발생했습니다."} -

+
+
+ +

"서버에 문제가 발생했습니다."

+

잠시 후 다시 시도해주세요.

+ + +
); }; diff --git a/src/shared/ui/InputField.tsx b/src/shared/ui/InputField.tsx index eabb255..92116e2 100644 --- a/src/shared/ui/InputField.tsx +++ b/src/shared/ui/InputField.tsx @@ -35,8 +35,8 @@ export const InputField = ({ ) : (