From 4de9752588ba6a9d90e3f0cc54eb93898c2b3dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 16:55:37 +0900 Subject: [PATCH 01/43] =?UTF-8?q?chore:=20.env=20gitignore=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index febf6eed64..567bffaceb 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ dist-ssr *storybook.log storybook-static + +.env From 24e72278d8430d11d7c85430d943c35344ca1de6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 16:56:03 +0900 Subject: [PATCH 02/43] =?UTF-8?q?chore(pacakge):=20msw=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 605 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 8 +- 2 files changed, 611 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8af111235c..cef3f90abe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "eslint-plugin-storybook": "^10.3.5", "gh-pages": "^6.3.0", "globals": "^17.5.0", + "msw": "^2.14.6", "playwright": "^1.59.1", "prettier": "^3.8.3", "storybook": "^10.3.5", @@ -1150,6 +1151,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/@joshwooding/vite-plugin-react-docgen-typescript": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.7.0.tgz", @@ -1234,6 +1322,31 @@ "react": ">=16" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "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/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -1298,6 +1411,31 @@ "node": ">= 8" } }, + "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/@oxc-project/types": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", @@ -2066,6 +2204,23 @@ "dev": true, "license": "MIT" }, + "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.59.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", @@ -2639,7 +2794,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2942,6 +3096,64 @@ } } }, + "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/cliui/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/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", @@ -3177,6 +3389,13 @@ "dev": true, "license": "MIT" }, + "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/empathic": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", @@ -3570,6 +3789,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/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -3789,6 +4035,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/gh-pages": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/gh-pages/-/gh-pages-6.3.0.tgz", @@ -3884,6 +4140,16 @@ "dev": true, "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", @@ -3906,6 +4172,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", @@ -4032,6 +4316,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", @@ -4064,6 +4358,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4692,6 +4993,61 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msw": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", + "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", + "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", @@ -4773,6 +5129,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", @@ -4898,6 +5261,13 @@ "node": "20 || >=22" } }, + "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", @@ -5330,6 +5700,16 @@ "node": ">=8" } }, + "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": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -5360,6 +5740,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/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5501,6 +5888,19 @@ "dev": true, "license": "ISC" }, + "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/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -5552,6 +5952,16 @@ "dev": true, "license": "MIT" }, + "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/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -5622,6 +6032,41 @@ "node": ">=10" } }, + "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/string-width/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-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -5728,6 +6173,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -5789,6 +6247,26 @@ "node": ">=14.0.0" } }, + "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/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5812,6 +6290,19 @@ "node": ">=6" } }, + "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/trim-repeated": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", @@ -5893,6 +6384,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": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -5964,6 +6471,16 @@ "node": ">=18.12.0" } }, + "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", @@ -6261,6 +6778,53 @@ "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/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/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/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -6299,6 +6863,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -6324,6 +6898,35 @@ "url": "https://github.com/sponsors/eemeli" } }, + "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 d24c6c4eed..9cb86bb788 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "eslint-plugin-storybook": "^10.3.5", "gh-pages": "^6.3.0", "globals": "^17.5.0", + "msw": "^2.14.6", "playwright": "^1.59.1", "prettier": "^3.8.3", "storybook": "^10.3.5", @@ -47,5 +48,10 @@ "typescript-eslint": "^8.59.1", "vite": "^8.0.10", "vitest": "^4.1.5" + }, + "msw": { + "workerDirectory": [ + "public" + ] } -} +} \ No newline at end of file From e7c06d6bb640bfbdf833b097ebcc2ac8ad8d55e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 16:57:08 +0900 Subject: [PATCH 03/43] =?UTF-8?q?chore(naming):=20=EC=9D=B8=ED=92=8B=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=ED=83=80=EC=9E=85=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Common/Form/InputFieldForm.tsx | 2 +- src/components/Select/CardSelect.tsx | 2 +- src/constants/inputField.ts | 2 +- src/types/{index.ts => field.ts} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename src/types/{index.ts => field.ts} (100%) diff --git a/src/components/Common/Form/InputFieldForm.tsx b/src/components/Common/Form/InputFieldForm.tsx index 8d73c19842..fc1864308f 100644 --- a/src/components/Common/Form/InputFieldForm.tsx +++ b/src/components/Common/Form/InputFieldForm.tsx @@ -2,7 +2,7 @@ import styled from '@emotion/styled'; import { ChangeEvent, KeyboardEvent, useRef, useState } from 'react'; import Label from '../Label/Label'; import ErrorMessage from '../ErrorMessage/ErrorMessage'; -import { InputFieldConfig } from '../../../types'; +import { InputFieldConfig } from '../../../types/field'; import InputField from '../InputField/InputField'; import { validateNaN } from '../../../utils/validate'; diff --git a/src/components/Select/CardSelect.tsx b/src/components/Select/CardSelect.tsx index be2d36b1cf..f50b2effa1 100644 --- a/src/components/Select/CardSelect.tsx +++ b/src/components/Select/CardSelect.tsx @@ -1,7 +1,7 @@ import { ChangeEvent } from 'react'; import styled from '@emotion/styled'; import { CARD_ISSUER_CONFIG } from '../../constants'; -import { SelectFieldConfig } from '../../types'; +import { SelectFieldConfig } from '../../types/field'; import { CardIssuerType } from '../Form/PaymentForm'; interface Props { diff --git a/src/constants/inputField.ts b/src/constants/inputField.ts index d9f22b6b0e..2d1b4235f8 100644 --- a/src/constants/inputField.ts +++ b/src/constants/inputField.ts @@ -1,4 +1,4 @@ -import { InputFieldConfig } from '../types'; +import { InputFieldConfig } from '../types/field'; export const SELECT_FIELD_CONFIG = { CARD_ISSUER: { diff --git a/src/types/index.ts b/src/types/field.ts similarity index 100% rename from src/types/index.ts rename to src/types/field.ts From d772f93470b4b0a8dfebefd4a95bbe479184cbcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 16:58:22 +0900 Subject: [PATCH 04/43] =?UTF-8?q?feat(type):=20Card=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=ED=83=80=EC=9E=85=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/card.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/types/card.ts diff --git a/src/types/card.ts b/src/types/card.ts new file mode 100644 index 0000000000..f09fc8c539 --- /dev/null +++ b/src/types/card.ts @@ -0,0 +1,9 @@ +type Card = { + id: string; + number: string; + expirationDate: string; + cvc: string; + issuerCode: string; +}; + +export type { Card }; From 4e1af8f95fa1ca2a12702b3553f2fa1674791182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 16:58:47 +0900 Subject: [PATCH 05/43] =?UTF-8?q?feat(api):=20fetch=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20HTTP=20=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/api.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/apis/api.ts diff --git a/src/apis/api.ts b/src/apis/api.ts new file mode 100644 index 0000000000..89f49f88e3 --- /dev/null +++ b/src/apis/api.ts @@ -0,0 +1,29 @@ +const BASE_URL = import.meta.env.VITE_BASE_URL; + +type RequestOptions = Omit; + +const request = async (path: string, init?: RequestInit): Promise => { + const res = await fetch(`${BASE_URL}${path}`, init); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const text = await res.text(); + return (text ? JSON.parse(text) : undefined) as TResponse; +}; + +export const http = { + get: (path: string, options?: RequestOptions) => + request(path, { ...options, method: 'GET' }), + + post: (path: string, body: TBody, options?: RequestOptions) => + request(path, { + ...options, + method: 'POST', + body: JSON.stringify(body), + }), + + delete: (path: string, options?: RequestOptions) => + request(path, { + ...options, + method: 'DELETE', + }), +}; From 2f964c059cb7cf67cc21074a752a57bb1bb6193b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 17:16:12 +0900 Subject: [PATCH 06/43] =?UTF-8?q?feat(api):=20=EC=9D=B8=EC=8A=A4=ED=84=B4?= =?UTF-8?q?=EC=8A=A4=20=EA=B8=B0=EB=B0=98=20api=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/cards.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/apis/cards.ts diff --git a/src/apis/cards.ts b/src/apis/cards.ts new file mode 100644 index 0000000000..738954b039 --- /dev/null +++ b/src/apis/cards.ts @@ -0,0 +1,19 @@ +import { Card } from '../types/card'; +import { http } from './api'; + +export type RegisterCardRequest = Omit; +export type RegisterCardResponse = Pick; + +export const registerCard = async (data: RegisterCardRequest): Promise => { + return await http.post(`/cards`, data); +}; + +export type CardListResponse = Omit[]; + +export const getCardList = async (): Promise => { + return await http.get(`/cards`); +}; + +export const deleteCard = async (id: string): Promise => { + return await http.delete(`/cards/${id}`); +}; From ef60971888e98896018bb7fb05b6acf0897a5082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 17:17:06 +0900 Subject: [PATCH 07/43] =?UTF-8?q?chore(msw):=20API=20msw=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/mockServiceWorker.js | 349 ++++++++++++++++++++++++++++++++++++ src/main.tsx | 22 ++- src/mocks/browser.ts | 4 + src/mocks/db.ts | 25 +++ src/mocks/handlers.ts | 29 +++ 5 files changed, 424 insertions(+), 5 deletions(-) create mode 100644 public/mockServiceWorker.js create mode 100644 src/mocks/browser.ts create mode 100644 src/mocks/db.ts create mode 100644 src/mocks/handlers.ts diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000000..33dde9e770 --- /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.6' +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/main.tsx b/src/main.tsx index 8830ab76f0..77381504b1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,8 +4,20 @@ import { RouterProvider } from 'react-router-dom'; import './reset.css'; import router from './routes/routes.tsx'; -createRoot(document.getElementById('root')!).render( - - - -); +async function enableMocking() { + if (import.meta.env.MODE !== 'development') return; + const { worker } = await import('../src/mocks/browser.ts'); + return worker.start({ + serviceWorker: { + url: `${import.meta.env.BASE_URL}mockServiceWorker.js`, + }, + }); +} + +enableMocking().then(() => { + createRoot(document.getElementById('root')!).render( + + + + ); +}); diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts new file mode 100644 index 0000000000..0a56427880 --- /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/db.ts b/src/mocks/db.ts new file mode 100644 index 0000000000..7bbfe980cc --- /dev/null +++ b/src/mocks/db.ts @@ -0,0 +1,25 @@ +import { Card } from '../types/card'; + +let cards: Card[] = [ + { + id: '550e8400-e29b-41d4-a716-446655440000', + issuerCode: '31', + number: '551112******9012', + expirationDate: '12/28', + cvc: '123', + }, +]; + +export const cardDB = { + list: (): Card[] => cards, + + add: (card: Omit): Card => { + const newCard: Card = { id: crypto.randomUUID(), ...card }; + cards = [...cards, newCard]; + return newCard; + }, + + remove: (id: string): void => { + cards = cards.filter((card) => card.id !== id); + }, +}; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts new file mode 100644 index 0000000000..895b65d19f --- /dev/null +++ b/src/mocks/handlers.ts @@ -0,0 +1,29 @@ +import { http, HttpResponse } from 'msw'; +import { CardListResponse, RegisterCardRequest, RegisterCardResponse } from '../apis/cards'; +import { cardDB } from './db'; + +export const handlers = [ + http.get('*/cards', () => { + const list: CardListResponse = cardDB + .list() + .map(({ id, issuerCode, number, expirationDate }) => ({ + id, + issuerCode, + number, + expirationDate, + })); + return HttpResponse.json(list, { status: 200 }); + }), + + http.post('*/cards', async ({ request }) => { + const body = (await request.json()) as RegisterCardRequest; + const created = cardDB.add(body); + const response: RegisterCardResponse = { id: created.id }; + return HttpResponse.json(response, { status: 201 }); + }), + + http.delete<{ id: string }>('*/cards/:id', ({ params }) => { + cardDB.remove(params.id); + return new HttpResponse(null, { status: 204 }); + }), +]; From 13d6a764abdf18e516dbec575aca538f34bb733f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 17:33:25 +0900 Subject: [PATCH 08/43] =?UTF-8?q?style(layout):=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 ServiceLayout -> FormLayout 네이밍 변경 --- src/App.tsx | 4 ++-- src/components/Layout/CardListLayout.tsx | 21 +++++++++++++++++++ .../{ServiceLayout.tsx => FormLayout.tsx} | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 src/components/Layout/CardListLayout.tsx rename src/components/Layout/{ServiceLayout.tsx => FormLayout.tsx} (89%) diff --git a/src/App.tsx b/src/App.tsx index 4de4b44d93..e09ac53e03 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,10 @@ import styled from '@emotion/styled'; -import ServiceLayout from './components/Layout/ServiceLayout'; +import { Outlet } from 'react-router-dom'; function App() { return ( - + ); } diff --git a/src/components/Layout/CardListLayout.tsx b/src/components/Layout/CardListLayout.tsx new file mode 100644 index 0000000000..bc296e87bc --- /dev/null +++ b/src/components/Layout/CardListLayout.tsx @@ -0,0 +1,21 @@ +import styled from '@emotion/styled'; +import { Outlet } from 'react-router-dom'; + +export default function CardListLayout() { + return ( + + + + ); +} + +const Layout = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 376px; + height: 100%; + padding: 40px 28px; + border: 1px solid #dddcdc; + overflow: auto; +`; diff --git a/src/components/Layout/ServiceLayout.tsx b/src/components/Layout/FormLayout.tsx similarity index 89% rename from src/components/Layout/ServiceLayout.tsx rename to src/components/Layout/FormLayout.tsx index 167b3c5ca0..c2e4b3e557 100644 --- a/src/components/Layout/ServiceLayout.tsx +++ b/src/components/Layout/FormLayout.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; import { Outlet } from 'react-router-dom'; -export default function ServiceLayout() { +export default function FormLayout() { return ( From 6d801c4f5e3c82e93c6610e528fc65f14b0d1273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 17:33:54 +0900 Subject: [PATCH 09/43] =?UTF-8?q?chore(route):=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/CardListPage.tsx | 7 +++++++ src/routes/routes.tsx | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 src/pages/CardListPage.tsx diff --git a/src/pages/CardListPage.tsx b/src/pages/CardListPage.tsx new file mode 100644 index 0000000000..5843bf854b --- /dev/null +++ b/src/pages/CardListPage.tsx @@ -0,0 +1,7 @@ +import styled from '@emotion/styled'; + +export default function CardListPage() { + return CardListPage; +} + +const Container = styled.div``; diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index e7d779c9bc..41394362a7 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -2,6 +2,9 @@ import { createBrowserRouter, Navigate } from 'react-router-dom'; import CardRegistrationPage from '../pages/CardRegistrationPage'; import App from '../App'; import RegistrationCompletionPage from '../pages/RegistrationCompletionPage'; +import CardListPage from '../pages/CardListPage'; +import FormLayout from '../components/Layout/FormLayout'; +import CardListLayout from '../components/Layout/CardListLayout'; const router = createBrowserRouter( [ @@ -10,8 +13,17 @@ const router = createBrowserRouter( element: , children: [ { index: true, element: }, - { path: 'registration', element: }, - { path: 'registration/completion', element: }, + { + element: , + children: [ + { path: 'registration', element: }, + { path: 'registration/completion', element: }, + ], + }, + { + element: , + children: [{ path: 'list', element: }], + }, ], }, ], From 5a607043d3515aaef49ec4c645df5f8c986a8371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 20:01:59 +0900 Subject: [PATCH 10/43] =?UTF-8?q?style:=20li=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/reset.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/reset.css b/src/reset.css index 01bf4c42a5..f443228ea6 100644 --- a/src/reset.css +++ b/src/reset.css @@ -45,7 +45,8 @@ h6 { } ol, -ul { +ul, +li { list-style: none; } From 0eccdff04365e6e7968683a64def85410de0da22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 20:02:35 +0900 Subject: [PATCH 11/43] =?UTF-8?q?chore:=20CardPreview=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/{ => Card}/CardPreview/CardNumbers.tsx | 0 src/components/{ => Card}/CardPreview/CardPreview.stories.tsx | 0 src/components/{ => Card}/CardPreview/CardPreview.tsx | 4 ++-- src/components/Form/PaymentForm.tsx | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename src/components/{ => Card}/CardPreview/CardNumbers.tsx (100%) rename src/components/{ => Card}/CardPreview/CardPreview.stories.tsx (100%) rename src/components/{ => Card}/CardPreview/CardPreview.tsx (95%) diff --git a/src/components/CardPreview/CardNumbers.tsx b/src/components/Card/CardPreview/CardNumbers.tsx similarity index 100% rename from src/components/CardPreview/CardNumbers.tsx rename to src/components/Card/CardPreview/CardNumbers.tsx diff --git a/src/components/CardPreview/CardPreview.stories.tsx b/src/components/Card/CardPreview/CardPreview.stories.tsx similarity index 100% rename from src/components/CardPreview/CardPreview.stories.tsx rename to src/components/Card/CardPreview/CardPreview.stories.tsx diff --git a/src/components/CardPreview/CardPreview.tsx b/src/components/Card/CardPreview/CardPreview.tsx similarity index 95% rename from src/components/CardPreview/CardPreview.tsx rename to src/components/Card/CardPreview/CardPreview.tsx index 216b6caabb..690a43dfae 100644 --- a/src/components/CardPreview/CardPreview.tsx +++ b/src/components/Card/CardPreview/CardPreview.tsx @@ -1,7 +1,7 @@ import { styled } from 'storybook/theming'; import CardNumbers from './CardNumbers'; -import { CardNumbersType } from '../Form/PaymentForm'; -import { BRAND_ICON_MAP, CARD_BRAND } from '../../constants'; +import { BRAND_ICON_MAP, CARD_BRAND } from '../../../constants'; +import { CardNumbersType } from '../../Form/PaymentForm'; export type CardBrand = (typeof CARD_BRAND)[keyof typeof CARD_BRAND]; diff --git a/src/components/Form/PaymentForm.tsx b/src/components/Form/PaymentForm.tsx index f6eb56560f..78cdf46fb5 100644 --- a/src/components/Form/PaymentForm.tsx +++ b/src/components/Form/PaymentForm.tsx @@ -1,6 +1,5 @@ import { ChangeEvent, SubmitEvent, useState } from 'react'; import styled from '@emotion/styled'; -import CardPreview from '../CardPreview/CardPreview'; import InputFieldLayout from '../Layout/InputFieldLayout'; import { cardNumbersValidator, @@ -21,6 +20,7 @@ import { detectCardBrand, getCardIssuerBackgroundColor } from '../../utils/cards import { getCardNumbersMaxLength } from '../../utils/fields'; import Button from '../Common/Button/Button'; import { useNavigate } from 'react-router-dom'; +import CardPreview from '../Card/CardPreview/CardPreview'; export type Step = 1 | 2 | 3 | 4 | 5 | 6; export type CardNumbersType = [string, string, string, string]; From dd9ebbdcb496e4c451d62cd95d4111e1a0c47499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 20:03:14 +0900 Subject: [PATCH 12/43] =?UTF-8?q?feat(card):=20=EB=93=B1=EB=A1=9D=EB=90=9C?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/cards.ts | 3 +- src/assets/delete.svg | 3 + .../Card/RegisteredCard/RegisteredCard.tsx | 80 +++++++++++++++++++ src/constants/cards.ts | 12 ++- 4 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 src/assets/delete.svg create mode 100644 src/components/Card/RegisteredCard/RegisteredCard.tsx diff --git a/src/apis/cards.ts b/src/apis/cards.ts index 738954b039..46da3fe037 100644 --- a/src/apis/cards.ts +++ b/src/apis/cards.ts @@ -8,7 +8,8 @@ export const registerCard = async (data: RegisterCardRequest): Promise(`/cards`, data); }; -export type CardListResponse = Omit[]; +export type CardItemResponse = Omit; +export type CardListResponse = CardItemResponse[]; export const getCardList = async (): Promise => { return await http.get(`/cards`); diff --git a/src/assets/delete.svg b/src/assets/delete.svg new file mode 100644 index 0000000000..54f84ea242 --- /dev/null +++ b/src/assets/delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/Card/RegisteredCard/RegisteredCard.tsx b/src/components/Card/RegisteredCard/RegisteredCard.tsx new file mode 100644 index 0000000000..669b35cacd --- /dev/null +++ b/src/components/Card/RegisteredCard/RegisteredCard.tsx @@ -0,0 +1,80 @@ +import styled from '@emotion/styled'; +import { CARD_ISSUER_CONFIG } from '../../../constants'; +import { CardItemResponse } from '../../../apis/cards'; +import deleteButton from '../../../assets/delete.svg'; + +interface Props { + data: CardItemResponse; + onDelete: (id: string) => void; +} + +export default function RegisteredCard({ data, onDelete }: Props) { + const issuer = Object.values(CARD_ISSUER_CONFIG).filter( + (issuer) => issuer.issuerCode === data.issuerCode + )[0]; + + return ( + + + + + {issuer.name} + {data.number} + {data.expirationDate} + + + onDelete(data.id)}> + delete-button + + + ); +} + +const Wrapper = styled.li` + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + width: 320px; + height: 73px; + padding: 12px; + border: 1px solid #e6e6e6; + border-radius: 5px; +`; + +const Issuer = styled.div<{ $color: string }>` + width: 64px; + height: 40px; + border-radius: 4px; + background-color: ${({ $color }) => $color}; +`; + +const Content = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + width: 178px; +`; + +const Name = styled.strong` + font-size: 14px; + font-weight: 700; + color: #353c49; +`; + +const CardNumber = styled.span` + font-size: 11px; + font-weight: 400; + color: #8c8c8c; +`; + +const ExpirationDate = styled.span` + font-size: 9.5px; + font-weight: 400; + color: #8c8c8c; +`; + +const DeleteButton = styled.button` + width: 30px; + height: 30px; +`; diff --git a/src/constants/cards.ts b/src/constants/cards.ts index 7882789eac..bf6188f5c5 100644 --- a/src/constants/cards.ts +++ b/src/constants/cards.ts @@ -1,34 +1,42 @@ export const CARD_ISSUER_CONFIG = { BC: { name: 'BC카드', + issuerCode: '31', color: '#F04651', }, SHINHAN: { name: '신한카드', + issuerCode: '41', color: '#0046FF', }, - KAKAO: { + KAKAOBANK: { name: '카카오뱅크', + issuerCode: '15', color: '#FFE600', }, HYUNDAI: { name: '현대카드', + issuerCode: '61', color: '#000000', }, WOORI: { name: '우리카드', + issuerCode: 'W1', color: '#007BC8', }, LOTTE: { name: '롯데카드', + issuerCode: '71', color: '#ED1C24', }, HANA: { name: '하나카드', + issuerCode: '21', color: '#009490', }, - KB: { + KOOKMIN: { name: '국민카드', + issuerCode: '11', color: '#6A6056', }, } as const; From 1b7e2a25df20e338b7fdafd7798417121ba5711f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 20:40:14 +0900 Subject: [PATCH 13/43] =?UTF-8?q?feat(card):=20=EB=93=B1=EB=A1=9D=EB=90=9C?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RegisteredCardItem.tsx} | 2 +- .../Card/CardList/RegisteredCardList.tsx | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) rename src/components/Card/{RegisteredCard/RegisteredCard.tsx => CardList/RegisteredCardItem.tsx} (95%) create mode 100644 src/components/Card/CardList/RegisteredCardList.tsx diff --git a/src/components/Card/RegisteredCard/RegisteredCard.tsx b/src/components/Card/CardList/RegisteredCardItem.tsx similarity index 95% rename from src/components/Card/RegisteredCard/RegisteredCard.tsx rename to src/components/Card/CardList/RegisteredCardItem.tsx index 669b35cacd..3da063ebd1 100644 --- a/src/components/Card/RegisteredCard/RegisteredCard.tsx +++ b/src/components/Card/CardList/RegisteredCardItem.tsx @@ -8,7 +8,7 @@ interface Props { onDelete: (id: string) => void; } -export default function RegisteredCard({ data, onDelete }: Props) { +export default function RegisteredCardItem({ data, onDelete }: Props) { const issuer = Object.values(CARD_ISSUER_CONFIG).filter( (issuer) => issuer.issuerCode === data.issuerCode )[0]; diff --git a/src/components/Card/CardList/RegisteredCardList.tsx b/src/components/Card/CardList/RegisteredCardList.tsx new file mode 100644 index 0000000000..c344a4d929 --- /dev/null +++ b/src/components/Card/CardList/RegisteredCardList.tsx @@ -0,0 +1,38 @@ +import styled from '@emotion/styled'; +import RegisteredCardItem from './RegisteredCardItem'; +import { CardListResponse } from '../../../apis/cards'; + +interface Props { + data: CardListResponse; + onDelete: (id: string) => void; + onClick: () => void; +} + +export default function RegisteredCardList({ data, onDelete, onClick }: Props) { + return ( + + {data.map((card) => ( + + ))} + + + 카드 추가 + + ); +} + +const Container = styled.ul` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const RegisterButton = styled.button` + width: 320px; + height: 44px; + border: 1px dashed #e6e6e6; + border-radius: 5px; + background-color: #fff; + font-size: 13px; + font-weight: 500; + color: #8c8c8c; +`; From ebed62f863518eb7b62d3e1b15ff220bca0ed2b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 20:41:36 +0900 Subject: [PATCH 14/43] =?UTF-8?q?feat(api):=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Card/CardList/EmptyCardList.tsx | 52 +++++++++++++++++++ src/pages/CardListPage.tsx | 48 ++++++++++++++++- src/routes/routes.tsx | 7 ++- 3 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 src/components/Card/CardList/EmptyCardList.tsx diff --git a/src/components/Card/CardList/EmptyCardList.tsx b/src/components/Card/CardList/EmptyCardList.tsx new file mode 100644 index 0000000000..1d23c29123 --- /dev/null +++ b/src/components/Card/CardList/EmptyCardList.tsx @@ -0,0 +1,52 @@ +import styled from '@emotion/styled'; + +export default function EmptyCardList({ onClick }: { onClick: () => void }) { + return ( + + + 등록된 카드가 없습니다 + 아래 버튼을 눌러 첫 카드를 등록해보세요 + 카드 추가하기 + + ); +} + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + width: 320px; + height: 330px; + padding-top: 100px; +`; + +const Card = styled.div` + width: 160px; + height: 100px; + border: 1px dashed #d9d9d9; + border-radius: 5px; + background-color: #f5f5f5; +`; + +const Title = styled.strong` + font-size: 20px; + font-weight: 700; + color: #353c49; +`; + +const HintText = styled.strong` + font-size: 12px; + font-weight: 400; + color: #8c8c8c; +`; + +const RegisterButton = styled.button` + width: 320px; + height: 44px; + border-radius: 5px; + background-color: #333; + font-size: 15px; + font-weight: 700; + color: #fff; +`; diff --git a/src/pages/CardListPage.tsx b/src/pages/CardListPage.tsx index 5843bf854b..e9f79d6397 100644 --- a/src/pages/CardListPage.tsx +++ b/src/pages/CardListPage.tsx @@ -1,7 +1,51 @@ import styled from '@emotion/styled'; +import { useEffect, useState } from 'react'; +import { CardListResponse, getCardList } from '../apis/cards'; +import RegisteredCardList from '../components/Card/CardList/RegisteredCardList'; +import { useNavigate } from 'react-router-dom'; +import EmptyCardList from '../components/Card/CardList/EmptyCardList'; export default function CardListPage() { - return CardListPage; + const navigate = useNavigate(); + const [data, setData] = useState([]); + + const fetchData = async () => { + const res = await getCardList(); + setData(res); + }; + + useEffect(() => { + fetchData(); + }, []); + + const handleDelete = (id: string) => { + console.log('id', id); + }; + + return ( + + 보유 카드 {data.length > 0 && `(${data.length})`} + + {data.length === 0 && navigate('/registration/completion')} />} + {data.length > 0 && ( + navigate('/registration/completion')} + /> + )} + + ); } -const Container = styled.div``; +const Container = styled.div` + width: 100%; +`; + +const Title = styled.h1` + width: 100%; + margin-bottom: 16px; + font-size: 18px; + font-wieght: 700; + color: #353c49; +`; diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index 41394362a7..c0e471f486 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -22,7 +22,12 @@ const router = createBrowserRouter( }, { element: , - children: [{ path: 'list', element: }], + children: [ + { + path: 'list', + element: , + }, + ], }, ], }, From 48ce60c0ad74cb1336baa03324d4fae54abe4aff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 18 May 2026 21:02:28 +0900 Subject: [PATCH 15/43] =?UTF-8?q?feat(card):=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EB=A7=88=EC=8A=A4=ED=82=B9=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Card/CardList/RegisteredCardItem.tsx | 5 +++-- src/utils/cards.ts | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/Card/CardList/RegisteredCardItem.tsx b/src/components/Card/CardList/RegisteredCardItem.tsx index 3da063ebd1..b3c942aafc 100644 --- a/src/components/Card/CardList/RegisteredCardItem.tsx +++ b/src/components/Card/CardList/RegisteredCardItem.tsx @@ -2,6 +2,7 @@ import styled from '@emotion/styled'; import { CARD_ISSUER_CONFIG } from '../../../constants'; import { CardItemResponse } from '../../../apis/cards'; import deleteButton from '../../../assets/delete.svg'; +import { formatCardNumber } from '../../../utils/cards'; interface Props { data: CardItemResponse; @@ -19,8 +20,8 @@ export default function RegisteredCardItem({ data, onDelete }: Props) { {issuer.name} - {data.number} - {data.expirationDate} + {formatCardNumber(data.number)} + 유효기간 {data.expirationDate} onDelete(data.id)}> diff --git a/src/utils/cards.ts b/src/utils/cards.ts index f52e321ba4..b9114909dd 100644 --- a/src/utils/cards.ts +++ b/src/utils/cards.ts @@ -15,3 +15,9 @@ export const detectCardBrand = (cardNumbers: CardNumbersType) => { CARD_BRAND['LOCAL'] ); }; + +export const formatCardNumber = (number: string): string => { + const first = number.slice(0, 4); + const last = number.slice(-4); + return `${first} **** **** ${last}`; +}; From 24f91464ffc2468df8da7b539b550501457429ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 00:53:43 +0900 Subject: [PATCH 16/43] =?UTF-8?q?feat:=20fallback=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{EmptyCardList.tsx => FabllbackView.tsx} | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) rename src/components/Card/CardList/{EmptyCardList.tsx => FabllbackView.tsx} (51%) diff --git a/src/components/Card/CardList/EmptyCardList.tsx b/src/components/Card/CardList/FabllbackView.tsx similarity index 51% rename from src/components/Card/CardList/EmptyCardList.tsx rename to src/components/Card/CardList/FabllbackView.tsx index 1d23c29123..4de2b997ce 100644 --- a/src/components/Card/CardList/EmptyCardList.tsx +++ b/src/components/Card/CardList/FabllbackView.tsx @@ -1,12 +1,23 @@ import styled from '@emotion/styled'; +import { ReactNode } from 'react'; -export default function EmptyCardList({ onClick }: { onClick: () => void }) { +interface Props { + icon: ReactNode; + title: string; + description: string; + action: { + label: string; + onClick: () => void; + }; +} + +export default function FallbackView({ icon, title, description, action }: Props) { return ( - - 등록된 카드가 없습니다 - 아래 버튼을 눌러 첫 카드를 등록해보세요 - 카드 추가하기 + {icon} + {title} + {description} + {action.label} ); } @@ -21,12 +32,10 @@ const Container = styled.div` padding-top: 100px; `; -const Card = styled.div` - width: 160px; - height: 100px; - border: 1px dashed #d9d9d9; - border-radius: 5px; - background-color: #f5f5f5; +const IconWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; `; const Title = styled.strong` @@ -43,7 +52,7 @@ const HintText = styled.strong` const RegisterButton = styled.button` width: 320px; - height: 44px; + min-height: 44px; border-radius: 5px; background-color: #333; font-size: 15px; From 944372831b4c6c4eb4ee322e2096897eb57c3544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 00:54:08 +0900 Subject: [PATCH 17/43] =?UTF-8?q?feat(skeleton):=20=EC=8A=A4=EC=BC=88?= =?UTF-8?q?=EB=A0=88=ED=86=A4=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Card/CardList/SkeletonCardList.tsx | 48 +++++++++++++++++++ src/components/Common/Skeleton/Skeleton.tsx | 35 ++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/components/Card/CardList/SkeletonCardList.tsx create mode 100644 src/components/Common/Skeleton/Skeleton.tsx diff --git a/src/components/Card/CardList/SkeletonCardList.tsx b/src/components/Card/CardList/SkeletonCardList.tsx new file mode 100644 index 0000000000..3c7483cb9a --- /dev/null +++ b/src/components/Card/CardList/SkeletonCardList.tsx @@ -0,0 +1,48 @@ +import styled from '@emotion/styled'; +import Skeleton from '../../Common/Skeleton/Skeleton'; + +export default function SkeletonCardList() { + return ( + + {Array.from({ length: 3 }).map((_, idx) => ( + + + + + + + + + + ))} + + + + ); +} + +const Container = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 16px; +`; + +const Wrapper = styled.li` + display: flex; + align-items: center; + gap: 12px; + width: 320px; + height: 69px; + padding: 12px; + border: 1px solid #e6e6e6; + border-radius: 5px; +`; + +const Content = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + width: 178px; +`; diff --git a/src/components/Common/Skeleton/Skeleton.tsx b/src/components/Common/Skeleton/Skeleton.tsx new file mode 100644 index 0000000000..307e3280f3 --- /dev/null +++ b/src/components/Common/Skeleton/Skeleton.tsx @@ -0,0 +1,35 @@ +import styled from '@emotion/styled'; +import { keyframes } from '@emotion/react'; + +interface Props { + width: string; + height: string; +} + +export default function Skeleton({ width, height }: Props) { + return ; +} + +const shimmer = keyframes` + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +`; + +const Wrapper = styled.div<{ $width: string; $height: string }>` + width: ${({ $width }) => ($width ? $width : '100%')}; + height: ${({ $height }) => ($height ? $height : '100%')}; + border-radius: 5px; + background: linear-gradient( + 90deg, + #f0f0f0 0%, + #f7f7f7 50%, + #f0f0f0 100% + ); + background-size: 200% 100%; + animation: ${shimmer} 1.5s ease-in-out infinite; +`; + From 3990400eae5bb1470d3efbed85a141bb11f5421d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 00:54:59 +0900 Subject: [PATCH 18/43] =?UTF-8?q?feat(api):=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=ED=9B=85=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useQuery.ts | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/hooks/useQuery.ts diff --git a/src/hooks/useQuery.ts b/src/hooks/useQuery.ts new file mode 100644 index 0000000000..6a2e0bfff2 --- /dev/null +++ b/src/hooks/useQuery.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; + +type Status = 'idle' | 'loading' | 'success' | 'error'; + +interface UseQueryOptions { + queryFn: () => Promise; +} + +export const useQuery = ({ queryFn }: UseQueryOptions) => { + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(null); + + const [data, setData] = useState(); + + useEffect(() => { + let cancelled = false; + + const fetchQuery = async () => { + setStatus('loading'); + try { + const res = await queryFn(); + if (cancelled) return; + + setData(res); + + setStatus('success'); + } catch (err) { + if (cancelled) return; + setError(err as Error); + + setStatus('error'); + } + }; + + fetchQuery(); + + return () => { + cancelled = true; + }; + }, []); + + return { + data, + error, + isIdle: status === 'idle', + isLoading: status === 'loading', + isSuccess: status === 'success', + isError: status === 'error', + }; +}; From f6354a7b4928451da2358e23f91a0f4fa3663d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 00:56:07 +0900 Subject: [PATCH 19/43] =?UTF-8?q?feat(api):=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20api=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/error.svg | 4 +++ src/pages/CardListPage.tsx | 52 +++++++++++++++++++++++++------------- 2 files changed, 38 insertions(+), 18 deletions(-) create mode 100644 src/assets/error.svg diff --git a/src/assets/error.svg b/src/assets/error.svg new file mode 100644 index 0000000000..37259fbe69 --- /dev/null +++ b/src/assets/error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/pages/CardListPage.tsx b/src/pages/CardListPage.tsx index e9f79d6397..849aa67401 100644 --- a/src/pages/CardListPage.tsx +++ b/src/pages/CardListPage.tsx @@ -1,39 +1,47 @@ import styled from '@emotion/styled'; -import { useEffect, useState } from 'react'; -import { CardListResponse, getCardList } from '../apis/cards'; +import { getCardList } from '../apis/cards'; import RegisteredCardList from '../components/Card/CardList/RegisteredCardList'; import { useNavigate } from 'react-router-dom'; -import EmptyCardList from '../components/Card/CardList/EmptyCardList'; +import { useQuery } from '../hooks/useQuery'; +import SkeletonCardList from '../components/Card/CardList/SkeletonCardList'; +import FabllbackView from '../components/Card/CardList/FabllbackView'; +import errorIcon from '../assets/error.svg'; export default function CardListPage() { const navigate = useNavigate(); - const [data, setData] = useState([]); - const fetchData = async () => { - const res = await getCardList(); - setData(res); - }; + const { data, isLoading, isSuccess, isError } = useQuery({ queryFn: getCardList }); - useEffect(() => { - fetchData(); - }, []); - - const handleDelete = (id: string) => { - console.log('id', id); - }; + const handleDelete = (id: string) => {}; return ( - 보유 카드 {data.length > 0 && `(${data.length})`} + 보유 카드 {isSuccess && data && data.length > 0 && `(${data.length})`} - {data.length === 0 && navigate('/registration/completion')} />} - {data.length > 0 && ( + {isLoading && } + {isSuccess && data && data.length === 0 && ( + } + title="등록된 카드가 없습니다" + description="아래 버튼을 눌러 첫 카드를 등록해보세요" + action={{ label: '카드 추가하기', onClick: () => navigate('/registration/completion') }} + /> + )} + {isSuccess && data && data.length > 0 && ( navigate('/registration/completion')} /> )} + {isError && ( + } + title="카드 목록을 불러올 수 없어요" + description="잠시 후 다시 시도해 주세요." + action={{ label: '다시 시도', onClick: () => navigate(0) }} + /> + )} ); } @@ -49,3 +57,11 @@ const Title = styled.h1` font-wieght: 700; color: #353c49; `; + +const EmptyCard = styled.div` + width: 160px; + height: 100px; + border: 1px dashed #d9d9d9; + border-radius: 5px; + background-color: #f5f5f5; +`; From a99b1acdcaaa63fdc64c44f71cc5b7faa4bf92e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 00:56:54 +0900 Subject: [PATCH 20/43] =?UTF-8?q?chore(msw):=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=EB=94=9C=EB=A0=88=EC=9D=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mocks/handlers.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 895b65d19f..33a55bddae 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,9 +1,9 @@ -import { http, HttpResponse } from 'msw'; +import { delay, http, HttpResponse } from 'msw'; import { CardListResponse, RegisterCardRequest, RegisterCardResponse } from '../apis/cards'; import { cardDB } from './db'; export const handlers = [ - http.get('*/cards', () => { + http.get('*/cards', async () => { const list: CardListResponse = cardDB .list() .map(({ id, issuerCode, number, expirationDate }) => ({ @@ -12,6 +12,8 @@ export const handlers = [ number, expirationDate, })); + + await delay(1500); return HttpResponse.json(list, { status: 200 }); }), From ca5f1bb35613f8f07ee730bb1dcbb57087a054ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 01:19:54 +0900 Subject: [PATCH 21/43] =?UTF-8?q?feat(api):=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=ED=9B=84?= =?UTF-8?q?=20=EB=A6=AC=ED=8C=A8=EC=B9=AD=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Card/CardList/SkeletonCardList.tsx | 4 +- src/hooks/useQuery.ts | 42 ++++++++++--------- src/pages/CardListPage.tsx | 9 ++-- 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/src/components/Card/CardList/SkeletonCardList.tsx b/src/components/Card/CardList/SkeletonCardList.tsx index 3c7483cb9a..949d7f0886 100644 --- a/src/components/Card/CardList/SkeletonCardList.tsx +++ b/src/components/Card/CardList/SkeletonCardList.tsx @@ -5,8 +5,8 @@ export default function SkeletonCardList() { return ( {Array.from({ length: 3 }).map((_, idx) => ( - - + + diff --git a/src/hooks/useQuery.ts b/src/hooks/useQuery.ts index 6a2e0bfff2..1c4463e049 100644 --- a/src/hooks/useQuery.ts +++ b/src/hooks/useQuery.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; type Status = 'idle' | 'loading' | 'success' | 'error'; @@ -12,39 +12,41 @@ export const useQuery = ({ queryFn }: UseQueryOptions) => { const [data, setData] = useState(); - useEffect(() => { - let cancelled = false; - - const fetchQuery = async () => { - setStatus('loading'); - try { - const res = await queryFn(); - if (cancelled) return; - - setData(res); + const fetchQuery = useCallback(async () => { + setStatus('loading'); + try { + const res = await queryFn(); - setStatus('success'); - } catch (err) { - if (cancelled) return; - setError(err as Error); + setData(res); + setStatus('success'); + setError(null); + } catch (err) { + setError(err as Error); + setStatus('error'); + } + }, [queryFn]); - setStatus('error'); - } - }; + useEffect(() => { + let cancelled = false; - fetchQuery(); + (async () => { + await fetchQuery(); + if (cancelled) setStatus('idle'); + })(); return () => { cancelled = true; }; - }, []); + }, [fetchQuery]); return { data, error, + status, isIdle: status === 'idle', isLoading: status === 'loading', isSuccess: status === 'success', isError: status === 'error', + refetch: fetchQuery, }; }; diff --git a/src/pages/CardListPage.tsx b/src/pages/CardListPage.tsx index 849aa67401..57a996147c 100644 --- a/src/pages/CardListPage.tsx +++ b/src/pages/CardListPage.tsx @@ -1,5 +1,5 @@ import styled from '@emotion/styled'; -import { getCardList } from '../apis/cards'; +import { deleteCard, getCardList } from '../apis/cards'; import RegisteredCardList from '../components/Card/CardList/RegisteredCardList'; import { useNavigate } from 'react-router-dom'; import { useQuery } from '../hooks/useQuery'; @@ -10,9 +10,12 @@ import errorIcon from '../assets/error.svg'; export default function CardListPage() { const navigate = useNavigate(); - const { data, isLoading, isSuccess, isError } = useQuery({ queryFn: getCardList }); + const { data, isLoading, isSuccess, isError, refetch } = useQuery({ queryFn: getCardList }); - const handleDelete = (id: string) => {}; + const handleDelete = async (id: string) => { + await deleteCard(id); + refetch(); + }; return ( From 4bd198dafd703a3b71ecc6d7186ea506815ad5f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 01:38:17 +0900 Subject: [PATCH 22/43] =?UTF-8?q?refactor(api):=20useQuery=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A8=EC=B9=AD=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useQuery.ts | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/src/hooks/useQuery.ts b/src/hooks/useQuery.ts index 1c4463e049..9f82475ffe 100644 --- a/src/hooks/useQuery.ts +++ b/src/hooks/useQuery.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; type Status = 'idle' | 'loading' | 'success' | 'error'; @@ -9,35 +9,34 @@ interface UseQueryOptions { export const useQuery = ({ queryFn }: UseQueryOptions) => { const [status, setStatus] = useState('idle'); const [error, setError] = useState(null); - const [data, setData] = useState(); - - const fetchQuery = useCallback(async () => { - setStatus('loading'); - try { - const res = await queryFn(); - - setData(res); - setStatus('success'); - setError(null); - } catch (err) { - setError(err as Error); - setStatus('error'); - } - }, [queryFn]); + const [refetchKey, setRefetchKey] = useState(0); useEffect(() => { let cancelled = false; (async () => { - await fetchQuery(); - if (cancelled) setStatus('idle'); + setStatus('loading'); + try { + const res = await queryFn(); + if (cancelled) return; + + setData(res); + setStatus('success'); + } catch (err) { + if (cancelled) return; + + setError(err as Error); + setStatus('error'); + } })(); return () => { cancelled = true; }; - }, [fetchQuery]); + }, [refetchKey, queryFn]); + + const flushRefetch = () => setRefetchKey((key) => key + 1); return { data, @@ -47,6 +46,6 @@ export const useQuery = ({ queryFn }: UseQueryOptions) => { isLoading: status === 'loading', isSuccess: status === 'success', isError: status === 'error', - refetch: fetchQuery, + refetch: flushRefetch, }; }; From eb8c300553ba8a903711e3c8bbde8b72bbddf495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 02:07:41 +0900 Subject: [PATCH 23/43] =?UTF-8?q?feat(api):=20=ED=8F=BC=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EB=93=B1=EB=A1=9D=20api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Form/PaymentForm.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/Form/PaymentForm.tsx b/src/components/Form/PaymentForm.tsx index 78cdf46fb5..acc58b0f63 100644 --- a/src/components/Form/PaymentForm.tsx +++ b/src/components/Form/PaymentForm.tsx @@ -21,6 +21,7 @@ import { getCardNumbersMaxLength } from '../../utils/fields'; import Button from '../Common/Button/Button'; import { useNavigate } from 'react-router-dom'; import CardPreview from '../Card/CardPreview/CardPreview'; +import { registerCard } from '../../apis/cards'; export type Step = 1 | 2 | 3 | 4 | 5 | 6; export type CardNumbersType = [string, string, string, string]; @@ -103,9 +104,20 @@ export default function PaymentForm() { setStep(2); }; - const handleSubmit = (e: SubmitEvent) => { + const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); + const issuer = Object.values(CARD_ISSUER_CONFIG).filter( + (issuer) => issuer.name === cardIssuer + )[0]; + + await registerCard({ + number: cardNumbers.join(''), + expirationDate: `${expirationDate['month']}/${expirationDate['year']}`, + cvc, + issuerCode: issuer.issuerCode, + }); + navigate('/registration/completion', { state: { prefix: cardNumbers[0], From ddd5898bfbd91882d2fc558b34077fa2763254fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 02:08:03 +0900 Subject: [PATCH 24/43] =?UTF-8?q?chore(msw):=20DB=20=EC=83=81=EC=88=98=20?= =?UTF-8?q?=EB=B9=88=20=EB=B0=B0=EC=97=B4=EB=A1=9C=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mocks/db.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/mocks/db.ts b/src/mocks/db.ts index 7bbfe980cc..442be7aa3d 100644 --- a/src/mocks/db.ts +++ b/src/mocks/db.ts @@ -1,14 +1,6 @@ import { Card } from '../types/card'; -let cards: Card[] = [ - { - id: '550e8400-e29b-41d4-a716-446655440000', - issuerCode: '31', - number: '551112******9012', - expirationDate: '12/28', - cvc: '123', - }, -]; +let cards: Card[] = []; export const cardDB = { list: (): Card[] => cards, From e7dcd2c6d644ab966b572daacbc6f8c80f02f6c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 02:08:23 +0900 Subject: [PATCH 25/43] =?UTF-8?q?chore(route):=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8C=85=20replace=20=EC=86=8D=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/RegistrationCompletionPage.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/RegistrationCompletionPage.tsx b/src/pages/RegistrationCompletionPage.tsx index 2d2a02f0cb..67ea80f3b7 100644 --- a/src/pages/RegistrationCompletionPage.tsx +++ b/src/pages/RegistrationCompletionPage.tsx @@ -8,7 +8,11 @@ export default function RegistrationCompletionPage() { const state = location.state; const navigate = useNavigate(); - if (!state) return ; + const handleNavigate = () => { + navigate('/list', { replace: true }); + }; + + if (!state) return ; return ( @@ -22,7 +26,7 @@ export default function RegistrationCompletionPage() { {`${state.cardIssuer}가 등록되었어요.`} - + ); } From 7ab2043239d526c9dd54b16d25c21643fc7ea149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 02:46:33 +0900 Subject: [PATCH 26/43] =?UTF-8?q?chore(test):=20RTL=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=84=A4=EC=B9=98=20=EB=B0=8F=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + package-lock.json | 535 +++++++++++++++++++++++++++++++++++++++++++- package.json | 9 +- src/mocks/server.ts | 4 + src/mocks/setup.ts | 15 ++ vite.config.ts | 9 + 6 files changed, 562 insertions(+), 11 deletions(-) create mode 100644 src/mocks/server.ts create mode 100644 src/mocks/setup.ts diff --git a/.gitignore b/.gitignore index 567bffaceb..0d15f8ad09 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ dist-ssr storybook-static .env +.env.test \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cef3f90abe..e11e96069f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,10 @@ "@storybook/addon-onboarding": "^10.3.5", "@storybook/addon-vitest": "^10.3.5", "@storybook/react-vite": "^10.3.5", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -35,6 +39,7 @@ "eslint-plugin-storybook": "^10.3.5", "gh-pages": "^6.3.0", "globals": "^17.5.0", + "jsdom": "^29.1.1", "msw": "^2.14.6", "playwright": "^1.59.1", "prettier": "^3.8.3", @@ -52,6 +57,57 @@ "dev": true, "license": "MIT" }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -308,6 +364,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@chromatic-com/storybook": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.1.2.tgz", @@ -329,6 +398,146 @@ "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1085,6 +1294,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1988,7 +2215,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2030,6 +2256,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@testing-library/user-event": { "version": "14.6.1", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", @@ -2060,8 +2314,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2804,7 +3057,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -2939,6 +3191,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -3231,6 +3493,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -3244,6 +3520,20 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3261,6 +3551,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3372,8 +3669,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.5.344", @@ -3406,6 +3702,19 @@ "node": ">=14" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4216,6 +4525,19 @@ "react-is": "^16.7.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4375,6 +4697,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -4443,6 +4772,57 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4838,7 +5218,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4894,6 +5273,13 @@ "node": ">=10" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5208,6 +5594,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5500,7 +5899,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5515,8 +5913,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/punycode": { "version": "2.3.1", @@ -5710,6 +6107,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -5836,6 +6243,19 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6173,6 +6593,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -6303,6 +6730,19 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-repeated": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", @@ -6438,6 +6878,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -6728,6 +7178,29 @@ "node": ">=18" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -6735,6 +7208,31 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6863,6 +7361,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 9cb86bb788..4df213992a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "preview": "vite preview", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", + "test": "vitest run --project unit", + "test:watch": "vitest --project unit", "deploy": "npm run build && gh-pages -d dist" }, "dependencies": { @@ -27,6 +29,10 @@ "@storybook/addon-onboarding": "^10.3.5", "@storybook/addon-vitest": "^10.3.5", "@storybook/react-vite": "^10.3.5", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -40,6 +46,7 @@ "eslint-plugin-storybook": "^10.3.5", "gh-pages": "^6.3.0", "globals": "^17.5.0", + "jsdom": "^29.1.1", "msw": "^2.14.6", "playwright": "^1.59.1", "prettier": "^3.8.3", @@ -54,4 +61,4 @@ "public" ] } -} \ No newline at end of file +} diff --git a/src/mocks/server.ts b/src/mocks/server.ts new file mode 100644 index 0000000000..e52fee0a67 --- /dev/null +++ b/src/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); diff --git a/src/mocks/setup.ts b/src/mocks/setup.ts new file mode 100644 index 0000000000..40d929dcbf --- /dev/null +++ b/src/mocks/setup.ts @@ -0,0 +1,15 @@ +import '@testing-library/jest-dom/vitest'; +import { afterAll, afterEach, beforeAll } from 'vitest'; +import { cleanup, configure } from '@testing-library/react'; +import { server } from './server'; +import { cardDB } from './db'; + +configure({ asyncUtilTimeout: 3000 }); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => { + cleanup(); + server.resetHandlers(); + cardDB.list().forEach(({ id }) => cardDB.remove(id)); +}); +afterAll(() => server.close()); diff --git a/vite.config.ts b/vite.config.ts index b9d1cf848a..e1a29c183a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,15 @@ export default defineConfig({ plugins: [react()], test: { projects: [ + { + extends: true, + test: { + name: 'unit', + environment: 'jsdom', + setupFiles: ['./src/mocks/setup.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}'], + }, + }, { extends: true, plugins: [ From cc465a6b012451735def2e823db688590bd35952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 02:48:27 +0900 Subject: [PATCH 27/43] =?UTF-8?q?test:=20=EC=B9=B4=EB=93=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/CardListPage.test.tsx | 61 +++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/__tests__/CardListPage.test.tsx diff --git a/src/__tests__/CardListPage.test.tsx b/src/__tests__/CardListPage.test.tsx new file mode 100644 index 0000000000..5a0994e0e1 --- /dev/null +++ b/src/__tests__/CardListPage.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import CardListPage from '../pages/CardListPage'; +import { cardDB } from '../mocks/db'; +import { server } from '../mocks/server'; + +const renderCardListPage = () => + render( + + + } /> + + + ); + +describe('CardListPage 통합 테스트', () => { + it('등록된 카드가 없으면 빈 상태 UI와 카드 추가 버튼을 보여준다', async () => { + renderCardListPage(); + + expect(await screen.findByText('등록된 카드가 없습니다')).toBeInTheDocument(); + expect(screen.getByText('아래 버튼을 눌러 첫 카드를 등록해보세요')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '카드 추가하기' })).toBeInTheDocument(); + }); + + it('등록된 카드가 있으면 카드사·번호·유효기간을 렌더링하고 개수를 표시한다', async () => { + cardDB.add({ + number: '4111111111111111', + expirationDate: '12/30', + cvc: '123', + issuerCode: '11', + }); + cardDB.add({ + number: '5555555555554444', + expirationDate: '06/28', + cvc: '456', + issuerCode: '41', + }); + + renderCardListPage(); + + expect(await screen.findByText('국민카드')).toBeInTheDocument(); + expect(screen.getByText('신한카드')).toBeInTheDocument(); + expect(screen.getByText('4111 **** **** 1111')).toBeInTheDocument(); + expect(screen.getByText('5555 **** **** 4444')).toBeInTheDocument(); + expect(screen.getByText('유효기간 12/30')).toBeInTheDocument(); + expect(screen.getByText('유효기간 06/28')).toBeInTheDocument(); + expect(screen.getByText('보유 카드 (2)')).toBeInTheDocument(); + }); + + it('카드 목록 API가 실패하면 에러 UI와 다시 시도 버튼을 보여준다', async () => { + server.use(http.get('*/cards', () => new HttpResponse(null, { status: 500 }))); + + renderCardListPage(); + + expect(await screen.findByText('카드 목록을 불러올 수 없어요')).toBeInTheDocument(); + expect(screen.getByText('잠시 후 다시 시도해 주세요.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '다시 시도' })).toBeInTheDocument(); + }); +}); From d9d721b71d0655a4f7f2d6f178433243f4dec038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 02:48:34 +0900 Subject: [PATCH 28/43] =?UTF-8?q?test:=20=EC=B9=B4=EB=93=9C=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/CardRegistrationPage.test.tsx | 72 +++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/__tests__/CardRegistrationPage.test.tsx diff --git a/src/__tests__/CardRegistrationPage.test.tsx b/src/__tests__/CardRegistrationPage.test.tsx new file mode 100644 index 0000000000..b2f6e0b509 --- /dev/null +++ b/src/__tests__/CardRegistrationPage.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import CardRegistrationPage from '../pages/CardRegistrationPage'; +import RegistrationCompletionPage from '../pages/RegistrationCompletionPage'; + +const renderRegistrationFlow = () => + render( + + + } /> + } /> + + + ); + +const fillValidForm = async (user: ReturnType) => { + const cardNumberInputs = screen.getAllByPlaceholderText('1234'); + await user.type(cardNumberInputs[0], '4111'); + await user.type(cardNumberInputs[1], '1111'); + await user.type(cardNumberInputs[2], '1111'); + await user.type(cardNumberInputs[3], '1111'); + + await user.selectOptions(await screen.findByLabelText('카드사 선택'), '국민카드'); + + await user.type(await screen.findByPlaceholderText('MM'), '12'); + await user.type(screen.getByPlaceholderText('YY'), '30'); + + await user.type(await screen.findByPlaceholderText('123'), '123'); + + await user.type(await screen.findByPlaceholderText('**'), '12'); +}; + +describe('CardRegistrationPage 통합 테스트', () => { + it('필수 입력을 모두 정상 입력하면 제출 버튼이 활성화된다', async () => { + const user = userEvent.setup(); + renderRegistrationFlow(); + + expect(screen.queryByRole('button', { name: '확인' })).not.toBeInTheDocument(); + + await fillValidForm(user); + + const submitButton = await screen.findByRole('button', { name: '확인' }); + expect(submitButton).toBeEnabled(); + }); + + it('카드 번호 첫 자리에 4를 입력하면 VISA 브랜드 아이콘이 나타난다', async () => { + const user = userEvent.setup(); + renderRegistrationFlow(); + + expect(screen.queryByAltText('card-brand-image')).not.toBeInTheDocument(); + + await user.type(screen.getAllByPlaceholderText('1234')[0], '4'); + + const brandImage = await screen.findByAltText('card-brand-image'); + expect(brandImage).toHaveAttribute('src', expect.stringContaining('visa.svg')); + }); + + it('폼 제출이 성공하면 완료 페이지로 이동해 등록 결과를 보여준다', async () => { + const user = userEvent.setup(); + renderRegistrationFlow(); + + await fillValidForm(user); + + await user.click(await screen.findByRole('button', { name: '확인' })); + + expect(await screen.findByText(/4111로 시작하는/)).toBeInTheDocument(); + expect(screen.getByText(/국민카드가 등록되었어요/)).toBeInTheDocument(); + expect(screen.getByAltText('complete-icon')).toBeInTheDocument(); + }); +}); From 47c3f1d0f726a09f4b63bfc30f04e26ed7318a31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 02:54:42 +0900 Subject: [PATCH 29/43] =?UTF-8?q?fix:=20import=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/fields.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/fields.ts b/src/utils/fields.ts index f174b2ffeb..d862305a09 100644 --- a/src/utils/fields.ts +++ b/src/utils/fields.ts @@ -1,4 +1,4 @@ -import { CardBrand } from '../components/CardPreview/CardPreview'; +import { CardBrand } from '../components/Card/CardPreview/CardPreview'; import { CARD_BRAND } from '../constants'; export const getCardNumbersMaxLength = ( From 151aee70d1a369f66933e451f1aeca61f67155e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 19 May 2026 02:59:45 +0900 Subject: [PATCH 30/43] =?UTF-8?q?chore:=20msw=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EB=8D=95=EC=85=98=EC=9A=A9=20=EA=B0=80=EB=93=9C=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main.tsx b/src/main.tsx index 77381504b1..9569a7b2e3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,7 +5,6 @@ import './reset.css'; import router from './routes/routes.tsx'; async function enableMocking() { - if (import.meta.env.MODE !== 'development') return; const { worker } = await import('../src/mocks/browser.ts'); return worker.start({ serviceWorker: { From 97f48c88c0148f459dac952c58f21f0523b02aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 25 May 2026 14:32:43 +0900 Subject: [PATCH 31/43] =?UTF-8?q?feat(api):=20HTTP=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20ApiError=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=AC=EC=A1=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/api.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/apis/api.ts b/src/apis/api.ts index 89f49f88e3..78c1d73e16 100644 --- a/src/apis/api.ts +++ b/src/apis/api.ts @@ -1,10 +1,35 @@ const BASE_URL = import.meta.env.VITE_BASE_URL; +export type ApiErrorCode = 'INVALID_CARD_NUMBER' | 'INVALID_CVC' | 'INVALID_EXPIRATION_DATE'; + +export class ApiError extends Error { + constructor( + public status: number, + public code: ApiErrorCode | string, + public message: string + ) { + super(message); + this.name = 'ApiError'; + } +} + type RequestOptions = Omit; +type ErrorResponse = { + code: ApiErrorCode | string; + message: string; +}; const request = async (path: string, init?: RequestInit): Promise => { const res = await fetch(`${BASE_URL}${path}`, init); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) { + const payload = (await res.json().catch(() => null)) as ErrorResponse | null; + + throw new ApiError( + res.status, + payload?.code ?? 'UNKNOWN_ERROR', + payload?.message ?? `요청 처리 중 문제가 발생했어요 (${res.status})` + ); + } const text = await res.text(); return (text ? JSON.parse(text) : undefined) as TResponse; From 591c5ba8cad990e31b4809d153bb2d9105526a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 25 May 2026 14:33:41 +0900 Subject: [PATCH 32/43] =?UTF-8?q?feat(msw):=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=9A=94=EC=B2=AD=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mocks/handlers.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 33a55bddae..6322eea1ab 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,7 +1,38 @@ import { delay, http, HttpResponse } from 'msw'; +import { ApiErrorCode } from '../apis/api'; import { CardListResponse, RegisterCardRequest, RegisterCardResponse } from '../apis/cards'; +import { CARD_BRAND_RULE } from '../constants'; +import { expirationDateValidator } from '../utils/validate'; import { cardDB } from './db'; +const ERROR_MESSAGE: Record = { + INVALID_CARD_NUMBER: '유효하지 않은 카드 번호입니다.', + INVALID_CVC: '유효하지 않은 CVC입니다.', + INVALID_EXPIRATION_DATE: '유효하지 않은 만료일입니다.', +}; + +const errorResponse = (status: number, code: ApiErrorCode) => + HttpResponse.json({ code, message: ERROR_MESSAGE[code] }, { status }); + +const isInvalidCardNumber = (number: string) => + !CARD_BRAND_RULE.some(({ prefixPattern }) => prefixPattern.test(number)); + +const isInvalidExpirationDate = (expirationDate: string) => { + const match = expirationDate.match(/^(\d{2})\/(\d{2})$/); + if (!match) return true; + + const [, month] = match; + return expirationDateValidator(month, 0).error; +}; + +const validateRegisterCard = (body: RegisterCardRequest) => { + if (isInvalidCardNumber(body.number)) return errorResponse(400, 'INVALID_CARD_NUMBER'); + if (body.cvc === '000') return errorResponse(400, 'INVALID_CVC'); + if (isInvalidExpirationDate(body.expirationDate)) + return errorResponse(400, 'INVALID_EXPIRATION_DATE'); + return null; +}; + export const handlers = [ http.get('*/cards', async () => { const list: CardListResponse = cardDB @@ -19,6 +50,10 @@ export const handlers = [ http.post('*/cards', async ({ request }) => { const body = (await request.json()) as RegisterCardRequest; + + const error = validateRegisterCard(body); + if (error) return error; + const created = cardDB.add(body); const response: RegisterCardResponse = { id: created.id }; return HttpResponse.json(response, { status: 201 }); From d79ee72d89e55988204311244bf813aad434228c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 25 May 2026 17:18:47 +0900 Subject: [PATCH 33/43] =?UTF-8?q?refactor(hooks):=20useQuery=20=ED=8C=A8?= =?UTF-8?q?=EC=B9=AD=20=EB=A1=9C=EC=A7=81=20useEffectEvent=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - queryFn을 deps에서 제거해 인라인 함수 주입 시 무한 요청 방지 - 성공 시 setError(null) 호출로 이전 에러 상태 초기화 --- src/hooks/useQuery.ts | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/hooks/useQuery.ts b/src/hooks/useQuery.ts index 9f82475ffe..980616dbbf 100644 --- a/src/hooks/useQuery.ts +++ b/src/hooks/useQuery.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useEffectEvent, useState } from 'react'; type Status = 'idle' | 'loading' | 'success' | 'error'; @@ -12,29 +12,35 @@ export const useQuery = ({ queryFn }: UseQueryOptions) => { const [data, setData] = useState(); const [refetchKey, setRefetchKey] = useState(0); - useEffect(() => { - let cancelled = false; + const runQuery = useEffectEvent(async (signal: { cancelled: boolean }) => { + setStatus('loading'); + + try { + const res = await queryFn(); + if (signal.cancelled) return; - (async () => { - setStatus('loading'); - try { - const res = await queryFn(); - if (cancelled) return; + setData(res); + setStatus('success'); + setError(null); + } catch (err) { + if (signal.cancelled) return; - setData(res); - setStatus('success'); - } catch (err) { - if (cancelled) return; + setError(err as Error); + setStatus('error'); + } + }); + + useEffect(() => { + const signal = { cancelled: false }; - setError(err as Error); - setStatus('error'); - } - })(); + // refetchKey만 deps에 포함되기 때문에 cascade render가 구조적으로 발생하지 않음. + // eslint-disable-next-line react-hooks/set-state-in-effect + runQuery(signal); return () => { - cancelled = true; + signal.cancelled = true; }; - }, [refetchKey, queryFn]); + }, [refetchKey]); const flushRefetch = () => setRefetchKey((key) => key + 1); From 7bc5aacd1e62f0b46b2bf79a209d9390576c80d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 25 May 2026 17:27:37 +0900 Subject: [PATCH 34/43] =?UTF-8?q?refactor(route):=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EA=B8=B0=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8C=85=20=EA=B2=BD=EB=A1=9C=20=EB=AA=85=EC=8B=9C=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/CardListPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/CardListPage.tsx b/src/pages/CardListPage.tsx index 57a996147c..ff6c3c8359 100644 --- a/src/pages/CardListPage.tsx +++ b/src/pages/CardListPage.tsx @@ -27,14 +27,14 @@ export default function CardListPage() { icon={} title="등록된 카드가 없습니다" description="아래 버튼을 눌러 첫 카드를 등록해보세요" - action={{ label: '카드 추가하기', onClick: () => navigate('/registration/completion') }} + action={{ label: '카드 추가하기', onClick: () => navigate('/registration') }} /> )} {isSuccess && data && data.length > 0 && ( navigate('/registration/completion')} + onClick={() => navigate('/registration')} /> )} {isError && ( From c6f6f993a6e5b3080ae6d1f0660df7571f95998f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 25 May 2026 17:52:50 +0900 Subject: [PATCH 35/43] =?UTF-8?q?refactor(api):=20api=20=EB=8B=A4=EC=8B=9C?= =?UTF-8?q?=20=EC=8B=9C=EB=8F=84=20=EB=A6=AC=ED=8C=A8=EC=B9=AD=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/CardListPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/CardListPage.tsx b/src/pages/CardListPage.tsx index ff6c3c8359..a778587fdd 100644 --- a/src/pages/CardListPage.tsx +++ b/src/pages/CardListPage.tsx @@ -42,7 +42,7 @@ export default function CardListPage() { icon={error-icon} title="카드 목록을 불러올 수 없어요" description="잠시 후 다시 시도해 주세요." - action={{ label: '다시 시도', onClick: () => navigate(0) }} + action={{ label: '다시 시도', onClick: () => refetch() }} /> )} From e4666f6026d2fdee98c257af2d70c9203ae482df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 25 May 2026 17:54:00 +0900 Subject: [PATCH 36/43] =?UTF-8?q?chore:=20=EC=B9=B4=EB=93=9C=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20api=20=EC=9A=94=EC=B2=AD=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=9E=84=EC=8B=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Form/PaymentForm.tsx | 38 ++++++++++++++++++----------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/components/Form/PaymentForm.tsx b/src/components/Form/PaymentForm.tsx index acc58b0f63..9457cf037e 100644 --- a/src/components/Form/PaymentForm.tsx +++ b/src/components/Form/PaymentForm.tsx @@ -22,6 +22,7 @@ import Button from '../Common/Button/Button'; import { useNavigate } from 'react-router-dom'; import CardPreview from '../Card/CardPreview/CardPreview'; import { registerCard } from '../../apis/cards'; +import { ApiError } from '../../apis/api'; export type Step = 1 | 2 | 3 | 4 | 5 | 6; export type CardNumbersType = [string, string, string, string]; @@ -111,20 +112,29 @@ export default function PaymentForm() { (issuer) => issuer.name === cardIssuer )[0]; - await registerCard({ - number: cardNumbers.join(''), - expirationDate: `${expirationDate['month']}/${expirationDate['year']}`, - cvc, - issuerCode: issuer.issuerCode, - }); - - navigate('/registration/completion', { - state: { - prefix: cardNumbers[0], - cardIssuer, - }, - replace: true, - }); + try { + await registerCard({ + number: cardNumbers.join(''), + expirationDate: `${expirationDate['month']}/${expirationDate['year']}`, + cvc, + issuerCode: issuer.issuerCode, + }); + + navigate('/registration/completion', { + state: { + prefix: cardNumbers[0], + cardIssuer, + }, + replace: true, + }); + } catch (err) { + if (err instanceof ApiError) { + alert(err.message); + // TODO: 에러 입력 필드 매핑 로직 추가 + } else { + alert('일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요'); + } + } }; return ( From 28a7c8158eee23d03a11768729a865493fd872e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 16 Jun 2026 02:46:29 +0900 Subject: [PATCH 37/43] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=A0=8C=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Common/Form/InputFieldForm.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/components/Common/Form/InputFieldForm.tsx b/src/components/Common/Form/InputFieldForm.tsx index fc1864308f..3dab65f7b1 100644 --- a/src/components/Common/Form/InputFieldForm.tsx +++ b/src/components/Common/Form/InputFieldForm.tsx @@ -1,5 +1,5 @@ import styled from '@emotion/styled'; -import { ChangeEvent, KeyboardEvent, useRef, useState } from 'react'; +import { ChangeEvent, KeyboardEvent, useRef } from 'react'; import Label from '../Label/Label'; import ErrorMessage from '../ErrorMessage/ErrorMessage'; import { InputFieldConfig } from '../../../types/field'; @@ -21,9 +21,8 @@ interface Props { export default function InputFieldForm({ fields, fieldConfig, onChanges }: Props) { const inputRefs = useRef<(HTMLInputElement | null)[]>([]); - const [activeFieldIdx, setActiveFieldIdx] = useState(null); - - const errorMessage = activeFieldIdx !== null ? fields[activeFieldIdx].errorMessage : ''; + const errorField = fields.find(({ touched, error }) => touched && error); + const errorMessage = errorField?.errorMessage ?? ''; const handleChange = ( e: ChangeEvent, @@ -78,14 +77,12 @@ export default function InputFieldForm({ fields, fieldConfig, onChanges }: Props value={value} placeholder={fieldConfig.placeholder[index]} onChange={(e) => handleChange(e, index, onChanges[index], maxLength)} - onFocus={() => setActiveFieldIdx(index)} - onBlur={() => setActiveFieldIdx(null)} onKeyDown={(e) => handleKeyDown(e, index)} /> ))} - {errorMessage.length > 0 && {errorMessage}} + {errorMessage} ); } From 8991c9894648b78a6d0c24d1155b28edd3d148ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 16 Jun 2026 02:47:14 +0900 Subject: [PATCH 38/43] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/api.ts | 12 ++++++-- src/components/Form/PaymentForm.tsx | 47 ++++++++++++++++++++++------- src/constants/inputField.ts | 1 + 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/apis/api.ts b/src/apis/api.ts index 78c1d73e16..6a2ba249f0 100644 --- a/src/apis/api.ts +++ b/src/apis/api.ts @@ -1,10 +1,19 @@ +import { InputFieldConfigType } from '../constants'; + const BASE_URL = import.meta.env.VITE_BASE_URL; export type ApiErrorCode = 'INVALID_CARD_NUMBER' | 'INVALID_CVC' | 'INVALID_EXPIRATION_DATE'; +export const ERROR_CODE_TO_FIELD: Record = { + INVALID_CARD_NUMBER: 'CARD_NUMBERS', + INVALID_CVC: 'CVC', + INVALID_EXPIRATION_DATE: 'EXPIRATION_DATE', +}; + +export const getFieldByErrorCode = (code: string): InputFieldConfigType | null => + code in ERROR_CODE_TO_FIELD ? ERROR_CODE_TO_FIELD[code as ApiErrorCode] : null; export class ApiError extends Error { constructor( - public status: number, public code: ApiErrorCode | string, public message: string ) { @@ -25,7 +34,6 @@ const request = async (path: string, init?: RequestInit): Promise null)) as ErrorResponse | null; throw new ApiError( - res.status, payload?.code ?? 'UNKNOWN_ERROR', payload?.message ?? `요청 처리 중 문제가 발생했어요 (${res.status})` ); diff --git a/src/components/Form/PaymentForm.tsx b/src/components/Form/PaymentForm.tsx index 9457cf037e..d0bfdb5845 100644 --- a/src/components/Form/PaymentForm.tsx +++ b/src/components/Form/PaymentForm.tsx @@ -11,6 +11,7 @@ import InputFieldForm from '../Common/Form/InputFieldForm'; import { CARD_ISSUER_CONFIG, INPUT_FIELD_CONFIG, + InputFieldConfigType, SELECT_FIELD_CONFIG, VALIDATION_RULE, } from '../../constants'; @@ -22,7 +23,7 @@ import Button from '../Common/Button/Button'; import { useNavigate } from 'react-router-dom'; import CardPreview from '../Card/CardPreview/CardPreview'; import { registerCard } from '../../apis/cards'; -import { ApiError } from '../../apis/api'; +import { ApiError, getFieldByErrorCode } from '../../apis/api'; export type Step = 1 | 2 | 3 | 4 | 5 | 6; export type CardNumbersType = [string, string, string, string]; @@ -39,6 +40,11 @@ export default function PaymentForm() { const [cardIssuer, setCardIssuer] = useState(null); const [cardNumbers, setCardNumbers] = useState(['', '', '', '']); + const [serverError, setServerError] = useState<{ + field: InputFieldConfigType; + message: string; + } | null>(null); + const isValid = cardNumbers.every( (value, index) => @@ -61,6 +67,7 @@ export default function PaymentForm() { }; const handleCVCChange = (e: ChangeEvent) => { + clearServerError('CVC'); const value = e.target.value; setCVC(value); @@ -70,6 +77,7 @@ export default function PaymentForm() { const handleExpirationDateChange = (field: keyof ExpirationDateType) => (e: ChangeEvent) => { + clearServerError('EXPIRATION_DATE'); const newExpirationDate = { ...expirationDate }; newExpirationDate[field] = e.target.value; setExpirationDate(newExpirationDate); @@ -78,8 +86,9 @@ export default function PaymentForm() { Object.values(newExpirationDate).every( (value, i) => !expirationDateValidator(value, i).error ) - ) + ) { setStep(4); + } }; const handleCardIssuerSelect = (value: CardIssuerType | null) => { @@ -89,6 +98,7 @@ export default function PaymentForm() { }; const handleCardNumbersChange = (index: number) => (e: ChangeEvent) => { + clearServerError('CARD_NUMBERS'); const newCardNumbers = [...cardNumbers] as CardNumbersType; newCardNumbers[index] = e.target.value; setCardNumbers(newCardNumbers); @@ -129,14 +139,32 @@ export default function PaymentForm() { }); } catch (err) { if (err instanceof ApiError) { - alert(err.message); - // TODO: 에러 입력 필드 매핑 로직 추가 + const field = getFieldByErrorCode(err.code); + if (field) setServerError({ field, message: err.message }); + else alert('일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요'); } else { alert('일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요'); } } }; + const clearServerError = (field: InputFieldConfigType) => { + if (serverError?.field === field) setServerError(null); + }; + + const withServerError = ( + field: InputFieldConfigType, + value: string, + errorField: { error: boolean; errorMessage: string } + ) => { + const hasServerError = serverError?.field === field; + return { + touched: hasServerError || !!value, + error: hasServerError || errorField.error, + errorMessage: hasServerError ? serverError.message : errorField.errorMessage, + }; + }; + return ( ({ value, - touched: !!value, maxLength: VALIDATION_RULE.PASSWORD_LENGTH, + touched: !!value, ...passwordValidator(value), }))} fieldConfig={INPUT_FIELD_CONFIG['PASSWORD']} @@ -172,9 +200,8 @@ export default function PaymentForm() { ({ value, - touched: !!value, maxLength: VALIDATION_RULE.CVC_LENGTH, - ...cvcValidator(value), + ...withServerError('CVC', value, cvcValidator(value)), }))} fieldConfig={INPUT_FIELD_CONFIG['CVC']} onChanges={[handleCVCChange]} @@ -190,9 +217,8 @@ export default function PaymentForm() { ({ value, - touched: !!value, maxLength: VALIDATION_RULE.EXPIRATION_DATE_LENGTH, - ...expirationDateValidator(value, index), + ...withServerError('EXPIRATION_DATE', value, expirationDateValidator(value, index)), }))} fieldConfig={INPUT_FIELD_CONFIG['EXPIRATION_DATE']} onChanges={[handleExpirationDateChange('month'), handleExpirationDateChange('year')]} @@ -226,9 +252,8 @@ export default function PaymentForm() { return { value, - touched: !!value, maxLength, - ...cardNumbersValidator(value, maxLength), + ...withServerError('CARD_NUMBERS', value, cardNumbersValidator(value, maxLength)), }; })} fieldConfig={INPUT_FIELD_CONFIG['CARD_NUMBERS']} diff --git a/src/constants/inputField.ts b/src/constants/inputField.ts index 2d1b4235f8..c8ad77a4d0 100644 --- a/src/constants/inputField.ts +++ b/src/constants/inputField.ts @@ -9,6 +9,7 @@ export const SELECT_FIELD_CONFIG = { }, } as const; +export type InputFieldConfigType = keyof typeof INPUT_FIELD_CONFIG; export const INPUT_FIELD_CONFIG = { CARD_NUMBERS: { id: 'cardNumbers', From fcf4272fec436781fa91c139d665b639d68b2ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Tue, 16 Jun 2026 03:47:55 +0900 Subject: [PATCH 39/43] =?UTF-8?q?refactor:=20isValid=20=ED=8C=90=EB=B3=84?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Form/PaymentForm.tsx | 63 ++++++++++++----------------- src/utils/fields.ts | 40 +++++++++++++++++- src/utils/validate.ts | 53 ------------------------ 3 files changed, 64 insertions(+), 92 deletions(-) diff --git a/src/components/Form/PaymentForm.tsx b/src/components/Form/PaymentForm.tsx index d0bfdb5845..39e1da3365 100644 --- a/src/components/Form/PaymentForm.tsx +++ b/src/components/Form/PaymentForm.tsx @@ -1,12 +1,7 @@ import { ChangeEvent, SubmitEvent, useState } from 'react'; import styled from '@emotion/styled'; import InputFieldLayout from '../Layout/InputFieldLayout'; -import { - cardNumbersValidator, - cvcValidator, - expirationDateValidator, - passwordValidator, -} from '../../utils/validate'; +import { expirationDateValidator } from '../../utils/validate'; import InputFieldForm from '../Common/Form/InputFieldForm'; import { CARD_ISSUER_CONFIG, @@ -18,7 +13,11 @@ import { import { convertValueFormat } from '../../utils/convert'; import CardSelect from '../Select/CardSelect'; import { detectCardBrand, getCardIssuerBackgroundColor } from '../../utils/cards'; -import { getCardNumbersMaxLength } from '../../utils/fields'; +import { + getCardNumbersMaxLength, + isCardRegistrationComplete, + isFieldComplete, +} from '../../utils/fields'; import Button from '../Common/Button/Button'; import { useNavigate } from 'react-router-dom'; import CardPreview from '../Card/CardPreview/CardPreview'; @@ -46,24 +45,14 @@ export default function PaymentForm() { } | null>(null); const isValid = - cardNumbers.every( - (value, index) => - !cardNumbersValidator( - value, - getCardNumbersMaxLength(detectCardBrand(cardNumbers), cardNumbers.length, index) - ).error - ) && - cardIssuer && - Object.values(expirationDate).every((value, i) => !expirationDateValidator(value, i).error) && - !cvcValidator(cvc).error && - !passwordValidator(password).error; + isCardRegistrationComplete({ cardNumbers, expirationDate, cvc, password, cardIssuer }) && + !serverError; const handlePasswordChange = (e: ChangeEvent) => { const value = e.target.value; - setPassword(e.target.value); - if (!passwordValidator(value).error) setStep(6); + if (isFieldComplete(value, VALIDATION_RULE.PASSWORD_LENGTH)) setStep(6); }; const handleCVCChange = (e: ChangeEvent) => { @@ -72,7 +61,7 @@ export default function PaymentForm() { setCVC(value); - if (!cvcValidator(value).error) setStep(5); + if (isFieldComplete(value, VALIDATION_RULE.CVC_LENGTH)) setStep(5); }; const handleExpirationDateChange = @@ -103,16 +92,11 @@ export default function PaymentForm() { newCardNumbers[index] = e.target.value; setCardNumbers(newCardNumbers); - if ( - newCardNumbers.every( - (value, index) => - !cardNumbersValidator( - value, - getCardNumbersMaxLength(detectCardBrand(cardNumbers), cardNumbers.length, index) - ).error - ) - ) - setStep(2); + const brand = detectCardBrand(newCardNumbers); + const allComplete = newCardNumbers.every((value, i) => + isFieldComplete(value, getCardNumbersMaxLength(brand, newCardNumbers.length, i)) + ); + if (allComplete) setStep(2); }; const handleSubmit = async (e: SubmitEvent) => { @@ -155,13 +139,13 @@ export default function PaymentForm() { const withServerError = ( field: InputFieldConfigType, value: string, - errorField: { error: boolean; errorMessage: string } + localErrorField: { error: boolean; errorMessage: string } = { error: false, errorMessage: '' } ) => { const hasServerError = serverError?.field === field; return { touched: hasServerError || !!value, - error: hasServerError || errorField.error, - errorMessage: hasServerError ? serverError.message : errorField.errorMessage, + error: hasServerError || localErrorField.error, + errorMessage: hasServerError ? serverError.message : localErrorField.errorMessage, }; }; @@ -187,7 +171,8 @@ export default function PaymentForm() { value, maxLength: VALIDATION_RULE.PASSWORD_LENGTH, touched: !!value, - ...passwordValidator(value), + error: false, + errorMessage: '', }))} fieldConfig={INPUT_FIELD_CONFIG['PASSWORD']} onChanges={[handlePasswordChange]} @@ -201,7 +186,7 @@ export default function PaymentForm() { fields={convertValueFormat(cvc).map((value) => ({ value, maxLength: VALIDATION_RULE.CVC_LENGTH, - ...withServerError('CVC', value, cvcValidator(value)), + ...withServerError('CVC', value), }))} fieldConfig={INPUT_FIELD_CONFIG['CVC']} onChanges={[handleCVCChange]} @@ -253,7 +238,7 @@ export default function PaymentForm() { return { value, maxLength, - ...withServerError('CARD_NUMBERS', value, cardNumbersValidator(value, maxLength)), + ...withServerError('CARD_NUMBERS', value), }; })} fieldConfig={INPUT_FIELD_CONFIG['CARD_NUMBERS']} @@ -261,7 +246,9 @@ export default function PaymentForm() { /> - {step >= 6 && } + {isCardRegistrationComplete({ cardNumbers, expirationDate, cvc, password, cardIssuer }) && ( + + )} ); diff --git a/src/utils/fields.ts b/src/utils/fields.ts index d862305a09..b1344af25f 100644 --- a/src/utils/fields.ts +++ b/src/utils/fields.ts @@ -1,5 +1,12 @@ import { CardBrand } from '../components/Card/CardPreview/CardPreview'; -import { CARD_BRAND } from '../constants'; +import { + CardIssuerType, + CardNumbersType, + ExpirationDateType, +} from '../components/Form/PaymentForm'; +import { CARD_BRAND, VALIDATION_RULE } from '../constants'; +import { detectCardBrand } from './cards'; +import { expirationDateValidator } from './validate'; export const getCardNumbersMaxLength = ( cardBrand: CardBrand, @@ -13,3 +20,34 @@ export const getCardNumbersMaxLength = ( return 4; }; + +export const isFieldComplete = (value: string, maxLength: number) => value.length === maxLength; + +export const isCardRegistrationComplete = ({ + cardNumbers, + expirationDate, + cvc, + password, + cardIssuer, +}: { + cardNumbers: CardNumbersType; + expirationDate: ExpirationDateType; + cvc: string; + password: string; + cardIssuer: CardIssuerType | null; +}) => { + const brand = detectCardBrand(cardNumbers); + + const cardNumbersComplete = cardNumbers.every((v, i) => + isFieldComplete(v, getCardNumbersMaxLength(brand, cardNumbers.length, i)) + ); + const isExpirationComplete = Object.values(expirationDate).every( + (v, i) => + isFieldComplete(v, VALIDATION_RULE.EXPIRATION_DATE_LENGTH) && + !expirationDateValidator(v, i).error + ); + const cvcComplete = isFieldComplete(cvc, VALIDATION_RULE.CVC_LENGTH); + const passwordComplete = isFieldComplete(password, VALIDATION_RULE.PASSWORD_LENGTH); + + return cardNumbersComplete && !!cardIssuer && isExpirationComplete && cvcComplete && passwordComplete; +}; diff --git a/src/utils/validate.ts b/src/utils/validate.ts index e41f4501c8..2ff8990cb3 100644 --- a/src/utils/validate.ts +++ b/src/utils/validate.ts @@ -1,27 +1,6 @@ import { CARD_BRAND_RULE, ERROR_MESSAGE, VALIDATION_RULE } from '../constants'; -export const cardNumbersValidator = (inputValue: string, maxLength: number) => { - if (validateInputValueLength(inputValue, maxLength)) { - return { - error: true, - errorMessage: ERROR_MESSAGE.MAX_LENGTH(maxLength), - }; - } - - return { - error: false, - errorMessage: '', - }; -}; - export const expirationDateValidator = (inputValue: string, index: number) => { - if (validateInputValueLength(inputValue, VALIDATION_RULE.EXPIRATION_DATE_LENGTH)) { - return { - error: true, - errorMessage: ERROR_MESSAGE.MAX_LENGTH(VALIDATION_RULE.EXPIRATION_DATE_LENGTH), - }; - } - // 월 if (index === 0 && validateMonth(inputValue, VALIDATION_RULE.MAX_MONTH)) { return { @@ -44,41 +23,9 @@ export const expirationDateValidator = (inputValue: string, index: number) => { }; }; -export const cvcValidator = (inputValue: string) => { - if (validateInputValueLength(inputValue, VALIDATION_RULE.CVC_LENGTH)) { - return { - error: true, - errorMessage: ERROR_MESSAGE.MAX_LENGTH(VALIDATION_RULE.CVC_LENGTH), - }; - } - - return { - error: false, - errorMessage: '', - }; -}; - -export const passwordValidator = (inputValue: string) => { - if (validateInputValueLength(inputValue, VALIDATION_RULE.PASSWORD_LENGTH)) { - return { - error: true, - errorMessage: ERROR_MESSAGE.MAX_LENGTH(VALIDATION_RULE.PASSWORD_LENGTH), - }; - } - - return { - error: false, - errorMessage: '', - }; -}; - // 숫자 외의 값이 입력되는 경우 검증 export const validateNaN = (inputValue: string) => isNaN(Number(inputValue)); -// 입력된 번호의 개수가 maxLength보다 작은 경우 검증 -const validateInputValueLength = (inputValue: string, inputMaxLength: number) => - inputValue.length < inputMaxLength; - // cardBrand 규칙이 맞는지 검증 export const validateCardBrand = (inputValue: string) => { return CARD_BRAND_RULE.some((brand) => !brand.pattern.test(inputValue)); From 79ef7962f5f41275b4bdab92f82034522b3c5fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Wed, 17 Jun 2026 01:47:25 +0900 Subject: [PATCH 40/43] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=83=AD=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Form/PaymentForm.tsx | 38 +++++++++++------------------ src/constants/inputField.ts | 34 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 24 deletions(-) diff --git a/src/components/Form/PaymentForm.tsx b/src/components/Form/PaymentForm.tsx index 39e1da3365..387c773626 100644 --- a/src/components/Form/PaymentForm.tsx +++ b/src/components/Form/PaymentForm.tsx @@ -5,6 +5,7 @@ import { expirationDateValidator } from '../../utils/validate'; import InputFieldForm from '../Common/Form/InputFieldForm'; import { CARD_ISSUER_CONFIG, + FIELD_STEP_SEQUENCE, INPUT_FIELD_CONFIG, InputFieldConfigType, SELECT_FIELD_CONFIG, @@ -13,18 +14,13 @@ import { import { convertValueFormat } from '../../utils/convert'; import CardSelect from '../Select/CardSelect'; import { detectCardBrand, getCardIssuerBackgroundColor } from '../../utils/cards'; -import { - getCardNumbersMaxLength, - isCardRegistrationComplete, - isFieldComplete, -} from '../../utils/fields'; +import { getCardNumbersMaxLength, isCardRegistrationComplete } from '../../utils/fields'; import Button from '../Common/Button/Button'; import { useNavigate } from 'react-router-dom'; import CardPreview from '../Card/CardPreview/CardPreview'; import { registerCard } from '../../apis/cards'; import { ApiError, getFieldByErrorCode } from '../../apis/api'; -export type Step = 1 | 2 | 3 | 4 | 5 | 6; export type CardNumbersType = [string, string, string, string]; export type ExpirationDateType = { month: string; year: string }; export type CardIssuerType = (typeof CARD_ISSUER_CONFIG)[keyof typeof CARD_ISSUER_CONFIG]['name']; @@ -32,7 +28,7 @@ export type CardIssuerType = (typeof CARD_ISSUER_CONFIG)[keyof typeof CARD_ISSUE export default function PaymentForm() { const navigate = useNavigate(); - const [step, setStep] = useState(1); + const [step, setStep] = useState(1); const [password, setPassword] = useState(''); const [cvc, setCVC] = useState(''); const [expirationDate, setExpirationDate] = useState({ month: '', year: '' }); @@ -52,16 +48,15 @@ export default function PaymentForm() { const value = e.target.value; setPassword(e.target.value); - if (isFieldComplete(value, VALIDATION_RULE.PASSWORD_LENGTH)) setStep(6); + advanceStep('password', value); }; const handleCVCChange = (e: ChangeEvent) => { clearServerError('CVC'); const value = e.target.value; - setCVC(value); - if (isFieldComplete(value, VALIDATION_RULE.CVC_LENGTH)) setStep(5); + advanceStep('cvc', value); }; const handleExpirationDateChange = @@ -71,19 +66,12 @@ export default function PaymentForm() { newExpirationDate[field] = e.target.value; setExpirationDate(newExpirationDate); - if ( - Object.values(newExpirationDate).every( - (value, i) => !expirationDateValidator(value, i).error - ) - ) { - setStep(4); - } + advanceStep('expirationDate', newExpirationDate); }; const handleCardIssuerSelect = (value: CardIssuerType | null) => { setCardIssuer(value); - - if (value) setStep(3); + advanceStep('cardIssuer', value); }; const handleCardNumbersChange = (index: number) => (e: ChangeEvent) => { @@ -92,11 +80,7 @@ export default function PaymentForm() { newCardNumbers[index] = e.target.value; setCardNumbers(newCardNumbers); - const brand = detectCardBrand(newCardNumbers); - const allComplete = newCardNumbers.every((value, i) => - isFieldComplete(value, getCardNumbersMaxLength(brand, newCardNumbers.length, i)) - ); - if (allComplete) setStep(2); + advanceStep('cardNumbers', newCardNumbers); }; const handleSubmit = async (e: SubmitEvent) => { @@ -132,6 +116,12 @@ export default function PaymentForm() { } }; + const advanceStep = (fieldName: string, value: T) => { + const index = FIELD_STEP_SEQUENCE.findIndex((f) => f.name === fieldName); + + if (FIELD_STEP_SEQUENCE[index].isComplete(value)) setStep(index + 2); + }; + const clearServerError = (field: InputFieldConfigType) => { if (serverError?.field === field) setServerError(null); }; diff --git a/src/constants/inputField.ts b/src/constants/inputField.ts index c8ad77a4d0..b8ba281a60 100644 --- a/src/constants/inputField.ts +++ b/src/constants/inputField.ts @@ -1,4 +1,9 @@ +import { CardNumbersType, ExpirationDateType } from '../components/Form/PaymentForm'; import { InputFieldConfig } from '../types/field'; +import { detectCardBrand } from '../utils/cards'; +import { getCardNumbersMaxLength, isFieldComplete } from '../utils/fields'; +import { expirationDateValidator } from '../utils/validate'; +import { VALIDATION_RULE } from './validation'; export const SELECT_FIELD_CONFIG = { CARD_ISSUER: { @@ -43,3 +48,32 @@ export const INPUT_FIELD_CONFIG = { placeholder: ['**'], }, } satisfies Record; + +export const FIELD_STEP_SEQUENCE = [ + { + name: 'cardNumbers', + isComplete: (cardNumbers: CardNumbersType) => { + const brand = detectCardBrand(cardNumbers); + return cardNumbers.every((value, i) => + isFieldComplete(value, getCardNumbersMaxLength(brand, cardNumbers.length, i)) + ); + }, + }, + { + name: 'cardIssuer', + isComplete: (value: string | null) => !!value, + }, + { + name: 'expirationDate', + isComplete: (expirationDate: ExpirationDateType) => + Object.values(expirationDate).every((value, i) => !expirationDateValidator(value, i).error), + }, + { + name: 'cvc', + isComplete: (value: string) => isFieldComplete(value, VALIDATION_RULE.CVC_LENGTH), + }, + { + name: 'password', + isComplete: (value: string) => isFieldComplete(value, VALIDATION_RULE.PASSWORD_LENGTH), + }, +]; From 99a922157e10fe8280258b2bdf5bd86b312cd077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 22 Jun 2026 11:26:09 +0900 Subject: [PATCH 41/43] =?UTF-8?q?refactor:=20=ED=8F=BC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20useCardForm=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=ED=9B=85=EC=9C=BC=EB=A1=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Form/PaymentForm.tsx | 132 +++++++++------------------- src/hooks/useCardForm.ts | 85 ++++++++++++++++++ 2 files changed, 126 insertions(+), 91 deletions(-) create mode 100644 src/hooks/useCardForm.ts diff --git a/src/components/Form/PaymentForm.tsx b/src/components/Form/PaymentForm.tsx index 387c773626..59fc00e686 100644 --- a/src/components/Form/PaymentForm.tsx +++ b/src/components/Form/PaymentForm.tsx @@ -1,11 +1,10 @@ -import { ChangeEvent, SubmitEvent, useState } from 'react'; +import { ChangeEvent, SubmitEvent } from 'react'; import styled from '@emotion/styled'; import InputFieldLayout from '../Layout/InputFieldLayout'; import { expirationDateValidator } from '../../utils/validate'; import InputFieldForm from '../Common/Form/InputFieldForm'; import { CARD_ISSUER_CONFIG, - FIELD_STEP_SEQUENCE, INPUT_FIELD_CONFIG, InputFieldConfigType, SELECT_FIELD_CONFIG, @@ -20,6 +19,7 @@ import { useNavigate } from 'react-router-dom'; import CardPreview from '../Card/CardPreview/CardPreview'; import { registerCard } from '../../apis/cards'; import { ApiError, getFieldByErrorCode } from '../../apis/api'; +import useCardForm from '../../hooks/useCardForm'; export type CardNumbersType = [string, string, string, string]; export type ExpirationDateType = { month: string; year: string }; @@ -27,81 +27,37 @@ export type CardIssuerType = (typeof CARD_ISSUER_CONFIG)[keyof typeof CARD_ISSUE export default function PaymentForm() { const navigate = useNavigate(); - - const [step, setStep] = useState(1); - const [password, setPassword] = useState(''); - const [cvc, setCVC] = useState(''); - const [expirationDate, setExpirationDate] = useState({ month: '', year: '' }); - const [cardIssuer, setCardIssuer] = useState(null); - const [cardNumbers, setCardNumbers] = useState(['', '', '', '']); - - const [serverError, setServerError] = useState<{ - field: InputFieldConfigType; - message: string; - } | null>(null); - - const isValid = - isCardRegistrationComplete({ cardNumbers, expirationDate, cvc, password, cardIssuer }) && - !serverError; - - const handlePasswordChange = (e: ChangeEvent) => { - const value = e.target.value; - setPassword(e.target.value); - - advanceStep('password', value); - }; - - const handleCVCChange = (e: ChangeEvent) => { - clearServerError('CVC'); - const value = e.target.value; - setCVC(value); - - advanceStep('cvc', value); - }; - - const handleExpirationDateChange = - (field: keyof ExpirationDateType) => (e: ChangeEvent) => { - clearServerError('EXPIRATION_DATE'); - const newExpirationDate = { ...expirationDate }; - newExpirationDate[field] = e.target.value; - setExpirationDate(newExpirationDate); - - advanceStep('expirationDate', newExpirationDate); - }; - - const handleCardIssuerSelect = (value: CardIssuerType | null) => { - setCardIssuer(value); - advanceStep('cardIssuer', value); - }; - - const handleCardNumbersChange = (index: number) => (e: ChangeEvent) => { - clearServerError('CARD_NUMBERS'); - const newCardNumbers = [...cardNumbers] as CardNumbersType; - newCardNumbers[index] = e.target.value; - setCardNumbers(newCardNumbers); - - advanceStep('cardNumbers', newCardNumbers); - }; + const { + values, + step, + serverError, + setServerError, + isValid, + handleCardNumberChange, + handleExpirationChange, + handleTextChange, + selectCardIssuer, + } = useCardForm(); const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); const issuer = Object.values(CARD_ISSUER_CONFIG).filter( - (issuer) => issuer.name === cardIssuer + (issuer) => issuer.name === values.cardIssuer )[0]; try { await registerCard({ - number: cardNumbers.join(''), - expirationDate: `${expirationDate['month']}/${expirationDate['year']}`, - cvc, + number: values.cardNumbers.join(''), + expirationDate: `${values.expirationDate['month']}/${values.expirationDate['year']}`, + cvc: values.cvc, issuerCode: issuer.issuerCode, }); navigate('/registration/completion', { state: { - prefix: cardNumbers[0], - cardIssuer, + prefix: values.cardNumbers[0], + cardIssuer: values.cardIssuer, }, replace: true, }); @@ -116,16 +72,6 @@ export default function PaymentForm() { } }; - const advanceStep = (fieldName: string, value: T) => { - const index = FIELD_STEP_SEQUENCE.findIndex((f) => f.name === fieldName); - - if (FIELD_STEP_SEQUENCE[index].isComplete(value)) setStep(index + 2); - }; - - const clearServerError = (field: InputFieldConfigType) => { - if (serverError?.field === field) setServerError(null); - }; - const withServerError = ( field: InputFieldConfigType, value: string, @@ -143,11 +89,11 @@ export default function PaymentForm() { @@ -157,7 +103,7 @@ export default function PaymentForm() { hintText={INPUT_FIELD_CONFIG['PASSWORD'].hintText} > ({ + fields={convertValueFormat(values.password).map((value) => ({ value, maxLength: VALIDATION_RULE.PASSWORD_LENGTH, touched: !!value, @@ -165,7 +111,7 @@ export default function PaymentForm() { errorMessage: '', }))} fieldConfig={INPUT_FIELD_CONFIG['PASSWORD']} - onChanges={[handlePasswordChange]} + onChanges={[(e: ChangeEvent) => handleTextChange('password')(e)]} /> )} @@ -173,13 +119,13 @@ export default function PaymentForm() { {step >= 4 && ( ({ + fields={convertValueFormat(values.cvc).map((value) => ({ value, maxLength: VALIDATION_RULE.CVC_LENGTH, ...withServerError('CVC', value), }))} fieldConfig={INPUT_FIELD_CONFIG['CVC']} - onChanges={[handleCVCChange]} + onChanges={[(e: ChangeEvent) => handleTextChange('cvc')(e)]} /> )} @@ -190,13 +136,13 @@ export default function PaymentForm() { hintText={INPUT_FIELD_CONFIG['EXPIRATION_DATE'].hintText} > ({ + fields={convertValueFormat(values.expirationDate).map((value, index) => ({ value, maxLength: VALIDATION_RULE.EXPIRATION_DATE_LENGTH, ...withServerError('EXPIRATION_DATE', value, expirationDateValidator(value, index)), }))} fieldConfig={INPUT_FIELD_CONFIG['EXPIRATION_DATE']} - onChanges={[handleExpirationDateChange('month'), handleExpirationDateChange('year')]} + onChanges={[handleExpirationChange('month'), handleExpirationChange('year')]} /> )} @@ -208,7 +154,7 @@ export default function PaymentForm() { > )} @@ -218,10 +164,10 @@ export default function PaymentForm() { hintText={INPUT_FIELD_CONFIG['CARD_NUMBERS'].hintText} > { + fields={convertValueFormat(values.cardNumbers).map((value, index) => { const maxLength = getCardNumbersMaxLength( - detectCardBrand(cardNumbers), - cardNumbers.length, + detectCardBrand(values.cardNumbers), + values.cardNumbers.length, index ); @@ -232,13 +178,17 @@ export default function PaymentForm() { }; })} fieldConfig={INPUT_FIELD_CONFIG['CARD_NUMBERS']} - onChanges={[0, 1, 2, 3].map(handleCardNumbersChange)} + onChanges={[0, 1, 2, 3].map(handleCardNumberChange)} /> - {isCardRegistrationComplete({ cardNumbers, expirationDate, cvc, password, cardIssuer }) && ( - - )} + {isCardRegistrationComplete({ + cardNumbers: values.cardNumbers, + expirationDate: values.expirationDate, + cvc: values.cvc, + password: values.password, + cardIssuer: values.cardIssuer, + }) && } ); diff --git a/src/hooks/useCardForm.ts b/src/hooks/useCardForm.ts new file mode 100644 index 0000000000..f19d5cdce8 --- /dev/null +++ b/src/hooks/useCardForm.ts @@ -0,0 +1,85 @@ +import { ChangeEvent, useState } from 'react'; +import { + CardIssuerType, + CardNumbersType, + ExpirationDateType, +} from '../components/Form/PaymentForm'; +import { FIELD_STEP_SEQUENCE, InputFieldConfigType } from '../constants'; +import { isCardRegistrationComplete } from '../utils/fields'; + +type FormValues = { + cardNumbers: CardNumbersType; + expirationDate: ExpirationDateType; + cvc: string; + password: string; + cardIssuer: CardIssuerType | null; +}; + +type ServerError = { field: InputFieldConfigType; message: string } | null; + +const SERVER_ERROR_FIELD: Partial> = { + cardNumbers: 'CARD_NUMBERS', + expirationDate: 'EXPIRATION_DATE', + cvc: 'CVC', +}; + +const useCardForm = () => { + const [values, setValues] = useState({ + cardNumbers: ['', '', '', ''], + expirationDate: { month: '', year: '' }, + cvc: '', + password: '', + cardIssuer: null, + }); + const [step, setStep] = useState(1); + const [serverError, setServerError] = useState(null); + + const setField = (name: K, newValue: FormValues[K]) => { + setValues((prev) => ({ ...prev, [name]: newValue })); + clearServerError(name); + advanceStep(name, newValue); + }; + + const handleCardNumberChange = (index: number) => (e: ChangeEvent) => { + const next = [...values.cardNumbers] as CardNumbersType; + next[index] = e.target.value; + setField('cardNumbers', next); + }; + + const handleExpirationChange = + (key: keyof ExpirationDateType) => (e: ChangeEvent) => { + setField('expirationDate', { ...values.expirationDate, [key]: e.target.value }); + }; + + const handleTextChange = (name: 'cvc' | 'password') => (e: ChangeEvent) => + setField(name, e.target.value); + + const selectCardIssuer = (value: CardIssuerType | null) => setField('cardIssuer', value); + + const advanceStep = (fieldName: string, value: T) => { + const index = FIELD_STEP_SEQUENCE.findIndex((f) => f.name === fieldName); + + if (FIELD_STEP_SEQUENCE[index].isComplete(value)) setStep(index + 2); + }; + + const clearServerError = (name: keyof FormValues) => { + const field = SERVER_ERROR_FIELD[name]; + if (field && serverError?.field === field) setServerError(null); + }; + + const isValid = isCardRegistrationComplete(values) && !serverError; + + return { + values, + step, + serverError, + setServerError, + isValid, + handleCardNumberChange, + handleExpirationChange, + handleTextChange, + selectCardIssuer, + }; +}; + +export default useCardForm; From b173b6fe1ace92502439d96be452dd15e02220be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 22 Jun 2026 11:34:08 +0900 Subject: [PATCH 42/43] =?UTF-8?q?refactor:=20config=20=EA=B0=9D=EC=B2=B4?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/api.ts | 6 ++--- src/components/Form/PaymentForm.tsx | 34 ++++++++++++++--------------- src/constants/inputField.ts | 10 ++++----- src/hooks/useCardForm.ts | 9 +------- 4 files changed, 26 insertions(+), 33 deletions(-) diff --git a/src/apis/api.ts b/src/apis/api.ts index 6a2ba249f0..22a8389362 100644 --- a/src/apis/api.ts +++ b/src/apis/api.ts @@ -4,9 +4,9 @@ const BASE_URL = import.meta.env.VITE_BASE_URL; export type ApiErrorCode = 'INVALID_CARD_NUMBER' | 'INVALID_CVC' | 'INVALID_EXPIRATION_DATE'; export const ERROR_CODE_TO_FIELD: Record = { - INVALID_CARD_NUMBER: 'CARD_NUMBERS', - INVALID_CVC: 'CVC', - INVALID_EXPIRATION_DATE: 'EXPIRATION_DATE', + INVALID_CARD_NUMBER: 'cardNumbers', + INVALID_CVC: 'cvc', + INVALID_EXPIRATION_DATE: 'expirationDate', }; export const getFieldByErrorCode = (code: string): InputFieldConfigType | null => diff --git a/src/components/Form/PaymentForm.tsx b/src/components/Form/PaymentForm.tsx index 59fc00e686..eca728fe75 100644 --- a/src/components/Form/PaymentForm.tsx +++ b/src/components/Form/PaymentForm.tsx @@ -99,8 +99,8 @@ export default function PaymentForm() { {step >= 5 && ( ({ @@ -110,21 +110,21 @@ export default function PaymentForm() { error: false, errorMessage: '', }))} - fieldConfig={INPUT_FIELD_CONFIG['PASSWORD']} + fieldConfig={INPUT_FIELD_CONFIG['password']} onChanges={[(e: ChangeEvent) => handleTextChange('password')(e)]} /> )} {step >= 4 && ( - + ({ value, maxLength: VALIDATION_RULE.CVC_LENGTH, - ...withServerError('CVC', value), + ...withServerError('cvc', value), }))} - fieldConfig={INPUT_FIELD_CONFIG['CVC']} + fieldConfig={INPUT_FIELD_CONFIG['cvc']} onChanges={[(e: ChangeEvent) => handleTextChange('cvc')(e)]} /> @@ -132,16 +132,16 @@ export default function PaymentForm() { {step >= 3 && ( ({ value, maxLength: VALIDATION_RULE.EXPIRATION_DATE_LENGTH, - ...withServerError('EXPIRATION_DATE', value, expirationDateValidator(value, index)), + ...withServerError('expirationDate', value, expirationDateValidator(value, index)), }))} - fieldConfig={INPUT_FIELD_CONFIG['EXPIRATION_DATE']} + fieldConfig={INPUT_FIELD_CONFIG['expirationDate']} onChanges={[handleExpirationChange('month'), handleExpirationChange('year')]} /> @@ -149,19 +149,19 @@ export default function PaymentForm() { {step >= 2 && ( )} { @@ -174,10 +174,10 @@ export default function PaymentForm() { return { value, maxLength, - ...withServerError('CARD_NUMBERS', value), + ...withServerError('cardNumbers', value), }; })} - fieldConfig={INPUT_FIELD_CONFIG['CARD_NUMBERS']} + fieldConfig={INPUT_FIELD_CONFIG['cardNumbers']} onChanges={[0, 1, 2, 3].map(handleCardNumberChange)} /> diff --git a/src/constants/inputField.ts b/src/constants/inputField.ts index b8ba281a60..9fafcd169d 100644 --- a/src/constants/inputField.ts +++ b/src/constants/inputField.ts @@ -6,7 +6,7 @@ import { expirationDateValidator } from '../utils/validate'; import { VALIDATION_RULE } from './validation'; export const SELECT_FIELD_CONFIG = { - CARD_ISSUER: { + cardIssuer: { id: 'cardIssuer', sectionTitle: '카드사를 선택해 주세요', hintText: '현재 국내 카드사만 가능합니다', @@ -16,7 +16,7 @@ export const SELECT_FIELD_CONFIG = { export type InputFieldConfigType = keyof typeof INPUT_FIELD_CONFIG; export const INPUT_FIELD_CONFIG = { - CARD_NUMBERS: { + cardNumbers: { id: 'cardNumbers', type: 'text', sectionTitle: '결제할 카드 번호를 입력해 주세요', @@ -24,7 +24,7 @@ export const INPUT_FIELD_CONFIG = { label: '카드 번호', placeholder: ['1234', '1234', '1234', '1234'], }, - EXPIRATION_DATE: { + expirationDate: { id: 'expirationDate', type: 'text', sectionTitle: '카드 유효기간을 입력해 주세요', @@ -32,14 +32,14 @@ export const INPUT_FIELD_CONFIG = { label: '유효 기간', placeholder: ['MM', 'YY'], }, - CVC: { + cvc: { id: 'cvc', type: 'text', sectionTitle: 'CVC 번호를 입력해 주세요', label: 'CVC', placeholder: ['123'], }, - PASSWORD: { + password: { id: 'password', type: 'password', sectionTitle: '비밀번호를 입력해 주세요', diff --git a/src/hooks/useCardForm.ts b/src/hooks/useCardForm.ts index f19d5cdce8..2ea861ffa7 100644 --- a/src/hooks/useCardForm.ts +++ b/src/hooks/useCardForm.ts @@ -17,12 +17,6 @@ type FormValues = { type ServerError = { field: InputFieldConfigType; message: string } | null; -const SERVER_ERROR_FIELD: Partial> = { - cardNumbers: 'CARD_NUMBERS', - expirationDate: 'EXPIRATION_DATE', - cvc: 'CVC', -}; - const useCardForm = () => { const [values, setValues] = useState({ cardNumbers: ['', '', '', ''], @@ -63,8 +57,7 @@ const useCardForm = () => { }; const clearServerError = (name: keyof FormValues) => { - const field = SERVER_ERROR_FIELD[name]; - if (field && serverError?.field === field) setServerError(null); + if (serverError?.field === name) setServerError(null); }; const isValid = isCardRegistrationComplete(values) && !serverError; From 4f07310610601dafa8fd00c2e42ceb63ca0674ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=88=EC=95=88?= Date: Mon, 22 Jun 2026 11:42:45 +0900 Subject: [PATCH 43/43] =?UTF-8?q?refactor:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Form/{InputFieldForm.tsx => FieldSet.tsx} | 2 +- src/components/Form/PaymentForm.tsx | 32 +++++++++---------- .../{InputFieldLayout.tsx => FieldLayout.tsx} | 2 +- 3 files changed, 18 insertions(+), 18 deletions(-) rename src/components/Common/Form/{InputFieldForm.tsx => FieldSet.tsx} (97%) rename src/components/Layout/{InputFieldLayout.tsx => FieldLayout.tsx} (94%) diff --git a/src/components/Common/Form/InputFieldForm.tsx b/src/components/Common/Form/FieldSet.tsx similarity index 97% rename from src/components/Common/Form/InputFieldForm.tsx rename to src/components/Common/Form/FieldSet.tsx index 3dab65f7b1..1840636016 100644 --- a/src/components/Common/Form/InputFieldForm.tsx +++ b/src/components/Common/Form/FieldSet.tsx @@ -18,7 +18,7 @@ interface Props { onChanges: ((e: ChangeEvent) => void)[]; } -export default function InputFieldForm({ fields, fieldConfig, onChanges }: Props) { +export default function FieldSet({ fields, fieldConfig, onChanges }: Props) { const inputRefs = useRef<(HTMLInputElement | null)[]>([]); const errorField = fields.find(({ touched, error }) => touched && error); diff --git a/src/components/Form/PaymentForm.tsx b/src/components/Form/PaymentForm.tsx index eca728fe75..6f392aa383 100644 --- a/src/components/Form/PaymentForm.tsx +++ b/src/components/Form/PaymentForm.tsx @@ -1,8 +1,8 @@ import { ChangeEvent, SubmitEvent } from 'react'; import styled from '@emotion/styled'; -import InputFieldLayout from '../Layout/InputFieldLayout'; +import FieldLayout from '../Layout/FieldLayout'; import { expirationDateValidator } from '../../utils/validate'; -import InputFieldForm from '../Common/Form/InputFieldForm'; +import FieldSet from '../Common/Form/FieldSet'; import { CARD_ISSUER_CONFIG, INPUT_FIELD_CONFIG, @@ -98,11 +98,11 @@ export default function PaymentForm() { {step >= 5 && ( - - ({ value, maxLength: VALIDATION_RULE.PASSWORD_LENGTH, @@ -113,12 +113,12 @@ export default function PaymentForm() { fieldConfig={INPUT_FIELD_CONFIG['password']} onChanges={[(e: ChangeEvent) => handleTextChange('password')(e)]} /> - + )} {step >= 4 && ( - - +
({ value, maxLength: VALIDATION_RULE.CVC_LENGTH, @@ -127,15 +127,15 @@ export default function PaymentForm() { fieldConfig={INPUT_FIELD_CONFIG['cvc']} onChanges={[(e: ChangeEvent) => handleTextChange('cvc')(e)]} /> - + )} {step >= 3 && ( - - ({ value, maxLength: VALIDATION_RULE.EXPIRATION_DATE_LENGTH, @@ -144,11 +144,11 @@ export default function PaymentForm() { fieldConfig={INPUT_FIELD_CONFIG['expirationDate']} onChanges={[handleExpirationChange('month'), handleExpirationChange('year')]} /> - + )} {step >= 2 && ( - @@ -156,14 +156,14 @@ export default function PaymentForm() { fieldConfig={SELECT_FIELD_CONFIG['cardIssuer']} onChange={selectCardIssuer} /> - + )} - - { const maxLength = getCardNumbersMaxLength( detectCardBrand(values.cardNumbers), @@ -180,7 +180,7 @@ export default function PaymentForm() { fieldConfig={INPUT_FIELD_CONFIG['cardNumbers']} onChanges={[0, 1, 2, 3].map(handleCardNumberChange)} /> - + {isCardRegistrationComplete({ cardNumbers: values.cardNumbers, diff --git a/src/components/Layout/InputFieldLayout.tsx b/src/components/Layout/FieldLayout.tsx similarity index 94% rename from src/components/Layout/InputFieldLayout.tsx rename to src/components/Layout/FieldLayout.tsx index 64c5b09d38..33e02d106b 100644 --- a/src/components/Layout/InputFieldLayout.tsx +++ b/src/components/Layout/FieldLayout.tsx @@ -2,7 +2,7 @@ import styled from '@emotion/styled'; import SectionTitle from '../Common/SectionTitle/SectionTitle'; import HintText from '../Common/HintText/HintText'; -export default function InputFieldLayout({ +export default function FieldLayout({ sectionTitle, hintText, children,