diff --git a/package-lock.json b/package-lock.json index f0c6412c1d..c165b12d5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,9 @@ "@storybook/addon-docs": "^10.3.5", "@storybook/addon-vitest": "^10.3.5", "@storybook/react-vite": "^10.3.5", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -32,6 +35,8 @@ "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.3.5", "globals": "^17.5.0", + "jsdom": "^29.1.1", + "msw": "^2.14.6", "playwright": "^1.59.1", "prettier": "^3.8.3", "storybook": "^10.3.5", @@ -49,6 +54,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", @@ -247,7 +303,6 @@ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -317,6 +372,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", @@ -338,6 +406,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", @@ -1033,6 +1241,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "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", @@ -1099,6 +1325,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", @@ -1187,6 +1500,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", @@ -1213,6 +1551,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", @@ -2040,6 +2403,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", @@ -2201,6 +2592,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", @@ -2774,7 +3182,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3061,6 +3468,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", @@ -3292,14 +3709,52 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, - "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==", + "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": "MIT", - "dependencies": { - "color-name": "~1.1.4" + "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" @@ -3381,6 +3836,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", @@ -3395,6 +3864,20 @@ "dev": true, "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/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3467,6 +3950,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", @@ -3637,6 +4127,13 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/empathic": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", @@ -4290,6 +4787,33 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4461,6 +4985,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4600,6 +5134,16 @@ "license": "ISC", "optional": true }, + "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-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -4694,6 +5238,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", @@ -4711,6 +5273,19 @@ "hermes-estree": "0.25.1" } }, + "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", @@ -4964,6 +5539,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -5042,6 +5627,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -5059,6 +5651,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5311,6 +5910,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "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", @@ -5821,6 +6471,13 @@ "node": ">= 0.4" } }, + "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/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -5884,6 +6541,61 @@ "dev": true, "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", @@ -6103,6 +6815,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/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -6185,6 +6904,32 @@ "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/parse5/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/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6239,6 +6984,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", @@ -6689,6 +7441,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/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": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -6723,6 +7495,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/rolldown": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", @@ -6832,6 +7611,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", @@ -7009,6 +7801,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", @@ -7062,6 +7867,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", @@ -7146,6 +7961,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/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -7342,6 +8192,26 @@ "dev": true, "license": "MIT" }, + "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", + "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", @@ -7403,6 +8273,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/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -7413,6 +8303,32 @@ "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/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/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -7471,6 +8387,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/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -7606,6 +8538,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -7639,6 +8581,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", @@ -7901,6 +8853,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", @@ -7908,6 +8883,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", @@ -8040,6 +9040,37 @@ "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/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", @@ -8078,6 +9109,33 @@ "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", + "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", @@ -8085,6 +9143,35 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 6ee3775a5b..1a8aa25fbd 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,11 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "test": "vitest run --project unit", "preview": "vite preview", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "deploy": "npm run build && touch dist/.nojekyll && cp dist/index.html dist/404.html && npx gh-pages -d dist" + "deploy": "VITE_ENABLE_MSW=true npm run build && touch dist/.nojekyll && cp dist/index.html dist/404.html && npx gh-pages -d dist" }, "dependencies": { "classnames": "^2.5.1", @@ -25,6 +26,9 @@ "@storybook/addon-docs": "^10.3.5", "@storybook/addon-vitest": "^10.3.5", "@storybook/react-vite": "^10.3.5", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -37,6 +41,8 @@ "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.3.5", "globals": "^17.5.0", + "jsdom": "^29.1.1", + "msw": "^2.14.6", "playwright": "^1.59.1", "prettier": "^3.8.3", "storybook": "^10.3.5", @@ -45,5 +51,10 @@ "vite": "^8.0.10", "vite-plugin-svgr": "^5.2.0", "vitest": "^4.1.5" + }, + "msw": { + "workerDirectory": [ + "public" + ] } -} +} \ No newline at end of file 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/App.tsx b/src/App.tsx index 41430aba61..36c2ae2a3f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,17 @@ import './core/styles/index.css'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; -import { Payments } from './pages/payments/Payments'; +import { CardListPage } from './pages/cardList/CardListPage'; +import { RegisterCardPage } from './pages/registerCard/ui/RegisterCardPage'; import { Result } from './pages/result/Result'; function App() { return ( - } /> + } /> + } /> + } /> } /> diff --git a/src/core/hooks/useInputFocus.ts b/src/core/hooks/useInputFocus.ts index 6691dd935f..63f8e847c4 100644 --- a/src/core/hooks/useInputFocus.ts +++ b/src/core/hooks/useInputFocus.ts @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useCallback, useRef } from 'react'; export type FocusableElement = HTMLInputElement | HTMLSelectElement; @@ -10,24 +10,24 @@ export interface UseInputFocusResult { export const useInputFocus = (): UseInputFocusResult => { const inputRefsMap = useRef>(new Map()); - const getMap = () => { + const getMap = useCallback(() => { if (!inputRefsMap.current) inputRefsMap.current = new Map(); return inputRefsMap.current; - }; + }, []); - const setInputRef = (node: FocusableElement | null, index: number): void => { + const setInputRef = useCallback((node: FocusableElement | null, index: number): void => { const map = getMap(); if (node) { map.set(index, node); return; } map.delete(index); - }; + }, [getMap]); - const focusNext = (index: number): void => { + const focusNext = useCallback((index: number): void => { const map = getMap(); map.get(index)?.focus(); - }; + }, [getMap]); return { setInputRef, focusNext }; }; diff --git a/src/core/lib/getBaseUrl.ts b/src/core/lib/getBaseUrl.ts new file mode 100644 index 0000000000..33944b7bfd --- /dev/null +++ b/src/core/lib/getBaseUrl.ts @@ -0,0 +1,16 @@ +const getBaseUrl = () => { + return import.meta.env.BASE_URL.endsWith('/') + ? import.meta.env.BASE_URL + : `${import.meta.env.BASE_URL}/`; +}; + +export const getApiUrl = (path: string): string => { + const baseUrl = getBaseUrl(); + const normalizedPath = path.startsWith('/') ? path.slice(1) : path; + + if (typeof window === 'undefined') { + return `http://localhost${baseUrl}${normalizedPath}`; + } + + return new URL(`${baseUrl}${normalizedPath}`, window.location.origin).toString(); +}; diff --git a/src/core/utils/validator.ts b/src/core/utils/validator.ts index 3e81001690..f82df235dc 100644 --- a/src/core/utils/validator.ts +++ b/src/core/utils/validator.ts @@ -3,12 +3,6 @@ export const isNumericString = (str: string) => { return regex.test(str); }; -export const isValidMonth = (month: string) => { - if (month.length === 1) return /^[0-1]$/.test(month); // 첫 자리 0,1만 - if (month.length === 2) return /^(0[1-9]|1[0-2])$/.test(month); // 01~12 - return true; -}; - export const isValidInputNumber = (input: string, maxLength: number) => { if (input !== '' && !isNumericString(input)) return false; if (input.length > maxLength) return false; diff --git a/src/entities/card/api/cards.ts b/src/entities/card/api/cards.ts new file mode 100644 index 0000000000..a86b492fb6 --- /dev/null +++ b/src/entities/card/api/cards.ts @@ -0,0 +1,55 @@ +import { getApiUrl } from '@/core/lib/getBaseUrl'; +import type { + CardListResponse, + DeleteCardResponse, + RegisterCardErrorResponse, + RegisterCardRequest, + RegisterCardResponse, +} from '@/entities/card/model/card'; + +export const isRegisterCardErrorResponse = (error: unknown): error is RegisterCardErrorResponse => { + if (error === null || typeof error !== 'object') return false; + + return 'code' in error && 'message' in error; +}; + +export const registerCard = async (payload: RegisterCardRequest): Promise => { + const response = await fetch(getApiUrl('/cards'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (response.status === 201) { + return response.json() as Promise; + } + + if (response.status === 400) { + const error = (await response.json()) as RegisterCardErrorResponse; + throw error; + } + + throw new Error('카드 등록에 실패했습니다.'); +}; + +export const getCards = async (): Promise => { + const response = await fetch(getApiUrl('/cards')); + + if (!response.ok) { + throw new Error('카드 목록을 불러오지 못했습니다.'); + } + + return response.json() as Promise; +}; + +export const deleteCard = async (id: string): Promise => { + const response = await fetch(getApiUrl(`/cards/${id}`), { + method: 'DELETE', + }); + + if (response.status !== 204) { + throw new Error('카드 삭제에 실패했습니다.'); + } +}; diff --git a/src/entities/card/config/bank.module.css b/src/entities/card/config/bank.module.css index 9d87aa4713..457a97f4c3 100644 --- a/src/entities/card/config/bank.module.css +++ b/src/entities/card/config/bank.module.css @@ -1,27 +1,27 @@ .unknown { background-color: #333333; } -.bc { +.red { background-color: #f04651; } -.shinhan { +.blue { background-color: #0046ff; } -.kakaobank { +.yellow { background-color: #ffe600; } -.hyundai { +.black { background-color: #000000; } -.woori { +.sky { background-color: #007bc8; } -.lotte { +.orange { background-color: #ed1c24; } -.hana { +.teal { background-color: #009490; } -.kookmin { +.gray { background-color: #6a6056; } diff --git a/src/entities/card/config/brandSvgMap.ts b/src/entities/card/config/brandSvgMap.ts index 62ebbe0456..db354601af 100644 --- a/src/entities/card/config/brandSvgMap.ts +++ b/src/entities/card/config/brandSvgMap.ts @@ -4,13 +4,12 @@ import UnionPaySvg from '@/entities/card/config/UnionPay.svg'; import MastercardSvg from '@/entities/card/config/Mastercard.svg'; import VisaSvg from '@/entities/card/config/Visa.svg'; -import { type Brand } from '../model/brand'; +import { type CardBrand } from '../model/cardNumber'; -export const BRAND_SVG_MAP: Record = { +export const BRAND_SVG_MAP: Record = { VISA: VisaSvg, MASTERCARD: MastercardSvg, AMEX: AmericanExpressSvg, DINERS: DinersSvg, UNIONPAY: UnionPaySvg, - UNKNOWN: undefined, }; diff --git a/src/entities/card/index.ts b/src/entities/card/index.ts deleted file mode 100644 index 09485ea4cf..0000000000 --- a/src/entities/card/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './model/cvc'; -export * from './model/numbers'; -export * from './model/brand'; - -export * from './lib/handleNumbers'; diff --git a/src/entities/card/lib/handleNumbers.ts b/src/entities/card/lib/handleNumbers.ts deleted file mode 100644 index 46f701bd05..0000000000 --- a/src/entities/card/lib/handleNumbers.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { BRAND_RULES, type Brand } from '../model/brand'; -import { getBrandByNumber } from '../model/numbers'; - -export const updateNumbers = ( - cardNumbers: string[], - inputValue: string, - index: number, - brand: Brand, -) => { - const next = [...cardNumbers]; - next[index] = inputValue; - const nextBrand = getBrandByNumber(next); - if (nextBrand !== brand) next[3] = next[3].slice(0, BRAND_RULES[nextBrand].format[3]); - return next; -}; diff --git a/src/entities/card/model/bank.ts b/src/entities/card/model/bank.ts index d21938c8af..4730ae0cb4 100644 --- a/src/entities/card/model/bank.ts +++ b/src/entities/card/model/bank.ts @@ -11,18 +11,36 @@ export const BANK = { export type Bank = (typeof BANK)[keyof typeof BANK]; export const BANKS = Object.values(BANK) as Bank[]; + +export type Code = '31' | '41' | '15' | '61' | 'W1' | '71' | '21' | '11'; interface BankConfig { - className: string; + color: string; label: string; + code: Code; } export const BANK_RULES: Record = { - BC: { className: 'bc', label: 'BC카드' }, - SHINHAN: { className: 'shinhan', label: '신한카드' }, - KAKAOBANK: { className: 'kakaobank', label: '카카오뱅크' }, - HYUNDAI: { className: 'hyundai', label: '현대카드' }, - WOORI: { className: 'woori', label: '우리카드' }, - LOTTE: { className: 'lotte', label: '롯데카드' }, - HANA: { className: 'hana', label: '하나카드' }, - KOOKMIN: { className: 'kookmin', label: '국민카드' }, + BC: { color: 'red', label: 'BC카드', code: '31' }, + SHINHAN: { color: 'blue', label: '신한카드', code: '41' }, + KAKAOBANK: { color: 'yellow', label: '카카오뱅크', code: '15' }, + HYUNDAI: { color: 'black', label: '현대카드', code: '61' }, + WOORI: { color: 'sky', label: '우리카드', code: 'W1' }, + LOTTE: { color: 'orange', label: '롯데카드', code: '71' }, + HANA: { color: 'teal', label: '하나카드', code: '21' }, + KOOKMIN: { color: 'gray', label: '국민카드', code: '11' }, +}; + +export const ISSUER_CODE_BANK: Record = { + '31': 'BC', + '41': 'SHINHAN', + '15': 'KAKAOBANK', + '61': 'HYUNDAI', + W1: 'WOORI', + '71': 'LOTTE', + '21': 'HANA', + '11': 'KOOKMIN', +}; + +export const getBankByIssuerCode = (issuerCode: string): Bank | undefined => { + return ISSUER_CODE_BANK[issuerCode as Code]; }; diff --git a/src/entities/card/model/card.ts b/src/entities/card/model/card.ts new file mode 100644 index 0000000000..19f568d37d --- /dev/null +++ b/src/entities/card/model/card.ts @@ -0,0 +1,31 @@ +export interface Card { + id: string; + issuerCode: string; + number: string; + expirationDate: string; +} + +export type CardListResponse = Card[]; + +export type DeleteCardResponse = void; + +export interface RegisterCardRequest { + number: string; + expirationDate: string; + cvc: string; + issuerCode: string; +} + +export interface RegisterCardResponse { + id: string; +} + +export type RegisterCardErrorCode = + | 'INVALID_CARD_NUMBER' + | 'INVALID_CVC' + | 'INVALID_EXPIRATION_DATE'; + +export interface RegisterCardErrorResponse { + code: RegisterCardErrorCode; + message: string; +} diff --git a/src/entities/card/model/brand.ts b/src/entities/card/model/cardNumber.ts similarity index 66% rename from src/entities/card/model/brand.ts rename to src/entities/card/model/cardNumber.ts index ad418dee3d..b05db50cad 100644 --- a/src/entities/card/model/brand.ts +++ b/src/entities/card/model/cardNumber.ts @@ -1,18 +1,16 @@ -export const BRAND = { +export const CARD_BRAND = { VISA: 'VISA', MASTERCARD: 'MASTERCARD', DINERS: 'DINERS', AMEX: 'AMEX', UNIONPAY: 'UNIONPAY', - UNKNOWN: 'UNKNOWN', } as const; -export type Brand = (typeof BRAND)[keyof typeof BRAND]; +export type CardBrand = (typeof CARD_BRAND)[keyof typeof CARD_BRAND]; type MatchCondition = (str: string) => boolean; interface BrandConfig { - format: number[]; length: number; isMatch: MatchCondition[]; } @@ -21,29 +19,24 @@ const isInRange = (str: string, min: number, max: number): boolean => { return Number(str) >= min && Number(str) <= max; }; -export const BRAND_RULES: Record = { +export const BRAND_RULES: Record = { VISA: { - format: [4, 4, 4, 4], length: 16, isMatch: [(str) => str.startsWith('4')], }, MASTERCARD: { - format: [4, 4, 4, 4], length: 16, isMatch: [(str) => isInRange(str.slice(0, 2), 51, 55)], }, DINERS: { - format: [4, 4, 4, 2], length: 14, isMatch: [(str) => str.startsWith('36')], }, AMEX: { - format: [4, 4, 4, 3], length: 15, isMatch: [(str) => str.startsWith('34'), (str) => str.startsWith('37')], }, UNIONPAY: { - format: [4, 4, 4, 4], length: 16, isMatch: [ (str) => isInRange(str.slice(0, 6), 622126, 622925), @@ -51,20 +44,22 @@ export const BRAND_RULES: Record = { (str) => isInRange(str.slice(0, 4), 6282, 6288), ], }, - UNKNOWN: { - format: [4, 4, 4, 4], - length: 16, - isMatch: [], - }, }; -export const getBrand = (cardNumber: string): Brand => { - const rulesEntries = Object.entries(BRAND_RULES) as [Brand, BrandConfig][]; +export const getCardBrand = (cardNumber: string): CardBrand | undefined => { + const rulesEntries = Object.entries(BRAND_RULES) as [CardBrand, BrandConfig][]; for (const [brand, config] of rulesEntries) { if (config.isMatch.some((match) => match(cardNumber))) { return brand; } } - return BRAND.UNKNOWN; + return; +}; + +export const validateCardNumber = (cardNumber: string): boolean => { + const brand = getCardBrand(cardNumber); + if (brand === undefined) return false; + + return cardNumber.length === BRAND_RULES[brand].length; }; diff --git a/src/entities/card/model/cvc.ts b/src/entities/card/model/cvc.ts index 7b7d49db2c..86d5d99ce8 100644 --- a/src/entities/card/model/cvc.ts +++ b/src/entities/card/model/cvc.ts @@ -1,14 +1,5 @@ export const CVC_LENGTH = 3; -export const ERROR_MESSAGE = { - TYPE: '숫자만 입력 가능합니다.', - RANGE: 'CVC를 전부 채워주세요.', -}; - -export const getErrorCvc = (cvc: string): string | undefined => { - if (cvc.length !== CVC_LENGTH) return ERROR_MESSAGE.RANGE; - return; -}; export const validateCvc = (cvc: string): boolean => { - return getErrorCvc(cvc) === undefined; + return cvc.length === CVC_LENGTH; }; diff --git a/src/entities/card/model/expiryDate.ts b/src/entities/card/model/expiryDate.ts index 6b1ab20b02..740c6e17af 100644 --- a/src/entities/card/model/expiryDate.ts +++ b/src/entities/card/model/expiryDate.ts @@ -1,37 +1,23 @@ -import { isNumericString, isValidMonth } from '@/core/utils/validator'; +import { isNumericString } from '@/core/utils/validator'; export interface ExpiryDate { month: string; year: string; } -export const MONTH_CONSTAND = { - LENGTH: 2, -}; -export const YEAR_CONSTAND = { - LENGTH: 2, -}; - -const ERROR_MESSAGE = { - TYPE: '숫자만 입력 가능합니다.', - RANGE: '01~12 사이로 입력해 주세요.', - EMPTY: '유효기간을 전부 채워주세요.', -}; +export const EXPIRY_MONTH_LENGTH = 2; +export const EXPIRY_YEAR_LENGTH = 2; -export const isValidFormatMonth = (month: string): boolean => { - if (!isValidMonth(month)) return false; +export const isValidMonth = (month: string) => { + if (month.length === 1) return /^[0-1]$/.test(month); + if (month.length === 2) return /^(0[1-9]|1[0-2])$/.test(month); return true; }; -export const validateMonth = (month: string): string | undefined => { - if (month.length !== MONTH_CONSTAND.LENGTH) return ERROR_MESSAGE.EMPTY; - if (!isValidMonth(month)) return ERROR_MESSAGE.RANGE; - if (!isNumericString(month)) return ERROR_MESSAGE.TYPE; - return; +export const validateExpiryMonth = (month: string): boolean => { + return isNumericString(month) && isValidMonth(month) && month.length === EXPIRY_MONTH_LENGTH; }; -export const validateYear = (year: string): string | undefined => { - if (year.length !== YEAR_CONSTAND.LENGTH) return ERROR_MESSAGE.EMPTY; - if (!isNumericString(year)) return ERROR_MESSAGE.TYPE; - return; +export const validateExpiryYear = (year: string): boolean => { + return isNumericString(year) && year.length === EXPIRY_YEAR_LENGTH; }; diff --git a/src/entities/card/model/numbers.ts b/src/entities/card/model/numbers.ts deleted file mode 100644 index 91cc7b6d2c..0000000000 --- a/src/entities/card/model/numbers.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { BRAND, getBrand, BRAND_RULES, type Brand } from './brand'; - -export const CARD_NUMBER_ERRORS = { - TYPE: '숫자만 입력 가능합니다.', - LENGTH: '카드 번호를 전부 채워주세요.', - UNKNOWN: '유효하지 않은 카드번호입니다.', -}; - -export const getBrandByNumber = (numbers: string[]): Brand => { - const stand = numbers[0].length !== 4 ? numbers[0] : numbers[0] + numbers[1]; - return getBrand(stand); -}; - -export const getNumberError = (numbers: string, length: number): string | undefined => { - return numbers.length !== length ? CARD_NUMBER_ERRORS.LENGTH : undefined; -}; - -export const getNumbersError = (fullNumbers: string): string | undefined => { - const brand = getBrand(fullNumbers); - if (brand === BRAND.UNKNOWN) return CARD_NUMBER_ERRORS.UNKNOWN; - if (fullNumbers.length !== BRAND_RULES[brand].length) return CARD_NUMBER_ERRORS.UNKNOWN; - return undefined; -}; - -export const getTotalErrorMessage = ( - isTouched: boolean, - fieldErrors: boolean[], - brand: string, -): string | undefined => { - const hasError = fieldErrors.some((e) => e === true); - - if (hasError) { - return CARD_NUMBER_ERRORS.LENGTH; - } - - if (isTouched && brand === BRAND.UNKNOWN) { - return CARD_NUMBER_ERRORS.UNKNOWN; - } -}; diff --git a/src/entities/card/model/password.ts b/src/entities/card/model/password.ts index 7138e0b9d0..1b2efba6db 100644 --- a/src/entities/card/model/password.ts +++ b/src/entities/card/model/password.ts @@ -1,14 +1,5 @@ export const PASSWORD_LENGTH = 2; -const ERROR_MESSAGE = { - RANGE: 'PASSWORD를 전부 채워주세요.', -}; - -export const getErrorPassword = (password: string): string | undefined => { - if (password.length !== PASSWORD_LENGTH) return ERROR_MESSAGE.RANGE; - return; -}; - export const validatePassword = (password: string): boolean => { - return getErrorPassword(password) === undefined; + return password.length === PASSWORD_LENGTH; }; diff --git a/src/entities/card/ui/CardItem.module.css b/src/entities/card/ui/CardItem.module.css new file mode 100644 index 0000000000..b1179027ee --- /dev/null +++ b/src/entities/card/ui/CardItem.module.css @@ -0,0 +1,45 @@ +.item { + display: flex; + align-items: center; + gap: 12px; + min-height: 58px; + padding: 10px; + border: 1px solid #e5e7eb; + border-radius: 6px; + background: #ffffff; +} + +.colorChip { + flex: 0 0 auto; + width: 58px; + height: 36px; + border-radius: 4px; +} + +.content { + min-width: 0; + flex: 1; +} + +.bank { + margin: 0 0 2px; + color: #1f2937; + font-size: 13px; + font-weight: 700; +} + +.number, +.expirationDate { + margin: 0; + color: #8a8a8a; + font-size: 10px; +} + +.deleteButton { + border: 0; + color: #9ca3af; + background: transparent; + font-size: 22px; + line-height: 1; + cursor: pointer; +} diff --git a/src/entities/card/ui/CardItem.tsx b/src/entities/card/ui/CardItem.tsx new file mode 100644 index 0000000000..860c9e3f8c --- /dev/null +++ b/src/entities/card/ui/CardItem.tsx @@ -0,0 +1,34 @@ +import bankColorStyles from '@/entities/card/config/bank.module.css'; +import { BANK_RULES, getBankByIssuerCode } from '@/entities/card/model/bank'; +import type { Card } from '@/entities/card/model/card'; + +import styles from './CardItem.module.css'; + +interface CardItemProps { + card: Card; + onDelete: (id: string) => void; +} + +export const CardItem = ({ card, onDelete }: CardItemProps) => { + const bank = getBankByIssuerCode(card.issuerCode); + const bankRule = bank !== undefined ? BANK_RULES[bank] : undefined; + + return ( +
+
+
+

{bankRule?.label ?? '알 수 없는 카드사'}

+

{card.number}

+

유효기간 {card.expirationDate}

+
+ +
+ ); +}; diff --git a/src/features/cardPreview/CardPreview.tsx b/src/features/cardPreview/CardPreview.tsx deleted file mode 100644 index 6ca79a74b8..0000000000 --- a/src/features/cardPreview/CardPreview.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import styles from './CardPreview.module.css'; -import bankStyles from '@/entities/card/config/bank.module.css'; - -import type { ExpiryDate } from '@/entities/card/model/expiryDate'; -import { BRAND_RULES, type Brand } from '@/entities/card'; -import { BANK_RULES, type Bank } from '@/entities/card/model/bank'; -import { BRAND_SVG_MAP } from '@/entities/card/config/brandSvgMap'; - -export interface CardInfo { - cardNumbers: string[]; - expiryDate: ExpiryDate; - brand: Brand; - bank: Bank | undefined; -} -interface CardPreviewProps { - info: CardInfo; -} - -const STAR = '●'; - -const CardNumber = ({ cardNumbers, brand }: { cardNumbers: string[]; brand: Brand }) => { - const INPUT_FORMAT = BRAND_RULES[brand].format; - return ( - <> - {INPUT_FORMAT.map((_, i) => ( - - {i >= 2 ? STAR.repeat(cardNumbers[i]?.length ?? 0) : (cardNumbers[i] ?? '')} - - ))} - - ); -}; - -export const CardPreview = ({ info }: CardPreviewProps) => { - const { cardNumbers, expiryDate, brand, bank } = info; - const brandImg = BRAND_SVG_MAP[brand]; - const bankClassName = bank !== undefined ? BANK_RULES[bank].className : ''; - - return ( -
-
- {brandImg && {brand}} -
- -
-
- {expiryDate.month && {expiryDate.month}/} - {expiryDate.year} -
-
-
- ); -}; diff --git a/src/features/registerCard/hooks/useExpiryDate.ts b/src/features/registerCard/hooks/useExpiryDate.ts deleted file mode 100644 index 03f15e3467..0000000000 --- a/src/features/registerCard/hooks/useExpiryDate.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { isNumericString } from '@/core/utils/validator'; -import { - isValidFormatMonth, - MONTH_CONSTAND, - validateMonth, - validateYear, - YEAR_CONSTAND, -} from '@/entities/card/model/expiryDate'; -import { useState } from 'react'; - -interface FieldState { - value: string; - errorMessage: string | undefined; - isValid: boolean; - maxLength: number; - handleChange: (value: string) => void; - handleBlur: () => void; -} - -export interface UseExpiryDateResult { - month: FieldState; - year: FieldState; - isValid: boolean; -} - -export const useExpiryDate = (): UseExpiryDateResult => { - const [monthValue, setMonthValue] = useState(''); - const [monthTouched, setMonthTouched] = useState(false); - const [yearValue, setYearValue] = useState(''); - const [yearTouched, setYearTouched] = useState(false); - - const monthError = validateMonth(monthValue); - const yearError = validateYear(yearValue); - const isValid = !monthError && !yearError; - - const handleMonthChange = (value: string) => { - if (value !== '' && !isNumericString(value)) return; - if (!isValidFormatMonth(value)) return; - setMonthValue(value); - setMonthTouched(false); - }; - - const handleYearChange = (value: string) => { - if (value !== '' && !isNumericString(value)) return; - setYearValue(value); - setYearTouched(false); - }; - - return { - month: { - value: monthValue, - errorMessage: monthTouched ? monthError : undefined, - isValid: !monthError, - handleChange: handleMonthChange, - handleBlur: () => setMonthTouched(true), - maxLength: MONTH_CONSTAND.LENGTH, - }, - year: { - value: yearValue, - errorMessage: yearTouched ? yearError : undefined, - isValid: !yearError, - handleChange: handleYearChange, - handleBlur: () => setYearTouched(true), - maxLength: YEAR_CONSTAND.LENGTH, - }, - isValid, - }; -}; diff --git a/src/features/registerCard/hooks/useNumbers.ts b/src/features/registerCard/hooks/useNumbers.ts deleted file mode 100644 index e3db94fab1..0000000000 --- a/src/features/registerCard/hooks/useNumbers.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { isNumericString } from '@/core/utils/validator'; -import { updateNumbers } from '@/entities/card/lib/handleNumbers'; -import { BRAND, BRAND_RULES, type Brand } from '@/entities/card/model/brand'; -import { - getBrandByNumber, - getNumberError, - getTotalErrorMessage, -} from '@/entities/card/model/numbers'; -import { useState } from 'react'; - -export interface UseNumbersResults { - values: string[]; - brand: Brand; - maxLengths: number[]; - infoErrorField: boolean[]; - totalErrorMessage: string | undefined; - handleChange: (input: string, index: number) => void; - handleBlur: (index: number) => void; -} - -export const useNumbers = (): UseNumbersResults => { - const [numbers, setNumbers] = useState(['', '', '', '']); - const [touched, setTouched] = useState([false, false, false, false]); - - const brand = getBrandByNumber(numbers); - const format = BRAND_RULES[brand].format; - - const errors = numbers.map((cardNumber, idx) => getNumberError(cardNumber, format[idx])); - const errorFiled = errors.map((error, idx) => error !== undefined && touched[idx]); - - const isTouched = touched.some((e) => e === true); - const totalErrorMessage = getTotalErrorMessage(isTouched, errorFiled, brand); - const isFilled = numbers.every((num, idx) => num.length === format[idx]); - const infoErrorField = - isFilled && brand === BRAND.UNKNOWN ? [true, true, true, true] : errorFiled; - - const handleChange = (inputValue: string, index: number) => { - if (inputValue !== '' && !isNumericString(inputValue)) return; - - const next = updateNumbers(numbers, inputValue, index, brand); - setNumbers(next); - }; - - const handleBlur = (index: number) => { - const next = [...touched]; - next[index] = true; - setTouched(next); - }; - - return { - values: numbers, - brand, - maxLengths: format, - infoErrorField, - totalErrorMessage, - handleChange, - handleBlur, - }; -}; diff --git a/src/features/registerCard/hooks/usePaymentsForm.ts b/src/features/registerCard/hooks/usePaymentsForm.ts new file mode 100644 index 0000000000..78a24fe7b5 --- /dev/null +++ b/src/features/registerCard/hooks/usePaymentsForm.ts @@ -0,0 +1,98 @@ +import type { Bank } from '@/entities/card/model/bank'; +import { validateCardNumber } from '@/entities/card/model/cardNumber'; +import { validateCvc } from '@/entities/card/model/cvc'; +import type { ExpiryDate } from '@/entities/card/model/expiryDate'; +import { validateExpiryMonth, validateExpiryYear } from '@/entities/card/model/expiryDate'; +import { validatePassword } from '@/entities/card/model/password'; +import type { CardPreviewInfo } from '@/pages/registerCard/model/cardPreview'; +import { type CardInfo } from '@/features/registerCard/model/registerCardForm'; +import { useState } from 'react'; +import type { CardNumberFieldControl } from '../ui/fields/CardNumberField'; +import type { ExpiryFieldControl } from '../ui/fields/ExpiryDateField'; +import type { BankFieldControl } from '../ui/fields/BankSelectField'; +import type { CvcFieldControl } from '../ui/fields/CvcField'; +import type { PasswordFieldControl } from '../ui/fields/PasswordField'; + +export const usePaymentsForm = (initalValue: CardInfo) => { + const [cardInfo, setCardInfo] = useState(initalValue); + + const { numbers, expiryDate, cvc, password, bank } = cardInfo; + + const cardPreviewInfo: CardPreviewInfo = { + numbers, + expiryDate, + bank, + }; + + const handleChangeNumbers = (value: string[]) => { + setCardInfo((prev) => ({ ...prev, numbers: value })); + }; + + const handleChangeExpiryDate = (value: ExpiryDate) => { + setCardInfo((prev) => ({ ...prev, expiryDate: value })); + }; + + const handleChangeBank = (value: Bank | undefined) => { + setCardInfo((prev) => ({ ...prev, bank: value })); + }; + + const handleChangeCvc = (value: string) => { + setCardInfo((prev) => ({ ...prev, cvc: value })); + }; + + const handleChangePassword = (value: string) => { + setCardInfo((prev) => ({ ...prev, password: value })); + }; + + const numbersField: CardNumberFieldControl = { + numbers, + shouldComplete: (value: string[]) => validateCardNumber(value.join('')), + onChange: handleChangeNumbers, + }; + + const expiryField: ExpiryFieldControl = { + expiryDate, + shouldComplete: (value: ExpiryDate) => + validateExpiryMonth(value.month) && validateExpiryYear(value.year), + onChange: handleChangeExpiryDate, + }; + + const bankField: BankFieldControl = { + bank, + shouldComplete: (value: Bank | undefined) => value !== undefined, + onChange: handleChangeBank, + }; + + const cvcField: CvcFieldControl = { + cvc, + shouldComplete: (value: string) => validateCvc(value), + onChange: handleChangeCvc, + }; + + const passwordField: PasswordFieldControl = { + password, + shouldComplete: (value: string) => validatePassword(value), + onChange: handleChangePassword, + }; + + const isNumbersValid = validateCardNumber(numbers.join('')); + const isExpiryValid = + validateExpiryMonth(expiryDate.month) && validateExpiryYear(expiryDate.year); + const isBankValid = bank !== undefined; + const isCvcValid = validateCvc(cvc); + const isPasswordValid = validatePassword(password); + + const isFormValid = + isNumbersValid && isExpiryValid && isBankValid && isCvcValid && isPasswordValid; + + return { + cardInfo, + cardPreviewInfo, + isFormValid, + numbersField, + expiryField, + bankField, + cvcField, + passwordField, + }; +}; diff --git a/src/features/registerCard/hooks/usePaymentsStep.ts b/src/features/registerCard/hooks/usePaymentsStep.ts index 3f982e0805..0fa9ba88c0 100644 --- a/src/features/registerCard/hooks/usePaymentsStep.ts +++ b/src/features/registerCard/hooks/usePaymentsStep.ts @@ -14,11 +14,13 @@ export const usePaymentStep = () => { }, [step, focusNext]); const toStep = (focusIndex: number) => { - if (step > focusIndex) return; + if (step < focusIndex) { + setStep(focusIndex); + pendingFocus.current = focusIndex; + return; + } - setStep(focusIndex); - pendingFocus.current = focusIndex; + focusNext(focusIndex); }; - return { step, toStep, setStepRef: setInputRef }; }; diff --git a/src/features/registerCard/lib/validateFieldData.ts b/src/features/registerCard/lib/validateFieldData.ts deleted file mode 100644 index 7526a425c0..0000000000 --- a/src/features/registerCard/lib/validateFieldData.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { getNumbersError, validateCvc } from '@/entities/card'; -import { validateMonth, validateYear } from '@/entities/card/model/expiryDate'; -import { validatePassword } from '@/entities/card/model/password'; -import type { FieldData } from '../model/payments'; - -export const validateFieldData = (fields: FieldData): boolean => { - const { numbers, month, year, bank, cvc, password } = fields; - - const isNumbersValid = getNumbersError(numbers) === undefined; - const isValidMonth = validateMonth(month) == undefined; - const isValidYear = validateYear(year) === undefined; - const isValidBank = bank !== undefined; - const isValidCvc = validateCvc(cvc); - const isValidPassword = validatePassword(password); - - return ( - isNumbersValid && isValidMonth && isValidYear && isValidBank && isValidCvc && isValidPassword - ); -}; diff --git a/src/features/registerCard/model/payments.ts b/src/features/registerCard/model/payments.ts deleted file mode 100644 index b46c2d6ee5..0000000000 --- a/src/features/registerCard/model/payments.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Bank } from '@/entities/card/model/bank'; - -export const FORM_ID = 'payments-form'; - -export interface FieldControl { - value: string; - handleChange: (v: string) => void; -} - -export interface FieldData { - numbers: string; - month: string; - year: string; - bank: Bank | undefined; - cvc: string; - password: string; -} - -export const STEP = { - NUMBERS: 0, - BANK: 1, - EXPIRY: 2, - CVC: 3, - PASSWORD: 4, - BUTTON: 5, -} as const; diff --git a/src/features/registerCard/model/registerCardForm.ts b/src/features/registerCard/model/registerCardForm.ts new file mode 100644 index 0000000000..f310d0cc52 --- /dev/null +++ b/src/features/registerCard/model/registerCardForm.ts @@ -0,0 +1,53 @@ +import { BANK_RULES, type Bank } from '@/entities/card/model/bank'; +import type { RegisterCardRequest } from '@/entities/card/model/card'; +import type { ExpiryDate } from '@/entities/card/model/expiryDate'; +import type { RegisterCardErrorCode } from '@/entities/card/model/card'; + +export const FORM_ID = 'payments-form'; + +export interface CardInfo { + numbers: string[]; + expiryDate: ExpiryDate; + cvc: string; + password: string; + bank: Bank | undefined; +} + +export interface FieldData { + numbers: string; + expiryDate: ExpiryDate; + bank: Bank | undefined; + cvc: string; + password: string; +} + +export type ServerError = { + field: ServerErrorField; + message: string; +} | null; + +export type FieldServerError = { + message: string; + onClear: () => void; +}; + +export type ServerErrorField = 'numbers' | 'expiryDate' | 'cvc'; +export type ServerFieldErrors = Partial>; +export const SERVER_ERROR_FIELD_MAP: Record = { + INVALID_CARD_NUMBER: 'numbers', + INVALID_EXPIRATION_DATE: 'expiryDate', + INVALID_CVC: 'cvc', +}; + +export const toRequestData = (cardInfo: CardInfo): RegisterCardRequest => { + if (cardInfo.bank === undefined) { + throw new Error('카드사를 선택해 주세요.'); + } + + return { + number: cardInfo.numbers.join(''), + expirationDate: `${cardInfo.expiryDate.month}/${cardInfo.expiryDate.year}`, + cvc: cardInfo.cvc, + issuerCode: BANK_RULES[cardInfo.bank].code, + }; +}; diff --git a/src/features/registerCard/model/registerCardNumber.ts b/src/features/registerCard/model/registerCardNumber.ts new file mode 100644 index 0000000000..1b067304d3 --- /dev/null +++ b/src/features/registerCard/model/registerCardNumber.ts @@ -0,0 +1,117 @@ +import { isNumericString } from '@/core/utils/validator'; +import { getCardBrand, validateCardNumber, type CardBrand } from '@/entities/card/model/cardNumber'; + +export const CARD_NUMBERS_ERROR_MESSAGES = { + TYPE: '숫자만 입력 가능합니다.', + LENGTH: '카드 번호를 전부 채워주세요.', + UNKNOWN: '유효하지 않은 카드번호입니다.', +}; + +export const CARD_BRAND_FORMAT: Record = { + VISA: [4, 4, 4, 4], + MASTERCARD: [4, 4, 4, 4], + DINERS: [4, 4, 4, 2], + AMEX: [4, 4, 4, 3], + UNIONPAY: [4, 4, 4, 4], + UNKNOWN: [4, 4, 4, 4], +}; + +export const isValidInputCardNumber = (value: string) => { + return value === '' || isNumericString(value); +}; + +export const updateCardNumberInputs = ( + values: string[], + inputValue: string, + index: number, +): string[] => { + return values.map((value, idx) => (idx === index ? inputValue : value)); +}; + +const getCardBrandFormat = (brand: CardBrand | undefined): number[] => { + return brand !== undefined ? CARD_BRAND_FORMAT[brand] : CARD_BRAND_FORMAT.UNKNOWN; +}; + +const getCardNumberErrors = (values: string[], format: number[]) => { + return values.map((value, idx) => { + if (value.length !== format[idx]) return CARD_NUMBERS_ERROR_MESSAGES.LENGTH; + return undefined; + }); +}; + +const isCompleteCardNumber = (numbers: string[], format: number[]): boolean => { + return numbers.every((number, index) => { + return number.length === format[index]; + }); +}; + +const getVisibleFieldErrors = (fieldErrors: (string | undefined)[], touched: boolean[]) => { + return fieldErrors.map((error, index) => (touched[index] ? error : undefined)); +}; + +const getCardNumberInformation = ({ + isComplete, + isValid, + visibleFieldErrors, +}: { + isComplete: boolean; + isValid: boolean; + visibleFieldErrors: (string | undefined)[]; +}) => { + const cardNumberError = isComplete && !isValid ? CARD_NUMBERS_ERROR_MESSAGES.UNKNOWN : undefined; + + const inputErrors = cardNumberError + ? visibleFieldErrors.map(() => cardNumberError) + : visibleFieldErrors; + + const totalErrorMessage = cardNumberError ?? visibleFieldErrors.find(Boolean); + + return { + inputErrors, + totalErrorMessage, + }; +}; + +export const getCardNumberFieldState = ({ + numbers, + touched, +}: { + numbers: string[]; + touched: boolean[]; +}) => { + const cardNumber = numbers.join(''); + const brand = getCardBrand(cardNumber); + const format = getCardBrandFormat(brand); + + const isValid = validateCardNumber(cardNumber); + const isComplete = isCompleteCardNumber(numbers, format); + + const fieldErrors = getCardNumberErrors(numbers, format); + const visibleFieldErrors = getVisibleFieldErrors(fieldErrors, touched); + + const { inputErrors, totalErrorMessage } = getCardNumberInformation({ + isComplete, + isValid, + visibleFieldErrors, + }); + + return { + format, + inputErrors, + totalErrorMessage, + brand, + }; +}; + +export const getNextCardNumberFieldState = (numbers: string[]) => { + const cardNumber = numbers.join(''); + const nextBrand = getCardBrand(cardNumber); + const format = getCardBrandFormat(nextBrand); + const isValid = validateCardNumber(cardNumber); + + return { cardNumber, nextBrand, format, isValid }; +}; + +export const sliceCardNumber = (numbers: string[], format: number[]) => { + return numbers.map((number, idx) => number.slice(0, format[idx])); +}; diff --git a/src/features/registerCard/model/registerCvc.ts b/src/features/registerCard/model/registerCvc.ts new file mode 100644 index 0000000000..bafd927577 --- /dev/null +++ b/src/features/registerCard/model/registerCvc.ts @@ -0,0 +1,20 @@ +import { isNumericString } from '@/core/utils/validator'; +import { CVC_LENGTH, validateCvc } from '@/entities/card/model/cvc'; + +export const CVC_ERROR_MESSAGE = { + EMPTY: 'CVC를 전부 채워주세요.', +}; + +export const isValidInputCvc = (value: string): boolean => { + return value === '' || isNumericString(value); +}; + +export const getCvcFieldState = (value: string, touched: boolean) => { + const errorMessage = !validateCvc(value) ? CVC_ERROR_MESSAGE.EMPTY : undefined; + const visibleErrorMessage = touched ? errorMessage : undefined; + + return { + errorMessage: visibleErrorMessage, + maxLength: CVC_LENGTH, + }; +}; diff --git a/src/features/registerCard/model/registerExpiryDate.ts b/src/features/registerCard/model/registerExpiryDate.ts new file mode 100644 index 0000000000..1be05b8f26 --- /dev/null +++ b/src/features/registerCard/model/registerExpiryDate.ts @@ -0,0 +1,53 @@ +import { isNumericString } from '@/core/utils/validator'; +import { + EXPIRY_MONTH_LENGTH, + EXPIRY_YEAR_LENGTH, + isValidMonth, + validateExpiryMonth, + validateExpiryYear, + type ExpiryDate, +} from '@/entities/card/model/expiryDate'; + +const EXPIRY_DATE_ERROR_MESSAGE = { + EMPTY: '유효기간을 전부 채워주세요.', +} as const; + +export interface ExpiryTouched { + month: boolean; + year: boolean; +} + +export const isValidMonthInput = (value: string): boolean => { + if (value !== '' && !isNumericString(value)) return false; + if (!isValidMonth(value)) return false; + return value.length <= EXPIRY_MONTH_LENGTH; +}; + +export const isValidYearInput = (value: string): boolean => { + if (value !== '' && !isNumericString(value)) return false; + return value.length <= EXPIRY_YEAR_LENGTH; +}; + +export const getExpiryDateFieldState = ({ + expiryDate, + touched, +}: { + expiryDate: ExpiryDate; + touched: ExpiryTouched; +}) => { + const monthError = !validateExpiryMonth(expiryDate.month) + ? EXPIRY_DATE_ERROR_MESSAGE.EMPTY + : undefined; + const yearError = !validateExpiryYear(expiryDate.year) + ? EXPIRY_DATE_ERROR_MESSAGE.EMPTY + : undefined; + + const visibleMonthError = touched.month ? monthError : undefined; + const visibleYearError = touched.year ? yearError : undefined; + + return { + visibleMonthError, + visibleYearError, + totalErrorMessage: visibleMonthError ?? visibleYearError, + }; +}; diff --git a/src/features/registerCard/model/registerPassword.ts b/src/features/registerCard/model/registerPassword.ts new file mode 100644 index 0000000000..6fc11eab50 --- /dev/null +++ b/src/features/registerCard/model/registerPassword.ts @@ -0,0 +1,20 @@ +import { isNumericString } from '@/core/utils/validator'; +import { PASSWORD_LENGTH, validatePassword } from '@/entities/card/model/password'; + +export const PASSWORD_ERROR_MESSAGE = { + EMPTY: 'PASSWORD를 전부 채워주세요.', +}; + +export const isValidInputPassword = (value: string): boolean => { + return value === '' || isNumericString(value); +}; + +export const getPasswordFieldState = (value: string, touched: boolean) => { + const errorMessage = !validatePassword(value) ? PASSWORD_ERROR_MESSAGE.EMPTY : undefined; + const visibleErrorMessage = touched ? errorMessage : undefined; + + return { + errorMessage: visibleErrorMessage, + maxLength: PASSWORD_LENGTH, + }; +}; diff --git a/src/features/registerCard/ui/cardForm/CardForm.tsx b/src/features/registerCard/ui/cardForm/CardForm.tsx index dc2764a5f5..ac5c20353e 100644 --- a/src/features/registerCard/ui/cardForm/CardForm.tsx +++ b/src/features/registerCard/ui/cardForm/CardForm.tsx @@ -1,24 +1,45 @@ import styles from './CardForm.module.css'; -import { CvcField } from '@/features/registerCard/ui/fields/CvcField'; -import { NumberField } from '@/features/registerCard/ui/fields/NumberField'; -import type { UseNumbersResults } from '../../hooks/useNumbers'; -import type { FieldControl } from '../../model/payments'; -import { PasswordField } from '../fields/PasswordField'; -import type { UseExpiryDateResult } from '../../hooks/useExpiryDate'; -import { ExpiryDateField } from '../fields/ExpiryDateField'; +import { CvcField, type CvcFieldControl } from '@/features/registerCard/ui/fields/CvcField'; +import { + NumberField, + type CardNumberFieldControl, +} from '@/features/registerCard/ui/fields/CardNumberField'; +import { PasswordField, type PasswordFieldControl } from '../fields/PasswordField'; +import { ExpiryDateField, type ExpiryFieldControl } from '../fields/ExpiryDateField'; import { BankSelectField, type BankFieldControl } from '../fields/BankSelectField'; import { usePaymentStep } from '../../hooks/usePaymentsStep'; -import { STEP } from '../../model/payments'; +import { + SERVER_ERROR_FIELD_MAP, + type ServerError, + type ServerErrorField, +} from '../../model/registerCardForm'; +import { useEffect, useState, type FormEvent } from 'react'; +import { isRegisterCardErrorResponse } from '@/entities/card/api/cards'; + +const STEP = { + NUMBERS: 0, + BANK: 1, + EXPIRY: 2, + CVC: 3, + PASSWORD: 4, + BUTTON: 5, +} as const; + +const SERVER_ERROR_STEP: Record = { + numbers: STEP.NUMBERS, + expiryDate: STEP.EXPIRY, + cvc: STEP.CVC, +}; export interface CardFormProps { - numbersField: UseNumbersResults; - expiryField: UseExpiryDateResult; + numbersField: CardNumberFieldControl; + expiryField: ExpiryFieldControl; bankField: BankFieldControl; - cvcField: FieldControl; - passwordField: FieldControl; + cvcField: CvcFieldControl; + passwordField: PasswordFieldControl; formId: string; - handleSubmit: (e: React.FormEvent) => void; + onRegister: () => void | Promise; } export const CardForm = ({ @@ -28,44 +49,72 @@ export const CardForm = ({ cvcField, passwordField, formId, - handleSubmit, + onRegister, }: CardFormProps) => { const { step, toStep, setStepRef } = usePaymentStep(); + const [serverError, setServerError] = useState(null); + + useEffect(() => { + if (serverError === null) return; + toStep(SERVER_ERROR_STEP[serverError.field]); + }, [toStep, serverError]); + + const getServerError = (field: ServerErrorField) => + serverError?.field === field + ? { message: serverError.message, onClear: () => setServerError(null) } + : undefined; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + try { + await onRegister(); + } catch (error) { + if (isRegisterCardErrorResponse(error)) { + setServerError({ + field: SERVER_ERROR_FIELD_MAP[error.code], + message: error.message, + }); + } + } + }; return (
- {step >= 4 && ( - setStepRef(node, STEP.PASSWORD)} - onComplate={() => {}} - /> + {step >= STEP.PASSWORD && ( + setStepRef(node, STEP.PASSWORD)} /> )} - {step >= 3 && ( + + {step >= STEP.CVC && ( toStep(STEP.PASSWORD)} setStepRef={(node) => setStepRef(node, STEP.CVC)} - onComplate={() => toStep(STEP.PASSWORD)} /> )} - {step >= 2 && ( + + {step >= STEP.EXPIRY && ( toStep(STEP.CVC)} setStepRef={(node) => setStepRef(node, STEP.EXPIRY)} - onComplate={() => toStep(STEP.CVC)} + serverError={getServerError('expiryDate')} /> )} - {step >= 1 && ( + + {step >= STEP.BANK && ( toStep(STEP.EXPIRY)} setStepRef={(node) => setStepRef(node, STEP.BANK)} - onComplate={() => toStep(STEP.EXPIRY)} /> )} + toStep(STEP.BANK)} setStepRef={(node) => setStepRef(node, STEP.NUMBERS)} - onComplate={() => toStep(STEP.BANK)} + serverError={getServerError('numbers')} /> ); diff --git a/src/features/registerCard/ui/fields/BankSelectField.tsx b/src/features/registerCard/ui/fields/BankSelectField.tsx index 1b5f1a0a0c..cd24c5ccb5 100644 --- a/src/features/registerCard/ui/fields/BankSelectField.tsx +++ b/src/features/registerCard/ui/fields/BankSelectField.tsx @@ -4,19 +4,28 @@ import { Field } from '@/core/components/field/Field'; import { BANK_RULES, BANKS, type Bank } from '@/entities/card/model/bank'; export interface BankFieldControl { - value: Bank | undefined; - handleChange: (v: Bank | undefined) => void; + bank: Bank | undefined; + shouldComplete: (value: Bank) => boolean; + onChange: (v: Bank) => void; } -export interface BankSelectFieldProps { - bankField: BankFieldControl; +export interface BankSelectFieldProps extends BankFieldControl { setStepRef: (node: HTMLSelectElement | null) => void; - onComplate: () => void; + onComplete: () => void; } -export const BankSelectField = ({ bankField, setStepRef, onComplate }: BankSelectFieldProps) => { - const { value, handleChange } = bankField; - const handleChangeBank = (value: Bank) => { - handleChange(value); - if (value !== undefined) onComplate(); + +export const BankSelectField = ({ + bank, + onChange, + shouldComplete, + setStepRef, + onComplete, +}: BankSelectFieldProps) => { + const handleChange = (value: Bank) => { + onChange(value); + + if (shouldComplete(value)) { + onComplete(); + } }; return ( @@ -24,10 +33,9 @@ export const BankSelectField = ({ bankField, setStepRef, onComplate }: BankSelec { + setInputRef(node, index); + if (index === 0) { + setStepRef(node); + } + }} + type="text" + key={`${index}`} + inputMode="numeric" + value={number} + placeholder={numberPlaceHolder(format[index])} + maxLength={format[index]} + isError={visibleInputErrors[index] !== undefined} + onChange={(e) => handleChangeNumbers(e.currentTarget.value, index)} + onBlur={() => handleBlur(index)} + /> + ))} + + ); +}; diff --git a/src/features/registerCard/ui/fields/CvcField.tsx b/src/features/registerCard/ui/fields/CvcField.tsx index b509069f44..986949c80c 100644 --- a/src/features/registerCard/ui/fields/CvcField.tsx +++ b/src/features/registerCard/ui/fields/CvcField.tsx @@ -1,47 +1,54 @@ import { Field } from '@/core/components/field/Field'; import { Input } from '@/core/components/input/Input'; import { useState } from 'react'; -import { CVC_LENGTH } from '@/entities/card'; -import { getErrorCvc, validateCvc } from '@/entities/card/model/cvc'; -import { isNumericString } from '@/core/utils/validator'; -import type { FieldControl } from '../../model/payments'; +import { getCvcFieldState, isValidInputCvc } from '../../model/registerCvc'; +import type { FieldServerError } from '../../model/registerCardForm'; -interface CvcFieldProps { - cvcField: FieldControl; +export interface CvcFieldControl { + cvc: string; + shouldComplete: (value: string) => boolean; + onChange: (v: string) => void; +} + +export interface CvcFieldProps extends CvcFieldControl { setStepRef: (node: HTMLInputElement | null) => void; - onComplate: () => void; + onComplete: () => void; + serverError?: FieldServerError; } -export const CvcField = ({ cvcField, setStepRef, onComplate }: CvcFieldProps) => { - const { handleChange, value } = cvcField; +export const CvcField = ({ + cvc, + shouldComplete, + onChange, + setStepRef, + onComplete, + serverError, +}: CvcFieldProps) => { const [touched, setTouched] = useState(false); - const handleChangeCvc = (inputValue: string): void => { - if (inputValue !== '' && !isNumericString(inputValue)) return; - handleChange(inputValue); - setTouched(false); - if (validateCvc(inputValue)) onComplate(); - }; + const { errorMessage, maxLength } = getCvcFieldState(cvc, touched); + const visibleErrorMessage = serverError?.message ?? errorMessage; - const handleBlur = () => { - setTouched(true); - }; + const handleChange = (inputValue: string): void => { + if (!isValidInputCvc(inputValue)) return; + onChange(inputValue); + serverError?.onClear(); - const error = !validateCvc(value); - const errorMessage = touched ? getErrorCvc(value) : undefined; + if (shouldComplete(inputValue)) onComplete(); + }; return ( - + handleChangeCvc(e.target.value)} - onBlur={() => handleBlur()} + isError={visibleErrorMessage !== undefined} + onChange={(e) => handleChange(e.target.value)} + onBlur={() => setTouched(true)} /> ); diff --git a/src/features/registerCard/ui/fields/ExpiryDateField.tsx b/src/features/registerCard/ui/fields/ExpiryDateField.tsx index e1e8021aed..33819e2812 100644 --- a/src/features/registerCard/ui/fields/ExpiryDateField.tsx +++ b/src/features/registerCard/ui/fields/ExpiryDateField.tsx @@ -1,38 +1,87 @@ import { Field } from '@/core/components/field/Field'; import { Input } from '@/core/components/input/Input'; -import type { UseExpiryDateResult } from '../../hooks/useExpiryDate'; import { useInputFocus } from '@/core/hooks/useInputFocus'; -import { validateMonth, validateYear } from '@/entities/card/model/expiryDate'; +import { + EXPIRY_MONTH_LENGTH, + EXPIRY_YEAR_LENGTH, + validateExpiryMonth, + type ExpiryDate, +} from '@/entities/card/model/expiryDate'; +import { + isValidMonthInput, + isValidYearInput, + getExpiryDateFieldState, + type ExpiryTouched, +} from '../../model/registerExpiryDate'; +import { useState } from 'react'; +import type { FieldServerError } from '../../model/registerCardForm'; -interface ExpiryDateField { - expiryField: UseExpiryDateResult; +export interface ExpiryFieldControl { + expiryDate: ExpiryDate; + shouldComplete: (value: ExpiryDate) => boolean; + onChange: (value: ExpiryDate) => void; +} + +export interface ExpiryDateFieldProps extends ExpiryFieldControl { setStepRef: (node: HTMLInputElement | null) => void; - onComplate: () => void; + onComplete?: () => void; + serverError?: FieldServerError; } -export const ExpiryDateField = ({ expiryField, onComplate, setStepRef }: ExpiryDateField) => { - const { month, year } = expiryField; +export const ExpiryDateField = ({ + expiryDate, + serverError, + shouldComplete, + onChange, + onComplete, + setStepRef, +}: ExpiryDateFieldProps) => { + const { month, year } = expiryDate; + const [touched, setTouched] = useState({ + month: false, + year: false, + }); const { setInputRef, focusNext } = useInputFocus(); - const handleChangeMonth = (value: string) => { - month.handleChange(value); - if (validateMonth(value) === undefined) focusNext(1); - }; + const { visibleMonthError, visibleYearError, totalErrorMessage } = getExpiryDateFieldState({ + expiryDate, + touched, + }); + const visibleErrorMessage = serverError?.message ?? totalErrorMessage; + + const handleChangeMonth = (month: string) => { + if (!isValidMonthInput(month)) return; + serverError?.onClear(); - const handleChangeYear = (value: string) => { - year.handleChange(value); - const monthError = validateMonth(expiryField.month.value); - const yearError = validateYear(value); - if (!monthError && !yearError) onComplate(); + const next = { + ...expiryDate, + month, + }; + onChange(next); + + if (validateExpiryMonth(month)) focusNext(1); + if (shouldComplete(next)) onComplete?.(); }; + const handleChangeYear = (year: string) => { + if (!isValidYearInput(year)) return; + serverError?.onClear(); + + const next = { + ...expiryDate, + year, + }; + onChange(next); + + if (shouldComplete(next)) onComplete?.(); + }; return ( { @@ -40,12 +89,17 @@ export const ExpiryDateField = ({ expiryField, onComplate, setStepRef }: ExpiryD }} type="text" inputMode="numeric" - value={month.value} - maxLength={month.maxLength} + value={month} + maxLength={EXPIRY_MONTH_LENGTH} placeholder="MM" - isError={month.errorMessage !== undefined} + isError={visibleMonthError !== undefined} onChange={(e) => handleChangeMonth(e.target.value)} - onBlur={() => month.handleBlur()} + onBlur={() => + setTouched((prev) => ({ + ...prev, + month: true, + })) + } /> { @@ -53,12 +107,17 @@ export const ExpiryDateField = ({ expiryField, onComplate, setStepRef }: ExpiryD }} type="text" inputMode="numeric" - value={year.value} - maxLength={year.maxLength} + value={year} + maxLength={EXPIRY_YEAR_LENGTH} placeholder="YY" - isError={year.errorMessage !== undefined} + isError={visibleYearError !== undefined} onChange={(e) => handleChangeYear(e.target.value)} - onBlur={() => year.handleBlur()} + onBlur={() => + setTouched((prev) => ({ + ...prev, + year: true, + })) + } /> ); diff --git a/src/features/registerCard/ui/fields/NumberField.tsx b/src/features/registerCard/ui/fields/NumberField.tsx deleted file mode 100644 index 0f71a8edd6..0000000000 --- a/src/features/registerCard/ui/fields/NumberField.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Field } from '@/core/components/field/Field'; -import { Input } from '@/core/components/input/Input'; -import type { UseNumbersResults } from '../../hooks/useNumbers'; -import { useInputFocus } from '@/core/hooks/useInputFocus'; -import { BRAND_RULES, getNumbersError } from '@/entities/card'; - -interface NumberFieldProps { - numbersField: UseNumbersResults; - setStepRef: (node: HTMLInputElement | null) => void; - - onComplate: () => void; -} - -export const NumberField = ({ numbersField, setStepRef, onComplate }: NumberFieldProps) => { - const { values, brand, totalErrorMessage, maxLengths, handleChange, handleBlur, infoErrorField } = - numbersField; - const { setInputRef, focusNext } = useInputFocus(); - - const handleChangeNumbers = (value: string, index: number) => { - handleChange(value, index); - - const next = [...values]; - next[index] = value; - if (getNumbersError(next.join('')) === undefined) onComplate(); - if (value.length === BRAND_RULES[brand].format[index]) focusNext(index + 1); - }; - - return ( - - {values.map((number, index) => ( - { - setInputRef(node, index); - setStepRef(node); - }} - type="text" - key={`${index}`} - inputMode="numeric" - value={number} - placeholder={Array.from({ length: maxLengths[index] }, (_, i) => i + 1).join('')} - maxLength={maxLengths[index]} - isError={infoErrorField[index]} - onChange={(e) => handleChangeNumbers(e.currentTarget.value, index)} - onBlur={() => handleBlur(index)} - /> - ))} - - ); -}; diff --git a/src/features/registerCard/ui/fields/PasswordField.tsx b/src/features/registerCard/ui/fields/PasswordField.tsx index 516f48010e..4b8b1d85bd 100644 --- a/src/features/registerCard/ui/fields/PasswordField.tsx +++ b/src/features/registerCard/ui/fields/PasswordField.tsx @@ -1,37 +1,41 @@ import { Field } from '@/core/components/field/Field'; import { Input } from '@/core/components/input/Input'; -import type { FieldControl } from '../../model/payments'; import { useState } from 'react'; -import { isNumericString } from '@/core/utils/validator'; -import { - getErrorPassword, - PASSWORD_LENGTH, - validatePassword, -} from '@/entities/card/model/password'; - -interface PasswordFieldProps { - passwordField: FieldControl; +import { getPasswordFieldState, isValidInputPassword } from '../../model/registerPassword'; + +export interface PasswordFieldControl { + password: string; + shouldComplete: (value: string) => boolean; + onChange: (v: string) => void; +} + +export interface PasswordFieldProps extends PasswordFieldControl { setStepRef: (node: HTMLInputElement | null) => void; - onComplate: () => void; + onComplete?: () => void; + serverErrorMessage?: string; } -export const PasswordField = ({ passwordField, setStepRef, onComplate }: PasswordFieldProps) => { - const { value, handleChange } = passwordField; +export const PasswordField = ({ + password, + shouldComplete, + onChange, + setStepRef, + onComplete, +}: PasswordFieldProps) => { const [touched, setTouched] = useState(false); - const handleChangePW = (inputValue: string): void => { - if (inputValue !== '' && !isNumericString(inputValue)) return; - handleChange(inputValue); - setTouched(false); - if (validatePassword(inputValue)) onComplate(); - }; + const { errorMessage, maxLength } = getPasswordFieldState(password, touched); + + const handleChange = (inputValue: string): void => { + if (!isValidInputPassword(inputValue)) return; + + onChange(inputValue); - const handleBlur = () => { - setTouched(true); + if (shouldComplete(inputValue)) { + onComplete?.(); + } }; - const error = validatePassword(value); - const errorMessage = touched ? getErrorPassword(value) : undefined; return ( setStepRef(node)} + ref={setStepRef} type="password" inputMode="numeric" - value={value} - maxLength={PASSWORD_LENGTH} + value={password} + maxLength={maxLength} placeholder="**" - isError={touched && error} - onChange={(e) => handleChangePW(e.target.value)} - onBlur={() => handleBlur()} + isError={touched && errorMessage !== undefined} + onChange={(e) => handleChange(e.target.value)} + onBlur={() => setTouched(true)} /> ); diff --git a/src/main.tsx b/src/main.tsx index 9ac8961f79..07fd08a1c2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,8 +3,39 @@ import { createRoot } from 'react-dom/client'; import App from './App.tsx'; -createRoot(document.getElementById('root')!).render( - - - , -); +const getBaseUrl = () => { + return import.meta.env.BASE_URL.endsWith('/') + ? import.meta.env.BASE_URL + : `${import.meta.env.BASE_URL}/`; +}; + +const enableMocking = async () => { + if (!import.meta.env.DEV && import.meta.env.VITE_ENABLE_MSW !== 'true') return; + + const { worker } = await import('./mocks/browser'); + const baseUrl = getBaseUrl(); + const workerUrl = `${baseUrl}mockServiceWorker.js`; + + try { + await worker.start({ + serviceWorker: { + url: workerUrl, + options: { + scope: baseUrl, + }, + }, + onUnhandledRequest: 'bypass', + }); + console.log('[MSW] Service worker registered at base URL:', workerUrl); + } catch (error) { + console.error('[MSW] Failed to start worker:', error); + } +}; + +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/handlers/cards.ts b/src/mocks/handlers/cards.ts new file mode 100644 index 0000000000..1f4f9f069f --- /dev/null +++ b/src/mocks/handlers/cards.ts @@ -0,0 +1,86 @@ +import { validateCardNumber } from '@/entities/card/model/cardNumber'; +import { http, HttpResponse } from 'msw'; + +import type { + Card, + RegisterCardErrorResponse, + RegisterCardRequest, + RegisterCardResponse, +} from '@/entities/card/model/card'; + +const cards: Card[] = [ + { + id: '550e8400-e29b-41d4-a716-446655440001', + issuerCode: '31', + number: '5511********9012', + expirationDate: '12/28', + }, + { + id: '550e8400-e29b-41d4-a716-446655440002', + issuerCode: '41', + number: '4111********1111', + expirationDate: '06/30', + }, + { + id: '550e8400-e29b-41d4-a716-446655440003', + issuerCode: '15', + number: '5234********7890', + expirationDate: '09/27', + }, +]; + +const registerCardError = (code: RegisterCardErrorResponse['code'], message: string): Response => { + return HttpResponse.json({ code, message }, { status: 400 }); +}; + +const isValidExpirationDate = (expirationDate: string): boolean => { + return /^(0[1-9]|1[0-2])\/\d{2}$/.test(expirationDate); +}; + +const maskCardNumber = (number: string): string => { + return `${number.slice(0, 6)}******${number.slice(-4)}`; +}; + +export const cardsHandlers = [ + http.get('*/cards', () => { + return HttpResponse.json(cards, { status: 200 }); + }), + + http.post('*/cards', async ({ request }) => { + const card = (await request.json()) as RegisterCardRequest; + + if (!validateCardNumber(card.number)) { + return registerCardError('INVALID_CARD_NUMBER', '유효하지 않은 카드 번호입니다.'); + } + + if (card.cvc === '000') { + return registerCardError('INVALID_CVC', '유효하지 않은 CVC입니다.'); + } + + if (!isValidExpirationDate(card.expirationDate)) { + return registerCardError('INVALID_EXPIRATION_DATE', '유효하지 않은 만료일입니다.'); + } + + const id = crypto.randomUUID(); + + cards.push({ + id, + issuerCode: card.issuerCode, + number: maskCardNumber(card.number), + expirationDate: card.expirationDate, + }); + + return HttpResponse.json({ id }, { status: 201 }); + }), + + http.delete('*/cards/:id', ({ params }) => { + const id = String(params.id); + const index = cards.findIndex((card) => card.id === id); + + if (index !== -1) { + cards.splice(index, 1); + } + + return new HttpResponse(null, { status: 204 }); + }), +]; diff --git a/src/mocks/handlers/index.ts b/src/mocks/handlers/index.ts new file mode 100644 index 0000000000..0e9deb86c6 --- /dev/null +++ b/src/mocks/handlers/index.ts @@ -0,0 +1,3 @@ +import { cardsHandlers } from './cards'; + +export const handlers = [...cardsHandlers]; 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/pages/cardList/CardListPage.tsx b/src/pages/cardList/CardListPage.tsx new file mode 100644 index 0000000000..1f0ae4f078 --- /dev/null +++ b/src/pages/cardList/CardListPage.tsx @@ -0,0 +1,26 @@ +import { useNavigate } from 'react-router-dom'; + +import { CardList } from './ui/CardList'; +import { useCardList } from './model/useCardListPage'; +import { CardListLoading } from './ui/CardListLoading'; +import { CardListError } from './ui/CardListError'; +import { CardListEmpty } from './ui/CardListEmpty'; + +export const CardListPage = () => { + const navigate = useNavigate(); + const { cards, status, refetch, removeCard } = useCardList(); + const handleAddCard = () => navigate('/register'); + + if (status === 'idle') return ; + if (status === 'loading') return ; + if (status === 'error') return void refetch()} />; + if (cards.length === 0) return ; + + return ( + void removeCard(cardId)} + /> + ); +}; diff --git a/src/pages/cardList/model/useCardListPage.ts b/src/pages/cardList/model/useCardListPage.ts new file mode 100644 index 0000000000..7cd317995b --- /dev/null +++ b/src/pages/cardList/model/useCardListPage.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { deleteCard, getCards } from '@/entities/card/api/cards'; +import type { Card } from '@/entities/card/model/card'; +type CardListStatus = 'idle' | 'loading' | 'success' | 'error'; + +export const useCardList = () => { + const [cards, setCards] = useState([]); + const [status, setStatus] = useState('idle'); + + const loadCards = useCallback(async () => { + setStatus('loading'); + + try { + const data = await getCards(); + setCards(data); + setStatus('success'); + } catch { + setStatus('error'); + } + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + void loadCards(); + }, [loadCards]); + + const removeCard = async (id: string) => { + if (!window.confirm('카드를 삭제하시겠습니까?')) return; + + try { + await deleteCard(id); + await loadCards(); + } catch { + setStatus('error'); + } + }; + + return { + cards, + status, + refetch: loadCards, + removeCard, + }; +}; diff --git a/src/pages/cardList/ui/CardList.module.css b/src/pages/cardList/ui/CardList.module.css new file mode 100644 index 0000000000..8881ac35d9 --- /dev/null +++ b/src/pages/cardList/ui/CardList.module.css @@ -0,0 +1,136 @@ +.page { + width: 320px; + min-height: 590px; + margin: 24px auto; + padding: 28px 24px; + border-radius: 10px; + background: #ffffff; +} + +.title { + margin: 0 0 18px; + color: #1f2937; + font-size: 16px; + font-weight: 700; +} + +.list { + display: flex; + flex-direction: column; + gap: 14px; + padding: 0; + margin: 0; + list-style: none; +} + +.addButton { + width: 100%; + height: 36px; + border: 1px dashed #e5e7eb; + border-radius: 6px; + color: #8a8a8a; + background: #ffffff; + cursor: pointer; +} + +.center { + display: flex; + min-height: 430px; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} + +.emptyImage { + width: 140px; + height: 84px; + margin-bottom: 18px; + border: 1px dashed #d6d6d6; + border-radius: 4px; + background: #f7f7f7; +} + +.message { + margin: 0 0 8px; + color: #1f2937; + font-size: 18px; + font-weight: 700; +} + +.description { + margin: 0 0 18px; + color: #9ca3af; + font-size: 12px; +} + +.primaryButton { + width: 100%; + height: 40px; + border: 0; + border-radius: 4px; + color: #ffffff; + font-weight: 700; + background: #333333; + cursor: pointer; +} + +.errorIcon { + display: flex; + width: 52px; + height: 52px; + align-items: center; + justify-content: center; + margin-bottom: 18px; + border-radius: 50%; + color: #ffffff; + background: #333333; + font-size: 28px; + font-weight: 800; +} + +.skeletonList { + display: flex; + flex-direction: column; + gap: 14px; +} + +.skeletonCard { + display: flex; + gap: 12px; + padding: 12px; + border: 1px solid #eeeeee; + border-radius: 6px; +} + +.skeletonBox { + width: 58px; + height: 36px; + border-radius: 4px; + background: #e9e9e9; +} + +.skeletonText { + flex: 1; +} + +.skeletonLine { + height: 8px; + margin-bottom: 8px; + border-radius: 999px; + background: #e9e9e9; +} + +.skeletonLineShort { + height: 8px; + width: 55%; + margin-bottom: 8px; + border-radius: 999px; + background: #e9e9e9; +} + +.skeletonAdd { + height: 36px; + border-radius: 6px; + background: #f5f5f5; +} diff --git a/src/pages/cardList/ui/CardList.tsx b/src/pages/cardList/ui/CardList.tsx new file mode 100644 index 0000000000..9e5702a561 --- /dev/null +++ b/src/pages/cardList/ui/CardList.tsx @@ -0,0 +1,30 @@ +import type { Card } from '@/entities/card/model/card'; +import { CardItem } from '@/entities/card/ui/CardItem'; + +import styles from './CardList.module.css'; + +interface CardListProps { + cards: Card[]; + onAddCard: () => void; + onDelete: (id: string) => void; +} + +export const CardList = ({ cards, onAddCard, onDelete }: CardListProps) => { + return ( +
+

보유 카드 ({cards.length})

+
    + {cards.map((card) => ( +
  • + +
  • + ))} +
  • + +
  • +
+
+ ); +}; diff --git a/src/pages/cardList/ui/CardListEmpty.tsx b/src/pages/cardList/ui/CardListEmpty.tsx new file mode 100644 index 0000000000..6e594ddfd1 --- /dev/null +++ b/src/pages/cardList/ui/CardListEmpty.tsx @@ -0,0 +1,21 @@ +import styles from './CardList.module.css'; + +interface CardListEmptyProps { + onAddCard: () => void; +} + +export const CardListEmpty = ({ onAddCard }: CardListEmptyProps) => { + return ( +
+

보유 카드

+
+ +
+ ); +}; diff --git a/src/pages/cardList/ui/CardListError.tsx b/src/pages/cardList/ui/CardListError.tsx new file mode 100644 index 0000000000..bed31f4bde --- /dev/null +++ b/src/pages/cardList/ui/CardListError.tsx @@ -0,0 +1,23 @@ +import styles from './CardList.module.css'; + +interface CardListErrorProps { + onRetry: () => void; +} + +export const CardListError = ({ onRetry }: CardListErrorProps) => { + return ( +
+

보유 카드

+
+ +

카드 목록을 불러올 수 없어요

+

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

+ +
+
+ ); +}; diff --git a/src/pages/cardList/ui/CardListLoading.tsx b/src/pages/cardList/ui/CardListLoading.tsx new file mode 100644 index 0000000000..5ae17093b5 --- /dev/null +++ b/src/pages/cardList/ui/CardListLoading.tsx @@ -0,0 +1,22 @@ +import styles from './CardList.module.css'; + +export const CardListLoading = () => { + return ( +
+

보유 카드

+
+ {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); +}; diff --git a/src/pages/payments/Payments.tsx b/src/pages/payments/Payments.tsx deleted file mode 100644 index 3df2feb2b5..0000000000 --- a/src/pages/payments/Payments.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import styles from './Payments.module.css'; - -import { CardForm } from '@/features/registerCard/ui/cardForm/CardForm'; - -import { useState } from 'react'; -import { useNumbers } from '@/features/registerCard/hooks/useNumbers'; -import { useExpiryDate } from '@/features/registerCard/hooks/useExpiryDate'; -import { CardPreview } from '@/features/cardPreview/CardPreview'; -import type { CardInfo } from '@/features/cardPreview/CardPreview'; -import type { Bank } from '@/entities/card/model/bank'; -import { SubmitButton } from '@/features/submitButton/SubmitButton'; -import { useNavigate } from 'react-router-dom'; -import { validateFieldData } from '@/features/registerCard/lib/validateFieldData'; -import { FORM_ID, type FieldData } from '@/features/registerCard/model/payments'; - -export const Payments = () => { - const numbersField = useNumbers(); - const expiryField = useExpiryDate(); - const [cvc, setCvc] = useState(''); - const [password, setPassword] = useState(''); - const [bank, setBank] = useState(); - - const fields = { - numbersField, - expiryField, - bankField: { - value: bank, - handleChange: (v: Bank | undefined) => setBank(v), - }, - cvcField: { - value: cvc, - handleChange: (v: string) => setCvc(v), - }, - passwordField: { - value: password, - handleChange: (v: string) => setPassword(v), - }, - }; - - const cardInfo: CardInfo = { - cardNumbers: numbersField.values, - bank: bank, - brand: numbersField.brand, - expiryDate: { - month: expiryField.month.value, - year: expiryField.year.value, - }, - }; - - const fieldData: FieldData = { - numbers: numbersField.values.join(''), - month: expiryField.month.value, - year: expiryField.year.value, - cvc, - password, - bank, - }; - const isFormValid = validateFieldData(fieldData); - - const navigate = useNavigate(); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - navigate('/result', { - state: { - cardNumbers: numbersField.values, - bank: bank, - }, - }); - }; - - return ( -
- - - {isFormValid && } -
- ); -}; diff --git a/src/pages/registerCard/model/cardPreview.ts b/src/pages/registerCard/model/cardPreview.ts new file mode 100644 index 0000000000..b03275a500 --- /dev/null +++ b/src/pages/registerCard/model/cardPreview.ts @@ -0,0 +1,43 @@ +import { type CardBrand } from '@/entities/card/model/cardNumber'; +import type { Bank } from '@/entities/card/model/bank'; +import type { ExpiryDate } from '@/entities/card/model/expiryDate'; + +export interface CardPreviewInfo { + numbers: string[]; + expiryDate: ExpiryDate; + bank: Bank | undefined; +} + +const STAR = '●'; + +const CARD_PREVIEW_FORMAT: Record = { + VISA: [4, 4, 4, 4], + MASTERCARD: [4, 4, 4, 4], + DINERS: [4, 6, 4], + AMEX: [4, 6, 5], + UNIONPAY: [4, 4, 4, 4], + UNKNOWN: [4, 4, 4, 4], +}; + +const getCardPreviewFormat = (brand: CardBrand | undefined): number[] => { + return brand !== undefined ? CARD_PREVIEW_FORMAT[brand] : CARD_PREVIEW_FORMAT.UNKNOWN; +}; + +const splitByFormat = (value: string, format: number[]): string[] => { + let cursor = 0; + + return format.map((length) => { + const sliced = value.slice(cursor, cursor + length); + cursor += length; + return sliced; + }); +}; + +export const getPreviewCardNumbers = (cardNumber: string, brand: CardBrand | undefined) => { + const format = getCardPreviewFormat(brand); + const sliceds = splitByFormat(cardNumber, format); + + return sliceds.map((sliced, index) => { + return index >= 2 ? STAR.repeat(sliced.length) : sliced; + }); +}; diff --git a/src/pages/payments/Payments.module.css b/src/pages/registerCard/ui/RegisterCardPage.module.css similarity index 100% rename from src/pages/payments/Payments.module.css rename to src/pages/registerCard/ui/RegisterCardPage.module.css diff --git a/src/pages/registerCard/ui/RegisterCardPage.tsx b/src/pages/registerCard/ui/RegisterCardPage.tsx new file mode 100644 index 0000000000..4244360547 --- /dev/null +++ b/src/pages/registerCard/ui/RegisterCardPage.tsx @@ -0,0 +1,53 @@ +import styles from './RegisterCardPage.module.css'; + +import { CardForm } from '@/features/registerCard/ui/cardForm/CardForm'; +import { CardPreview } from '@/pages/registerCard/ui/cardPreview/CardPreview'; +import { SubmitButton } from './submitButton/SubmitButton'; +import { useNavigate } from 'react-router-dom'; +import { registerCard } from '@/entities/card/api/cards'; +import { usePaymentsForm } from '@/features/registerCard/hooks/usePaymentsForm'; +import { + FORM_ID, + toRequestData, + type CardInfo, +} from '@/features/registerCard/model/registerCardForm'; + +const initCardInfo: CardInfo = { + numbers: ['', '', '', ''], + expiryDate: { + month: '', + year: '', + }, + cvc: '', + password: '', + bank: undefined, +}; + +export const RegisterCardPage = () => { + const navigate = useNavigate(); + const paymentsForm = usePaymentsForm(initCardInfo); + + const onRegister = async () => { + const { cardInfo } = paymentsForm; + await registerCard(toRequestData(cardInfo)); + navigate('/cards'); + }; + + return ( +
+ + + + + {paymentsForm.isFormValid && } +
+ ); +}; diff --git a/src/features/cardPreview/CardPreview.module.css b/src/pages/registerCard/ui/cardPreview/CardPreview.module.css similarity index 100% rename from src/features/cardPreview/CardPreview.module.css rename to src/pages/registerCard/ui/cardPreview/CardPreview.module.css diff --git a/src/features/cardPreview/CardPreview.stories.tsx b/src/pages/registerCard/ui/cardPreview/CardPreview.stories.tsx similarity index 75% rename from src/features/cardPreview/CardPreview.stories.tsx rename to src/pages/registerCard/ui/cardPreview/CardPreview.stories.tsx index 0c10bbf022..3501d7637a 100644 --- a/src/features/cardPreview/CardPreview.stories.tsx +++ b/src/pages/registerCard/ui/cardPreview/CardPreview.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { CardPreview } from './CardPreview'; -import { BRAND } from '@/entities/card/model/brand'; +import { CardPreview } from '@/pages/registerCard/ui/cardPreview/CardPreview'; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = { @@ -15,9 +14,8 @@ type Story = StoryObj; export const Default: Story = { args: { info: { - brand: BRAND.UNKNOWN, bank: undefined, - cardNumbers: ['1234', '5678', '0000', '0000'], + numbers: ['1234', '5678', '0000', '0000'], expiryDate: { month: '04', year: '28' }, }, }, diff --git a/src/pages/registerCard/ui/cardPreview/CardPreview.tsx b/src/pages/registerCard/ui/cardPreview/CardPreview.tsx new file mode 100644 index 0000000000..7a3080daf2 --- /dev/null +++ b/src/pages/registerCard/ui/cardPreview/CardPreview.tsx @@ -0,0 +1,58 @@ +import styles from './CardPreview.module.css'; +import bankStyles from '@/entities/card/config/bank.module.css'; + +import { BANK_RULES } from '@/entities/card/model/bank'; +import { BRAND_SVG_MAP } from '@/entities/card/config/brandSvgMap'; +import { getCardBrand, type CardBrand } from '@/entities/card/model/cardNumber'; +import { + getPreviewCardNumbers, + type CardPreviewInfo, +} from '@/pages/registerCard/model/cardPreview'; + +interface CardPreviewProps { + info: CardPreviewInfo; +} + +const CardNumber = ({ + cardNumbers, + brand, +}: { + cardNumbers: string; + brand: CardBrand | undefined; +}) => { + const previewNumbers = getPreviewCardNumbers(cardNumbers, brand); + return ( + <> + {previewNumbers.map((number, index) => ( + + {number} + + ))} + + ); +}; + +export const CardPreview = ({ info }: CardPreviewProps) => { + const { numbers, expiryDate, bank } = info; + + const cardNumbers = numbers.join(''); + const brand = getCardBrand(cardNumbers); + const brandImg = brand !== undefined ? BRAND_SVG_MAP[brand] : ''; + + const bankClassName = bank !== undefined ? BANK_RULES[bank].color : ''; + + return ( +
+
+ {brandImg && {brand}} +
+ +
+
+ {expiryDate.month && {expiryDate.month}/} + {expiryDate.year} +
+
+
+ ); +}; diff --git a/src/features/submitButton/SubmitButton.module.css b/src/pages/registerCard/ui/submitButton/SubmitButton.module.css similarity index 100% rename from src/features/submitButton/SubmitButton.module.css rename to src/pages/registerCard/ui/submitButton/SubmitButton.module.css diff --git a/src/features/submitButton/SubmitButton.tsx b/src/pages/registerCard/ui/submitButton/SubmitButton.tsx similarity index 100% rename from src/features/submitButton/SubmitButton.tsx rename to src/pages/registerCard/ui/submitButton/SubmitButton.tsx diff --git a/src/pages/result/Result.tsx b/src/pages/result/Result.tsx index 340f2a9e5a..5764ec7cba 100644 --- a/src/pages/result/Result.tsx +++ b/src/pages/result/Result.tsx @@ -5,7 +5,7 @@ import { useLocation, useNavigate, Navigate } from 'react-router-dom'; import { type Bank, BANK_RULES } from '@/entities/card/model/bank'; interface ResultState { - cardNumbers: string[]; + numbers: string[]; bank: Bank; } @@ -14,7 +14,7 @@ export const Result = () => { const { state } = useLocation() as { state: ResultState }; if (!state) return ; - const firstFour = state.cardNumbers[0]; + const firstFour = state.numbers[0]; const bankLabel = BANK_RULES[state.bank].label; return ( diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000000..cacbfe2a82 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,19 @@ +import '@testing-library/jest-dom/vitest'; + +import { cleanup } from '@testing-library/react'; +import { afterAll, afterEach, beforeAll } from 'vitest'; + +import { server } from '@/mocks/server'; + +beforeAll(() => { + server.listen({ onUnhandledRequest: 'error' }); +}); + +afterEach(() => { + server.resetHandlers(); + cleanup(); +}); + +afterAll(() => { + server.close(); +}); diff --git a/vite.config.ts b/vite.config.ts index 1fb3303161..f401492301 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,6 +17,15 @@ export default defineConfig({ plugins: [react(), svgr()], test: { projects: [ + { + extends: true, + test: { + name: 'unit', + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + }, + }, { extends: true, plugins: [