From 8fa29fea04a868935ee4a792a87c38875acd2806 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Mon, 4 Aug 2025 12:42:42 +0900 Subject: [PATCH 01/35] =?UTF-8?q?chore:=20eslint,=20prettier=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc.cjs | 18 -------- .prettierignore | 29 ++++++++++++ .prettierrc.yaml | 12 +++++ eslint.config.js | 86 +++++++++++++++++++++++++++++++++++ package.json | 6 +++ pnpm-lock.yaml | 115 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 248 insertions(+), 18 deletions(-) delete mode 100644 .eslintrc.cjs create mode 100644 .prettierignore create mode 100644 .prettierrc.yaml create mode 100644 eslint.config.js diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index d6c95379..00000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', - ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - plugins: ['react-refresh'], - rules: { - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, -} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..d864f7aa --- /dev/null +++ b/.prettierignore @@ -0,0 +1,29 @@ +# Prettier 무시 파일들 +node_modules/ +dist/ +build/ +.vite/ +coverage/ +pnpm-lock.yaml +package-lock.json +yarn.lock + +# 바이너리 파일들 +*.png +*.jpg +*.jpeg +*.gif +*.svg +*.ico +*.woff +*.woff2 +*.ttf +*.eot + +# 원본 파일들 +src/origin/ +src/refactoring(hint)/ + +# 기타 +.env* +.DS_Store diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 00000000..6cb1f985 --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,12 @@ +printWidth: 100 +tabWidth: 2 +useTabs: false +singleQuote: false +semi: true +bracketSpacing: true +arrowParens: always +trailingComma: none +jsxSingleQuote: false +bracketSameLine: false +plugins: + - prettier-plugin-tailwindcss diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..d9ab94d1 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,86 @@ +import js from "@eslint/js"; +import tseslint from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import prettierConfig from "eslint-config-prettier"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import simpleImportSort from "eslint-plugin-simple-import-sort"; +import globals from "globals"; + +const ignoresConfig = { + ignores: [ + "node_modules/**", + "dist/**", + "build/**", + ".vite/**", + "coverage/**", + "src/origin/", + "src/refactoring(hint)/" + ] +}; + +const baseConfig = { + files: ["**/*.{js,jsx,ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + sourceType: "module" + }, + rules: { + ...js.configs.recommended.rules + } +}; + +const typescriptConfig = { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module" + } + }, + plugins: { + "@typescript-eslint": tseslint + }, + rules: { + ...tseslint.configs.recommended.rules + } +}; + +const reactConfig = { + files: ["**/*.{jsx,tsx}"], + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { + allowConstantExport: true + } + ] + } +}; + +const importSortConfig = { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: { + "simple-import-sort": simpleImportSort + }, + rules: { + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error" + } +}; + +export default [ + ignoresConfig, + baseConfig, + typescriptConfig, + reactConfig, + importSortConfig, + prettierConfig +]; diff --git a/package.json b/package.json index 79034acb..8b8f8f9f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "react-dom": "^19.1.1" }, "devDependencies": { + "@eslint/js": "^9.32.0", "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -30,9 +31,14 @@ "@vitejs/plugin-react-swc": "^3.11.0", "@vitest/ui": "^3.2.4", "eslint": "^9.32.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", + "eslint-plugin-simple-import-sort": "^12.1.1", + "globals": "^16.3.0", "jsdom": "^26.1.0", + "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.6.14", "typescript": "^5.9.2", "vite": "^7.0.6", "vitest": "^3.2.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85..6b0d3bd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ importers: specifier: ^19.1.1 version: 19.1.1(react@19.1.1) devDependencies: + '@eslint/js': + specifier: ^9.32.0 + version: 9.32.0 '@testing-library/jest-dom': specifier: ^6.6.4 version: 6.6.4 @@ -45,15 +48,30 @@ importers: eslint: specifier: ^9.32.0 version: 9.32.0 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.32.0) eslint-plugin-react-hooks: specifier: ^5.2.0 version: 5.2.0(eslint@9.32.0) eslint-plugin-react-refresh: specifier: ^0.4.20 version: 0.4.20(eslint@9.32.0) + eslint-plugin-simple-import-sort: + specifier: ^12.1.1 + version: 12.1.1(eslint@9.32.0) + globals: + specifier: ^16.3.0 + version: 16.3.0 jsdom: specifier: ^26.1.0 version: 26.1.0 + prettier: + specifier: ^3.6.2 + version: 3.6.2 + prettier-plugin-tailwindcss: + specifier: ^0.6.14 + version: 0.6.14(prettier@3.6.2) typescript: specifier: ^5.9.2 version: 5.9.2 @@ -865,6 +883,12 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + eslint-plugin-react-hooks@5.2.0: resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} engines: {node: '>=10'} @@ -876,6 +900,11 @@ packages: peerDependencies: eslint: '>=8.40' + eslint-plugin-simple-import-sort@12.1.1: + resolution: {integrity: sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==} + peerDependencies: + eslint: '>=5.0.0' + eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -991,6 +1020,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globals@16.3.0: + resolution: {integrity: sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==} + engines: {node: '>=18'} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1211,6 +1244,72 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier-plugin-tailwindcss@0.6.14: + resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==} + engines: {node: '>=14.21.3'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-import-sort: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-style-order: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-import-sort: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-style-order: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -2216,6 +2315,10 @@ snapshots: escape-string-regexp@4.0.0: {} + eslint-config-prettier@10.1.8(eslint@9.32.0): + dependencies: + eslint: 9.32.0 + eslint-plugin-react-hooks@5.2.0(eslint@9.32.0): dependencies: eslint: 9.32.0 @@ -2224,6 +2327,10 @@ snapshots: dependencies: eslint: 9.32.0 + eslint-plugin-simple-import-sort@12.1.1(eslint@9.32.0): + dependencies: + eslint: 9.32.0 + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -2360,6 +2467,8 @@ snapshots: globals@14.0.0: {} + globals@16.3.0: {} + graphemer@1.4.0: {} has-flag@3.0.0: {} @@ -2559,6 +2668,12 @@ snapshots: prelude-ls@1.2.1: {} + prettier-plugin-tailwindcss@0.6.14(prettier@3.6.2): + dependencies: + prettier: 3.6.2 + + prettier@3.6.2: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 From bd1782e5f3a67fed0e30821b633130e6c70f3987 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Mon, 4 Aug 2025 12:44:34 +0900 Subject: [PATCH 02/35] =?UTF-8?q?chore:=20lefthook=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lefthook.yaml | 11 +++++ package.json | 6 ++- pnpm-lock.yaml | 100 ++++++++++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 4 +- 4 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 lefthook.yaml diff --git a/lefthook.yaml b/lefthook.yaml new file mode 100644 index 00000000..7750c9de --- /dev/null +++ b/lefthook.yaml @@ -0,0 +1,11 @@ +pre-commit: + parallel: true + commands: + lint: + glob: "**/*.{js,ts,tsx}" + run: pnpm lint:check + fail_text: "🚨 ESLint 검사 실패! 'pnpm lint'로 수정 후 다시 커밋하세요." + format: + glob: "**/*.{js,ts,tsx,html,json,yaml,yml,md}" + run: pnpm format:check + fail_text: "🎨 코드 포맷팅이 필요합니다! 'pnpm format'로 수정 후 다시 커밋하세요." diff --git a/package.json b/package.json index 8b8f8f9f..deeb38d6 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", "build": "tsc -b && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --fix", + "lint:check": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "dependencies": { "react": "^19.1.1", @@ -37,6 +40,7 @@ "eslint-plugin-simple-import-sort": "^12.1.1", "globals": "^16.3.0", "jsdom": "^26.1.0", + "lefthook": "^1.12.2", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.14", "typescript": "^5.9.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b0d3bd2..60cdf48d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 + lefthook: + specifier: ^1.12.2 + version: 1.12.2 prettier: specifier: ^3.6.2 version: 3.6.2 @@ -1120,6 +1123,60 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + lefthook-darwin-arm64@1.12.2: + resolution: {integrity: sha512-fTxeI9tEskrHjc3QyEO+AG7impBXY2Ed8V5aiRc3fw9POfYtVh9b5jRx90fjk2+ld5hf+Z1DsyyLq/vOHDFskQ==} + cpu: [arm64] + os: [darwin] + + lefthook-darwin-x64@1.12.2: + resolution: {integrity: sha512-T1dCDKAAfdHgYZ8qtrS02SJSHoR52RFcrGArFNll9Mu4ZSV19Sp8BO+kTwDUOcLYdcPGNaqOp9PkRBQGZWQC7g==} + cpu: [x64] + os: [darwin] + + lefthook-freebsd-arm64@1.12.2: + resolution: {integrity: sha512-2n9z7Q4BKeMBoB9cuEdv0UBQH82Z4GgBQpCrfjCtyzpDnYQwrH8Tkrlnlko4qPh9MM6nLLGIYMKsA5nltzo8Cg==} + cpu: [arm64] + os: [freebsd] + + lefthook-freebsd-x64@1.12.2: + resolution: {integrity: sha512-1hNY/irY+/3kjRzKoJYxG+m3BYI8QxopJUK1PQnknGo1Wy5u302SdX+tR7pnpz6JM5chrNw4ozSbKKOvdZ5VEw==} + cpu: [x64] + os: [freebsd] + + lefthook-linux-arm64@1.12.2: + resolution: {integrity: sha512-1W4swYIVRkxq/LFTuuK4oVpd6NtTKY4E3VY2Uq2JDkIOJV46+8qGBF+C/QA9K3O9chLffgN7c+i+NhIuGiZ/Vw==} + cpu: [arm64] + os: [linux] + + lefthook-linux-x64@1.12.2: + resolution: {integrity: sha512-J6VGuMfhq5iCsg1Pv7xULbuXC63gP5LaikT0PhkyBNMi3HQneZFDJ8k/sp0Ue9HkQv6QfWIo3/FgB9gz38MCFw==} + cpu: [x64] + os: [linux] + + lefthook-openbsd-arm64@1.12.2: + resolution: {integrity: sha512-wncDRW3ml24DaOyH22KINumjvCohswbQqbxyH2GORRCykSnE859cTjOrRIchTKBIARF7PSeGPUtS7EK0+oDbaw==} + cpu: [arm64] + os: [openbsd] + + lefthook-openbsd-x64@1.12.2: + resolution: {integrity: sha512-2jDOkCHNnc/oK/vR62hAf3vZb1EQ6Md2GjIlgZ/V7A3ztOsM8QZ5IxwYN3D1UOIR5ZnwMBy7PtmTJC/HJrig5w==} + cpu: [x64] + os: [openbsd] + + lefthook-windows-arm64@1.12.2: + resolution: {integrity: sha512-ZMH/q6UNSidhHEG/1QoqIl1n4yPTBWuVmKx5bONtKHicoz4QCQ+QEiNjKsG5OO4C62nfyHGThmweCzZVUQECJw==} + cpu: [arm64] + os: [win32] + + lefthook-windows-x64@1.12.2: + resolution: {integrity: sha512-TqT2jIPcTQ9uwaw+v+DTmvnUHM/p7bbsSrPoPX+fRXSGLzFjyiY+12C9dObSwfCQq6rT70xqQJ9AmftJQsa5/Q==} + cpu: [x64] + os: [win32] + + lefthook@1.12.2: + resolution: {integrity: sha512-2CeTu5NcmoT9YnqsHTq/TF36MlqlzHzhivGx3DrXHwcff4TdvrkIwUTA56huM3Nlo5ODAF/0hlPzaKLmNHCBnQ==} + hasBin: true + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2567,6 +2624,49 @@ snapshots: dependencies: json-buffer: 3.0.1 + lefthook-darwin-arm64@1.12.2: + optional: true + + lefthook-darwin-x64@1.12.2: + optional: true + + lefthook-freebsd-arm64@1.12.2: + optional: true + + lefthook-freebsd-x64@1.12.2: + optional: true + + lefthook-linux-arm64@1.12.2: + optional: true + + lefthook-linux-x64@1.12.2: + optional: true + + lefthook-openbsd-arm64@1.12.2: + optional: true + + lefthook-openbsd-x64@1.12.2: + optional: true + + lefthook-windows-arm64@1.12.2: + optional: true + + lefthook-windows-x64@1.12.2: + optional: true + + lefthook@1.12.2: + optionalDependencies: + lefthook-darwin-arm64: 1.12.2 + lefthook-darwin-x64: 1.12.2 + lefthook-freebsd-arm64: 1.12.2 + lefthook-freebsd-x64: 1.12.2 + lefthook-linux-arm64: 1.12.2 + lefthook-linux-x64: 1.12.2 + lefthook-openbsd-arm64: 1.12.2 + lefthook-openbsd-x64: 1.12.2 + lefthook-windows-arm64: 1.12.2 + lefthook-windows-x64: 1.12.2 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0ba40649..45ea4147 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,3 @@ -ignoredBuiltDependencies: - - esbuild - onlyBuiltDependencies: - '@swc/core' + - lefthook From ced472940ec16f387e5244e02f67ef65a115859c Mon Sep 17 00:00:00 2001 From: chan9yu Date: Tue, 5 Aug 2025 22:56:42 +0900 Subject: [PATCH 03/35] =?UTF-8?q?chore:=20prettier=EC=99=80=20eslint?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=B0=8F=20=EC=9B=90=EB=B3=B8=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .prettierignore | 6 ++++++ eslint.config.js | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.prettierignore b/.prettierignore index d864f7aa..92d76cf8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -20,9 +20,15 @@ yarn.lock *.ttf *.eot +# 테스트 파일들 +src/advanced/__tests__/ +src/basic/__tests__/ +src/origin/__tests__/ + # 원본 파일들 src/origin/ src/refactoring(hint)/ +index.origin.html # 기타 .env* diff --git a/eslint.config.js b/eslint.config.js index d9ab94d1..6dfe7b9b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,8 +14,10 @@ const ignoresConfig = { "build/**", ".vite/**", "coverage/**", + "src/**/__tests__/**", "src/origin/", - "src/refactoring(hint)/" + "src/refactoring(hint)/", + "index.origin.html" ] }; From 66c9df76519b14f2ba7f44fc200e997a9f390cb1 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Thu, 7 Aug 2025 11:30:34 +0900 Subject: [PATCH 04/35] =?UTF-8?q?fix:=20ESLint=EC=99=80=20Prettier=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 57 +- index.advanced.html | 22 +- index.basic.html | 22 +- pnpm-workspace.yaml | 2 +- src/advanced/App.tsx | 1561 +++++++++++++++++++++++------------------ src/advanced/main.tsx | 13 +- src/basic/App.tsx | 1561 +++++++++++++++++++++++------------------ src/basic/main.tsx | 13 +- src/setupTests.ts | 2 +- src/types.ts | 2 +- vite.config.ts | 16 +- 11 files changed, 1847 insertions(+), 1424 deletions(-) diff --git a/README.md b/README.md index 3198c545..4a19edbc 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,15 @@ 이번 과제는 단일책임원칙을 위반한 거대한 컴포넌트를 리팩토링 하는 것입니다. React의 컴포넌트는 단일 책임 원칙(Single Responsibility Principle, SRP)을 따르는 것이 좋습니다. 즉, 각 컴포넌트는 하나의 책임만을 가져야 합니다. 하지만 실제로는 여러 가지 기능을 가진 거대한 컴포넌트를 작성하는 경우가 많습니다. [목표] + ## 1. 취지 + - React의 추구미(!)를 이해해보아요! - 단일 책임 원칙(SRP)을 위반한 거대한 컴포넌트가 얼마나 안 좋은지 경험해보아요! - 단일 책임이라는 개념을 이해하기 상태, 순수함수, 컴포넌트, 훅 등 다양한 계층을 이해해합니다. - 엔티티와 UI를 구분하고 데이터, 상태, 비즈니스 로직 등의 특징이 다르다는 것을 이해해보세요. - 이를 통해 적절한 Custom Hook과 유틸리티 함수를 분리하고, 컴포넌트 계층 구조를 정리하는 능력을 갖춥니다! - ## 2. 목표 모든 소프트웨어에는 적절한 책임과 계층이 존재합니다. 하나의 계층(Component)만으로 소프트웨어를 구성하게 되면 나중에는 정리정돈이 되지 않은 코드를 만나게 됩니다. 예전에는 이러한 BestPractice에 대해서 혼돈의 시대였지만 FE가 진화를 거듭하는 과정에서 적절한 계측에 대한 합의가 이루어지고 있는 상태입니다. @@ -22,7 +23,7 @@ React의 주요 책임 계층은 Component, hook, function 등이 있습니다. - 엔티티를 다루는 상태와 그렇지 않은 상태 - cart, isCartFull vs isShowPopup - 엔티티를 다루는 컴포넌트와 훅 - CartItemView, useCart(), useProduct() - 엔티티를 다루지 않는 컴포넌트와 훅 - Button, useRoute, useEvent 등 -- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) +- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) 이번 과제의 목표는 이러한 계층을 이해하고 분리하여 정리정돈을 하는 기준이나 방법등을 습득하는데 있습니다. @@ -35,34 +36,34 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. #### 1) 장바구니 페이지 요구사항 - 상품 목록 - - 상품명, 가격, 재고 수량 등을 표시 - - 각 상품의 할인 정보 표시 - - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 + - 상품명, 가격, 재고 수량 등을 표시 + - 각 상품의 할인 정보 표시 + - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 - 장바구니 - - 장바구니 내 상품 수량 조절 가능 - - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 - - 적용된 할인율 표시 (예: "10% 할인 적용") - - 장바구니 내 모든 상품의 총액을 계산해야 + - 장바구니 내 상품 수량 조절 가능 + - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 + - 적용된 할인율 표시 (예: "10% 할인 적용") + - 장바구니 내 모든 상품의 총액을 계산해야 - 쿠폰 할인 - - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 + - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 - 주문요약 - - 할인 전 총 금액 - - 총 할인 금액 - - 최종 결제 금액 + - 할인 전 총 금액 + - 총 할인 금액 + - 최종 결제 금액 #### 2) 관리자 페이지 요구사항 - 상품 관리 - - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 - - 새로운 상품 추가 가능 - - 상품 제거 가능 + - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 + - 새로운 상품 추가 가능 + - 상품 제거 가능 - 할인 관리 - - 상품별 할인 정보 추가/수정/삭제 가능 - - 할인 조건 설정 (구매 수량에 따른 할인율) + - 상품별 할인 정보 추가/수정/삭제 가능 + - 할인 조건 설정 (구매 수량에 따른 할인율) - 쿠폰 관리 - - 전체 상품에 적용 가능한 쿠폰 생성 - - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) - - 할인 유형은 금액 또는 비율로 설정 가능 + - 전체 상품에 적용 가능한 쿠폰 생성 + - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) + - 할인 유형은 금액 또는 비율로 설정 가능 ### (2) 코드 개선 요구사항 @@ -88,9 +89,8 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. ### (3) 테스트 코드 통과하기 - - ## 심화과제: Props drilling + - 이번 심화과제는 Context나 Jotai를 사용해서 Props drilling을 없애는 것입니다. - 어떤 props는 남겨야 하는지, 어떤 props는 제거해야 하는지에 대한 기준을 세워보세요. - Context나 Jotai를 사용하여 상태를 관리하는 방법을 익히고, 이를 통해 컴포넌트 간의 데이터 전달을 효율적으로 처리할 수 있습니다. @@ -102,9 +102,8 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. - basic에서 열심히 컴포넌트를 분리해주었겠죠? - 아마 그 과정에서 container - presenter 패턴으로 만들어졌기에 props drilling이 상당히 불편했을거에요. - 그래서 심화과제에서는 props drilling을 제거하는 작업을 할거에요. - - 전역상태관리가 아직 낯설다 - jotai를 선택해주세요 (참고자료 참고) - - 나는 깊이를 공부해보고 싶아. - context를 선택해서 상태관리를 해보세요. - + - 전역상태관리가 아직 낯설다 - jotai를 선택해주세요 (참고자료 참고) + - 나는 깊이를 공부해보고 싶아. - context를 선택해서 상태관리를 해보세요. ### (1) 요구사항 @@ -112,15 +111,11 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. - Context나 Jotai를 사용하여 상태를 관리합니다. - 테스트 코드를 통과합니다. - ### (2) 힌트 - UI 컴포넌트와 엔티티 컴포넌트는 각각 props를 다르게 받는게 좋습니다. - - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, + - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, - 엔티티 컴포넌트는 가급적 엔티티를 중심으로 전달받는 것이 좋습니다. - 특히 콜백의 경우, - UI 컴포넌트는 이벤트 핸들러를 props로 받아서 처리하도록 해서 재사용성을 높이지만, - 엔티티 컴포넌트는 props가 아닌 컴포넌트 내부에서 상태를 관리하는 것이 좋습니다. - - - diff --git a/index.advanced.html b/index.advanced.html index 97a2b3e1..30c87546 100644 --- a/index.advanced.html +++ b/index.advanced.html @@ -1,13 +1,13 @@ - - - - 장바구니로 학습하는 디자인패턴 - - - -
- - - \ No newline at end of file + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + diff --git a/index.basic.html b/index.basic.html index 67da41be..72a7adad 100644 --- a/index.basic.html +++ b/index.basic.html @@ -1,13 +1,13 @@ - - - - 장바구니로 학습하는 디자인패턴 - - - -
- - - \ No newline at end of file + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 45ea4147..8a4fd4d8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,3 @@ onlyBuiltDependencies: - - '@swc/core' + - "@swc/core" - lefthook diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1..45a2bf68 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,5 +1,6 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { FormEvent, useCallback, useEffect, useState } from "react"; + +import { CartItem, Coupon, Product } from "../types"; interface ProductWithUI extends Product { description?: string; @@ -9,65 +10,62 @@ interface ProductWithUI extends Product { interface Notification { id: string; message: string; - type: 'error' | 'success' | 'warning'; + type: "error" | "success" | "warning"; } // 초기 데이터 const initialProducts: ProductWithUI[] = [ { - id: 'p1', - name: '상품1', + id: "p1", + name: "상품1", price: 10000, stock: 20, discounts: [ { quantity: 10, rate: 0.1 }, { quantity: 20, rate: 0.2 } ], - description: '최고급 품질의 프리미엄 상품입니다.' + description: "최고급 품질의 프리미엄 상품입니다." }, { - id: 'p2', - name: '상품2', + id: "p2", + name: "상품2", price: 20000, stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", isRecommended: true }, { - id: 'p3', - name: '상품3', + id: "p3", + name: "상품3", price: 30000, stock: 20, discounts: [ { quantity: 10, rate: 0.2 }, { quantity: 30, rate: 0.25 } ], - description: '대용량과 고성능을 자랑하는 상품입니다.' + description: "대용량과 고성능을 자랑하는 상품입니다." } ]; const initialCoupons: Coupon[] = [ { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", discountValue: 5000 }, { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", discountValue: 10 } ]; const App = () => { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); + const saved = localStorage.getItem("products"); if (saved) { try { return JSON.parse(saved); @@ -79,7 +77,7 @@ const App = () => { }); const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); + const saved = localStorage.getItem("cart"); if (saved) { try { return JSON.parse(saved); @@ -91,7 +89,7 @@ const App = () => { }); const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); + const saved = localStorage.getItem("coupons"); if (saved) { try { return JSON.parse(saved); @@ -106,59 +104,58 @@ const App = () => { const [isAdmin, setIsAdmin] = useState(false); const [notifications, setNotifications] = useState([]); const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); // Admin const [editingProduct, setEditingProduct] = useState(null); const [productForm, setProductForm] = useState({ - name: '', + name: "", price: 0, stock: 0, - description: '', + description: "", discounts: [] as Array<{ quantity: number; rate: number }> }); const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", discountValue: 0 }); - const formatPrice = (price: number, productId?: string): string => { if (productId) { - const product = products.find(p => p.id === productId); + const product = products.find((p) => p.id === productId); if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; + return "SOLD OUT"; } } if (isAdmin) { return `${price.toLocaleString()}원`; } - + return `₩${price.toLocaleString()}`; }; const getMaxApplicableDiscount = (item: CartItem): number => { const { discounts } = item.product; const { quantity } = item; - + const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate : maxDiscount; }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); if (hasBulkPurchase) { return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 } - + return baseDiscount; }; @@ -166,10 +163,11 @@ const App = () => { const { price } = item.product; const { quantity } = item; const discount = getMaxApplicableDiscount(item); - + return Math.round(price * quantity * (1 - discount)); }; + // eslint-disable-next-line react-hooks/exhaustive-deps const calculateCartTotal = (): { totalBeforeDiscount: number; totalAfterDiscount: number; @@ -177,17 +175,19 @@ const App = () => { let totalBeforeDiscount = 0; let totalAfterDiscount = 0; - cart.forEach(item => { + cart.forEach((item) => { const itemPrice = item.product.price * item.quantity; totalBeforeDiscount += itemPrice; totalAfterDiscount += calculateItemTotal(item); }); if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { + if (selectedCoupon.discountType === "amount") { totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); } } @@ -198,23 +198,25 @@ const App = () => { }; const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); + const cartItem = cart.find((item) => item.product.id === product.id); const remaining = product.stock - (cartItem?.quantity || 0); - + return remaining; }; - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); const [totalItemCount, setTotalItemCount] = useState(0); - useEffect(() => { const count = cart.reduce((sum, item) => sum + item.quantity, 0); @@ -222,18 +224,18 @@ const App = () => { }, [cart]); useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); + localStorage.setItem("products", JSON.stringify(products)); }, [products]); useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); + localStorage.setItem("coupons", JSON.stringify(coupons)); }, [coupons]); useEffect(() => { if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); + localStorage.setItem("cart", JSON.stringify(cart)); } else { - localStorage.removeItem('cart'); + localStorage.removeItem("cart"); } }, [cart]); @@ -244,130 +246,148 @@ const App = () => { return () => clearTimeout(timer); }, [searchTerm]); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + addNotification("재고가 부족합니다!", "error"); + return; + } - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; + setCart((prevCart) => { + const existingItem = prevCart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); } - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); + return [...prevCart, { product, quantity: 1 }]; + }); + + addNotification("장바구니에 담았습니다", "success"); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [cart, addNotification, getRemainingStock] + ); const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); }, []); - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } - const product = products.find(p => p.id === productId); - if (!product) return; + const product = products.find((p) => p.id === productId); + if (!product) return; - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, "error"); + return; + } - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } + setCart((prevCart) => + prevCart.map((item) => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ) + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [products, removeFromCart, addNotification, getRemainingStock] + ); - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal().totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + addNotification("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", "error"); + return; + } + + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [addNotification, calculateCartTotal] + ); const completeOrder = useCallback(() => { const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); setCart([]); setSelectedCoupon(null); }, [addNotification]); - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + setProducts((prev) => [...prev, product]); + addNotification("상품이 추가되었습니다.", "success"); + }, + [addNotification] + ); - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + addNotification("상품이 수정되었습니다.", "success"); + }, + [addNotification] + ); - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification("상품이 삭제되었습니다.", "success"); + }, + [addNotification] + ); - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + addNotification("쿠폰이 추가되었습니다.", "success"); + }, + [coupons, addNotification] + ); - const handleProductSubmit = (e: React.FormEvent) => { + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification("쿠폰이 삭제되었습니다.", "success"); + }, + [selectedCoupon, addNotification] + ); + + const handleProductSubmit = (e: FormEvent) => { e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { + if (editingProduct && editingProduct !== "new") { updateProduct(editingProduct, productForm); setEditingProduct(null); } else { @@ -376,18 +396,18 @@ const App = () => { discounts: productForm.discounts }); } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setProductForm({ name: "", price: 0, stock: 0, description: "", discounts: [] }); setEditingProduct(null); setShowProductForm(false); }; - const handleCouponSubmit = (e: React.FormEvent) => { + const handleCouponSubmit = (e: FormEvent) => { e.preventDefault(); addCoupon(couponForm); setCouponForm({ - name: '', - code: '', - discountType: 'amount', + name: "", + code: "", + discountType: "amount", discountValue: 0 }); setShowCouponForm(false); @@ -399,7 +419,7 @@ const App = () => { name: product.name, price: product.price, stock: product.stock, - description: product.description || '', + description: product.description || "", discounts: product.discounts || [] }); setShowProductForm(true); @@ -408,52 +428,61 @@ const App = () => { const totals = calculateCartTotal(); const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ? products.filter( + (product) => + product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) ) : products; return (
{notifications.length > 0 && ( -
- {notifications.map(notif => ( +
+ {notifications.map((notif) => (
{notif.message} -
))}
)} -
-
-
-
+
+
+
+

SHOP

{/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} {!isAdmin && ( -
+
setSearchTerm(e.target.value)} placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:outline-none" />
)} @@ -461,21 +490,29 @@ const App = () => {
-
+
{isAdmin ? ( -
+

관리자 대시보드

-

상품과 쿠폰을 관리할 수 있습니다

+

상품과 쿠폰을 관리할 수 있습니다

-
+
- {activeTab === 'products' ? ( -
-
-
-

상품 목록

- + {activeTab === "products" ? ( +
+
+
+

상품 목록

+ +
-
-
- - - - - - - - - - - - {(activeTab === 'products' ? products : products).map(product => ( - - - - - - +
+
상품명가격재고설명작업
{product.name}{formatPrice(product.price, product.id)} - 10 ? 'bg-green-100 text-green-800' : - product.stock > 0 ? 'bg-yellow-100 text-yellow-800' : - 'bg-red-100 text-red-800' - }`}> - {product.stock}개 - - {product.description || '-'} - - -
+ + + + + + + - ))} - -
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
-
- {showProductForm && ( -
-
-

- {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} -

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification('가격은 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, price: 0 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(value) }); + + + {(activeTab === "products" ? products : products).map((product) => ( + + + {product.name} + + + {formatPrice(product.price, product.id)} + + + 10 + ? "bg-green-100 text-green-800" + : product.stock > 0 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`} + > + {product.stock}개 + + + + {product.description || "-"} + + + + + + + ))} + + +
+ {showProductForm && ( +
+ +

+ {editingProduct === "new" ? "새 상품 추가" : "상품 수정"} +

+
+
+ + + setProductForm({ ...productForm, name: e.target.value }) } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification('재고는 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); - setProductForm({ ...productForm, stock: 9999 }); + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + required + /> +
+
+ + + setProductForm({ ...productForm, description: e.target.value }) } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + /> +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + price: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + setProductForm({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification("가격은 0보다 커야 합니다", "error"); + setProductForm({ ...productForm, price: 0 }); + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="숫자만 입력" + required + /> +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + stock: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + addNotification("재고는 0보다 커야 합니다", "error"); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + addNotification("재고는 9999개를 초과할 수 없습니다", "error"); + setProductForm({ ...productForm, stock: 9999 }); + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="숫자만 입력" + required + /> +
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 px-2 py-1 border rounded" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 px-2 py-1 border rounded" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = parseInt(e.target.value) || 0; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-20 rounded border px-2 py-1" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-16 rounded border px-2 py-1" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
-
-
- -
- - -
- -
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {coupons.map(coupon => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
-
- ))} - -
- +
+ )} +
+ ) : ( +
+
+

쿠폰 관리

- - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === 'percentage') { - if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} - required - /> -
-
-
- +
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + +
-
- )} -
+ + {showCouponForm && ( +
+
+

새 쿠폰 생성

+
+
+ + + setCouponForm({ ...couponForm, name: e.target.value }) + } + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="신규 가입 쿠폰" + required + /> +
+
+ + + setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }) + } + className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setCouponForm({ + ...couponForm, + discountValue: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + if (couponForm.discountType === "percentage") { + if (value > 100) { + addNotification("할인율은 100%를 초과할 수 없습니다", "error"); + setCouponForm({ ...couponForm, discountValue: 100 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } else { + if (value > 100000) { + addNotification( + "할인 금액은 100,000원을 초과할 수 없습니다", + "error" + ); + setCouponForm({ ...couponForm, discountValue: 100000 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder={couponForm.discountType === "amount" ? "5000" : "10"} + required + /> +
+
+
+ + +
+
+
+ )} +
)}
) : ( -
+
{/* 상품 목록 */}
-
+

전체 상품

-
- 총 {products.length}개 상품 -
+
총 {products.length}개 상품
{filteredProducts.length === 0 ? ( -
-

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

+
+

+ "{debouncedSearchTerm}"에 대한 검색 결과가 없습니다. +

) : ( -
- {filteredProducts.map(product => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

{product.description}

- )} - - {/* 가격 정보 */} -
-

{formatPrice(product.price, product.id)}

+
+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(product); + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% -

+ + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + )}
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

)} + + {/* 가격 정보 */} +
+

+ {formatPrice(product.price, product.id)} +

+ {product.discounts.length > 0 && ( +

+ {product.discounts[0].quantity}개 이상 구매시 할인{" "} + {product.discounts[0].rate * 100}% +

+ )} +
+ + {/* 재고 상태 */} +
+ {remainingStock <= 5 && remainingStock > 0 && ( +

+ 품절임박! {remainingStock}개 남음 +

+ )} + {remainingStock > 5 && ( +

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} +
- - {/* 장바구니 버튼 */} -
-
- ); + ); })}
)}
- +
-
-

- - +
+

+ + 장바구니

{cart.length === 0 ? ( -
- - +
+ + -

장바구니가 비어있습니다

+

장바구니가 비어있습니다

) : (
- {cart.map(item => { + {cart.map((item) => { const itemTotal = calculateItemTotal(item); const originalPrice = item.product.price * item.quantity; const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; - + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + return (
-
-

{item.product.name}

-
- - {item.quantity} -
{hasDiscount && ( - -{discountRate}% + + -{discountRate}% + )}

{Math.round(itemTotal).toLocaleString()}원 @@ -1051,62 +1254,72 @@ const App = () => { {cart.length > 0 && ( <> -

-
+
+

쿠폰 할인

- +
{coupons.length > 0 && ( - )}
-
-

결제 정보

+
+

결제 정보

상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 + + {totals.totalBeforeDiscount.toLocaleString()}원 +
{totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && (
할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 + + - + {( + totals.totalBeforeDiscount - totals.totalAfterDiscount + ).toLocaleString()} + 원 +
)} -
+
결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 + + {totals.totalAfterDiscount.toLocaleString()}원 +
- + - -
+ +

* 실제 결제는 이루어지지 않습니다

@@ -1121,4 +1334,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/advanced/main.tsx b/src/advanced/main.tsx index e63eef4a..79f72b70 100644 --- a/src/advanced/main.tsx +++ b/src/advanced/main.tsx @@ -1,9 +1,10 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from "react"; +import ReactDOM from "react-dom/client"; -ReactDOM.createRoot(document.getElementById('root')!).render( +import App from "./App.tsx"; + +ReactDOM.createRoot(document.getElementById("root")!).render( - , -) + +); diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1..45a2bf68 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,5 +1,6 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { FormEvent, useCallback, useEffect, useState } from "react"; + +import { CartItem, Coupon, Product } from "../types"; interface ProductWithUI extends Product { description?: string; @@ -9,65 +10,62 @@ interface ProductWithUI extends Product { interface Notification { id: string; message: string; - type: 'error' | 'success' | 'warning'; + type: "error" | "success" | "warning"; } // 초기 데이터 const initialProducts: ProductWithUI[] = [ { - id: 'p1', - name: '상품1', + id: "p1", + name: "상품1", price: 10000, stock: 20, discounts: [ { quantity: 10, rate: 0.1 }, { quantity: 20, rate: 0.2 } ], - description: '최고급 품질의 프리미엄 상품입니다.' + description: "최고급 품질의 프리미엄 상품입니다." }, { - id: 'p2', - name: '상품2', + id: "p2", + name: "상품2", price: 20000, stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", isRecommended: true }, { - id: 'p3', - name: '상품3', + id: "p3", + name: "상품3", price: 30000, stock: 20, discounts: [ { quantity: 10, rate: 0.2 }, { quantity: 30, rate: 0.25 } ], - description: '대용량과 고성능을 자랑하는 상품입니다.' + description: "대용량과 고성능을 자랑하는 상품입니다." } ]; const initialCoupons: Coupon[] = [ { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", discountValue: 5000 }, { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", discountValue: 10 } ]; const App = () => { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); + const saved = localStorage.getItem("products"); if (saved) { try { return JSON.parse(saved); @@ -79,7 +77,7 @@ const App = () => { }); const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); + const saved = localStorage.getItem("cart"); if (saved) { try { return JSON.parse(saved); @@ -91,7 +89,7 @@ const App = () => { }); const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); + const saved = localStorage.getItem("coupons"); if (saved) { try { return JSON.parse(saved); @@ -106,59 +104,58 @@ const App = () => { const [isAdmin, setIsAdmin] = useState(false); const [notifications, setNotifications] = useState([]); const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); // Admin const [editingProduct, setEditingProduct] = useState(null); const [productForm, setProductForm] = useState({ - name: '', + name: "", price: 0, stock: 0, - description: '', + description: "", discounts: [] as Array<{ quantity: number; rate: number }> }); const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", discountValue: 0 }); - const formatPrice = (price: number, productId?: string): string => { if (productId) { - const product = products.find(p => p.id === productId); + const product = products.find((p) => p.id === productId); if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; + return "SOLD OUT"; } } if (isAdmin) { return `${price.toLocaleString()}원`; } - + return `₩${price.toLocaleString()}`; }; const getMaxApplicableDiscount = (item: CartItem): number => { const { discounts } = item.product; const { quantity } = item; - + const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate : maxDiscount; }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); if (hasBulkPurchase) { return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 } - + return baseDiscount; }; @@ -166,10 +163,11 @@ const App = () => { const { price } = item.product; const { quantity } = item; const discount = getMaxApplicableDiscount(item); - + return Math.round(price * quantity * (1 - discount)); }; + // eslint-disable-next-line react-hooks/exhaustive-deps const calculateCartTotal = (): { totalBeforeDiscount: number; totalAfterDiscount: number; @@ -177,17 +175,19 @@ const App = () => { let totalBeforeDiscount = 0; let totalAfterDiscount = 0; - cart.forEach(item => { + cart.forEach((item) => { const itemPrice = item.product.price * item.quantity; totalBeforeDiscount += itemPrice; totalAfterDiscount += calculateItemTotal(item); }); if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { + if (selectedCoupon.discountType === "amount") { totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); } } @@ -198,23 +198,25 @@ const App = () => { }; const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); + const cartItem = cart.find((item) => item.product.id === product.id); const remaining = product.stock - (cartItem?.quantity || 0); - + return remaining; }; - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); const [totalItemCount, setTotalItemCount] = useState(0); - useEffect(() => { const count = cart.reduce((sum, item) => sum + item.quantity, 0); @@ -222,18 +224,18 @@ const App = () => { }, [cart]); useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); + localStorage.setItem("products", JSON.stringify(products)); }, [products]); useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); + localStorage.setItem("coupons", JSON.stringify(coupons)); }, [coupons]); useEffect(() => { if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); + localStorage.setItem("cart", JSON.stringify(cart)); } else { - localStorage.removeItem('cart'); + localStorage.removeItem("cart"); } }, [cart]); @@ -244,130 +246,148 @@ const App = () => { return () => clearTimeout(timer); }, [searchTerm]); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + addNotification("재고가 부족합니다!", "error"); + return; + } - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; + setCart((prevCart) => { + const existingItem = prevCart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); } - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); + return [...prevCart, { product, quantity: 1 }]; + }); + + addNotification("장바구니에 담았습니다", "success"); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [cart, addNotification, getRemainingStock] + ); const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); }, []); - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } - const product = products.find(p => p.id === productId); - if (!product) return; + const product = products.find((p) => p.id === productId); + if (!product) return; - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, "error"); + return; + } - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } + setCart((prevCart) => + prevCart.map((item) => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ) + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [products, removeFromCart, addNotification, getRemainingStock] + ); - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal().totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + addNotification("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", "error"); + return; + } + + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [addNotification, calculateCartTotal] + ); const completeOrder = useCallback(() => { const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); setCart([]); setSelectedCoupon(null); }, [addNotification]); - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + setProducts((prev) => [...prev, product]); + addNotification("상품이 추가되었습니다.", "success"); + }, + [addNotification] + ); - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + addNotification("상품이 수정되었습니다.", "success"); + }, + [addNotification] + ); - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification("상품이 삭제되었습니다.", "success"); + }, + [addNotification] + ); - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + addNotification("쿠폰이 추가되었습니다.", "success"); + }, + [coupons, addNotification] + ); - const handleProductSubmit = (e: React.FormEvent) => { + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification("쿠폰이 삭제되었습니다.", "success"); + }, + [selectedCoupon, addNotification] + ); + + const handleProductSubmit = (e: FormEvent) => { e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { + if (editingProduct && editingProduct !== "new") { updateProduct(editingProduct, productForm); setEditingProduct(null); } else { @@ -376,18 +396,18 @@ const App = () => { discounts: productForm.discounts }); } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setProductForm({ name: "", price: 0, stock: 0, description: "", discounts: [] }); setEditingProduct(null); setShowProductForm(false); }; - const handleCouponSubmit = (e: React.FormEvent) => { + const handleCouponSubmit = (e: FormEvent) => { e.preventDefault(); addCoupon(couponForm); setCouponForm({ - name: '', - code: '', - discountType: 'amount', + name: "", + code: "", + discountType: "amount", discountValue: 0 }); setShowCouponForm(false); @@ -399,7 +419,7 @@ const App = () => { name: product.name, price: product.price, stock: product.stock, - description: product.description || '', + description: product.description || "", discounts: product.discounts || [] }); setShowProductForm(true); @@ -408,52 +428,61 @@ const App = () => { const totals = calculateCartTotal(); const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ? products.filter( + (product) => + product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) ) : products; return (
{notifications.length > 0 && ( -
- {notifications.map(notif => ( +
+ {notifications.map((notif) => (
{notif.message} -
))}
)} -
-
-
-
+
+
+
+

SHOP

{/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} {!isAdmin && ( -
+
setSearchTerm(e.target.value)} placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:outline-none" />
)} @@ -461,21 +490,29 @@ const App = () => {
-
+
{isAdmin ? ( -
+

관리자 대시보드

-

상품과 쿠폰을 관리할 수 있습니다

+

상품과 쿠폰을 관리할 수 있습니다

-
+
- {activeTab === 'products' ? ( -
-
-
-

상품 목록

- + {activeTab === "products" ? ( +
+
+
+

상품 목록

+ +
-
-
- - - - - - - - - - - - {(activeTab === 'products' ? products : products).map(product => ( - - - - - - +
+
상품명가격재고설명작업
{product.name}{formatPrice(product.price, product.id)} - 10 ? 'bg-green-100 text-green-800' : - product.stock > 0 ? 'bg-yellow-100 text-yellow-800' : - 'bg-red-100 text-red-800' - }`}> - {product.stock}개 - - {product.description || '-'} - - -
+ + + + + + + - ))} - -
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
-
- {showProductForm && ( -
-
-

- {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} -

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification('가격은 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, price: 0 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(value) }); + + + {(activeTab === "products" ? products : products).map((product) => ( + + + {product.name} + + + {formatPrice(product.price, product.id)} + + + 10 + ? "bg-green-100 text-green-800" + : product.stock > 0 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`} + > + {product.stock}개 + + + + {product.description || "-"} + + + + + + + ))} + + +
+ {showProductForm && ( +
+ +

+ {editingProduct === "new" ? "새 상품 추가" : "상품 수정"} +

+
+
+ + + setProductForm({ ...productForm, name: e.target.value }) } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification('재고는 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); - setProductForm({ ...productForm, stock: 9999 }); + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + required + /> +
+
+ + + setProductForm({ ...productForm, description: e.target.value }) } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + /> +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + price: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + setProductForm({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification("가격은 0보다 커야 합니다", "error"); + setProductForm({ ...productForm, price: 0 }); + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="숫자만 입력" + required + /> +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + stock: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + addNotification("재고는 0보다 커야 합니다", "error"); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + addNotification("재고는 9999개를 초과할 수 없습니다", "error"); + setProductForm({ ...productForm, stock: 9999 }); + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="숫자만 입력" + required + /> +
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 px-2 py-1 border rounded" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 px-2 py-1 border rounded" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = parseInt(e.target.value) || 0; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-20 rounded border px-2 py-1" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-16 rounded border px-2 py-1" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
-
-
- -
- - -
- -
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {coupons.map(coupon => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
-
- ))} - -
- +
+ )} +
+ ) : ( +
+
+

쿠폰 관리

- - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === 'percentage') { - if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} - required - /> -
-
-
- +
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + +
-
- )} -
+ + {showCouponForm && ( +
+
+

새 쿠폰 생성

+
+
+ + + setCouponForm({ ...couponForm, name: e.target.value }) + } + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="신규 가입 쿠폰" + required + /> +
+
+ + + setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }) + } + className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setCouponForm({ + ...couponForm, + discountValue: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + if (couponForm.discountType === "percentage") { + if (value > 100) { + addNotification("할인율은 100%를 초과할 수 없습니다", "error"); + setCouponForm({ ...couponForm, discountValue: 100 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } else { + if (value > 100000) { + addNotification( + "할인 금액은 100,000원을 초과할 수 없습니다", + "error" + ); + setCouponForm({ ...couponForm, discountValue: 100000 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder={couponForm.discountType === "amount" ? "5000" : "10"} + required + /> +
+
+
+ + +
+
+
+ )} +
)}
) : ( -
+
{/* 상품 목록 */}
-
+

전체 상품

-
- 총 {products.length}개 상품 -
+
총 {products.length}개 상품
{filteredProducts.length === 0 ? ( -
-

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

+
+

+ "{debouncedSearchTerm}"에 대한 검색 결과가 없습니다. +

) : ( -
- {filteredProducts.map(product => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

{product.description}

- )} - - {/* 가격 정보 */} -
-

{formatPrice(product.price, product.id)}

+
+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(product); + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% -

+ + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + )}
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

)} + + {/* 가격 정보 */} +
+

+ {formatPrice(product.price, product.id)} +

+ {product.discounts.length > 0 && ( +

+ {product.discounts[0].quantity}개 이상 구매시 할인{" "} + {product.discounts[0].rate * 100}% +

+ )} +
+ + {/* 재고 상태 */} +
+ {remainingStock <= 5 && remainingStock > 0 && ( +

+ 품절임박! {remainingStock}개 남음 +

+ )} + {remainingStock > 5 && ( +

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} +
- - {/* 장바구니 버튼 */} -
-
- ); + ); })}
)}
- +
-
-

- - +
+

+ + 장바구니

{cart.length === 0 ? ( -
- - +
+ + -

장바구니가 비어있습니다

+

장바구니가 비어있습니다

) : (
- {cart.map(item => { + {cart.map((item) => { const itemTotal = calculateItemTotal(item); const originalPrice = item.product.price * item.quantity; const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; - + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + return (
-
-

{item.product.name}

-
- - {item.quantity} -
{hasDiscount && ( - -{discountRate}% + + -{discountRate}% + )}

{Math.round(itemTotal).toLocaleString()}원 @@ -1051,62 +1254,72 @@ const App = () => { {cart.length > 0 && ( <> -

-
+
+

쿠폰 할인

- +
{coupons.length > 0 && ( - )}
-
-

결제 정보

+
+

결제 정보

상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 + + {totals.totalBeforeDiscount.toLocaleString()}원 +
{totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && (
할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 + + - + {( + totals.totalBeforeDiscount - totals.totalAfterDiscount + ).toLocaleString()} + 원 +
)} -
+
결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 + + {totals.totalAfterDiscount.toLocaleString()}원 +
- + - -
+ +

* 실제 결제는 이루어지지 않습니다

@@ -1121,4 +1334,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/main.tsx b/src/basic/main.tsx index e63eef4a..79f72b70 100644 --- a/src/basic/main.tsx +++ b/src/basic/main.tsx @@ -1,9 +1,10 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from "react"; +import ReactDOM from "react-dom/client"; -ReactDOM.createRoot(document.getElementById('root')!).render( +import App from "./App.tsx"; + +ReactDOM.createRoot(document.getElementById("root")!).render( - , -) + +); diff --git a/src/setupTests.ts b/src/setupTests.ts index 7b0828bf..d0de870d 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1 +1 @@ -import '@testing-library/jest-dom'; +import "@testing-library/jest-dom"; diff --git a/src/types.ts b/src/types.ts index 5489e296..aafe0aba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,6 @@ export interface CartItem { export interface Coupon { name: string; code: string; - discountType: 'amount' | 'percentage'; + discountType: "amount" | "percentage"; discountValue: number; } diff --git a/vite.config.ts b/vite.config.ts index e6c4016b..b014e7a9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,16 @@ -import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react-swc'; +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vite"; +import { defineConfig as defineTestConfig, mergeConfig } from "vitest/config"; export default mergeConfig( defineConfig({ - plugins: [react()], + plugins: [react()] }), defineTestConfig({ test: { globals: true, - environment: 'jsdom', - setupFiles: './src/setupTests.ts' - }, + environment: "jsdom", + setupFiles: "./src/setupTests.ts" + } }) -) +); From 994fab10b1cc39ab7b5e2738c1c67c5b1d0b36f1 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Thu, 7 Aug 2025 15:43:24 +0900 Subject: [PATCH 05/35] =?UTF-8?q?fix:=20ESLint=EC=99=80=20Prettier=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 1 - .github/workflows/ci.yml | 1 - README.md | 57 +- index.advanced.html | 22 +- index.basic.html | 22 +- pnpm-workspace.yaml | 2 +- src/advanced/App.tsx | 1561 +++++++++++++++++------------- src/advanced/main.tsx | 13 +- src/basic/App.tsx | 1561 +++++++++++++++++------------- src/basic/main.tsx | 13 +- src/setupTests.ts | 2 +- src/types.ts | 2 +- vite.config.ts | 16 +- 13 files changed, 1847 insertions(+), 1426 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 65ba6d2d..93a62b3a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -39,7 +39,6 @@ - [ ] 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요? - [ ] 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요? - ## 과제 셀프회고 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13336e27..61f46fba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,3 @@ - name: CI on: diff --git a/README.md b/README.md index 3198c545..4a19edbc 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,15 @@ 이번 과제는 단일책임원칙을 위반한 거대한 컴포넌트를 리팩토링 하는 것입니다. React의 컴포넌트는 단일 책임 원칙(Single Responsibility Principle, SRP)을 따르는 것이 좋습니다. 즉, 각 컴포넌트는 하나의 책임만을 가져야 합니다. 하지만 실제로는 여러 가지 기능을 가진 거대한 컴포넌트를 작성하는 경우가 많습니다. [목표] + ## 1. 취지 + - React의 추구미(!)를 이해해보아요! - 단일 책임 원칙(SRP)을 위반한 거대한 컴포넌트가 얼마나 안 좋은지 경험해보아요! - 단일 책임이라는 개념을 이해하기 상태, 순수함수, 컴포넌트, 훅 등 다양한 계층을 이해해합니다. - 엔티티와 UI를 구분하고 데이터, 상태, 비즈니스 로직 등의 특징이 다르다는 것을 이해해보세요. - 이를 통해 적절한 Custom Hook과 유틸리티 함수를 분리하고, 컴포넌트 계층 구조를 정리하는 능력을 갖춥니다! - ## 2. 목표 모든 소프트웨어에는 적절한 책임과 계층이 존재합니다. 하나의 계층(Component)만으로 소프트웨어를 구성하게 되면 나중에는 정리정돈이 되지 않은 코드를 만나게 됩니다. 예전에는 이러한 BestPractice에 대해서 혼돈의 시대였지만 FE가 진화를 거듭하는 과정에서 적절한 계측에 대한 합의가 이루어지고 있는 상태입니다. @@ -22,7 +23,7 @@ React의 주요 책임 계층은 Component, hook, function 등이 있습니다. - 엔티티를 다루는 상태와 그렇지 않은 상태 - cart, isCartFull vs isShowPopup - 엔티티를 다루는 컴포넌트와 훅 - CartItemView, useCart(), useProduct() - 엔티티를 다루지 않는 컴포넌트와 훅 - Button, useRoute, useEvent 등 -- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) +- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) 이번 과제의 목표는 이러한 계층을 이해하고 분리하여 정리정돈을 하는 기준이나 방법등을 습득하는데 있습니다. @@ -35,34 +36,34 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. #### 1) 장바구니 페이지 요구사항 - 상품 목록 - - 상품명, 가격, 재고 수량 등을 표시 - - 각 상품의 할인 정보 표시 - - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 + - 상품명, 가격, 재고 수량 등을 표시 + - 각 상품의 할인 정보 표시 + - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 - 장바구니 - - 장바구니 내 상품 수량 조절 가능 - - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 - - 적용된 할인율 표시 (예: "10% 할인 적용") - - 장바구니 내 모든 상품의 총액을 계산해야 + - 장바구니 내 상품 수량 조절 가능 + - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 + - 적용된 할인율 표시 (예: "10% 할인 적용") + - 장바구니 내 모든 상품의 총액을 계산해야 - 쿠폰 할인 - - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 + - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 - 주문요약 - - 할인 전 총 금액 - - 총 할인 금액 - - 최종 결제 금액 + - 할인 전 총 금액 + - 총 할인 금액 + - 최종 결제 금액 #### 2) 관리자 페이지 요구사항 - 상품 관리 - - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 - - 새로운 상품 추가 가능 - - 상품 제거 가능 + - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 + - 새로운 상품 추가 가능 + - 상품 제거 가능 - 할인 관리 - - 상품별 할인 정보 추가/수정/삭제 가능 - - 할인 조건 설정 (구매 수량에 따른 할인율) + - 상품별 할인 정보 추가/수정/삭제 가능 + - 할인 조건 설정 (구매 수량에 따른 할인율) - 쿠폰 관리 - - 전체 상품에 적용 가능한 쿠폰 생성 - - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) - - 할인 유형은 금액 또는 비율로 설정 가능 + - 전체 상품에 적용 가능한 쿠폰 생성 + - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) + - 할인 유형은 금액 또는 비율로 설정 가능 ### (2) 코드 개선 요구사항 @@ -88,9 +89,8 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. ### (3) 테스트 코드 통과하기 - - ## 심화과제: Props drilling + - 이번 심화과제는 Context나 Jotai를 사용해서 Props drilling을 없애는 것입니다. - 어떤 props는 남겨야 하는지, 어떤 props는 제거해야 하는지에 대한 기준을 세워보세요. - Context나 Jotai를 사용하여 상태를 관리하는 방법을 익히고, 이를 통해 컴포넌트 간의 데이터 전달을 효율적으로 처리할 수 있습니다. @@ -102,9 +102,8 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. - basic에서 열심히 컴포넌트를 분리해주었겠죠? - 아마 그 과정에서 container - presenter 패턴으로 만들어졌기에 props drilling이 상당히 불편했을거에요. - 그래서 심화과제에서는 props drilling을 제거하는 작업을 할거에요. - - 전역상태관리가 아직 낯설다 - jotai를 선택해주세요 (참고자료 참고) - - 나는 깊이를 공부해보고 싶아. - context를 선택해서 상태관리를 해보세요. - + - 전역상태관리가 아직 낯설다 - jotai를 선택해주세요 (참고자료 참고) + - 나는 깊이를 공부해보고 싶아. - context를 선택해서 상태관리를 해보세요. ### (1) 요구사항 @@ -112,15 +111,11 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. - Context나 Jotai를 사용하여 상태를 관리합니다. - 테스트 코드를 통과합니다. - ### (2) 힌트 - UI 컴포넌트와 엔티티 컴포넌트는 각각 props를 다르게 받는게 좋습니다. - - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, + - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, - 엔티티 컴포넌트는 가급적 엔티티를 중심으로 전달받는 것이 좋습니다. - 특히 콜백의 경우, - UI 컴포넌트는 이벤트 핸들러를 props로 받아서 처리하도록 해서 재사용성을 높이지만, - 엔티티 컴포넌트는 props가 아닌 컴포넌트 내부에서 상태를 관리하는 것이 좋습니다. - - - diff --git a/index.advanced.html b/index.advanced.html index 97a2b3e1..30c87546 100644 --- a/index.advanced.html +++ b/index.advanced.html @@ -1,13 +1,13 @@ - - - - 장바구니로 학습하는 디자인패턴 - - - -
- - - \ No newline at end of file + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + diff --git a/index.basic.html b/index.basic.html index 67da41be..72a7adad 100644 --- a/index.basic.html +++ b/index.basic.html @@ -1,13 +1,13 @@ - - - - 장바구니로 학습하는 디자인패턴 - - - -
- - - \ No newline at end of file + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 45ea4147..8a4fd4d8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,3 @@ onlyBuiltDependencies: - - '@swc/core' + - "@swc/core" - lefthook diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1..45a2bf68 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,5 +1,6 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { FormEvent, useCallback, useEffect, useState } from "react"; + +import { CartItem, Coupon, Product } from "../types"; interface ProductWithUI extends Product { description?: string; @@ -9,65 +10,62 @@ interface ProductWithUI extends Product { interface Notification { id: string; message: string; - type: 'error' | 'success' | 'warning'; + type: "error" | "success" | "warning"; } // 초기 데이터 const initialProducts: ProductWithUI[] = [ { - id: 'p1', - name: '상품1', + id: "p1", + name: "상품1", price: 10000, stock: 20, discounts: [ { quantity: 10, rate: 0.1 }, { quantity: 20, rate: 0.2 } ], - description: '최고급 품질의 프리미엄 상품입니다.' + description: "최고급 품질의 프리미엄 상품입니다." }, { - id: 'p2', - name: '상품2', + id: "p2", + name: "상품2", price: 20000, stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", isRecommended: true }, { - id: 'p3', - name: '상품3', + id: "p3", + name: "상품3", price: 30000, stock: 20, discounts: [ { quantity: 10, rate: 0.2 }, { quantity: 30, rate: 0.25 } ], - description: '대용량과 고성능을 자랑하는 상품입니다.' + description: "대용량과 고성능을 자랑하는 상품입니다." } ]; const initialCoupons: Coupon[] = [ { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", discountValue: 5000 }, { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", discountValue: 10 } ]; const App = () => { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); + const saved = localStorage.getItem("products"); if (saved) { try { return JSON.parse(saved); @@ -79,7 +77,7 @@ const App = () => { }); const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); + const saved = localStorage.getItem("cart"); if (saved) { try { return JSON.parse(saved); @@ -91,7 +89,7 @@ const App = () => { }); const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); + const saved = localStorage.getItem("coupons"); if (saved) { try { return JSON.parse(saved); @@ -106,59 +104,58 @@ const App = () => { const [isAdmin, setIsAdmin] = useState(false); const [notifications, setNotifications] = useState([]); const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); // Admin const [editingProduct, setEditingProduct] = useState(null); const [productForm, setProductForm] = useState({ - name: '', + name: "", price: 0, stock: 0, - description: '', + description: "", discounts: [] as Array<{ quantity: number; rate: number }> }); const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", discountValue: 0 }); - const formatPrice = (price: number, productId?: string): string => { if (productId) { - const product = products.find(p => p.id === productId); + const product = products.find((p) => p.id === productId); if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; + return "SOLD OUT"; } } if (isAdmin) { return `${price.toLocaleString()}원`; } - + return `₩${price.toLocaleString()}`; }; const getMaxApplicableDiscount = (item: CartItem): number => { const { discounts } = item.product; const { quantity } = item; - + const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate : maxDiscount; }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); if (hasBulkPurchase) { return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 } - + return baseDiscount; }; @@ -166,10 +163,11 @@ const App = () => { const { price } = item.product; const { quantity } = item; const discount = getMaxApplicableDiscount(item); - + return Math.round(price * quantity * (1 - discount)); }; + // eslint-disable-next-line react-hooks/exhaustive-deps const calculateCartTotal = (): { totalBeforeDiscount: number; totalAfterDiscount: number; @@ -177,17 +175,19 @@ const App = () => { let totalBeforeDiscount = 0; let totalAfterDiscount = 0; - cart.forEach(item => { + cart.forEach((item) => { const itemPrice = item.product.price * item.quantity; totalBeforeDiscount += itemPrice; totalAfterDiscount += calculateItemTotal(item); }); if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { + if (selectedCoupon.discountType === "amount") { totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); } } @@ -198,23 +198,25 @@ const App = () => { }; const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); + const cartItem = cart.find((item) => item.product.id === product.id); const remaining = product.stock - (cartItem?.quantity || 0); - + return remaining; }; - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); const [totalItemCount, setTotalItemCount] = useState(0); - useEffect(() => { const count = cart.reduce((sum, item) => sum + item.quantity, 0); @@ -222,18 +224,18 @@ const App = () => { }, [cart]); useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); + localStorage.setItem("products", JSON.stringify(products)); }, [products]); useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); + localStorage.setItem("coupons", JSON.stringify(coupons)); }, [coupons]); useEffect(() => { if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); + localStorage.setItem("cart", JSON.stringify(cart)); } else { - localStorage.removeItem('cart'); + localStorage.removeItem("cart"); } }, [cart]); @@ -244,130 +246,148 @@ const App = () => { return () => clearTimeout(timer); }, [searchTerm]); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + addNotification("재고가 부족합니다!", "error"); + return; + } - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; + setCart((prevCart) => { + const existingItem = prevCart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); } - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); + return [...prevCart, { product, quantity: 1 }]; + }); + + addNotification("장바구니에 담았습니다", "success"); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [cart, addNotification, getRemainingStock] + ); const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); }, []); - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } - const product = products.find(p => p.id === productId); - if (!product) return; + const product = products.find((p) => p.id === productId); + if (!product) return; - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, "error"); + return; + } - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } + setCart((prevCart) => + prevCart.map((item) => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ) + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [products, removeFromCart, addNotification, getRemainingStock] + ); - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal().totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + addNotification("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", "error"); + return; + } + + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [addNotification, calculateCartTotal] + ); const completeOrder = useCallback(() => { const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); setCart([]); setSelectedCoupon(null); }, [addNotification]); - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + setProducts((prev) => [...prev, product]); + addNotification("상품이 추가되었습니다.", "success"); + }, + [addNotification] + ); - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + addNotification("상품이 수정되었습니다.", "success"); + }, + [addNotification] + ); - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification("상품이 삭제되었습니다.", "success"); + }, + [addNotification] + ); - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + addNotification("쿠폰이 추가되었습니다.", "success"); + }, + [coupons, addNotification] + ); - const handleProductSubmit = (e: React.FormEvent) => { + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification("쿠폰이 삭제되었습니다.", "success"); + }, + [selectedCoupon, addNotification] + ); + + const handleProductSubmit = (e: FormEvent) => { e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { + if (editingProduct && editingProduct !== "new") { updateProduct(editingProduct, productForm); setEditingProduct(null); } else { @@ -376,18 +396,18 @@ const App = () => { discounts: productForm.discounts }); } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setProductForm({ name: "", price: 0, stock: 0, description: "", discounts: [] }); setEditingProduct(null); setShowProductForm(false); }; - const handleCouponSubmit = (e: React.FormEvent) => { + const handleCouponSubmit = (e: FormEvent) => { e.preventDefault(); addCoupon(couponForm); setCouponForm({ - name: '', - code: '', - discountType: 'amount', + name: "", + code: "", + discountType: "amount", discountValue: 0 }); setShowCouponForm(false); @@ -399,7 +419,7 @@ const App = () => { name: product.name, price: product.price, stock: product.stock, - description: product.description || '', + description: product.description || "", discounts: product.discounts || [] }); setShowProductForm(true); @@ -408,52 +428,61 @@ const App = () => { const totals = calculateCartTotal(); const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ? products.filter( + (product) => + product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) ) : products; return (
{notifications.length > 0 && ( -
- {notifications.map(notif => ( +
+ {notifications.map((notif) => (
{notif.message} -
))}
)} -
-
-
-
+
+
+
+

SHOP

{/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} {!isAdmin && ( -
+
setSearchTerm(e.target.value)} placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:outline-none" />
)} @@ -461,21 +490,29 @@ const App = () => {
-
+
{isAdmin ? ( -
+

관리자 대시보드

-

상품과 쿠폰을 관리할 수 있습니다

+

상품과 쿠폰을 관리할 수 있습니다

-
+
- {activeTab === 'products' ? ( -
-
-
-

상품 목록

- + {activeTab === "products" ? ( +
+
+
+

상품 목록

+ +
-
-
- - - - - - - - - - - - {(activeTab === 'products' ? products : products).map(product => ( - - - - - - +
+
상품명가격재고설명작업
{product.name}{formatPrice(product.price, product.id)} - 10 ? 'bg-green-100 text-green-800' : - product.stock > 0 ? 'bg-yellow-100 text-yellow-800' : - 'bg-red-100 text-red-800' - }`}> - {product.stock}개 - - {product.description || '-'} - - -
+ + + + + + + - ))} - -
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
-
- {showProductForm && ( -
-
-

- {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} -

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification('가격은 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, price: 0 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(value) }); + + + {(activeTab === "products" ? products : products).map((product) => ( + + + {product.name} + + + {formatPrice(product.price, product.id)} + + + 10 + ? "bg-green-100 text-green-800" + : product.stock > 0 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`} + > + {product.stock}개 + + + + {product.description || "-"} + + + + + + + ))} + + +
+ {showProductForm && ( +
+ +

+ {editingProduct === "new" ? "새 상품 추가" : "상품 수정"} +

+
+
+ + + setProductForm({ ...productForm, name: e.target.value }) } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification('재고는 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); - setProductForm({ ...productForm, stock: 9999 }); + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + required + /> +
+
+ + + setProductForm({ ...productForm, description: e.target.value }) } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + /> +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + price: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + setProductForm({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification("가격은 0보다 커야 합니다", "error"); + setProductForm({ ...productForm, price: 0 }); + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="숫자만 입력" + required + /> +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + stock: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + addNotification("재고는 0보다 커야 합니다", "error"); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + addNotification("재고는 9999개를 초과할 수 없습니다", "error"); + setProductForm({ ...productForm, stock: 9999 }); + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="숫자만 입력" + required + /> +
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 px-2 py-1 border rounded" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 px-2 py-1 border rounded" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = parseInt(e.target.value) || 0; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-20 rounded border px-2 py-1" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-16 rounded border px-2 py-1" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
-
-
- -
- - -
- -
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {coupons.map(coupon => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
-
- ))} - -
- +
+ )} +
+ ) : ( +
+
+

쿠폰 관리

- - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === 'percentage') { - if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} - required - /> -
-
-
- +
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + +
-
- )} -
+ + {showCouponForm && ( +
+
+

새 쿠폰 생성

+
+
+ + + setCouponForm({ ...couponForm, name: e.target.value }) + } + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="신규 가입 쿠폰" + required + /> +
+
+ + + setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }) + } + className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setCouponForm({ + ...couponForm, + discountValue: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + if (couponForm.discountType === "percentage") { + if (value > 100) { + addNotification("할인율은 100%를 초과할 수 없습니다", "error"); + setCouponForm({ ...couponForm, discountValue: 100 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } else { + if (value > 100000) { + addNotification( + "할인 금액은 100,000원을 초과할 수 없습니다", + "error" + ); + setCouponForm({ ...couponForm, discountValue: 100000 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder={couponForm.discountType === "amount" ? "5000" : "10"} + required + /> +
+
+
+ + +
+
+
+ )} +
)}
) : ( -
+
{/* 상품 목록 */}
-
+

전체 상품

-
- 총 {products.length}개 상품 -
+
총 {products.length}개 상품
{filteredProducts.length === 0 ? ( -
-

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

+
+

+ "{debouncedSearchTerm}"에 대한 검색 결과가 없습니다. +

) : ( -
- {filteredProducts.map(product => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

{product.description}

- )} - - {/* 가격 정보 */} -
-

{formatPrice(product.price, product.id)}

+
+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(product); + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% -

+ + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + )}
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

)} + + {/* 가격 정보 */} +
+

+ {formatPrice(product.price, product.id)} +

+ {product.discounts.length > 0 && ( +

+ {product.discounts[0].quantity}개 이상 구매시 할인{" "} + {product.discounts[0].rate * 100}% +

+ )} +
+ + {/* 재고 상태 */} +
+ {remainingStock <= 5 && remainingStock > 0 && ( +

+ 품절임박! {remainingStock}개 남음 +

+ )} + {remainingStock > 5 && ( +

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} +
- - {/* 장바구니 버튼 */} -
-
- ); + ); })}
)}
- +
-
-

- - +
+

+ + 장바구니

{cart.length === 0 ? ( -
- - +
+ + -

장바구니가 비어있습니다

+

장바구니가 비어있습니다

) : (
- {cart.map(item => { + {cart.map((item) => { const itemTotal = calculateItemTotal(item); const originalPrice = item.product.price * item.quantity; const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; - + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + return (
-
-

{item.product.name}

-
- - {item.quantity} -
{hasDiscount && ( - -{discountRate}% + + -{discountRate}% + )}

{Math.round(itemTotal).toLocaleString()}원 @@ -1051,62 +1254,72 @@ const App = () => { {cart.length > 0 && ( <> -

-
+
+

쿠폰 할인

- +
{coupons.length > 0 && ( - )}
-
-

결제 정보

+
+

결제 정보

상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 + + {totals.totalBeforeDiscount.toLocaleString()}원 +
{totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && (
할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 + + - + {( + totals.totalBeforeDiscount - totals.totalAfterDiscount + ).toLocaleString()} + 원 +
)} -
+
결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 + + {totals.totalAfterDiscount.toLocaleString()}원 +
- + - -
+ +

* 실제 결제는 이루어지지 않습니다

@@ -1121,4 +1334,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/advanced/main.tsx b/src/advanced/main.tsx index e63eef4a..79f72b70 100644 --- a/src/advanced/main.tsx +++ b/src/advanced/main.tsx @@ -1,9 +1,10 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from "react"; +import ReactDOM from "react-dom/client"; -ReactDOM.createRoot(document.getElementById('root')!).render( +import App from "./App.tsx"; + +ReactDOM.createRoot(document.getElementById("root")!).render( - , -) + +); diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1..45a2bf68 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,5 +1,6 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { FormEvent, useCallback, useEffect, useState } from "react"; + +import { CartItem, Coupon, Product } from "../types"; interface ProductWithUI extends Product { description?: string; @@ -9,65 +10,62 @@ interface ProductWithUI extends Product { interface Notification { id: string; message: string; - type: 'error' | 'success' | 'warning'; + type: "error" | "success" | "warning"; } // 초기 데이터 const initialProducts: ProductWithUI[] = [ { - id: 'p1', - name: '상품1', + id: "p1", + name: "상품1", price: 10000, stock: 20, discounts: [ { quantity: 10, rate: 0.1 }, { quantity: 20, rate: 0.2 } ], - description: '최고급 품질의 프리미엄 상품입니다.' + description: "최고급 품질의 프리미엄 상품입니다." }, { - id: 'p2', - name: '상품2', + id: "p2", + name: "상품2", price: 20000, stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", isRecommended: true }, { - id: 'p3', - name: '상품3', + id: "p3", + name: "상품3", price: 30000, stock: 20, discounts: [ { quantity: 10, rate: 0.2 }, { quantity: 30, rate: 0.25 } ], - description: '대용량과 고성능을 자랑하는 상품입니다.' + description: "대용량과 고성능을 자랑하는 상품입니다." } ]; const initialCoupons: Coupon[] = [ { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", discountValue: 5000 }, { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", discountValue: 10 } ]; const App = () => { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); + const saved = localStorage.getItem("products"); if (saved) { try { return JSON.parse(saved); @@ -79,7 +77,7 @@ const App = () => { }); const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); + const saved = localStorage.getItem("cart"); if (saved) { try { return JSON.parse(saved); @@ -91,7 +89,7 @@ const App = () => { }); const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); + const saved = localStorage.getItem("coupons"); if (saved) { try { return JSON.parse(saved); @@ -106,59 +104,58 @@ const App = () => { const [isAdmin, setIsAdmin] = useState(false); const [notifications, setNotifications] = useState([]); const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); // Admin const [editingProduct, setEditingProduct] = useState(null); const [productForm, setProductForm] = useState({ - name: '', + name: "", price: 0, stock: 0, - description: '', + description: "", discounts: [] as Array<{ quantity: number; rate: number }> }); const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", discountValue: 0 }); - const formatPrice = (price: number, productId?: string): string => { if (productId) { - const product = products.find(p => p.id === productId); + const product = products.find((p) => p.id === productId); if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; + return "SOLD OUT"; } } if (isAdmin) { return `${price.toLocaleString()}원`; } - + return `₩${price.toLocaleString()}`; }; const getMaxApplicableDiscount = (item: CartItem): number => { const { discounts } = item.product; const { quantity } = item; - + const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate : maxDiscount; }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); if (hasBulkPurchase) { return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 } - + return baseDiscount; }; @@ -166,10 +163,11 @@ const App = () => { const { price } = item.product; const { quantity } = item; const discount = getMaxApplicableDiscount(item); - + return Math.round(price * quantity * (1 - discount)); }; + // eslint-disable-next-line react-hooks/exhaustive-deps const calculateCartTotal = (): { totalBeforeDiscount: number; totalAfterDiscount: number; @@ -177,17 +175,19 @@ const App = () => { let totalBeforeDiscount = 0; let totalAfterDiscount = 0; - cart.forEach(item => { + cart.forEach((item) => { const itemPrice = item.product.price * item.quantity; totalBeforeDiscount += itemPrice; totalAfterDiscount += calculateItemTotal(item); }); if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { + if (selectedCoupon.discountType === "amount") { totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); } } @@ -198,23 +198,25 @@ const App = () => { }; const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); + const cartItem = cart.find((item) => item.product.id === product.id); const remaining = product.stock - (cartItem?.quantity || 0); - + return remaining; }; - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); const [totalItemCount, setTotalItemCount] = useState(0); - useEffect(() => { const count = cart.reduce((sum, item) => sum + item.quantity, 0); @@ -222,18 +224,18 @@ const App = () => { }, [cart]); useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); + localStorage.setItem("products", JSON.stringify(products)); }, [products]); useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); + localStorage.setItem("coupons", JSON.stringify(coupons)); }, [coupons]); useEffect(() => { if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); + localStorage.setItem("cart", JSON.stringify(cart)); } else { - localStorage.removeItem('cart'); + localStorage.removeItem("cart"); } }, [cart]); @@ -244,130 +246,148 @@ const App = () => { return () => clearTimeout(timer); }, [searchTerm]); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + addNotification("재고가 부족합니다!", "error"); + return; + } - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; + setCart((prevCart) => { + const existingItem = prevCart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); } - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); + return [...prevCart, { product, quantity: 1 }]; + }); + + addNotification("장바구니에 담았습니다", "success"); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [cart, addNotification, getRemainingStock] + ); const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); }, []); - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } - const product = products.find(p => p.id === productId); - if (!product) return; + const product = products.find((p) => p.id === productId); + if (!product) return; - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, "error"); + return; + } - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } + setCart((prevCart) => + prevCart.map((item) => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ) + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [products, removeFromCart, addNotification, getRemainingStock] + ); - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal().totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + addNotification("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", "error"); + return; + } + + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [addNotification, calculateCartTotal] + ); const completeOrder = useCallback(() => { const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); setCart([]); setSelectedCoupon(null); }, [addNotification]); - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + setProducts((prev) => [...prev, product]); + addNotification("상품이 추가되었습니다.", "success"); + }, + [addNotification] + ); - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + addNotification("상품이 수정되었습니다.", "success"); + }, + [addNotification] + ); - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification("상품이 삭제되었습니다.", "success"); + }, + [addNotification] + ); - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + addNotification("쿠폰이 추가되었습니다.", "success"); + }, + [coupons, addNotification] + ); - const handleProductSubmit = (e: React.FormEvent) => { + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification("쿠폰이 삭제되었습니다.", "success"); + }, + [selectedCoupon, addNotification] + ); + + const handleProductSubmit = (e: FormEvent) => { e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { + if (editingProduct && editingProduct !== "new") { updateProduct(editingProduct, productForm); setEditingProduct(null); } else { @@ -376,18 +396,18 @@ const App = () => { discounts: productForm.discounts }); } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setProductForm({ name: "", price: 0, stock: 0, description: "", discounts: [] }); setEditingProduct(null); setShowProductForm(false); }; - const handleCouponSubmit = (e: React.FormEvent) => { + const handleCouponSubmit = (e: FormEvent) => { e.preventDefault(); addCoupon(couponForm); setCouponForm({ - name: '', - code: '', - discountType: 'amount', + name: "", + code: "", + discountType: "amount", discountValue: 0 }); setShowCouponForm(false); @@ -399,7 +419,7 @@ const App = () => { name: product.name, price: product.price, stock: product.stock, - description: product.description || '', + description: product.description || "", discounts: product.discounts || [] }); setShowProductForm(true); @@ -408,52 +428,61 @@ const App = () => { const totals = calculateCartTotal(); const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ? products.filter( + (product) => + product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) ) : products; return (
{notifications.length > 0 && ( -
- {notifications.map(notif => ( +
+ {notifications.map((notif) => (
{notif.message} -
))}
)} -
-
-
-
+
+
+
+

SHOP

{/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} {!isAdmin && ( -
+
setSearchTerm(e.target.value)} placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:outline-none" />
)} @@ -461,21 +490,29 @@ const App = () => {
-
+
{isAdmin ? ( -
+

관리자 대시보드

-

상품과 쿠폰을 관리할 수 있습니다

+

상품과 쿠폰을 관리할 수 있습니다

-
+
- {activeTab === 'products' ? ( -
-
-
-

상품 목록

- + {activeTab === "products" ? ( +
+
+
+

상품 목록

+ +
-
-
- - - - - - - - - - - - {(activeTab === 'products' ? products : products).map(product => ( - - - - - - +
+
상품명가격재고설명작업
{product.name}{formatPrice(product.price, product.id)} - 10 ? 'bg-green-100 text-green-800' : - product.stock > 0 ? 'bg-yellow-100 text-yellow-800' : - 'bg-red-100 text-red-800' - }`}> - {product.stock}개 - - {product.description || '-'} - - -
+ + + + + + + - ))} - -
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
-
- {showProductForm && ( -
-
-

- {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} -

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification('가격은 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, price: 0 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(value) }); + + + {(activeTab === "products" ? products : products).map((product) => ( + + + {product.name} + + + {formatPrice(product.price, product.id)} + + + 10 + ? "bg-green-100 text-green-800" + : product.stock > 0 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`} + > + {product.stock}개 + + + + {product.description || "-"} + + + + + + + ))} + + +
+ {showProductForm && ( +
+ +

+ {editingProduct === "new" ? "새 상품 추가" : "상품 수정"} +

+
+
+ + + setProductForm({ ...productForm, name: e.target.value }) } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification('재고는 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); - setProductForm({ ...productForm, stock: 9999 }); + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + required + /> +
+
+ + + setProductForm({ ...productForm, description: e.target.value }) } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + /> +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + price: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + setProductForm({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification("가격은 0보다 커야 합니다", "error"); + setProductForm({ ...productForm, price: 0 }); + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="숫자만 입력" + required + /> +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + stock: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + addNotification("재고는 0보다 커야 합니다", "error"); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + addNotification("재고는 9999개를 초과할 수 없습니다", "error"); + setProductForm({ ...productForm, stock: 9999 }); + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="숫자만 입력" + required + /> +
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 px-2 py-1 border rounded" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 px-2 py-1 border rounded" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = parseInt(e.target.value) || 0; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-20 rounded border px-2 py-1" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-16 rounded border px-2 py-1" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
-
-
- -
- - -
- -
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {coupons.map(coupon => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
-
- ))} - -
- +
+ )} +
+ ) : ( +
+
+

쿠폰 관리

- - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === 'percentage') { - if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} - required - /> -
-
-
- +
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + +
-
- )} -
+ + {showCouponForm && ( +
+
+

새 쿠폰 생성

+
+
+ + + setCouponForm({ ...couponForm, name: e.target.value }) + } + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="신규 가입 쿠폰" + required + /> +
+
+ + + setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }) + } + className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setCouponForm({ + ...couponForm, + discountValue: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + if (couponForm.discountType === "percentage") { + if (value > 100) { + addNotification("할인율은 100%를 초과할 수 없습니다", "error"); + setCouponForm({ ...couponForm, discountValue: 100 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } else { + if (value > 100000) { + addNotification( + "할인 금액은 100,000원을 초과할 수 없습니다", + "error" + ); + setCouponForm({ ...couponForm, discountValue: 100000 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder={couponForm.discountType === "amount" ? "5000" : "10"} + required + /> +
+
+
+ + +
+
+
+ )} +
)}
) : ( -
+
{/* 상품 목록 */}
-
+

전체 상품

-
- 총 {products.length}개 상품 -
+
총 {products.length}개 상품
{filteredProducts.length === 0 ? ( -
-

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

+
+

+ "{debouncedSearchTerm}"에 대한 검색 결과가 없습니다. +

) : ( -
- {filteredProducts.map(product => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

{product.description}

- )} - - {/* 가격 정보 */} -
-

{formatPrice(product.price, product.id)}

+
+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(product); + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% -

+ + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + )}
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

)} + + {/* 가격 정보 */} +
+

+ {formatPrice(product.price, product.id)} +

+ {product.discounts.length > 0 && ( +

+ {product.discounts[0].quantity}개 이상 구매시 할인{" "} + {product.discounts[0].rate * 100}% +

+ )} +
+ + {/* 재고 상태 */} +
+ {remainingStock <= 5 && remainingStock > 0 && ( +

+ 품절임박! {remainingStock}개 남음 +

+ )} + {remainingStock > 5 && ( +

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} +
- - {/* 장바구니 버튼 */} -
-
- ); + ); })}
)}
- +
-
-

- - +
+

+ + 장바구니

{cart.length === 0 ? ( -
- - +
+ + -

장바구니가 비어있습니다

+

장바구니가 비어있습니다

) : (
- {cart.map(item => { + {cart.map((item) => { const itemTotal = calculateItemTotal(item); const originalPrice = item.product.price * item.quantity; const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; - + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + return (
-
-

{item.product.name}

-
- - {item.quantity} -
{hasDiscount && ( - -{discountRate}% + + -{discountRate}% + )}

{Math.round(itemTotal).toLocaleString()}원 @@ -1051,62 +1254,72 @@ const App = () => { {cart.length > 0 && ( <> -

-
+
+

쿠폰 할인

- +
{coupons.length > 0 && ( - )}
-
-

결제 정보

+
+

결제 정보

상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 + + {totals.totalBeforeDiscount.toLocaleString()}원 +
{totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && (
할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 + + - + {( + totals.totalBeforeDiscount - totals.totalAfterDiscount + ).toLocaleString()} + 원 +
)} -
+
결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 + + {totals.totalAfterDiscount.toLocaleString()}원 +
- + - -
+ +

* 실제 결제는 이루어지지 않습니다

@@ -1121,4 +1334,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/main.tsx b/src/basic/main.tsx index e63eef4a..79f72b70 100644 --- a/src/basic/main.tsx +++ b/src/basic/main.tsx @@ -1,9 +1,10 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from "react"; +import ReactDOM from "react-dom/client"; -ReactDOM.createRoot(document.getElementById('root')!).render( +import App from "./App.tsx"; + +ReactDOM.createRoot(document.getElementById("root")!).render( - , -) + +); diff --git a/src/setupTests.ts b/src/setupTests.ts index 7b0828bf..d0de870d 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1 +1 @@ -import '@testing-library/jest-dom'; +import "@testing-library/jest-dom"; diff --git a/src/types.ts b/src/types.ts index 5489e296..aafe0aba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,6 @@ export interface CartItem { export interface Coupon { name: string; code: string; - discountType: 'amount' | 'percentage'; + discountType: "amount" | "percentage"; discountValue: number; } diff --git a/vite.config.ts b/vite.config.ts index e6c4016b..b014e7a9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,16 @@ -import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react-swc'; +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vite"; +import { defineConfig as defineTestConfig, mergeConfig } from "vitest/config"; export default mergeConfig( defineConfig({ - plugins: [react()], + plugins: [react()] }), defineTestConfig({ test: { globals: true, - environment: 'jsdom', - setupFiles: './src/setupTests.ts' - }, + environment: "jsdom", + setupFiles: "./src/setupTests.ts" + } }) -) +); From 1bfc82ae6e2717a70381283154c0bb657a951c79 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Thu, 7 Aug 2025 15:44:43 +0900 Subject: [PATCH 06/35] =?UTF-8?q?feat:=20app=20=EB=94=94=EB=A0=89=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=EC=84=B1=20=EB=B0=8F=20=EC=9D=B4=EA=B4=80?= =?UTF-8?q?=EC=9E=91=EC=97=85,=20Header=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EC=99=80=20Admin=20/=20Cart=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=B6=84=EB=A6=AC=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 1337 --------------------------- src/basic/__tests__/origin.test.tsx | 5 +- src/basic/app/App.tsx | 532 +++++++++++ src/basic/app/components/Header.tsx | 75 ++ src/basic/app/components/index.ts | 1 + src/basic/app/index.ts | 1 + src/basic/app/pages/AdminPage.tsx | 561 +++++++++++ src/basic/app/pages/CartPage.tsx | 331 +++++++ src/basic/app/pages/index.ts | 2 + src/basic/main.tsx | 18 +- 10 files changed, 1518 insertions(+), 1345 deletions(-) delete mode 100644 src/basic/App.tsx create mode 100644 src/basic/app/App.tsx create mode 100644 src/basic/app/components/Header.tsx create mode 100644 src/basic/app/components/index.ts create mode 100644 src/basic/app/index.ts create mode 100644 src/basic/app/pages/AdminPage.tsx create mode 100644 src/basic/app/pages/CartPage.tsx create mode 100644 src/basic/app/pages/index.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx deleted file mode 100644 index 45a2bf68..00000000 --- a/src/basic/App.tsx +++ /dev/null @@ -1,1337 +0,0 @@ -import { FormEvent, useCallback, useEffect, useState } from "react"; - -import { CartItem, Coupon, Product } from "../types"; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: "error" | "success" | "warning"; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: "p1", - name: "상품1", - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: "최고급 품질의 프리미엄 상품입니다." - }, - { - id: "p2", - name: "상품2", - price: 20000, - stock: 20, - discounts: [{ quantity: 10, rate: 0.15 }], - description: "다양한 기능을 갖춘 실용적인 상품입니다.", - isRecommended: true - }, - { - id: "p3", - name: "상품3", - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: "대용량과 고성능을 자랑하는 상품입니다." - } -]; - -const initialCoupons: Coupon[] = [ - { - name: "5000원 할인", - code: "AMOUNT5000", - discountType: "amount", - discountValue: 5000 - }, - { - name: "10% 할인", - code: "PERCENT10", - discountType: "percentage", - discountValue: 10 - } -]; - -const App = () => { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem("products"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem("cart"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem("coupons"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: "", - price: 0, - stock: 0, - description: "", - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: "", - code: "", - discountType: "amount" as "amount" | "percentage", - discountValue: 0 - }); - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find((p) => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return "SOLD OUT"; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach((item) => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === "amount") { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round( - totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) - ); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find((item) => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback( - (message: string, type: "error" | "success" | "warning" = "success") => { - const id = Date.now().toString(); - setNotifications((prev) => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications((prev) => prev.filter((n) => n.id !== id)); - }, 3000); - }, - [] - ); - - const [totalItemCount, setTotalItemCount] = useState(0); - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem("products", JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem("coupons", JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem("cart", JSON.stringify(cart)); - } else { - localStorage.removeItem("cart"); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback( - (product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification("재고가 부족합니다!", "error"); - return; - } - - setCart((prevCart) => { - const existingItem = prevCart.find((item) => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); - return prevCart; - } - - return prevCart.map((item) => - item.product.id === product.id ? { ...item, quantity: newQuantity } : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification("장바구니에 담았습니다", "success"); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [cart, addNotification, getRemainingStock] - ); - - const removeFromCart = useCallback((productId: string) => { - setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback( - (productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find((p) => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, "error"); - return; - } - - setCart((prevCart) => - prevCart.map((item) => - item.product.id === productId ? { ...item, quantity: newQuantity } : item - ) - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [products, removeFromCart, addNotification, getRemainingStock] - ); - - const applyCoupon = useCallback( - (coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === "percentage") { - addNotification("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", "error"); - return; - } - - setSelectedCoupon(coupon); - addNotification("쿠폰이 적용되었습니다.", "success"); - }, - [addNotification, calculateCartTotal] - ); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback( - (newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts((prev) => [...prev, product]); - addNotification("상품이 추가되었습니다.", "success"); - }, - [addNotification] - ); - - const updateProduct = useCallback( - (productId: string, updates: Partial) => { - setProducts((prev) => - prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) - ); - addNotification("상품이 수정되었습니다.", "success"); - }, - [addNotification] - ); - - const deleteProduct = useCallback( - (productId: string) => { - setProducts((prev) => prev.filter((p) => p.id !== productId)); - addNotification("상품이 삭제되었습니다.", "success"); - }, - [addNotification] - ); - - const addCoupon = useCallback( - (newCoupon: Coupon) => { - const existingCoupon = coupons.find((c) => c.code === newCoupon.code); - if (existingCoupon) { - addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); - return; - } - setCoupons((prev) => [...prev, newCoupon]); - addNotification("쿠폰이 추가되었습니다.", "success"); - }, - [coupons, addNotification] - ); - - const deleteCoupon = useCallback( - (couponCode: string) => { - setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification("쿠폰이 삭제되었습니다.", "success"); - }, - [selectedCoupon, addNotification] - ); - - const handleProductSubmit = (e: FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== "new") { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: "", price: 0, stock: 0, description: "", discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: "", - code: "", - discountType: "amount", - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || "", - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter( - (product) => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && - product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; - - return ( -
- {notifications.length > 0 && ( -
- {notifications.map((notif) => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && ( -
- setSearchTerm(e.target.value)} - placeholder="상품 검색..." - className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:outline-none" - /> -
- )} -
- -
-
-
- -
- {isAdmin ? ( -
-
-

관리자 대시보드

-

상품과 쿠폰을 관리할 수 있습니다

-
-
- -
- - {activeTab === "products" ? ( -
-
-
-

상품 목록

- -
-
- -
- - - - - - - - - - - - {(activeTab === "products" ? products : products).map((product) => ( - - - - - - - - ))} - -
- 상품명 - - 가격 - - 재고 - - 설명 - - 작업 -
- {product.name} - - {formatPrice(product.price, product.id)} - - 10 - ? "bg-green-100 text-green-800" - : product.stock > 0 - ? "bg-yellow-100 text-yellow-800" - : "bg-red-100 text-red-800" - }`} - > - {product.stock}개 - - - {product.description || "-"} - - - -
-
- {showProductForm && ( -
-
-

- {editingProduct === "new" ? "새 상품 추가" : "상품 수정"} -

-
-
- - - setProductForm({ ...productForm, name: e.target.value }) - } - className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - required - /> -
-
- - - setProductForm({ ...productForm, description: e.target.value }) - } - className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - /> -
-
- - { - const value = e.target.value; - if (value === "" || /^\d+$/.test(value)) { - setProductForm({ - ...productForm, - price: value === "" ? 0 : parseInt(value) - }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === "") { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification("가격은 0보다 커야 합니다", "error"); - setProductForm({ ...productForm, price: 0 }); - } - }} - className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === "" || /^\d+$/.test(value)) { - setProductForm({ - ...productForm, - stock: value === "" ? 0 : parseInt(value) - }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === "") { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification("재고는 0보다 커야 합니다", "error"); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification("재고는 9999개를 초과할 수 없습니다", "error"); - setProductForm({ ...productForm, stock: 9999 }); - } - }} - className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - placeholder="숫자만 입력" - required - /> -
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 rounded border px-2 py-1" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 rounded border px-2 py-1" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} - -
-
- -
- - -
-
-
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {coupons.map((coupon) => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === "amount" - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
- -
-
- ))} - -
- -
-
- - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- - - setCouponForm({ ...couponForm, name: e.target.value }) - } - className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }) - } - className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === "" || /^\d+$/.test(value)) { - setCouponForm({ - ...couponForm, - discountValue: value === "" ? 0 : parseInt(value) - }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === "percentage") { - if (value > 100) { - addNotification("할인율은 100%를 초과할 수 없습니다", "error"); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification( - "할인 금액은 100,000원을 초과할 수 없습니다", - "error" - ); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - placeholder={couponForm.discountType === "amount" ? "5000" : "10"} - required - /> -
-
-
- - -
-
-
- )} -
-
- )} -
- ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

-
총 {products.length}개 상품
-
- {filteredProducts.length === 0 ? ( -
-

- "{debouncedSearchTerm}"에 대한 검색 결과가 없습니다. -

-
- ) : ( -
- {filteredProducts.map((product) => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

- {product.description} -

- )} - - {/* 가격 정보 */} -
-

- {formatPrice(product.price, product.id)} -

- {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인{" "} - {product.discounts[0].rate * 100}% -

- )} -
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

- 품절임박! {remainingStock}개 남음 -

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

- )} -
- - {/* 장바구니 버튼 */} - -
-
- ); - })} -
- )} -
-
- -
-
-
-

- - - - 장바구니 -

- {cart.length === 0 ? ( -
- - - -

장바구니가 비어있습니다

-
- ) : ( -
- {cart.map((item) => { - const itemTotal = calculateItemTotal(item); - const originalPrice = item.product.price * item.quantity; - const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount - ? Math.round((1 - itemTotal / originalPrice) * 100) - : 0; - - return ( -
-
-

- {item.product.name} -

- -
-
-
- - - {item.quantity} - - -
-
- {hasDiscount && ( - - -{discountRate}% - - )} -

- {Math.round(itemTotal).toLocaleString()}원 -

-
-
-
- ); - })} -
- )} -
- - {cart.length > 0 && ( - <> -
-
-

쿠폰 할인

- -
- {coupons.length > 0 && ( - - )} -
- -
-

결제 정보

-
-
- 상품 금액 - - {totals.totalBeforeDiscount.toLocaleString()}원 - -
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( -
- 할인 금액 - - - - {( - totals.totalBeforeDiscount - totals.totalAfterDiscount - ).toLocaleString()} - 원 - -
- )} -
- 결제 예정 금액 - - {totals.totalAfterDiscount.toLocaleString()}원 - -
-
- - - -
-

* 실제 결제는 이루어지지 않습니다

-
-
- - )} -
-
-
- )} -
-
- ); -}; - -export default App; diff --git a/src/basic/__tests__/origin.test.tsx b/src/basic/__tests__/origin.test.tsx index 3f5c3d55..5f857960 100644 --- a/src/basic/__tests__/origin.test.tsx +++ b/src/basic/__tests__/origin.test.tsx @@ -1,8 +1,9 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { vi } from 'vitest'; -import App from '../App'; + import '../../setupTests'; +import { App } from '../app'; describe('쇼핑몰 앱 통합 테스트', () => { beforeEach(() => { diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx new file mode 100644 index 00000000..c36e5445 --- /dev/null +++ b/src/basic/app/App.tsx @@ -0,0 +1,532 @@ +import { FormEvent, useCallback, useEffect, useState } from "react"; + +import type { CartItem, Coupon, Product } from "../../types"; +import { Header } from "./components"; +import { AdminPage, CartPage } from "./pages"; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +// 초기 데이터 +const initialProducts: ProductWithUI[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 } + ], + description: "최고급 품질의 프리미엄 상품입니다." + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 } + ], + description: "대용량과 고성능을 자랑하는 상품입니다." + } +]; + +const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000 + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10 + } +]; + +export function App() { + const [products, setProducts] = useState(() => { + const saved = localStorage.getItem("products"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialProducts; + } + } + return initialProducts; + }); + + const [cart, setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return []; + } + } + return []; + }); + + const [coupons, setCoupons] = useState(() => { + const saved = localStorage.getItem("coupons"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialCoupons; + } + } + return initialCoupons; + }); + + const [selectedCoupon, setSelectedCoupon] = useState(null); + const [isAdmin, setIsAdmin] = useState(false); + const [notifications, setNotifications] = useState([]); + const [showCouponForm, setShowCouponForm] = useState(false); + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); + const [showProductForm, setShowProductForm] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + + // Admin + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] as Array<{ quantity: number; rate: number }> + }); + + const [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0 + }); + + const formatPrice = (price: number, productId?: string): string => { + if (productId) { + const product = products.find((p) => p.id === productId); + if (product && getRemainingStock(product) <= 0) { + return "SOLD OUT"; + } + } + + if (isAdmin) { + return `${price.toLocaleString()}원`; + } + + return `₩${price.toLocaleString()}`; + }; + + const getMaxApplicableDiscount = (item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; + }; + + const calculateItemTotal = (item: CartItem): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item); + + return Math.round(price * quantity * (1 - discount)); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + const calculateCartTotal = (): { + totalBeforeDiscount: number; + totalAfterDiscount: number; + } => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === "amount") { + totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount) + }; + }; + + const getRemainingStock = (product: Product): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; + }; + + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); + + const [totalItemCount, setTotalItemCount] = useState(0); + + useEffect(() => { + const count = cart.reduce((sum, item) => sum + item.quantity, 0); + setTotalItemCount(count); + }, [cart]); + + useEffect(() => { + localStorage.setItem("products", JSON.stringify(products)); + }, [products]); + + useEffect(() => { + localStorage.setItem("coupons", JSON.stringify(coupons)); + }, [coupons]); + + useEffect(() => { + if (cart.length > 0) { + localStorage.setItem("cart", JSON.stringify(cart)); + } else { + localStorage.removeItem("cart"); + } + }, [cart]); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + return () => clearTimeout(timer); + }, [searchTerm]); + + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + addNotification("재고가 부족합니다!", "error"); + return; + } + + setCart((prevCart) => { + const existingItem = prevCart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + addNotification("장바구니에 담았습니다", "success"); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [cart, addNotification, getRemainingStock] + ); + + const removeFromCart = useCallback((productId: string) => { + setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); + }, []); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } + + const product = products.find((p) => p.id === productId); + if (!product) return; + + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, "error"); + return; + } + + setCart((prevCart) => + prevCart.map((item) => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ) + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [products, removeFromCart, addNotification, getRemainingStock] + ); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal().totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + addNotification("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", "error"); + return; + } + + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [addNotification, calculateCartTotal] + ); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); + setCart([]); + setSelectedCoupon(null); + }, [addNotification]); + + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + setProducts((prev) => [...prev, product]); + addNotification("상품이 추가되었습니다.", "success"); + }, + [addNotification] + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + addNotification("상품이 수정되었습니다.", "success"); + }, + [addNotification] + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification("상품이 삭제되었습니다.", "success"); + }, + [addNotification] + ); + + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + addNotification("쿠폰이 추가되었습니다.", "success"); + }, + [coupons, addNotification] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification("쿠폰이 삭제되었습니다.", "success"); + }, + [selectedCoupon, addNotification] + ); + + const handleProductSubmit = (e: FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== "new") { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProduct({ + ...productForm, + discounts: productForm.discounts + }); + } + setProductForm({ name: "", price: 0, stock: 0, description: "", discounts: [] }); + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleCouponSubmit = (e: FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm({ + name: "", + code: "", + discountType: "amount", + discountValue: 0 + }); + setShowCouponForm(false); + }; + + const startEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [] + }); + setShowProductForm(true); + }; + + const totals = calculateCartTotal(); + + const filteredProducts = debouncedSearchTerm + ? products.filter( + (product) => + product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ) + : products; + + return ( +
+
+ +
+ {isAdmin ? ( + + ) : ( + + )} +
+ + {notifications.length > 0 && ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/basic/app/components/Header.tsx b/src/basic/app/components/Header.tsx new file mode 100644 index 00000000..b29289f9 --- /dev/null +++ b/src/basic/app/components/Header.tsx @@ -0,0 +1,75 @@ +import type { CartItem } from "../../../types"; + +type HeaderProps = { + isAdmin: boolean; + searchTerm: string; + setSearchTerm: (term: string) => void; + setIsAdmin: (isAdmin: boolean) => void; + cart: CartItem[]; + totalItemCount: number; +}; + +export function Header({ + cart, + isAdmin, + searchTerm, + setIsAdmin, + setSearchTerm, + totalItemCount +}: HeaderProps) { + return ( +
+
+
+
+

SHOP

+ {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} + {!isAdmin && ( +
+ setSearchTerm(e.target.value)} + placeholder="상품 검색..." + className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:outline-none" + /> +
+ )} +
+ +
+
+
+ ); +} diff --git a/src/basic/app/components/index.ts b/src/basic/app/components/index.ts new file mode 100644 index 00000000..9e08a64d --- /dev/null +++ b/src/basic/app/components/index.ts @@ -0,0 +1 @@ +export * from "./Header"; diff --git a/src/basic/app/index.ts b/src/basic/app/index.ts new file mode 100644 index 00000000..c8543026 --- /dev/null +++ b/src/basic/app/index.ts @@ -0,0 +1 @@ +export * from "./App"; diff --git a/src/basic/app/pages/AdminPage.tsx b/src/basic/app/pages/AdminPage.tsx new file mode 100644 index 00000000..730e786b --- /dev/null +++ b/src/basic/app/pages/AdminPage.tsx @@ -0,0 +1,561 @@ +import { FormEvent } from "react"; + +import type { Coupon, Product } from "../../../types"; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +type ProductForm = { + name: string; + price: number; + stock: number; + description: string; + discounts: Array<{ quantity: number; rate: number }>; +}; + +type CouponForm = { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +}; + +type AdminPageProps = { + setActiveTab: (tab: "products" | "coupons") => void; + activeTab: "products" | "coupons"; + setEditingProduct: (id: string | null) => void; + setProductForm: (form: ProductForm) => void; + setShowProductForm: (show: boolean) => void; + products: ProductWithUI[]; + formatPrice: (price: number, productId?: string) => string; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; + showProductForm: boolean; + handleProductSubmit: (e: FormEvent) => void; + editingProduct: string | null; + productForm: ProductForm; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; + coupons: Coupon[]; + deleteCoupon: (couponCode: string) => void; + setShowCouponForm: (show: boolean) => void; + showCouponForm: boolean; + handleCouponSubmit: (e: FormEvent) => void; + couponForm: CouponForm; + setCouponForm: (form: CouponForm) => void; +}; + +export function AdminPage({ + setProductForm, + setShowProductForm, + products, + formatPrice, + startEditProduct, + deleteProduct, + showProductForm, + handleProductSubmit, + editingProduct, + productForm, + addNotification, + coupons, + deleteCoupon, + setShowCouponForm, + showCouponForm, + handleCouponSubmit, + couponForm, + setCouponForm, + activeTab, + setActiveTab, + setEditingProduct +}: AdminPageProps) { + return ( +
+
+

관리자 대시보드

+

상품과 쿠폰을 관리할 수 있습니다

+
+
+ +
+ + {activeTab === "products" ? ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {(activeTab === "products" ? products : products).map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {formatPrice(product.price, product.id)} + + 10 + ? "bg-green-100 text-green-800" + : product.stock > 0 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`} + > + {product.stock}개 + + + {product.description || "-"} + + + +
+
+ {showProductForm && ( +
+
+

+ {editingProduct === "new" ? "새 상품 추가" : "상품 수정"} +

+
+
+ + setProductForm({ ...productForm, name: e.target.value })} + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + required + /> +
+
+ + + setProductForm({ ...productForm, description: e.target.value }) + } + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + /> +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + price: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + setProductForm({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification("가격은 0보다 커야 합니다", "error"); + setProductForm({ ...productForm, price: 0 }); + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="숫자만 입력" + required + /> +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + stock: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + addNotification("재고는 0보다 커야 합니다", "error"); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + addNotification("재고는 9999개를 초과할 수 없습니다", "error"); + setProductForm({ ...productForm, stock: 9999 }); + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="숫자만 입력" + required + /> +
+
+
+ +
+ {productForm.discounts.map((discount, index) => ( +
+ { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = parseInt(e.target.value) || 0; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-20 rounded border px-2 py-1" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-16 rounded border px-2 py-1" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ )} +
+ ) : ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + +
+ +
+
+ + {showCouponForm && ( +
+
+

새 쿠폰 생성

+
+
+ + setCouponForm({ ...couponForm, name: e.target.value })} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="신규 가입 쿠폰" + required + /> +
+
+ + + setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }) + } + className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setCouponForm({ + ...couponForm, + discountValue: value === "" ? 0 : parseInt(value) + }); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + if (couponForm.discountType === "percentage") { + if (value > 100) { + addNotification("할인율은 100%를 초과할 수 없습니다", "error"); + setCouponForm({ ...couponForm, discountValue: 100 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } else { + if (value > 100000) { + addNotification( + "할인 금액은 100,000원을 초과할 수 없습니다", + "error" + ); + setCouponForm({ ...couponForm, discountValue: 100000 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } + }} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder={couponForm.discountType === "amount" ? "5000" : "10"} + required + /> +
+
+
+ + +
+
+
+ )} +
+
+ )} +
+ ); +} diff --git a/src/basic/app/pages/CartPage.tsx b/src/basic/app/pages/CartPage.tsx new file mode 100644 index 00000000..30dac0b4 --- /dev/null +++ b/src/basic/app/pages/CartPage.tsx @@ -0,0 +1,331 @@ +import type { CartItem, Coupon, Product } from "../../../types"; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +type CartPageProps = { + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + getRemainingStock: (product: Product) => number; + formatPrice: (price: number, productId?: string) => string; + addToCart: (product: ProductWithUI) => void; + cart: CartItem[]; + calculateItemTotal: (item: CartItem) => number; + updateQuantity: (productId: string, newQuantity: number) => void; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + completeOrder: () => void; + removeFromCart: (productId: string) => void; +}; + +export function CartPage({ + addToCart, + applyCoupon, + calculateItemTotal, + cart, + completeOrder, + coupons, + debouncedSearchTerm, + filteredProducts, + formatPrice, + getRemainingStock, + products, + selectedCoupon, + setSelectedCoupon, + totals, + updateQuantity, + removeFromCart +}: CartPageProps) { + return ( +
+
+ {/* 상품 목록 */} +
+
+

전체 상품

+
총 {products.length}개 상품
+
+ {filteredProducts.length === 0 ? ( +
+

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

+
+ ) : ( +
+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(product); + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

+ )} + + {/* 가격 정보 */} +
+

+ {formatPrice(product.price, product.id)} +

+ {product.discounts.length > 0 && ( +

+ {product.discounts[0].quantity}개 이상 구매시 할인{" "} + {product.discounts[0].rate * 100}% +

+ )} +
+ + {/* 재고 상태 */} +
+ {remainingStock <= 5 && remainingStock > 0 && ( +

+ 품절임박! {remainingStock}개 남음 +

+ )} + {remainingStock > 5 && ( +

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); + })} +
+ )} +
+
+ +
+
+
+

+ + + + 장바구니 +

+ {cart.length === 0 ? ( +
+ + + +

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = calculateItemTotal(item); + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + + return ( +
+
+

+ {item.product.name} +

+ +
+
+
+ + + {item.quantity} + + +
+
+ {hasDiscount && ( + + -{discountRate}% + + )} +

+ {Math.round(itemTotal).toLocaleString()}원 +

+
+
+
+ ); + })} +
+ )} +
+ + {cart.length > 0 && ( + <> +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ +
+

결제 정보

+
+
+ 상품 금액 + + {totals.totalBeforeDiscount.toLocaleString()}원 + +
+ {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( +
+ 할인 금액 + + -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()} + 원 + +
+ )} +
+ 결제 예정 금액 + + {totals.totalAfterDiscount.toLocaleString()}원 + +
+
+ + + +
+

* 실제 결제는 이루어지지 않습니다

+
+
+ + )} +
+
+
+ ); +} diff --git a/src/basic/app/pages/index.ts b/src/basic/app/pages/index.ts new file mode 100644 index 00000000..dfb7b6b6 --- /dev/null +++ b/src/basic/app/pages/index.ts @@ -0,0 +1,2 @@ +export * from "./AdminPage"; +export * from "./CartPage"; diff --git a/src/basic/main.tsx b/src/basic/main.tsx index 79f72b70..d08c16bb 100644 --- a/src/basic/main.tsx +++ b/src/basic/main.tsx @@ -1,10 +1,16 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; -import App from "./App.tsx"; +import { App } from "./app"; -ReactDOM.createRoot(document.getElementById("root")!).render( - +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error('Root element with id "root" not found'); +} + +createRoot(rootElement).render( + - + ); From e7041e70e1a60f373a985bbc44b403895d099c7b Mon Sep 17 00:00:00 2001 From: chan9yu Date: Thu, 7 Aug 2025 15:49:43 +0900 Subject: [PATCH 07/35] =?UTF-8?q?feat:=20cart=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/App.tsx | 175 +++--------------- src/basic/domains/cart/hooks/index.ts | 1 + .../domains/cart/hooks/useCartActions.ts | 118 ++++++++++++ src/basic/domains/cart/index.ts | 3 + src/basic/domains/cart/types/entities.ts | 11 ++ src/basic/domains/cart/types/index.ts | 1 + src/basic/domains/cart/utils/calculators.ts | 61 ++++++ src/basic/domains/cart/utils/index.ts | 1 + src/types.ts | 8 +- 9 files changed, 221 insertions(+), 158 deletions(-) create mode 100644 src/basic/domains/cart/hooks/index.ts create mode 100644 src/basic/domains/cart/hooks/useCartActions.ts create mode 100644 src/basic/domains/cart/index.ts create mode 100644 src/basic/domains/cart/types/entities.ts create mode 100644 src/basic/domains/cart/types/index.ts create mode 100644 src/basic/domains/cart/utils/calculators.ts create mode 100644 src/basic/domains/cart/utils/index.ts diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx index c36e5445..68c8f998 100644 --- a/src/basic/app/App.tsx +++ b/src/basic/app/App.tsx @@ -1,6 +1,13 @@ import { FormEvent, useCallback, useEffect, useState } from "react"; -import type { CartItem, Coupon, Product } from "../../types"; +import type { Coupon, Product } from "../../types"; +import { + calculateCartTotal, + calculateItemTotal, + type CartItem, + getRemainingStock, + useCartActions +} from "../domains/cart"; import { Header } from "./components"; import { AdminPage, CartPage } from "./pages"; @@ -131,7 +138,7 @@ export function App() { const formatPrice = (price: number, productId?: string): string => { if (productId) { const product = products.find((p) => p.id === productId); - if (product && getRemainingStock(product) <= 0) { + if (product && getRemainingStock(product, cart) <= 0) { return "SOLD OUT"; } } @@ -143,69 +150,6 @@ export function App() { return `₩${price.toLocaleString()}`; }; - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach((item) => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === "amount") { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round( - totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) - ); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find((item) => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - const addNotification = useCallback( (message: string, type: "error" | "success" | "warning" = "success") => { const id = Date.now().toString(); @@ -220,6 +164,16 @@ export function App() { const [totalItemCount, setTotalItemCount] = useState(0); + // Cart actions using domain hook + const { addToCart, removeFromCart, updateQuantity, applyCoupon, completeOrder } = useCartActions({ + cart, + products, + selectedCoupon, + setCart, + setSelectedCoupon, + addNotification + }); + useEffect(() => { const count = cart.reduce((sum, item) => sum + item.quantity, 0); setTotalItemCount(count); @@ -248,91 +202,6 @@ export function App() { return () => clearTimeout(timer); }, [searchTerm]); - const addToCart = useCallback( - (product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification("재고가 부족합니다!", "error"); - return; - } - - setCart((prevCart) => { - const existingItem = prevCart.find((item) => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); - return prevCart; - } - - return prevCart.map((item) => - item.product.id === product.id ? { ...item, quantity: newQuantity } : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification("장바구니에 담았습니다", "success"); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [cart, addNotification, getRemainingStock] - ); - - const removeFromCart = useCallback((productId: string) => { - setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback( - (productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find((p) => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, "error"); - return; - } - - setCart((prevCart) => - prevCart.map((item) => - item.product.id === productId ? { ...item, quantity: newQuantity } : item - ) - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [products, removeFromCart, addNotification, getRemainingStock] - ); - - const applyCoupon = useCallback( - (coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === "percentage") { - addNotification("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", "error"); - return; - } - - setSelectedCoupon(coupon); - addNotification("쿠폰이 적용되었습니다.", "success"); - }, - [addNotification, calculateCartTotal] - ); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - const addProduct = useCallback( (newProduct: Omit) => { const product: ProductWithUI = { @@ -427,7 +296,7 @@ export function App() { setShowProductForm(true); }; - const totals = calculateCartTotal(); + const totals = calculateCartTotal(cart, selectedCoupon); const filteredProducts = debouncedSearchTerm ? products.filter( @@ -478,14 +347,14 @@ export function App() { calculateItemTotal(item, cart)} cart={cart} completeOrder={completeOrder} coupons={coupons} debouncedSearchTerm={debouncedSearchTerm} filteredProducts={filteredProducts} formatPrice={formatPrice} - getRemainingStock={getRemainingStock} + getRemainingStock={(product: Product) => getRemainingStock(product, cart)} products={products} removeFromCart={removeFromCart} selectedCoupon={selectedCoupon} diff --git a/src/basic/domains/cart/hooks/index.ts b/src/basic/domains/cart/hooks/index.ts new file mode 100644 index 00000000..6b468e64 --- /dev/null +++ b/src/basic/domains/cart/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useCartActions"; diff --git a/src/basic/domains/cart/hooks/useCartActions.ts b/src/basic/domains/cart/hooks/useCartActions.ts new file mode 100644 index 00000000..4da8f129 --- /dev/null +++ b/src/basic/domains/cart/hooks/useCartActions.ts @@ -0,0 +1,118 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useCallback } from "react"; + +import type { Coupon, Product } from "../../../../types"; +import type { CartItem } from "../types"; +import { calculateCartTotal, getRemainingStock } from "../utils"; + +interface UseCartActionsParams { + cart: CartItem[]; + products: Product[]; + selectedCoupon: Coupon | null; + setCart: Dispatch>; + setSelectedCoupon: (coupon: Coupon | null) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +} + +export function useCartActions({ + cart, + products, + selectedCoupon, + setCart, + setSelectedCoupon, + addNotification +}: UseCartActionsParams) { + const addToCart = useCallback( + (product: Product) => { + const remainingStock = getRemainingStock(product, cart); + if (remainingStock <= 0) { + addNotification("재고가 부족합니다!", "error"); + return; + } + + setCart((prevCart) => { + const existingItem = prevCart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + addNotification("장바구니에 담았습니다", "success"); + }, + [cart, addNotification, setCart] + ); + + const removeFromCart = useCallback( + (productId: string) => { + setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); + }, + [setCart] + ); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } + + const product = products.find((p) => p.id === productId); + if (!product) return; + + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, "error"); + return; + } + + setCart((prevCart) => + prevCart.map((item) => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ) + ); + }, + [products, removeFromCart, addNotification, setCart] + ); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + addNotification("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", "error"); + return; + } + + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [cart, selectedCoupon, addNotification, setSelectedCoupon] + ); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); + setCart([]); + setSelectedCoupon(null); + }, [addNotification, setCart, setSelectedCoupon]); + + return { + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + completeOrder + }; +} diff --git a/src/basic/domains/cart/index.ts b/src/basic/domains/cart/index.ts new file mode 100644 index 00000000..51bffbcd --- /dev/null +++ b/src/basic/domains/cart/index.ts @@ -0,0 +1,3 @@ +export * from "./hooks"; +export * from "./types"; +export * from "./utils"; diff --git a/src/basic/domains/cart/types/entities.ts b/src/basic/domains/cart/types/entities.ts new file mode 100644 index 00000000..184f5ea5 --- /dev/null +++ b/src/basic/domains/cart/types/entities.ts @@ -0,0 +1,11 @@ +import type { Product } from "../../../../types"; + +export interface CartItem { + product: Product; + quantity: number; +} + +export interface CartTotals { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} diff --git a/src/basic/domains/cart/types/index.ts b/src/basic/domains/cart/types/index.ts new file mode 100644 index 00000000..fc8e74dd --- /dev/null +++ b/src/basic/domains/cart/types/index.ts @@ -0,0 +1 @@ +export type * from "./entities"; diff --git a/src/basic/domains/cart/utils/calculators.ts b/src/basic/domains/cart/utils/calculators.ts new file mode 100644 index 00000000..502b96c6 --- /dev/null +++ b/src/basic/domains/cart/utils/calculators.ts @@ -0,0 +1,61 @@ +import type { Coupon, Product } from "../../../../types"; +import type { CartItem } from "../types"; + +export function getMaxApplicableDiscount(item: CartItem, cart: CartItem[]) { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; +} + +export function calculateItemTotal(item: CartItem, cart: CartItem[]) { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +} + +export function calculateCartTotal(cart: CartItem[], selectedCoupon: Coupon | null) { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === "amount") { + totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount) + }; +} + +export function getRemainingStock(product: Product, cart: CartItem[]) { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +} diff --git a/src/basic/domains/cart/utils/index.ts b/src/basic/domains/cart/utils/index.ts new file mode 100644 index 00000000..137f47a3 --- /dev/null +++ b/src/basic/domains/cart/utils/index.ts @@ -0,0 +1 @@ +export * from "./calculators"; diff --git a/src/types.ts b/src/types.ts index aafe0aba..618763de 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,14 +11,12 @@ export interface Discount { rate: number; } -export interface CartItem { - product: Product; - quantity: number; -} - export interface Coupon { name: string; code: string; discountType: "amount" | "percentage"; discountValue: number; } + +// Re-export cart types +export type { CartItem, CartTotals } from "./basic/domains/cart/types"; From 8af8c5f894056be750d4a77a2dc77a99019307f2 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Thu, 7 Aug 2025 16:10:13 +0900 Subject: [PATCH 08/35] =?UTF-8?q?feat:=20coupon=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/App.tsx | 96 ++++++++----------- .../domains/cart/hooks/useCartActions.ts | 25 +---- src/basic/domains/coupon/constants/index.ts | 1 + .../domains/coupon/constants/initialData.ts | 16 ++++ src/basic/domains/coupon/hooks/index.ts | 1 + .../domains/coupon/hooks/useCouponActions.ts | 78 +++++++++++++++ src/basic/domains/coupon/index.ts | 4 + src/basic/domains/coupon/types/entities.ts | 6 ++ src/basic/domains/coupon/types/index.ts | 1 + src/basic/domains/coupon/utils/index.ts | 1 + src/basic/domains/coupon/utils/validators.ts | 30 ++++++ src/types.ts | 10 +- 12 files changed, 184 insertions(+), 85 deletions(-) create mode 100644 src/basic/domains/coupon/constants/index.ts create mode 100644 src/basic/domains/coupon/constants/initialData.ts create mode 100644 src/basic/domains/coupon/hooks/index.ts create mode 100644 src/basic/domains/coupon/hooks/useCouponActions.ts create mode 100644 src/basic/domains/coupon/index.ts create mode 100644 src/basic/domains/coupon/types/entities.ts create mode 100644 src/basic/domains/coupon/types/index.ts create mode 100644 src/basic/domains/coupon/utils/index.ts create mode 100644 src/basic/domains/coupon/utils/validators.ts diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx index 68c8f998..02e43bb4 100644 --- a/src/basic/app/App.tsx +++ b/src/basic/app/App.tsx @@ -8,6 +8,7 @@ import { getRemainingStock, useCartActions } from "../domains/cart"; +import { INITIAL_COUPONS, useCouponActions } from "../domains/coupon"; import { Header } from "./components"; import { AdminPage, CartPage } from "./pages"; @@ -57,21 +58,6 @@ const initialProducts: ProductWithUI[] = [ } ]; -const initialCoupons: Coupon[] = [ - { - name: "5000원 할인", - code: "AMOUNT5000", - discountType: "amount", - discountValue: 5000 - }, - { - name: "10% 할인", - code: "PERCENT10", - discountType: "percentage", - discountValue: 10 - } -]; - export function App() { const [products, setProducts] = useState(() => { const saved = localStorage.getItem("products"); @@ -103,10 +89,10 @@ export function App() { try { return JSON.parse(saved); } catch { - return initialCoupons; + return INITIAL_COUPONS; } } - return initialCoupons; + return INITIAL_COUPONS; }); const [selectedCoupon, setSelectedCoupon] = useState(null); @@ -128,10 +114,10 @@ export function App() { discounts: [] as Array<{ quantity: number; rate: number }> }); - const [couponForm, setCouponForm] = useState({ + const [couponForm, setCouponForm] = useState({ name: "", code: "", - discountType: "amount" as "amount" | "percentage", + discountType: "amount", discountValue: 0 }); @@ -165,15 +151,36 @@ export function App() { const [totalItemCount, setTotalItemCount] = useState(0); // Cart actions using domain hook - const { addToCart, removeFromCart, updateQuantity, applyCoupon, completeOrder } = useCartActions({ + const { addToCart, removeFromCart, updateQuantity, completeOrder } = useCartActions({ cart, products, - selectedCoupon, setCart, setSelectedCoupon, addNotification }); + // Coupon actions using domain hook + const { + deleteCoupon, + applyCoupon: applyCouponBase, + handleCouponSubmit + } = useCouponActions({ + coupons, + selectedCoupon, + setCoupons, + setSelectedCoupon, + addNotification + }); + + // Wrapper for applyCoupon with cart total calculation + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; + applyCouponBase(coupon, currentTotal); + }, + [applyCouponBase, cart, selectedCoupon] + ); + useEffect(() => { const count = cart.reduce((sum, item) => sum + item.quantity, 0); setTotalItemCount(count); @@ -232,30 +239,6 @@ export function App() { [addNotification] ); - const addCoupon = useCallback( - (newCoupon: Coupon) => { - const existingCoupon = coupons.find((c) => c.code === newCoupon.code); - if (existingCoupon) { - addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); - return; - } - setCoupons((prev) => [...prev, newCoupon]); - addNotification("쿠폰이 추가되었습니다.", "success"); - }, - [coupons, addNotification] - ); - - const deleteCoupon = useCallback( - (couponCode: string) => { - setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification("쿠폰이 삭제되었습니다.", "success"); - }, - [selectedCoupon, addNotification] - ); - const handleProductSubmit = (e: FormEvent) => { e.preventDefault(); if (editingProduct && editingProduct !== "new") { @@ -272,16 +255,19 @@ export function App() { setShowProductForm(false); }; - const handleCouponSubmit = (e: FormEvent) => { + const handleCouponSubmitWrapper = (e: FormEvent) => { e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: "", - code: "", - discountType: "amount", - discountValue: 0 - }); - setShowCouponForm(false); + handleCouponSubmit( + couponForm, + () => + setCouponForm({ + name: "", + code: "", + discountType: "amount", + discountValue: 0 + }), + setShowCouponForm + ); }; const startEditProduct = (product: ProductWithUI) => { @@ -329,7 +315,7 @@ export function App() { deleteProduct={deleteProduct} editingProduct={editingProduct} formatPrice={formatPrice} - handleCouponSubmit={handleCouponSubmit} + handleCouponSubmit={handleCouponSubmitWrapper} handleProductSubmit={handleProductSubmit} productForm={productForm} products={products} diff --git a/src/basic/domains/cart/hooks/useCartActions.ts b/src/basic/domains/cart/hooks/useCartActions.ts index 4da8f129..7d43f0e0 100644 --- a/src/basic/domains/cart/hooks/useCartActions.ts +++ b/src/basic/domains/cart/hooks/useCartActions.ts @@ -1,23 +1,20 @@ import type { Dispatch, SetStateAction } from "react"; import { useCallback } from "react"; -import type { Coupon, Product } from "../../../../types"; -import type { CartItem } from "../types"; -import { calculateCartTotal, getRemainingStock } from "../utils"; +import type { CartItem, Product } from "../../../../types"; +import { getRemainingStock } from "../utils"; interface UseCartActionsParams { cart: CartItem[]; products: Product[]; - selectedCoupon: Coupon | null; setCart: Dispatch>; - setSelectedCoupon: (coupon: Coupon | null) => void; + setSelectedCoupon: (coupon: null) => void; addNotification: (message: string, type?: "error" | "success" | "warning") => void; } export function useCartActions({ cart, products, - selectedCoupon, setCart, setSelectedCoupon, addNotification @@ -86,21 +83,6 @@ export function useCartActions({ [products, removeFromCart, addNotification, setCart] ); - const applyCoupon = useCallback( - (coupon: Coupon) => { - const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === "percentage") { - addNotification("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", "error"); - return; - } - - setSelectedCoupon(coupon); - addNotification("쿠폰이 적용되었습니다.", "success"); - }, - [cart, selectedCoupon, addNotification, setSelectedCoupon] - ); - const completeOrder = useCallback(() => { const orderNumber = `ORD-${Date.now()}`; addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); @@ -112,7 +94,6 @@ export function useCartActions({ addToCart, removeFromCart, updateQuantity, - applyCoupon, completeOrder }; } diff --git a/src/basic/domains/coupon/constants/index.ts b/src/basic/domains/coupon/constants/index.ts new file mode 100644 index 00000000..b313b308 --- /dev/null +++ b/src/basic/domains/coupon/constants/index.ts @@ -0,0 +1 @@ +export * from "./initialData"; diff --git a/src/basic/domains/coupon/constants/initialData.ts b/src/basic/domains/coupon/constants/initialData.ts new file mode 100644 index 00000000..2ae94127 --- /dev/null +++ b/src/basic/domains/coupon/constants/initialData.ts @@ -0,0 +1,16 @@ +import type { Coupon } from "../types"; + +export const INITIAL_COUPONS: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000 + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10 + } +]; diff --git a/src/basic/domains/coupon/hooks/index.ts b/src/basic/domains/coupon/hooks/index.ts new file mode 100644 index 00000000..2e2aebd6 --- /dev/null +++ b/src/basic/domains/coupon/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useCouponActions"; diff --git a/src/basic/domains/coupon/hooks/useCouponActions.ts b/src/basic/domains/coupon/hooks/useCouponActions.ts new file mode 100644 index 00000000..e6f1ce90 --- /dev/null +++ b/src/basic/domains/coupon/hooks/useCouponActions.ts @@ -0,0 +1,78 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useCallback } from "react"; + +import type { Coupon } from "../types"; +import { validateCouponCode, validateCouponUsage } from "../utils"; + +interface UseCouponActionsParams { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setCoupons: Dispatch>; + setSelectedCoupon: (coupon: Coupon | null) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +} + +export function useCouponActions({ + coupons, + selectedCoupon, + setCoupons, + setSelectedCoupon, + addNotification +}: UseCouponActionsParams) { + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const validation = validateCouponCode(newCoupon.code, coupons); + if (!validation.valid) { + addNotification(validation.message, "error"); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + addNotification("쿠폰이 추가되었습니다.", "success"); + }, + [coupons, setCoupons, addNotification] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification("쿠폰이 삭제되었습니다.", "success"); + }, + [selectedCoupon, setCoupons, setSelectedCoupon, addNotification] + ); + + const applyCoupon = useCallback( + (coupon: Coupon, totalAmount?: number) => { + // totalAmount가 제공되지 않으면 기본 검증 없이 적용 + if (totalAmount !== undefined) { + const validation = validateCouponUsage(coupon, totalAmount); + if (!validation.valid) { + addNotification(validation.message, "error"); + return; + } + } + + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [setSelectedCoupon, addNotification] + ); + + const handleCouponSubmit = useCallback( + (couponForm: Coupon, resetForm: () => void, setShowForm: (show: boolean) => void) => { + addCoupon(couponForm); + resetForm(); + setShowForm(false); + }, + [addCoupon] + ); + + return { + addCoupon, + deleteCoupon, + applyCoupon, + handleCouponSubmit + }; +} diff --git a/src/basic/domains/coupon/index.ts b/src/basic/domains/coupon/index.ts new file mode 100644 index 00000000..d5d9ef29 --- /dev/null +++ b/src/basic/domains/coupon/index.ts @@ -0,0 +1,4 @@ +export * from "./constants"; +export * from "./hooks"; +export * from "./types"; +export * from "./utils"; diff --git a/src/basic/domains/coupon/types/entities.ts b/src/basic/domains/coupon/types/entities.ts new file mode 100644 index 00000000..5f575011 --- /dev/null +++ b/src/basic/domains/coupon/types/entities.ts @@ -0,0 +1,6 @@ +export interface Coupon { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +} diff --git a/src/basic/domains/coupon/types/index.ts b/src/basic/domains/coupon/types/index.ts new file mode 100644 index 00000000..fc8e74dd --- /dev/null +++ b/src/basic/domains/coupon/types/index.ts @@ -0,0 +1 @@ +export type * from "./entities"; diff --git a/src/basic/domains/coupon/utils/index.ts b/src/basic/domains/coupon/utils/index.ts new file mode 100644 index 00000000..58564490 --- /dev/null +++ b/src/basic/domains/coupon/utils/index.ts @@ -0,0 +1 @@ +export * from "./validators"; diff --git a/src/basic/domains/coupon/utils/validators.ts b/src/basic/domains/coupon/utils/validators.ts new file mode 100644 index 00000000..3fdee023 --- /dev/null +++ b/src/basic/domains/coupon/utils/validators.ts @@ -0,0 +1,30 @@ +import type { Coupon } from "../types"; + +export function validateCouponUsage(coupon: Coupon, totalAmount: number) { + if (totalAmount < 10000 && coupon.discountType === "percentage") { + return { + valid: false, + message: "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다." + }; + } + + return { + valid: true, + message: "쿠폰이 적용되었습니다." + }; +} + +export function validateCouponCode(code: string, existingCoupons: Coupon[]) { + const existingCoupon = existingCoupons.find((c) => c.code === code); + if (existingCoupon) { + return { + valid: false, + message: "이미 존재하는 쿠폰 코드입니다." + }; + } + + return { + valid: true, + message: "사용 가능한 쿠폰 코드입니다." + }; +} diff --git a/src/types.ts b/src/types.ts index 618763de..3bf740d0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,12 +11,6 @@ export interface Discount { rate: number; } -export interface Coupon { - name: string; - code: string; - discountType: "amount" | "percentage"; - discountValue: number; -} - -// Re-export cart types +// Re-export domain types export type { CartItem, CartTotals } from "./basic/domains/cart/types"; +export type { Coupon } from "./basic/domains/coupon/types"; From 4b1b495e440fce6b05f063ab1b740a10d192801a Mon Sep 17 00:00:00 2001 From: chan9yu Date: Thu, 7 Aug 2025 18:35:35 +0900 Subject: [PATCH 09/35] =?UTF-8?q?feat:=20product=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/App.tsx | 166 +++++------------- src/basic/domains/product/constants/index.ts | 1 + .../domains/product/constants/initialData.ts | 35 ++++ src/basic/domains/product/hooks/index.ts | 1 + .../product/hooks/useProductActions.ts | 93 ++++++++++ src/basic/domains/product/index.ts | 5 + src/basic/domains/product/models/index.ts | 1 + src/basic/domains/product/types/entities.ts | 25 +++ src/basic/domains/product/types/index.ts | 1 + src/basic/domains/product/utils/formatters.ts | 35 ++++ src/basic/domains/product/utils/index.ts | 1 + src/types.ts | 19 +- 12 files changed, 242 insertions(+), 141 deletions(-) create mode 100644 src/basic/domains/product/constants/index.ts create mode 100644 src/basic/domains/product/constants/initialData.ts create mode 100644 src/basic/domains/product/hooks/index.ts create mode 100644 src/basic/domains/product/hooks/useProductActions.ts create mode 100644 src/basic/domains/product/index.ts create mode 100644 src/basic/domains/product/models/index.ts create mode 100644 src/basic/domains/product/types/entities.ts create mode 100644 src/basic/domains/product/types/index.ts create mode 100644 src/basic/domains/product/utils/formatters.ts create mode 100644 src/basic/domains/product/utils/index.ts diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx index 02e43bb4..758472fe 100644 --- a/src/basic/app/App.tsx +++ b/src/basic/app/App.tsx @@ -1,6 +1,5 @@ import { FormEvent, useCallback, useEffect, useState } from "react"; -import type { Coupon, Product } from "../../types"; import { calculateCartTotal, calculateItemTotal, @@ -8,56 +7,25 @@ import { getRemainingStock, useCartActions } from "../domains/cart"; -import { INITIAL_COUPONS, useCouponActions } from "../domains/coupon"; +import { type Coupon, INITIAL_COUPONS, useCouponActions } from "../domains/coupon"; +import { + filterProducts, + formatPrice, + INITIAL_PRODUCTS, + type Product, + type ProductForm, + type ProductWithUI, + useProductActions +} from "../domains/product"; import { Header } from "./components"; import { AdminPage, CartPage } from "./pages"; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - interface Notification { id: string; message: string; type: "error" | "success" | "warning"; } -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: "p1", - name: "상품1", - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: "최고급 품질의 프리미엄 상품입니다." - }, - { - id: "p2", - name: "상품2", - price: 20000, - stock: 20, - discounts: [{ quantity: 10, rate: 0.15 }], - description: "다양한 기능을 갖춘 실용적인 상품입니다.", - isRecommended: true - }, - { - id: "p3", - name: "상품3", - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: "대용량과 고성능을 자랑하는 상품입니다." - } -]; - export function App() { const [products, setProducts] = useState(() => { const saved = localStorage.getItem("products"); @@ -65,10 +33,10 @@ export function App() { try { return JSON.parse(saved); } catch { - return initialProducts; + return INITIAL_PRODUCTS; } } - return initialProducts; + return INITIAL_PRODUCTS; }); const [cart, setCart] = useState(() => { @@ -106,12 +74,12 @@ export function App() { // Admin const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ + const [productForm, setProductForm] = useState({ name: "", price: 0, stock: 0, description: "", - discounts: [] as Array<{ quantity: number; rate: number }> + discounts: [] }); const [couponForm, setCouponForm] = useState({ @@ -121,20 +89,12 @@ export function App() { discountValue: 0 }); - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find((p) => p.id === productId); - if (product && getRemainingStock(product, cart) <= 0) { - return "SOLD OUT"; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; + const formatPriceWithContext = useCallback( + (price: number, productId?: string) => { + return formatPrice(price, productId, products, cart, isAdmin); + }, + [products, cart, isAdmin] + ); const addNotification = useCallback( (message: string, type: "error" | "success" | "warning" = "success") => { @@ -181,6 +141,12 @@ export function App() { [applyCouponBase, cart, selectedCoupon] ); + // Product actions using domain hook + const { deleteProduct, handleProductSubmit, startEditProduct } = useProductActions({ + setProducts, + addNotification + }); + useEffect(() => { const count = cart.reduce((sum, item) => sum + item.quantity, 0); setTotalItemCount(count); @@ -209,50 +175,15 @@ export function App() { return () => clearTimeout(timer); }, [searchTerm]); - const addProduct = useCallback( - (newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts((prev) => [...prev, product]); - addNotification("상품이 추가되었습니다.", "success"); - }, - [addNotification] - ); - - const updateProduct = useCallback( - (productId: string, updates: Partial) => { - setProducts((prev) => - prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) - ); - addNotification("상품이 수정되었습니다.", "success"); - }, - [addNotification] - ); - - const deleteProduct = useCallback( - (productId: string) => { - setProducts((prev) => prev.filter((p) => p.id !== productId)); - addNotification("상품이 삭제되었습니다.", "success"); - }, - [addNotification] - ); - - const handleProductSubmit = (e: FormEvent) => { + const handleProductSubmitWrapper = (e: FormEvent) => { e.preventDefault(); - if (editingProduct && editingProduct !== "new") { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: "", price: 0, stock: 0, description: "", discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); + handleProductSubmit( + productForm, + editingProduct, + () => setProductForm({ name: "", price: 0, stock: 0, description: "", discounts: [] }), + setEditingProduct, + setShowProductForm + ); }; const handleCouponSubmitWrapper = (e: FormEvent) => { @@ -270,28 +201,13 @@ export function App() { ); }; - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || "", - discounts: product.discounts || [] - }); - setShowProductForm(true); + const startEditProductWrapper = (product: ProductWithUI) => { + startEditProduct(product, setEditingProduct, setProductForm, setShowProductForm); }; const totals = calculateCartTotal(cart, selectedCoupon); - const filteredProducts = debouncedSearchTerm - ? products.filter( - (product) => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && - product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const filteredProducts = filterProducts(products, debouncedSearchTerm); return (
@@ -314,9 +230,9 @@ export function App() { deleteCoupon={deleteCoupon} deleteProduct={deleteProduct} editingProduct={editingProduct} - formatPrice={formatPrice} + formatPrice={formatPriceWithContext} handleCouponSubmit={handleCouponSubmitWrapper} - handleProductSubmit={handleProductSubmit} + handleProductSubmit={handleProductSubmitWrapper} productForm={productForm} products={products} setActiveTab={setActiveTab} @@ -327,7 +243,7 @@ export function App() { setShowProductForm={setShowProductForm} showCouponForm={showCouponForm} showProductForm={showProductForm} - startEditProduct={startEditProduct} + startEditProduct={startEditProductWrapper} /> ) : ( getRemainingStock(product, cart)} products={products} removeFromCart={removeFromCart} diff --git a/src/basic/domains/product/constants/index.ts b/src/basic/domains/product/constants/index.ts new file mode 100644 index 00000000..b313b308 --- /dev/null +++ b/src/basic/domains/product/constants/index.ts @@ -0,0 +1 @@ +export * from "./initialData"; diff --git a/src/basic/domains/product/constants/initialData.ts b/src/basic/domains/product/constants/initialData.ts new file mode 100644 index 00000000..11d7f1aa --- /dev/null +++ b/src/basic/domains/product/constants/initialData.ts @@ -0,0 +1,35 @@ +import type { ProductWithUI } from "../types"; + +export const INITIAL_PRODUCTS: ProductWithUI[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 } + ], + description: "최고급 품질의 프리미엄 상품입니다." + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 } + ], + description: "대용량과 고성능을 자랑하는 상품입니다." + } +]; diff --git a/src/basic/domains/product/hooks/index.ts b/src/basic/domains/product/hooks/index.ts new file mode 100644 index 00000000..20c97741 --- /dev/null +++ b/src/basic/domains/product/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useProductActions"; diff --git a/src/basic/domains/product/hooks/useProductActions.ts b/src/basic/domains/product/hooks/useProductActions.ts new file mode 100644 index 00000000..06a60a48 --- /dev/null +++ b/src/basic/domains/product/hooks/useProductActions.ts @@ -0,0 +1,93 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useCallback } from "react"; + +import type { ProductForm, ProductWithUI } from "../types"; + +interface UseProductActionsParams { + setProducts: Dispatch>; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +} + +export function useProductActions({ setProducts, addNotification }: UseProductActionsParams) { + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + setProducts((prev) => [...prev, product]); + addNotification("상품이 추가되었습니다.", "success"); + }, + [setProducts, addNotification] + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + addNotification("상품이 수정되었습니다.", "success"); + }, + [setProducts, addNotification] + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification("상품이 삭제되었습니다.", "success"); + }, + [setProducts, addNotification] + ); + + const handleProductSubmit = useCallback( + ( + productForm: ProductForm, + editingProduct: string | null, + resetForm: () => void, + setEditingProduct: (id: string | null) => void, + setShowForm: (show: boolean) => void + ) => { + if (editingProduct && editingProduct !== "new") { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProduct({ + ...productForm, + discounts: productForm.discounts + }); + } + resetForm(); + setEditingProduct(null); + setShowForm(false); + }, + [addProduct, updateProduct] + ); + + const startEditProduct = useCallback( + ( + product: ProductWithUI, + setEditingProduct: (id: string) => void, + setProductForm: (form: ProductForm) => void, + setShowForm: (show: boolean) => void + ) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [] + }); + setShowForm(true); + }, + [] + ); + + return { + addProduct, + updateProduct, + deleteProduct, + handleProductSubmit, + startEditProduct + }; +} diff --git a/src/basic/domains/product/index.ts b/src/basic/domains/product/index.ts new file mode 100644 index 00000000..4909c233 --- /dev/null +++ b/src/basic/domains/product/index.ts @@ -0,0 +1,5 @@ +export * from "./constants"; +export * from "./hooks"; +export * from "./models"; +export * from "./types"; +export * from "./utils"; diff --git a/src/basic/domains/product/models/index.ts b/src/basic/domains/product/models/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/basic/domains/product/models/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/basic/domains/product/types/entities.ts b/src/basic/domains/product/types/entities.ts new file mode 100644 index 00000000..e8b461a3 --- /dev/null +++ b/src/basic/domains/product/types/entities.ts @@ -0,0 +1,25 @@ +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface Discount { + quantity: number; + rate: number; +} + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export interface ProductForm { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} diff --git a/src/basic/domains/product/types/index.ts b/src/basic/domains/product/types/index.ts new file mode 100644 index 00000000..fc8e74dd --- /dev/null +++ b/src/basic/domains/product/types/index.ts @@ -0,0 +1 @@ +export type * from "./entities"; diff --git a/src/basic/domains/product/utils/formatters.ts b/src/basic/domains/product/utils/formatters.ts new file mode 100644 index 00000000..3badfe26 --- /dev/null +++ b/src/basic/domains/product/utils/formatters.ts @@ -0,0 +1,35 @@ +import type { CartItem } from "../../cart/types"; +import { getRemainingStock } from "../../cart/utils"; +import type { ProductWithUI } from "../types"; + +export function formatPrice( + price: number, + productId?: string, + products?: ProductWithUI[], + cart?: CartItem[], + isAdmin?: boolean +) { + if (productId && products && cart) { + const product = products.find((p) => p.id === productId); + if (product && getRemainingStock(product, cart) <= 0) { + return "SOLD OUT"; + } + } + + if (isAdmin) { + return `${price.toLocaleString()}원`; + } + + return `₩${price.toLocaleString()}`; +} + +export function filterProducts(products: ProductWithUI[], searchTerm: string) { + if (!searchTerm) return products; + + const lowerSearchTerm = searchTerm.toLowerCase(); + return products.filter( + (product) => + product.name.toLowerCase().includes(lowerSearchTerm) || + (product.description && product.description.toLowerCase().includes(lowerSearchTerm)) + ); +} diff --git a/src/basic/domains/product/utils/index.ts b/src/basic/domains/product/utils/index.ts new file mode 100644 index 00000000..96552da5 --- /dev/null +++ b/src/basic/domains/product/utils/index.ts @@ -0,0 +1 @@ +export * from "./formatters"; diff --git a/src/types.ts b/src/types.ts index 3bf740d0..ab0f9565 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,16 +1,3 @@ -export interface Product { - id: string; - name: string; - price: number; - stock: number; - discounts: Discount[]; -} - -export interface Discount { - quantity: number; - rate: number; -} - -// Re-export domain types -export type { CartItem, CartTotals } from "./basic/domains/cart/types"; -export type { Coupon } from "./basic/domains/coupon/types"; +export type * from "./basic/domains/cart"; +export type * from "./basic/domains/coupon"; +export type * from "./basic/domains/product"; From f11179d99247f75d421161f6b844b004ecc6af38 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Thu, 7 Aug 2025 18:56:12 +0900 Subject: [PATCH 10/35] =?UTF-8?q?feat:=20=EA=B3=B5=EC=9A=A9=20notification?= =?UTF-8?q?s=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/App.tsx | 55 ++----------------- .../shared/components/NotificationList.tsx | 41 ++++++++++++++ src/basic/shared/components/index.ts | 1 + src/basic/shared/hooks/index.ts | 1 + src/basic/shared/hooks/useNotifications.ts | 29 ++++++++++ src/basic/shared/index.ts | 4 ++ src/basic/shared/types/index.ts | 1 + src/basic/shared/types/notification.ts | 5 ++ src/basic/shared/utils/index.ts | 1 + src/types.ts | 1 + 10 files changed, 89 insertions(+), 50 deletions(-) create mode 100644 src/basic/shared/components/NotificationList.tsx create mode 100644 src/basic/shared/components/index.ts create mode 100644 src/basic/shared/hooks/index.ts create mode 100644 src/basic/shared/hooks/useNotifications.ts create mode 100644 src/basic/shared/index.ts create mode 100644 src/basic/shared/types/index.ts create mode 100644 src/basic/shared/types/notification.ts create mode 100644 src/basic/shared/utils/index.ts diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx index 758472fe..8c141adb 100644 --- a/src/basic/app/App.tsx +++ b/src/basic/app/App.tsx @@ -17,15 +17,10 @@ import { type ProductWithUI, useProductActions } from "../domains/product"; +import { NotificationList, useNotifications } from "../shared"; import { Header } from "./components"; import { AdminPage, CartPage } from "./pages"; -interface Notification { - id: string; - message: string; - type: "error" | "success" | "warning"; -} - export function App() { const [products, setProducts] = useState(() => { const saved = localStorage.getItem("products"); @@ -65,7 +60,6 @@ export function App() { const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); const [showCouponForm, setShowCouponForm] = useState(false); const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); const [showProductForm, setShowProductForm] = useState(false); @@ -89,6 +83,9 @@ export function App() { discountValue: 0 }); + // Notification management + const { notifications, addNotification, removeNotification } = useNotifications(); + const formatPriceWithContext = useCallback( (price: number, productId?: string) => { return formatPrice(price, productId, products, cart, isAdmin); @@ -96,18 +93,6 @@ export function App() { [products, cart, isAdmin] ); - const addNotification = useCallback( - (message: string, type: "error" | "success" | "warning" = "success") => { - const id = Date.now().toString(); - setNotifications((prev) => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications((prev) => prev.filter((n) => n.id !== id)); - }, 3000); - }, - [] - ); - const [totalItemCount, setTotalItemCount] = useState(0); // Cart actions using domain hook @@ -267,37 +252,7 @@ export function App() { )}
- {notifications.length > 0 && ( -
- {notifications.map((notif) => ( -
- {notif.message} - -
- ))} -
- )} +
); } diff --git a/src/basic/shared/components/NotificationList.tsx b/src/basic/shared/components/NotificationList.tsx new file mode 100644 index 00000000..9341e72b --- /dev/null +++ b/src/basic/shared/components/NotificationList.tsx @@ -0,0 +1,41 @@ +import type { Notification } from "../types"; + +interface NotificationListProps { + notifications: Notification[]; + onRemove: (id: string) => void; +} + +export function NotificationList({ notifications, onRemove }: NotificationListProps) { + if (notifications.length === 0) { + return null; + } + + return ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ); +} diff --git a/src/basic/shared/components/index.ts b/src/basic/shared/components/index.ts new file mode 100644 index 00000000..fc1c436b --- /dev/null +++ b/src/basic/shared/components/index.ts @@ -0,0 +1 @@ +export * from "./NotificationList"; diff --git a/src/basic/shared/hooks/index.ts b/src/basic/shared/hooks/index.ts new file mode 100644 index 00000000..726951fd --- /dev/null +++ b/src/basic/shared/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useNotifications"; diff --git a/src/basic/shared/hooks/useNotifications.ts b/src/basic/shared/hooks/useNotifications.ts new file mode 100644 index 00000000..7b8b73af --- /dev/null +++ b/src/basic/shared/hooks/useNotifications.ts @@ -0,0 +1,29 @@ +import { useCallback, useState } from "react"; + +import type { Notification } from "../types"; + +export function useNotifications() { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); + + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return { + notifications, + addNotification, + removeNotification + }; +} diff --git a/src/basic/shared/index.ts b/src/basic/shared/index.ts new file mode 100644 index 00000000..057ddce2 --- /dev/null +++ b/src/basic/shared/index.ts @@ -0,0 +1,4 @@ +export * from "./components"; +export * from "./hooks"; +export * from "./types"; +export * from "./utils"; diff --git a/src/basic/shared/types/index.ts b/src/basic/shared/types/index.ts new file mode 100644 index 00000000..dd99c4d0 --- /dev/null +++ b/src/basic/shared/types/index.ts @@ -0,0 +1 @@ +export * from "./notification"; diff --git a/src/basic/shared/types/notification.ts b/src/basic/shared/types/notification.ts new file mode 100644 index 00000000..9f97fcbb --- /dev/null +++ b/src/basic/shared/types/notification.ts @@ -0,0 +1,5 @@ +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} diff --git a/src/basic/shared/utils/index.ts b/src/basic/shared/utils/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/basic/shared/utils/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/types.ts b/src/types.ts index ab0f9565..cbb9a68b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ export type * from "./basic/domains/cart"; export type * from "./basic/domains/coupon"; export type * from "./basic/domains/product"; +export type * from "./basic/shared"; From 51637d56bf942a7766e8f3ffb843ad0159789046 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Thu, 7 Aug 2025 23:24:45 +0900 Subject: [PATCH 11/35] =?UTF-8?q?feat:=20useLocalStorageState=20=ED=9B=85?= =?UTF-8?q?=20=EA=B5=AC=EC=84=B1,=20App.tsx=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/App.tsx | 57 ++++--------------- src/basic/shared/hooks/index.ts | 1 + .../shared/hooks/useLocalStorageState.ts | 36 ++++++++++++ 3 files changed, 47 insertions(+), 47 deletions(-) create mode 100644 src/basic/shared/hooks/useLocalStorageState.ts diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx index 8c141adb..a4b6adbb 100644 --- a/src/basic/app/App.tsx +++ b/src/basic/app/App.tsx @@ -17,45 +17,24 @@ import { type ProductWithUI, useProductActions } from "../domains/product"; -import { NotificationList, useNotifications } from "../shared"; +import { NotificationList, useLocalStorageState, useNotifications } from "../shared"; import { Header } from "./components"; import { AdminPage, CartPage } from "./pages"; export function App() { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem("products"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return INITIAL_PRODUCTS; - } - } - return INITIAL_PRODUCTS; + const [products, setProducts] = useLocalStorageState({ + key: "products", + initialState: INITIAL_PRODUCTS }); - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem("cart"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; + const [cart, setCart] = useLocalStorageState({ + key: "cart", + initialState: [] }); - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem("coupons"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return INITIAL_COUPONS; - } - } - return INITIAL_COUPONS; + const [coupons, setCoupons] = useLocalStorageState({ + key: "coupons", + initialState: INITIAL_COUPONS }); const [selectedCoupon, setSelectedCoupon] = useState(null); @@ -137,22 +116,6 @@ export function App() { setTotalItemCount(count); }, [cart]); - useEffect(() => { - localStorage.setItem("products", JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem("coupons", JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem("cart", JSON.stringify(cart)); - } else { - localStorage.removeItem("cart"); - } - }, [cart]); - useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchTerm(searchTerm); diff --git a/src/basic/shared/hooks/index.ts b/src/basic/shared/hooks/index.ts index 726951fd..ef8f5126 100644 --- a/src/basic/shared/hooks/index.ts +++ b/src/basic/shared/hooks/index.ts @@ -1 +1,2 @@ +export * from "./useLocalStorageState"; export * from "./useNotifications"; diff --git a/src/basic/shared/hooks/useLocalStorageState.ts b/src/basic/shared/hooks/useLocalStorageState.ts new file mode 100644 index 00000000..bff52df4 --- /dev/null +++ b/src/basic/shared/hooks/useLocalStorageState.ts @@ -0,0 +1,36 @@ +import { type Dispatch, type SetStateAction, useCallback, useEffect, useState } from "react"; + +type UseLocalStorageStateProps = { + key: string; + initialState: S; +}; + +export function useLocalStorageState({ + key, + initialState +}: UseLocalStorageStateProps): [S, Dispatch>] { + const readLocalStorage = useCallback(() => { + try { + const item = localStorage.getItem(key); + return item ? (JSON.parse(item) as S) : initialState; + } catch { + return initialState; + } + }, [key, initialState]); + + const [state, setState] = useState(readLocalStorage); + + useEffect(() => { + const isEmpty = state === undefined || state === null; + const isEmptyObject = typeof state === "object" && Object.keys(state || {}).length === 0; + const isEmptyArray = Array.isArray(state) && state.length === 0; + + if (isEmpty || isEmptyObject || isEmptyArray) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(state)); + } + }, [state, key]); + + return [state, setState]; +} From b7776678c3feec9fe63328f957de482d74a59cab Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 03:32:48 +0900 Subject: [PATCH 12/35] =?UTF-8?q?refactor:=20SVG=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=EB=93=A4=EC=9D=84=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/components/Header.tsx | 17 ++---- src/basic/app/pages/AdminPage.tsx | 38 ++----------- src/basic/app/pages/CartPage.tsx | 53 +++---------------- .../shared/components/icons/CartIcon.tsx | 16 ++++++ .../shared/components/icons/CloseIcon.tsx | 11 ++++ .../components/icons/ImagePlaceholderIcon.tsx | 19 +++++++ .../shared/components/icons/PlusIcon.tsx | 11 ++++ .../components/icons/ShoppingBagIcon.tsx | 22 ++++++++ .../shared/components/icons/TrashIcon.tsx | 16 ++++++ src/basic/shared/components/icons/index.ts | 6 +++ src/basic/shared/components/index.ts | 1 + 11 files changed, 115 insertions(+), 95 deletions(-) create mode 100644 src/basic/shared/components/icons/CartIcon.tsx create mode 100644 src/basic/shared/components/icons/CloseIcon.tsx create mode 100644 src/basic/shared/components/icons/ImagePlaceholderIcon.tsx create mode 100644 src/basic/shared/components/icons/PlusIcon.tsx create mode 100644 src/basic/shared/components/icons/ShoppingBagIcon.tsx create mode 100644 src/basic/shared/components/icons/TrashIcon.tsx create mode 100644 src/basic/shared/components/icons/index.ts diff --git a/src/basic/app/components/Header.tsx b/src/basic/app/components/Header.tsx index b29289f9..4b103e5b 100644 --- a/src/basic/app/components/Header.tsx +++ b/src/basic/app/components/Header.tsx @@ -1,4 +1,5 @@ -import type { CartItem } from "../../../types"; +import type { CartItem } from "../../domains/cart"; +import { CartIcon } from "../../shared"; type HeaderProps = { isAdmin: boolean; @@ -47,19 +48,7 @@ export function Header({ {!isAdmin && (
- - - + {cart.length > 0 && ( {totalItemCount} diff --git a/src/basic/app/pages/AdminPage.tsx b/src/basic/app/pages/AdminPage.tsx index 730e786b..142ec8d3 100644 --- a/src/basic/app/pages/AdminPage.tsx +++ b/src/basic/app/pages/AdminPage.tsx @@ -1,6 +1,7 @@ import { FormEvent } from "react"; import type { Coupon, Product } from "../../../types"; +import { CloseIcon, PlusIcon, TrashIcon } from "../../shared"; interface ProductWithUI extends Product { description?: string; @@ -319,19 +320,7 @@ export function AdminPage({ }} className="text-red-600 hover:text-red-800" > - - - +
))} @@ -407,19 +396,7 @@ export function AdminPage({ onClick={() => deleteCoupon(coupon.code)} className="text-gray-400 transition-colors hover:text-red-600" > - - - +
@@ -430,14 +407,7 @@ export function AdminPage({ onClick={() => setShowCouponForm(!showCouponForm)} className="flex flex-col items-center text-gray-400 hover:text-gray-600" > - - - +

새 쿠폰 추가

diff --git a/src/basic/app/pages/CartPage.tsx b/src/basic/app/pages/CartPage.tsx index 30dac0b4..2cfa0926 100644 --- a/src/basic/app/pages/CartPage.tsx +++ b/src/basic/app/pages/CartPage.tsx @@ -1,4 +1,5 @@ import type { CartItem, Coupon, Product } from "../../../types"; +import { CloseIcon, ImagePlaceholderIcon, ShoppingBagIcon } from "../../shared"; interface ProductWithUI extends Product { description?: string; @@ -71,20 +72,9 @@ export function CartPage({ {/* 상품 이미지 영역 (placeholder) */}
- - - +
+ {product.isRecommended && ( BEST @@ -156,31 +146,12 @@ export function CartPage({

- - - + 장바구니

{cart.length === 0 ? (
- - - +

장바구니가 비어있습니다

) : ( @@ -203,19 +174,7 @@ export function CartPage({ onClick={() => removeFromCart(item.product.id)} className="ml-2 text-gray-400 hover:text-red-500" > - - - +
diff --git a/src/basic/shared/components/icons/CartIcon.tsx b/src/basic/shared/components/icons/CartIcon.tsx new file mode 100644 index 00000000..c4ba9a45 --- /dev/null +++ b/src/basic/shared/components/icons/CartIcon.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from "react"; + +type CartIconProps = SVGProps; + +export function CartIcon({ className = "w-6 h-6", ...rest }: CartIconProps) { + return ( + + + + ); +} diff --git a/src/basic/shared/components/icons/CloseIcon.tsx b/src/basic/shared/components/icons/CloseIcon.tsx new file mode 100644 index 00000000..6e4cef6b --- /dev/null +++ b/src/basic/shared/components/icons/CloseIcon.tsx @@ -0,0 +1,11 @@ +import type { SVGProps } from "react"; + +type CloseIconProps = SVGProps; + +export function CloseIcon({ className = "w-4 h-4", ...rest }: CloseIconProps) { + return ( + + + + ); +} diff --git a/src/basic/shared/components/icons/ImagePlaceholderIcon.tsx b/src/basic/shared/components/icons/ImagePlaceholderIcon.tsx new file mode 100644 index 00000000..f2090948 --- /dev/null +++ b/src/basic/shared/components/icons/ImagePlaceholderIcon.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from "react"; + +type ImagePlaceholderIconProps = SVGProps; + +export function ImagePlaceholderIcon({ + className = "w-24 h-24", + ...rest +}: ImagePlaceholderIconProps) { + return ( + + + + ); +} diff --git a/src/basic/shared/components/icons/PlusIcon.tsx b/src/basic/shared/components/icons/PlusIcon.tsx new file mode 100644 index 00000000..6b014dd1 --- /dev/null +++ b/src/basic/shared/components/icons/PlusIcon.tsx @@ -0,0 +1,11 @@ +import type { SVGProps } from "react"; + +type PlusIconProps = SVGProps; + +export function PlusIcon({ className = "w-6 h-6", ...rest }: PlusIconProps) { + return ( + + + + ); +} diff --git a/src/basic/shared/components/icons/ShoppingBagIcon.tsx b/src/basic/shared/components/icons/ShoppingBagIcon.tsx new file mode 100644 index 00000000..ada901f9 --- /dev/null +++ b/src/basic/shared/components/icons/ShoppingBagIcon.tsx @@ -0,0 +1,22 @@ +import type { SVGProps } from "react"; + +type ShoppingBagIconProps = SVGProps & { + strokeWidth?: number; +}; + +export function ShoppingBagIcon({ + className = "w-5 h-5", + strokeWidth = 2, + ...rest +}: ShoppingBagIconProps) { + return ( + + + + ); +} diff --git a/src/basic/shared/components/icons/TrashIcon.tsx b/src/basic/shared/components/icons/TrashIcon.tsx new file mode 100644 index 00000000..d64db2f0 --- /dev/null +++ b/src/basic/shared/components/icons/TrashIcon.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from "react"; + +type TrashIconProps = SVGProps; + +export function TrashIcon({ className = "w-5 h-5", ...rest }: TrashIconProps) { + return ( + + + + ); +} diff --git a/src/basic/shared/components/icons/index.ts b/src/basic/shared/components/icons/index.ts new file mode 100644 index 00000000..5394b7f8 --- /dev/null +++ b/src/basic/shared/components/icons/index.ts @@ -0,0 +1,6 @@ +export * from "./CartIcon"; +export * from "./CloseIcon"; +export * from "./ImagePlaceholderIcon"; +export * from "./PlusIcon"; +export * from "./ShoppingBagIcon"; +export * from "./TrashIcon"; diff --git a/src/basic/shared/components/index.ts b/src/basic/shared/components/index.ts index fc1c436b..d3da3d34 100644 --- a/src/basic/shared/components/index.ts +++ b/src/basic/shared/components/index.ts @@ -1 +1,2 @@ +export * from "./icons"; export * from "./NotificationList"; From 95b4ba009319e89d80126e7e76776735fadecd9d Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 04:03:35 +0900 Subject: [PATCH 13/35] =?UTF-8?q?feat:=20useDebounceValue=20=ED=9B=85=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1,=20App.tsx=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/App.tsx | 17 ++++++++--------- src/basic/shared/hooks/index.ts | 1 + src/basic/shared/hooks/useDebounceValue.ts | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 src/basic/shared/hooks/useDebounceValue.ts diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx index a4b6adbb..2509a01e 100644 --- a/src/basic/app/App.tsx +++ b/src/basic/app/App.tsx @@ -17,7 +17,12 @@ import { type ProductWithUI, useProductActions } from "../domains/product"; -import { NotificationList, useLocalStorageState, useNotifications } from "../shared"; +import { + NotificationList, + useDebounceValue, + useLocalStorageState, + useNotifications +} from "../shared"; import { Header } from "./components"; import { AdminPage, CartPage } from "./pages"; @@ -43,7 +48,8 @@ export function App() { const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); const [showProductForm, setShowProductForm] = useState(false); const [searchTerm, setSearchTerm] = useState(""); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + + const debouncedSearchTerm = useDebounceValue({ delay: 500, value: searchTerm }); // Admin const [editingProduct, setEditingProduct] = useState(null); @@ -116,13 +122,6 @@ export function App() { setTotalItemCount(count); }, [cart]); - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - const handleProductSubmitWrapper = (e: FormEvent) => { e.preventDefault(); handleProductSubmit( diff --git a/src/basic/shared/hooks/index.ts b/src/basic/shared/hooks/index.ts index ef8f5126..cdf98c0d 100644 --- a/src/basic/shared/hooks/index.ts +++ b/src/basic/shared/hooks/index.ts @@ -1,2 +1,3 @@ +export * from "./useDebounceValue"; export * from "./useLocalStorageState"; export * from "./useNotifications"; diff --git a/src/basic/shared/hooks/useDebounceValue.ts b/src/basic/shared/hooks/useDebounceValue.ts new file mode 100644 index 00000000..b82e1fda --- /dev/null +++ b/src/basic/shared/hooks/useDebounceValue.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +type UseDebounceValueProps = { + delay: number; + value: T; +}; + +export function useDebounceValue({ delay, value }: UseDebounceValueProps) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const debouncedTimeout = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(debouncedTimeout); + }, [value, delay]); + + return debouncedValue; +} From 43ef38ebc08e03d7ea72546793b95e7a4617e270 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 04:23:46 +0900 Subject: [PATCH 14/35] =?UTF-8?q?feat:=20=EB=94=94=EB=B0=94=EC=9A=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EC=83=81=ED=99=94=EB=A5=BC=20=EC=9C=84=ED=95=9C=20useDebounceS?= =?UTF-8?q?tate=20=ED=9B=85=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/App.tsx | 15 ++++++++------- src/basic/shared/hooks/index.ts | 1 + src/basic/shared/hooks/useDebounceState.ts | 22 ++++++++++++++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 src/basic/shared/hooks/useDebounceState.ts diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx index 2509a01e..8997c0d6 100644 --- a/src/basic/app/App.tsx +++ b/src/basic/app/App.tsx @@ -19,7 +19,7 @@ import { } from "../domains/product"; import { NotificationList, - useDebounceValue, + useDebounceState, useLocalStorageState, useNotifications } from "../shared"; @@ -42,14 +42,18 @@ export function App() { initialState: INITIAL_COUPONS }); + const [searchTerm, setSearchTerm, debouncedSearchTerm] = useDebounceState({ + delay: 500, + initialValue: "" + }); + + const { notifications, addNotification, removeNotification } = useNotifications(); + const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [showCouponForm, setShowCouponForm] = useState(false); const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - - const debouncedSearchTerm = useDebounceValue({ delay: 500, value: searchTerm }); // Admin const [editingProduct, setEditingProduct] = useState(null); @@ -68,9 +72,6 @@ export function App() { discountValue: 0 }); - // Notification management - const { notifications, addNotification, removeNotification } = useNotifications(); - const formatPriceWithContext = useCallback( (price: number, productId?: string) => { return formatPrice(price, productId, products, cart, isAdmin); diff --git a/src/basic/shared/hooks/index.ts b/src/basic/shared/hooks/index.ts index cdf98c0d..ee16b4c0 100644 --- a/src/basic/shared/hooks/index.ts +++ b/src/basic/shared/hooks/index.ts @@ -1,3 +1,4 @@ +export * from "./useDebounceState"; export * from "./useDebounceValue"; export * from "./useLocalStorageState"; export * from "./useNotifications"; diff --git a/src/basic/shared/hooks/useDebounceState.ts b/src/basic/shared/hooks/useDebounceState.ts new file mode 100644 index 00000000..a5b9c1a6 --- /dev/null +++ b/src/basic/shared/hooks/useDebounceState.ts @@ -0,0 +1,22 @@ +import { type Dispatch, type SetStateAction, useState } from "react"; + +import { useDebounceValue } from "./useDebounceValue"; + +type UseDebounceStateProps = { + delay: number; + initialValue: S; +}; + +export function useDebounceState({ + delay, + initialValue +}: UseDebounceStateProps): [S, Dispatch>, S] { + const [state, setState] = useState(initialValue); + + const debouncedState = useDebounceValue({ + delay, + value: state + }); + + return [state, setState, debouncedState]; +} From c6bdbc7390882e3bea1de4c3d431f21670667d59 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 05:00:19 +0900 Subject: [PATCH 15/35] =?UTF-8?q?refactor:=20NotificationList=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=9C=84=EC=B9=98=20=EC=9D=B4?= =?UTF-8?q?=EA=B4=80=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/shared/components/index.ts | 2 +- src/basic/shared/components/{ => ui}/NotificationList.tsx | 2 +- src/basic/shared/components/ui/index.ts | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) rename src/basic/shared/components/{ => ui}/NotificationList.tsx (96%) create mode 100644 src/basic/shared/components/ui/index.ts diff --git a/src/basic/shared/components/index.ts b/src/basic/shared/components/index.ts index d3da3d34..ac365264 100644 --- a/src/basic/shared/components/index.ts +++ b/src/basic/shared/components/index.ts @@ -1,2 +1,2 @@ export * from "./icons"; -export * from "./NotificationList"; +export * from "./ui"; diff --git a/src/basic/shared/components/NotificationList.tsx b/src/basic/shared/components/ui/NotificationList.tsx similarity index 96% rename from src/basic/shared/components/NotificationList.tsx rename to src/basic/shared/components/ui/NotificationList.tsx index 9341e72b..5663918b 100644 --- a/src/basic/shared/components/NotificationList.tsx +++ b/src/basic/shared/components/ui/NotificationList.tsx @@ -1,4 +1,4 @@ -import type { Notification } from "../types"; +import type { Notification } from "../../types"; interface NotificationListProps { notifications: Notification[]; diff --git a/src/basic/shared/components/ui/index.ts b/src/basic/shared/components/ui/index.ts new file mode 100644 index 00000000..fc1c436b --- /dev/null +++ b/src/basic/shared/components/ui/index.ts @@ -0,0 +1 @@ +export * from "./NotificationList"; From a95e154a889f9e8ff4530a10a0131756cec8cad6 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 05:00:56 +0900 Subject: [PATCH 16/35] =?UTF-8?q?chore:=20tailwind-variants=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- pnpm-lock.yaml | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index deeb38d6..84ef66a1 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ }, "dependencies": { "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "tailwind-variants": "^2.1.0" }, "devDependencies": { "@eslint/js": "^9.32.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60cdf48d..29e94644 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: react-dom: specifier: ^19.1.1 version: 19.1.1(react@19.1.1) + tailwind-variants: + specifier: ^2.1.0 + version: 2.1.0(tailwindcss@4.1.11) devDependencies: '@eslint/js': specifier: ^9.32.0 @@ -1478,6 +1481,19 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-variants@2.1.0: + resolution: {integrity: sha512-82m0eRex0z6A3GpvfoTCpHr+wWJmbecfVZfP3mqLoDxeya5tN4mYJQZwa5Aw1hRZTedwpu1D2JizYenoEdyD8w==} + engines: {node: '>=16.x', pnpm: '>=7.x'} + peerDependencies: + tailwind-merge: '>=3.0.0' + tailwindcss: '*' + peerDependenciesMeta: + tailwind-merge: + optional: true + + tailwindcss@4.1.11: + resolution: {integrity: sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2886,6 +2902,12 @@ snapshots: symbol-tree@3.2.4: {} + tailwind-variants@2.1.0(tailwindcss@4.1.11): + dependencies: + tailwindcss: 4.1.11 + + tailwindcss@4.1.11: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} From 9ab05be5c41ceea7d211bdd3eeb9f7bf6b139e08 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 05:10:41 +0900 Subject: [PATCH 17/35] =?UTF-8?q?feat:=20=EA=B3=B5=EC=9A=A9=20SearchInput?= =?UTF-8?q?=20UI=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/components/Header.tsx | 9 ++-- src/basic/app/pages/AdminPage.tsx | 45 +++++++++---------- .../shared/components/ui/SearchInput.tsx | 35 +++++++++++++++ src/basic/shared/components/ui/index.ts | 1 + 4 files changed, 63 insertions(+), 27 deletions(-) create mode 100644 src/basic/shared/components/ui/SearchInput.tsx diff --git a/src/basic/app/components/Header.tsx b/src/basic/app/components/Header.tsx index 4b103e5b..e272287b 100644 --- a/src/basic/app/components/Header.tsx +++ b/src/basic/app/components/Header.tsx @@ -1,5 +1,5 @@ import type { CartItem } from "../../domains/cart"; -import { CartIcon } from "../../shared"; +import { CartIcon, SearchInput } from "../../shared"; type HeaderProps = { isAdmin: boolean; @@ -26,13 +26,14 @@ export function Header({

SHOP

{/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} {!isAdmin && ( -
- + setSearchTerm(e.target.value)} placeholder="상품 검색..." - className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:outline-none" + color="blue" + size="lg" />
)} diff --git a/src/basic/app/pages/AdminPage.tsx b/src/basic/app/pages/AdminPage.tsx index 142ec8d3..588930a7 100644 --- a/src/basic/app/pages/AdminPage.tsx +++ b/src/basic/app/pages/AdminPage.tsx @@ -1,7 +1,7 @@ import { FormEvent } from "react"; import type { Coupon, Product } from "../../../types"; -import { CloseIcon, PlusIcon, TrashIcon } from "../../shared"; +import { CloseIcon, PlusIcon, SearchInput, TrashIcon } from "../../shared"; interface ProductWithUI extends Product { description?: string; @@ -198,30 +198,30 @@ export function AdminPage({
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + className="shadow-sm" required />
- - setProductForm({ ...productForm, description: e.target.value }) } - className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + className="shadow-sm" />
- - { const value = e.target.value; @@ -241,15 +241,15 @@ export function AdminPage({ setProductForm({ ...productForm, price: 0 }); } }} - className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + className="shadow-sm" placeholder="숫자만 입력" required />
- - { const value = e.target.value; @@ -272,7 +272,7 @@ export function AdminPage({ setProductForm({ ...productForm, stock: 9999 }); } }} - className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + className="shadow-sm" placeholder="숫자만 입력" required /> @@ -419,27 +419,25 @@ export function AdminPage({

새 쿠폰 생성

- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + className="text-sm shadow-sm" placeholder="신규 가입 쿠폰" required />
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }) } - className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + className="font-mono text-sm shadow-sm" placeholder="WELCOME2024" required /> @@ -466,8 +464,9 @@ export function AdminPage({ - { const value = e.target.value; @@ -499,7 +498,7 @@ export function AdminPage({ } } }} - className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + className="text-sm shadow-sm" placeholder={couponForm.discountType === "amount" ? "5000" : "10"} required /> diff --git a/src/basic/shared/components/ui/SearchInput.tsx b/src/basic/shared/components/ui/SearchInput.tsx new file mode 100644 index 00000000..8da93f5e --- /dev/null +++ b/src/basic/shared/components/ui/SearchInput.tsx @@ -0,0 +1,35 @@ +import type { ComponentPropsWithRef } from "react"; +import { tv } from "tailwind-variants"; + +type SearchInputProps = Omit, "size" | "color"> & { + label?: string; + size?: "lg" | "md"; + color?: "blue" | "indigo"; +}; + +const inputVariants = tv({ + base: "w-full border border-gray-300 py-2", + variants: { + size: { + md: "rounded-md px-3", + lg: "rounded-lg px-4" + }, + color: { + blue: "focus:border-blue-500 focus:outline-none", + indigo: "focus:border-indigo-500 focus:ring-indigo-500" + } + }, + defaultVariants: { + size: "md", + color: "indigo" + } +}); + +export function SearchInput({ className, label, size, color, ...rest }: SearchInputProps) { + return ( + <> + {label && } + + + ); +} diff --git a/src/basic/shared/components/ui/index.ts b/src/basic/shared/components/ui/index.ts index fc1c436b..b55ae60b 100644 --- a/src/basic/shared/components/ui/index.ts +++ b/src/basic/shared/components/ui/index.ts @@ -1 +1,2 @@ export * from "./NotificationList"; +export * from "./SearchInput"; From d2b1e24f51833cd730bf014388c917901e3be689 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 05:18:32 +0900 Subject: [PATCH 18/35] =?UTF-8?q?feat:=20=EA=B3=B5=EC=9A=A9=20BadgeContain?= =?UTF-8?q?er=20UI=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/components/Header.tsx | 11 +++-------- .../shared/components/ui/BadgeContainer.tsx | 19 +++++++++++++++++++ src/basic/shared/components/ui/index.ts | 1 + 3 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 src/basic/shared/components/ui/BadgeContainer.tsx diff --git a/src/basic/app/components/Header.tsx b/src/basic/app/components/Header.tsx index e272287b..4f393d90 100644 --- a/src/basic/app/components/Header.tsx +++ b/src/basic/app/components/Header.tsx @@ -1,5 +1,5 @@ import type { CartItem } from "../../domains/cart"; -import { CartIcon, SearchInput } from "../../shared"; +import { BadgeContainer, CartIcon, SearchInput } from "../../shared"; type HeaderProps = { isAdmin: boolean; @@ -48,14 +48,9 @@ export function Header({ {isAdmin ? "쇼핑몰로 돌아가기" : "관리자 페이지로"} {!isAdmin && ( -
+ 0}> - {cart.length > 0 && ( - - {totalItemCount} - - )} -
+ )}
diff --git a/src/basic/shared/components/ui/BadgeContainer.tsx b/src/basic/shared/components/ui/BadgeContainer.tsx new file mode 100644 index 00000000..7f730703 --- /dev/null +++ b/src/basic/shared/components/ui/BadgeContainer.tsx @@ -0,0 +1,19 @@ +import { PropsWithChildren } from "react"; + +type BadgeContainerProps = PropsWithChildren<{ + label: string; + visible: boolean; +}>; + +export function BadgeContainer({ label, visible, children }: BadgeContainerProps) { + return ( +
+ {children} + {visible && ( + + {label} + + )} +
+ ); +} diff --git a/src/basic/shared/components/ui/index.ts b/src/basic/shared/components/ui/index.ts index b55ae60b..51780124 100644 --- a/src/basic/shared/components/ui/index.ts +++ b/src/basic/shared/components/ui/index.ts @@ -1,2 +1,3 @@ +export * from "./BadgeContainer"; export * from "./NotificationList"; export * from "./SearchInput"; From 2382d34579d627aca80d361a03c3019449585242 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 05:26:49 +0900 Subject: [PATCH 19/35] =?UTF-8?q?feat:=20=EA=B3=B5=EC=9A=A9=20useToggle=20?= =?UTF-8?q?hook=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/shared/hooks/index.ts | 1 + src/basic/shared/hooks/useToggle.ts | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 src/basic/shared/hooks/useToggle.ts diff --git a/src/basic/shared/hooks/index.ts b/src/basic/shared/hooks/index.ts index ee16b4c0..815d51bd 100644 --- a/src/basic/shared/hooks/index.ts +++ b/src/basic/shared/hooks/index.ts @@ -2,3 +2,4 @@ export * from "./useDebounceState"; export * from "./useDebounceValue"; export * from "./useLocalStorageState"; export * from "./useNotifications"; +export * from "./useToggle"; diff --git a/src/basic/shared/hooks/useToggle.ts b/src/basic/shared/hooks/useToggle.ts new file mode 100644 index 00000000..509dd0b1 --- /dev/null +++ b/src/basic/shared/hooks/useToggle.ts @@ -0,0 +1,11 @@ +import { useState } from "react"; + +export function useToggle(defaultValue?: boolean) { + const [value, setValue] = useState(!!defaultValue); + + const toggle = () => { + setValue((prev) => !prev); + }; + + return [value, toggle, setValue] as const; +} From 96afdf3bbe60e07500c3eef3376ae0cbb6bccec3 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 07:40:01 +0900 Subject: [PATCH 20/35] =?UTF-8?q?feat:=20AdminToggleButton=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=AA=A8=EB=93=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/App.tsx | 16 +++++++----- .../app/components/AdminToggleButton.tsx | 26 +++++++++++++++++++ src/basic/app/components/Header.tsx | 25 +++++++----------- 3 files changed, 45 insertions(+), 22 deletions(-) create mode 100644 src/basic/app/components/AdminToggleButton.tsx diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx index 8997c0d6..019f826e 100644 --- a/src/basic/app/App.tsx +++ b/src/basic/app/App.tsx @@ -21,7 +21,8 @@ import { NotificationList, useDebounceState, useLocalStorageState, - useNotifications + useNotifications, + useToggle } from "../shared"; import { Header } from "./components"; import { AdminPage, CartPage } from "./pages"; @@ -49,8 +50,9 @@ export function App() { const { notifications, addNotification, removeNotification } = useNotifications(); + const [isAdminMode, toggleAdminMode] = useToggle(false); + const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); const [showCouponForm, setShowCouponForm] = useState(false); const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); const [showProductForm, setShowProductForm] = useState(false); @@ -74,9 +76,9 @@ export function App() { const formatPriceWithContext = useCallback( (price: number, productId?: string) => { - return formatPrice(price, productId, products, cart, isAdmin); + return formatPrice(price, productId, products, cart, isAdminMode); }, - [products, cart, isAdmin] + [products, cart, isAdminMode] ); const [totalItemCount, setTotalItemCount] = useState(0); @@ -160,16 +162,16 @@ export function App() { return (
- {isAdmin ? ( + {isAdminMode ? ( void; +}; + +const adminToggle = tv({ + base: "rounded px-3 py-1.5 text-sm transition-colors", + variants: { + mode: { + admin: "bg-gray-800 text-white", + cart: "text-gray-600 hover:text-gray-900" + } + } +}); + +export function AdminToggleButton({ isAdmin, onToggleAdminMode }: AdminToggleButtonProps) { + const buttonClassName = adminToggle({ mode: isAdmin ? "admin" : "cart" }); + + return ( + + ); +} diff --git a/src/basic/app/components/Header.tsx b/src/basic/app/components/Header.tsx index 4f393d90..3081948e 100644 --- a/src/basic/app/components/Header.tsx +++ b/src/basic/app/components/Header.tsx @@ -1,20 +1,21 @@ import type { CartItem } from "../../domains/cart"; import { BadgeContainer, CartIcon, SearchInput } from "../../shared"; +import { AdminToggleButton } from "./AdminToggleButton"; type HeaderProps = { - isAdmin: boolean; + isAdminMode: boolean; + onToggleAdminMode: () => void; + cart: CartItem[]; searchTerm: string; setSearchTerm: (term: string) => void; - setIsAdmin: (isAdmin: boolean) => void; - cart: CartItem[]; totalItemCount: number; }; export function Header({ + isAdminMode, + onToggleAdminMode, cart, - isAdmin, searchTerm, - setIsAdmin, setSearchTerm, totalItemCount }: HeaderProps) { @@ -25,7 +26,7 @@ export function Header({

SHOP

{/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && ( + {!isAdminMode && (
)}
+
- - + +
@@ -505,19 +507,16 @@ export function AdminPage({
- - + +
diff --git a/src/basic/app/pages/CartPage.tsx b/src/basic/app/pages/CartPage.tsx index 2cfa0926..a729475e 100644 --- a/src/basic/app/pages/CartPage.tsx +++ b/src/basic/app/pages/CartPage.tsx @@ -1,5 +1,5 @@ import type { CartItem, Coupon, Product } from "../../../types"; -import { CloseIcon, ImagePlaceholderIcon, ShoppingBagIcon } from "../../shared"; +import { Button, CloseIcon, ImagePlaceholderIcon, ShoppingBagIcon } from "../../shared"; interface ProductWithUI extends Product { description?: string; @@ -122,17 +122,14 @@ export function CartPage({
{/* 장바구니 버튼 */} - +
); @@ -270,12 +267,9 @@ export function CartPage({ - +

* 실제 결제는 이루어지지 않습니다

diff --git a/src/basic/shared/components/ui/Button.tsx b/src/basic/shared/components/ui/Button.tsx new file mode 100644 index 00000000..d741d240 --- /dev/null +++ b/src/basic/shared/components/ui/Button.tsx @@ -0,0 +1,34 @@ +import type { ComponentPropsWithRef } from "react"; +import { tv } from "tailwind-variants"; + +type ButtonProps = Omit, "size" | "color"> & { + size?: "lg" | "md" | "sm"; + color?: "primary" | "secondary" | "danger" | "dark" | "neutral" | "yellow"; +}; + +const buttonVariants = tv({ + base: "rounded font-medium transition-colors focus:outline-none disabled:cursor-not-allowed disabled:opacity-50", + variants: { + size: { + sm: "px-3 py-1.5 text-sm", + md: "px-4 py-2 text-base", + lg: "px-6 py-3 text-base" + }, + color: { + primary: "bg-indigo-600 text-white hover:bg-indigo-700 disabled:bg-indigo-300", + secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300 disabled:bg-gray-100", + danger: "bg-red-600 text-white hover:bg-red-700 disabled:bg-red-300", + dark: "bg-gray-900 text-white hover:bg-gray-800 disabled:bg-gray-400", + neutral: "bg-gray-800 text-white hover:bg-gray-700 disabled:bg-gray-400", + yellow: "bg-yellow-400 text-gray-900 hover:bg-yellow-500 disabled:bg-yellow-200" + } + }, + defaultVariants: { + size: "md", + color: "primary" + } +}); + +export function Button({ className, size, color, ...rest }: ButtonProps) { + return +
))} diff --git a/src/basic/shared/components/ui/index.ts b/src/basic/shared/components/ui/index.ts index 51780124..f19e5fab 100644 --- a/src/basic/shared/components/ui/index.ts +++ b/src/basic/shared/components/ui/index.ts @@ -1,3 +1,4 @@ export * from "./BadgeContainer"; +export * from "./Button"; export * from "./NotificationList"; export * from "./SearchInput"; From 4651a4c35ee2f56dd09c0fe0a663a161bc39c774 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 08:15:08 +0900 Subject: [PATCH 22/35] =?UTF-8?q?refactor:=20Notification=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Notification 범용 UI 컴포넌트 분리 - NotificationList를 app/components로 이관 --- src/basic/app/App.tsx | 10 +--- src/basic/app/components/NotificationList.tsx | 20 ++++++++ src/basic/app/components/index.ts | 1 + .../shared/components/ui/Notification.tsx | 36 ++++++++++++++ .../shared/components/ui/NotificationList.tsx | 47 ------------------- src/basic/shared/components/ui/index.ts | 2 +- src/basic/shared/types/notification.ts | 2 +- 7 files changed, 61 insertions(+), 57 deletions(-) create mode 100644 src/basic/app/components/NotificationList.tsx create mode 100644 src/basic/shared/components/ui/Notification.tsx delete mode 100644 src/basic/shared/components/ui/NotificationList.tsx diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx index 019f826e..c8168a11 100644 --- a/src/basic/app/App.tsx +++ b/src/basic/app/App.tsx @@ -17,14 +17,8 @@ import { type ProductWithUI, useProductActions } from "../domains/product"; -import { - NotificationList, - useDebounceState, - useLocalStorageState, - useNotifications, - useToggle -} from "../shared"; -import { Header } from "./components"; +import { useDebounceState, useLocalStorageState, useNotifications, useToggle } from "../shared"; +import { Header, NotificationList } from "./components"; import { AdminPage, CartPage } from "./pages"; export function App() { diff --git a/src/basic/app/components/NotificationList.tsx b/src/basic/app/components/NotificationList.tsx new file mode 100644 index 00000000..dae2e7ff --- /dev/null +++ b/src/basic/app/components/NotificationList.tsx @@ -0,0 +1,20 @@ +import { Notification, type NotificationItem } from "../../shared"; + +interface NotificationListProps { + notifications: NotificationItem[]; + onRemove: (id: string) => void; +} + +export function NotificationList({ notifications, onRemove }: NotificationListProps) { + if (notifications.length === 0) { + return null; + } + + return ( +
+ {notifications.map((notif) => ( + + ))} +
+ ); +} diff --git a/src/basic/app/components/index.ts b/src/basic/app/components/index.ts index 9e08a64d..11530f66 100644 --- a/src/basic/app/components/index.ts +++ b/src/basic/app/components/index.ts @@ -1 +1,2 @@ export * from "./Header"; +export * from "./NotificationList"; diff --git a/src/basic/shared/components/ui/Notification.tsx b/src/basic/shared/components/ui/Notification.tsx new file mode 100644 index 00000000..7a8f5e60 --- /dev/null +++ b/src/basic/shared/components/ui/Notification.tsx @@ -0,0 +1,36 @@ +import { tv } from "tailwind-variants"; + +import type { NotificationItem } from "../../types"; +import { CloseIcon } from "../icons"; + +type NotificationProps = NotificationItem & { + onRemove: (id: string) => void; + className?: string; +}; + +const notification = tv({ + base: "flex items-center justify-between rounded-md p-4 text-white shadow-md", + variants: { + intent: { + success: "bg-green-600", + warning: "bg-yellow-600", + error: "bg-red-600" + } + }, + defaultVariants: { + intent: "success" + } +}); + +export function Notification({ id, message, type, onRemove, className }: NotificationProps) { + const handleRemove = () => onRemove(id); + + return ( +
+ {message} + +
+ ); +} diff --git a/src/basic/shared/components/ui/NotificationList.tsx b/src/basic/shared/components/ui/NotificationList.tsx deleted file mode 100644 index d18248df..00000000 --- a/src/basic/shared/components/ui/NotificationList.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { Notification } from "../../types"; -import { Button } from "./Button"; - -interface NotificationListProps { - notifications: Notification[]; - onRemove: (id: string) => void; -} - -export function NotificationList({ notifications, onRemove }: NotificationListProps) { - if (notifications.length === 0) { - return null; - } - - return ( -
- {notifications.map((notif) => ( -
- {notif.message} - -
- ))} -
- ); -} diff --git a/src/basic/shared/components/ui/index.ts b/src/basic/shared/components/ui/index.ts index f19e5fab..7a06b0f1 100644 --- a/src/basic/shared/components/ui/index.ts +++ b/src/basic/shared/components/ui/index.ts @@ -1,4 +1,4 @@ export * from "./BadgeContainer"; export * from "./Button"; -export * from "./NotificationList"; +export * from "./Notification"; export * from "./SearchInput"; diff --git a/src/basic/shared/types/notification.ts b/src/basic/shared/types/notification.ts index 9f97fcbb..928e15b6 100644 --- a/src/basic/shared/types/notification.ts +++ b/src/basic/shared/types/notification.ts @@ -1,4 +1,4 @@ -export interface Notification { +export interface NotificationItem { id: string; message: string; type: "error" | "success" | "warning"; From efd455296ef595ef919525f7a1215638c464684b Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 10:04:26 +0900 Subject: [PATCH 23/35] =?UTF-8?q?refactor:=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=B3=84=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC=EB=A1=9C=20?= =?UTF-8?q?App.tsx=20=EB=B3=B5=EC=9E=A1=EB=8F=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/App.tsx | 104 ++---------- src/basic/app/components/NotificationList.tsx | 2 +- src/basic/app/pages/AdminPage.tsx | 152 ++++++++++-------- src/basic/app/pages/CartPage.tsx | 12 +- 4 files changed, 103 insertions(+), 167 deletions(-) diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx index c8168a11..d6285030 100644 --- a/src/basic/app/App.tsx +++ b/src/basic/app/App.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { calculateCartTotal, @@ -9,13 +9,10 @@ import { } from "../domains/cart"; import { type Coupon, INITIAL_COUPONS, useCouponActions } from "../domains/coupon"; import { - filterProducts, formatPrice, INITIAL_PRODUCTS, type Product, - type ProductForm, - type ProductWithUI, - useProductActions + type ProductWithUI } from "../domains/product"; import { useDebounceState, useLocalStorageState, useNotifications, useToggle } from "../shared"; import { Header, NotificationList } from "./components"; @@ -47,26 +44,6 @@ export function App() { const [isAdminMode, toggleAdminMode] = useToggle(false); const [selectedCoupon, setSelectedCoupon] = useState(null); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); - const [showProductForm, setShowProductForm] = useState(false); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: "", - price: 0, - stock: 0, - description: "", - discounts: [] - }); - - const [couponForm, setCouponForm] = useState({ - name: "", - code: "", - discountType: "amount", - discountValue: 0 - }); const formatPriceWithContext = useCallback( (price: number, productId?: string) => { @@ -77,7 +54,6 @@ export function App() { const [totalItemCount, setTotalItemCount] = useState(0); - // Cart actions using domain hook const { addToCart, removeFromCart, updateQuantity, completeOrder } = useCartActions({ cart, products, @@ -86,12 +62,7 @@ export function App() { addNotification }); - // Coupon actions using domain hook - const { - deleteCoupon, - applyCoupon: applyCouponBase, - handleCouponSubmit - } = useCouponActions({ + const { applyCoupon: applyCouponBase } = useCouponActions({ coupons, selectedCoupon, setCoupons, @@ -99,7 +70,6 @@ export function App() { addNotification }); - // Wrapper for applyCoupon with cart total calculation const applyCoupon = useCallback( (coupon: Coupon) => { const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; @@ -108,51 +78,11 @@ export function App() { [applyCouponBase, cart, selectedCoupon] ); - // Product actions using domain hook - const { deleteProduct, handleProductSubmit, startEditProduct } = useProductActions({ - setProducts, - addNotification - }); - useEffect(() => { const count = cart.reduce((sum, item) => sum + item.quantity, 0); setTotalItemCount(count); }, [cart]); - const handleProductSubmitWrapper = (e: FormEvent) => { - e.preventDefault(); - handleProductSubmit( - productForm, - editingProduct, - () => setProductForm({ name: "", price: 0, stock: 0, description: "", discounts: [] }), - setEditingProduct, - setShowProductForm - ); - }; - - const handleCouponSubmitWrapper = (e: FormEvent) => { - e.preventDefault(); - handleCouponSubmit( - couponForm, - () => - setCouponForm({ - name: "", - code: "", - discountType: "amount", - discountValue: 0 - }), - setShowCouponForm - ); - }; - - const startEditProductWrapper = (product: ProductWithUI) => { - startEditProduct(product, setEditingProduct, setProductForm, setShowProductForm); - }; - - const totals = calculateCartTotal(cart, selectedCoupon); - - const filteredProducts = filterProducts(products, debouncedSearchTerm); - return (
{isAdminMode ? ( ) : ( getRemainingStock(product, cart)} products={products} removeFromCart={removeFromCart} selectedCoupon={selectedCoupon} setSelectedCoupon={setSelectedCoupon} - totals={totals} updateQuantity={updateQuantity} /> )} diff --git a/src/basic/app/components/NotificationList.tsx b/src/basic/app/components/NotificationList.tsx index dae2e7ff..52b37c84 100644 --- a/src/basic/app/components/NotificationList.tsx +++ b/src/basic/app/components/NotificationList.tsx @@ -13,7 +13,7 @@ export function NotificationList({ notifications, onRemove }: NotificationListPr return (
{notifications.map((notif) => ( - + ))}
); diff --git a/src/basic/app/pages/AdminPage.tsx b/src/basic/app/pages/AdminPage.tsx index 1caa6052..1161d91b 100644 --- a/src/basic/app/pages/AdminPage.tsx +++ b/src/basic/app/pages/AdminPage.tsx @@ -1,75 +1,99 @@ -import { FormEvent } from "react"; +import { type Dispatch, FormEvent, type SetStateAction, useState } from "react"; -import type { Coupon, Product } from "../../../types"; +import type { CartItem, Coupon } from "../../../types"; +import { useCouponActions } from "../../domains/coupon"; +import { + formatPrice, + type ProductForm, + type ProductWithUI, + useProductActions +} from "../../domains/product"; import { Button, CloseIcon, PlusIcon, SearchInput, TrashIcon } from "../../shared"; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -type ProductForm = { - name: string; - price: number; - stock: number; - description: string; - discounts: Array<{ quantity: number; rate: number }>; -}; - -type CouponForm = { - name: string; - code: string; - discountType: "amount" | "percentage"; - discountValue: number; -}; - type AdminPageProps = { - setActiveTab: (tab: "products" | "coupons") => void; - activeTab: "products" | "coupons"; - setEditingProduct: (id: string | null) => void; - setProductForm: (form: ProductForm) => void; - setShowProductForm: (show: boolean) => void; products: ProductWithUI[]; - formatPrice: (price: number, productId?: string) => string; - startEditProduct: (product: ProductWithUI) => void; - deleteProduct: (productId: string) => void; - showProductForm: boolean; - handleProductSubmit: (e: FormEvent) => void; - editingProduct: string | null; - productForm: ProductForm; - addNotification: (message: string, type?: "error" | "success" | "warning") => void; + setProducts: Dispatch>; coupons: Coupon[]; - deleteCoupon: (couponCode: string) => void; - setShowCouponForm: (show: boolean) => void; - showCouponForm: boolean; - handleCouponSubmit: (e: FormEvent) => void; - couponForm: CouponForm; - setCouponForm: (form: CouponForm) => void; + setCoupons: Dispatch>; + cart: CartItem[]; + isAdminMode: boolean; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; }; export function AdminPage({ - setProductForm, - setShowProductForm, products, - formatPrice, - startEditProduct, - deleteProduct, - showProductForm, - handleProductSubmit, - editingProduct, - productForm, - addNotification, + setProducts, coupons, - deleteCoupon, - setShowCouponForm, - showCouponForm, - handleCouponSubmit, - couponForm, - setCouponForm, - activeTab, - setActiveTab, - setEditingProduct + setCoupons, + cart, + isAdminMode, + addNotification }: AdminPageProps) { + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); + const [showProductForm, setShowProductForm] = useState(false); + const [showCouponForm, setShowCouponForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] + }); + const [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0 + }); + + const { deleteProduct, handleProductSubmit, startEditProduct } = useProductActions({ + setProducts, + addNotification + }); + + const { deleteCoupon, handleCouponSubmit } = useCouponActions({ + coupons, + selectedCoupon: null, + setCoupons, + setSelectedCoupon: () => {}, + addNotification + }); + + const formatPriceWithContext = (price: number, productId?: string) => { + return formatPrice(price, productId, products, cart, isAdminMode); + }; + + const handleProductSubmitWrapper = (e: FormEvent) => { + e.preventDefault(); + handleProductSubmit( + productForm, + editingProduct, + () => setProductForm({ name: "", price: 0, stock: 0, description: "", discounts: [] }), + setEditingProduct, + setShowProductForm + ); + }; + + const handleCouponSubmitWrapper = (e: FormEvent) => { + e.preventDefault(); + handleCouponSubmit( + couponForm, + () => + setCouponForm({ + name: "", + code: "", + discountType: "amount", + discountValue: 0 + }), + setShowCouponForm + ); + }; + + const startEditProductWrapper = (product: ProductWithUI) => { + startEditProduct(product, setEditingProduct, setProductForm, setShowProductForm); + }; + return (
@@ -154,7 +178,7 @@ export function AdminPage({ {product.name} - {formatPrice(product.price, product.id)} + {formatPriceWithContext(product.price, product.id)}
{showProductForm && (
-
+

{editingProduct === "new" ? "새 상품 추가" : "상품 수정"}

@@ -417,7 +441,7 @@ export function AdminPage({ {showCouponForm && (
- +

새 쿠폰 생성

diff --git a/src/basic/app/pages/CartPage.tsx b/src/basic/app/pages/CartPage.tsx index a729475e..20935a9e 100644 --- a/src/basic/app/pages/CartPage.tsx +++ b/src/basic/app/pages/CartPage.tsx @@ -1,4 +1,6 @@ import type { CartItem, Coupon, Product } from "../../../types"; +import { calculateCartTotal } from "../../domains/cart"; +import { filterProducts } from "../../domains/product"; import { Button, CloseIcon, ImagePlaceholderIcon, ShoppingBagIcon } from "../../shared"; interface ProductWithUI extends Product { @@ -8,7 +10,6 @@ interface ProductWithUI extends Product { type CartPageProps = { products: ProductWithUI[]; - filteredProducts: ProductWithUI[]; debouncedSearchTerm: string; getRemainingStock: (product: Product) => number; formatPrice: (price: number, productId?: string) => string; @@ -20,10 +21,6 @@ type CartPageProps = { selectedCoupon: Coupon | null; applyCoupon: (coupon: Coupon) => void; setSelectedCoupon: (coupon: Coupon | null) => void; - totals: { - totalBeforeDiscount: number; - totalAfterDiscount: number; - }; completeOrder: () => void; removeFromCart: (productId: string) => void; }; @@ -36,16 +33,17 @@ export function CartPage({ completeOrder, coupons, debouncedSearchTerm, - filteredProducts, formatPrice, getRemainingStock, products, selectedCoupon, setSelectedCoupon, - totals, updateQuantity, removeFromCart }: CartPageProps) { + const filteredProducts = filterProducts(products, debouncedSearchTerm); + const totals = calculateCartTotal(cart, selectedCoupon); + return (
From eda3a152b91f4e05b695d615fd812e0a548cd3d4 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 10:57:56 +0900 Subject: [PATCH 24/35] =?UTF-8?q?feat:=20CartPage=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/App.tsx | 2 - src/basic/app/pages/CartPage.tsx | 252 ++---------------- .../cart/components/CartItemHeader.tsx | 22 ++ .../domains/cart/components/CartItemInfo.tsx | 42 +++ .../domains/cart/components/CartItemList.tsx | 42 +++ .../domains/cart/components/CartSidebar.tsx | 55 ++++ .../cart/components/CheckoutButton.tsx | 20 ++ .../cart/components/CouponSelector.tsx | 50 ++++ .../cart/components/EmptyCartMessage.tsx | 10 + .../domains/cart/components/ItemPricing.tsx | 18 ++ .../cart/components/PaymentInfoLine.tsx | 46 ++++ .../cart/components/PaymentSummary.tsx | 47 ++++ .../cart/components/QuantitySelector.tsx | 33 +++ src/basic/domains/cart/components/index.ts | 10 + src/basic/domains/cart/index.ts | 1 + .../product/components/EmptySearchResult.tsx | 11 + .../product/components/ProductCard.tsx | 51 ++++ .../product/components/ProductImage.tsx | 30 +++ .../product/components/ProductInfo.tsx | 13 + .../product/components/ProductList.tsx | 54 ++++ .../product/components/ProductPricing.tsx | 19 ++ .../product/components/StockStatus.tsx | 30 +++ src/basic/domains/product/components/index.ts | 7 + src/basic/domains/product/index.ts | 1 + 24 files changed, 634 insertions(+), 232 deletions(-) create mode 100644 src/basic/domains/cart/components/CartItemHeader.tsx create mode 100644 src/basic/domains/cart/components/CartItemInfo.tsx create mode 100644 src/basic/domains/cart/components/CartItemList.tsx create mode 100644 src/basic/domains/cart/components/CartSidebar.tsx create mode 100644 src/basic/domains/cart/components/CheckoutButton.tsx create mode 100644 src/basic/domains/cart/components/CouponSelector.tsx create mode 100644 src/basic/domains/cart/components/EmptyCartMessage.tsx create mode 100644 src/basic/domains/cart/components/ItemPricing.tsx create mode 100644 src/basic/domains/cart/components/PaymentInfoLine.tsx create mode 100644 src/basic/domains/cart/components/PaymentSummary.tsx create mode 100644 src/basic/domains/cart/components/QuantitySelector.tsx create mode 100644 src/basic/domains/cart/components/index.ts create mode 100644 src/basic/domains/product/components/EmptySearchResult.tsx create mode 100644 src/basic/domains/product/components/ProductCard.tsx create mode 100644 src/basic/domains/product/components/ProductImage.tsx create mode 100644 src/basic/domains/product/components/ProductInfo.tsx create mode 100644 src/basic/domains/product/components/ProductList.tsx create mode 100644 src/basic/domains/product/components/ProductPricing.tsx create mode 100644 src/basic/domains/product/components/StockStatus.tsx create mode 100644 src/basic/domains/product/components/index.ts diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx index d6285030..bee2d05c 100644 --- a/src/basic/app/App.tsx +++ b/src/basic/app/App.tsx @@ -93,7 +93,6 @@ export function App() { setSearchTerm={setSearchTerm} totalItemCount={totalItemCount} /> -
{isAdminMode ? ( )}
-
); diff --git a/src/basic/app/pages/CartPage.tsx b/src/basic/app/pages/CartPage.tsx index 20935a9e..86daf261 100644 --- a/src/basic/app/pages/CartPage.tsx +++ b/src/basic/app/pages/CartPage.tsx @@ -1,7 +1,6 @@ import type { CartItem, Coupon, Product } from "../../../types"; -import { calculateCartTotal } from "../../domains/cart"; -import { filterProducts } from "../../domains/product"; -import { Button, CloseIcon, ImagePlaceholderIcon, ShoppingBagIcon } from "../../shared"; +import { calculateCartTotal, CartSidebar } from "../../domains/cart"; +import { filterProducts, ProductList } from "../../domains/product"; interface ProductWithUI extends Product { description?: string; @@ -47,235 +46,28 @@ export function CartPage({ return (
- {/* 상품 목록 */} -
-
-

전체 상품

-
총 {products.length}개 상품
-
- {filteredProducts.length === 0 ? ( -
-

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

-
- ) : ( -
- {filteredProducts.map((product) => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- -
- - {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

- {product.description} -

- )} - - {/* 가격 정보 */} -
-

- {formatPrice(product.price, product.id)} -

- {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인{" "} - {product.discounts[0].rate * 100}% -

- )} -
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

- 품절임박! {remainingStock}개 남음 -

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

- )} -
- - {/* 장바구니 버튼 */} - -
-
- ); - })} -
- )} -
+
-
-
-
-

- - 장바구니 -

- {cart.length === 0 ? ( -
- -

장바구니가 비어있습니다

-
- ) : ( -
- {cart.map((item) => { - const itemTotal = calculateItemTotal(item); - const originalPrice = item.product.price * item.quantity; - const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount - ? Math.round((1 - itemTotal / originalPrice) * 100) - : 0; - - return ( -
-
-

- {item.product.name} -

- -
-
-
- - - {item.quantity} - - -
-
- {hasDiscount && ( - - -{discountRate}% - - )} -

- {Math.round(itemTotal).toLocaleString()}원 -

-
-
-
- ); - })} -
- )} -
- - {cart.length > 0 && ( - <> -
-
-

쿠폰 할인

- -
- {coupons.length > 0 && ( - - )} -
- -
-

결제 정보

-
-
- 상품 금액 - - {totals.totalBeforeDiscount.toLocaleString()}원 - -
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( -
- 할인 금액 - - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()} - 원 - -
- )} -
- 결제 예정 금액 - - {totals.totalAfterDiscount.toLocaleString()}원 - -
-
- - - -
-

* 실제 결제는 이루어지지 않습니다

-
-
- - )} -
+
); diff --git a/src/basic/domains/cart/components/CartItemHeader.tsx b/src/basic/domains/cart/components/CartItemHeader.tsx new file mode 100644 index 00000000..305cfd30 --- /dev/null +++ b/src/basic/domains/cart/components/CartItemHeader.tsx @@ -0,0 +1,22 @@ +import { CloseIcon } from "../../../shared"; + +type CartItemHeaderProps = { + productName: string; + productId: string; + onRemove: (productId: string) => void; +}; + +export function CartItemHeader({ productName, productId, onRemove }: CartItemHeaderProps) { + const handleRemove = () => { + onRemove(productId); + }; + + return ( +
+

{productName}

+ +
+ ); +} diff --git a/src/basic/domains/cart/components/CartItemInfo.tsx b/src/basic/domains/cart/components/CartItemInfo.tsx new file mode 100644 index 00000000..68cd6dd9 --- /dev/null +++ b/src/basic/domains/cart/components/CartItemInfo.tsx @@ -0,0 +1,42 @@ +import { CartItem } from "../types"; +import { CartItemHeader } from "./CartItemHeader"; +import { ItemPricing } from "./ItemPricing"; +import { QuantitySelector } from "./QuantitySelector"; + +type CartItemInfoProps = { + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + item: CartItem; + itemTotal: number; +}; + +export function CartItemInfo({ + updateQuantity, + removeFromCart, + item, + itemTotal +}: CartItemInfoProps) { + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; + + return ( +
+ + +
+ + + +
+
+ ); +} diff --git a/src/basic/domains/cart/components/CartItemList.tsx b/src/basic/domains/cart/components/CartItemList.tsx new file mode 100644 index 00000000..ba0d2e7d --- /dev/null +++ b/src/basic/domains/cart/components/CartItemList.tsx @@ -0,0 +1,42 @@ +import { ShoppingBagIcon } from "../../../shared"; +import type { CartItem } from "../types"; +import { CartItemInfo } from "./CartItemInfo"; +import { EmptyCartMessage } from "./EmptyCartMessage"; + +type CartItemListProps = { + cart: CartItem[]; + calculateItemTotal: (item: CartItem) => number; + updateQuantity: (productId: string, newQuantity: number) => void; + removeFromCart: (productId: string) => void; +}; + +export function CartItemList({ + cart, + calculateItemTotal, + updateQuantity, + removeFromCart +}: CartItemListProps) { + return ( +
+

+ + 장바구니 +

+ {cart.length === 0 ? ( + + ) : ( +
+ {cart.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/basic/domains/cart/components/CartSidebar.tsx b/src/basic/domains/cart/components/CartSidebar.tsx new file mode 100644 index 00000000..ecefa997 --- /dev/null +++ b/src/basic/domains/cart/components/CartSidebar.tsx @@ -0,0 +1,55 @@ +import type { CartItem, Coupon } from "../../../../types"; +import { CartItemList } from "./CartItemList"; +import { CouponSelector } from "./CouponSelector"; +import { PaymentSummary } from "./PaymentSummary"; + +type CartSidebarProps = { + cart: CartItem[]; + calculateItemTotal: (item: CartItem) => number; + updateQuantity: (productId: string, newQuantity: number) => void; + removeFromCart: (productId: string) => void; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + completeOrder: () => void; +}; + +export function CartSidebar({ + cart, + calculateItemTotal, + updateQuantity, + removeFromCart, + coupons, + selectedCoupon, + applyCoupon, + setSelectedCoupon, + totals, + completeOrder +}: CartSidebarProps) { + return ( +
+ + {cart.length > 0 && ( + <> + + + + )} +
+ ); +} diff --git a/src/basic/domains/cart/components/CheckoutButton.tsx b/src/basic/domains/cart/components/CheckoutButton.tsx new file mode 100644 index 00000000..bcf1f6eb --- /dev/null +++ b/src/basic/domains/cart/components/CheckoutButton.tsx @@ -0,0 +1,20 @@ +import { Button } from "../../../shared"; + +type CheckoutButtonProps = { + totalAmount: number; + onCompleteOrder: () => void; +}; + +export function CheckoutButton({ totalAmount, onCompleteOrder }: CheckoutButtonProps) { + return ( + <> + + +
+

* 실제 결제는 이루어지지 않습니다

+
+ + ); +} diff --git a/src/basic/domains/cart/components/CouponSelector.tsx b/src/basic/domains/cart/components/CouponSelector.tsx new file mode 100644 index 00000000..29a0f45b --- /dev/null +++ b/src/basic/domains/cart/components/CouponSelector.tsx @@ -0,0 +1,50 @@ +import type { ChangeEvent } from "react"; + +import type { Coupon } from "../../../../types"; + +type CouponSelectorProps = { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; +}; + +export function CouponSelector({ + coupons, + selectedCoupon, + applyCoupon, + setSelectedCoupon +}: CouponSelectorProps) { + const handleCouponChange = (event: ChangeEvent) => { + const coupon = coupons.find((coupon) => coupon.code === event.target.value); + if (coupon) applyCoupon(coupon); + else setSelectedCoupon(null); + }; + + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/basic/domains/cart/components/EmptyCartMessage.tsx b/src/basic/domains/cart/components/EmptyCartMessage.tsx new file mode 100644 index 00000000..c227234c --- /dev/null +++ b/src/basic/domains/cart/components/EmptyCartMessage.tsx @@ -0,0 +1,10 @@ +import { ShoppingBagIcon } from "../../../shared"; + +export function EmptyCartMessage() { + return ( +
+ +

장바구니가 비어있습니다

+
+ ); +} diff --git a/src/basic/domains/cart/components/ItemPricing.tsx b/src/basic/domains/cart/components/ItemPricing.tsx new file mode 100644 index 00000000..4e7e3c27 --- /dev/null +++ b/src/basic/domains/cart/components/ItemPricing.tsx @@ -0,0 +1,18 @@ +type ItemPricingProps = { + itemTotal: number; + hasDiscount: boolean; + discountRate: number; +}; + +export function ItemPricing({ itemTotal, hasDiscount, discountRate }: ItemPricingProps) { + return ( +
+ {hasDiscount && ( + -{discountRate}% + )} +

+ {Math.round(itemTotal).toLocaleString()}원 +

+
+ ); +} diff --git a/src/basic/domains/cart/components/PaymentInfoLine.tsx b/src/basic/domains/cart/components/PaymentInfoLine.tsx new file mode 100644 index 00000000..4a80502e --- /dev/null +++ b/src/basic/domains/cart/components/PaymentInfoLine.tsx @@ -0,0 +1,46 @@ +import { tv } from "tailwind-variants"; + +const paymentInfoLine = tv({ + slots: { + container: "flex justify-between", + label: "", + value: "" + }, + variants: { + variant: { + default: { + label: "text-gray-600", + value: "font-medium" + }, + highlighted: { + label: "text-red-500", + value: "" + }, + total: { + container: "border-t border-gray-200 py-2", + label: "font-semibold", + value: "text-lg font-bold text-gray-900" + } + } + }, + defaultVariants: { + variant: "default" + } +}); + +type PaymentInfoLineProps = { + label: string; + value: string; + variant?: "default" | "highlighted" | "total"; +}; + +export function PaymentInfoLine({ label, value, variant = "default" }: PaymentInfoLineProps) { + const { container, label: labelClass, value: valueClass } = paymentInfoLine({ variant }); + + return ( +
+ {label} + {value} +
+ ); +} diff --git a/src/basic/domains/cart/components/PaymentSummary.tsx b/src/basic/domains/cart/components/PaymentSummary.tsx new file mode 100644 index 00000000..dc9cd361 --- /dev/null +++ b/src/basic/domains/cart/components/PaymentSummary.tsx @@ -0,0 +1,47 @@ +import { CheckoutButton } from "./CheckoutButton"; +import { PaymentInfoLine } from "./PaymentInfoLine"; + +type PaymentSummaryProps = { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + completeOrder: () => void; +}; + +export function PaymentSummary({ totals, completeOrder }: PaymentSummaryProps) { + const handleCompleteOrder = () => { + completeOrder(); + }; + + const discountAmount = totals.totalBeforeDiscount - totals.totalAfterDiscount; + + return ( +
+

결제 정보

+
+ + {discountAmount > 0 && ( + + )} + +
+ + +
+ ); +} diff --git a/src/basic/domains/cart/components/QuantitySelector.tsx b/src/basic/domains/cart/components/QuantitySelector.tsx new file mode 100644 index 00000000..ca52771e --- /dev/null +++ b/src/basic/domains/cart/components/QuantitySelector.tsx @@ -0,0 +1,33 @@ +type QuantitySelectorProps = { + quantity: number; + productId: string; + onUpdateQuantity: (productId: string, newQuantity: number) => void; +}; + +export function QuantitySelector({ quantity, productId, onUpdateQuantity }: QuantitySelectorProps) { + const handleDecrease = () => { + onUpdateQuantity(productId, quantity - 1); + }; + + const handleIncrease = () => { + onUpdateQuantity(productId, quantity + 1); + }; + + return ( +
+ + {quantity} + +
+ ); +} diff --git a/src/basic/domains/cart/components/index.ts b/src/basic/domains/cart/components/index.ts new file mode 100644 index 00000000..1aeee378 --- /dev/null +++ b/src/basic/domains/cart/components/index.ts @@ -0,0 +1,10 @@ +export * from "./CartItemHeader"; +export * from "./CartItemList"; +export * from "./CartSidebar"; +export * from "./CheckoutButton"; +export * from "./CouponSelector"; +export * from "./EmptyCartMessage"; +export * from "./ItemPricing"; +export * from "./PaymentInfoLine"; +export * from "./PaymentSummary"; +export * from "./QuantitySelector"; diff --git a/src/basic/domains/cart/index.ts b/src/basic/domains/cart/index.ts index 51bffbcd..057ddce2 100644 --- a/src/basic/domains/cart/index.ts +++ b/src/basic/domains/cart/index.ts @@ -1,3 +1,4 @@ +export * from "./components"; export * from "./hooks"; export * from "./types"; export * from "./utils"; diff --git a/src/basic/domains/product/components/EmptySearchResult.tsx b/src/basic/domains/product/components/EmptySearchResult.tsx new file mode 100644 index 00000000..da3de347 --- /dev/null +++ b/src/basic/domains/product/components/EmptySearchResult.tsx @@ -0,0 +1,11 @@ +type EmptySearchResultProps = { + searchTerm: string; +}; + +export function EmptySearchResult({ searchTerm }: EmptySearchResultProps) { + return ( +
+

"{searchTerm}"에 대한 검색 결과가 없습니다.

+
+ ); +} diff --git a/src/basic/domains/product/components/ProductCard.tsx b/src/basic/domains/product/components/ProductCard.tsx new file mode 100644 index 00000000..7d320d8a --- /dev/null +++ b/src/basic/domains/product/components/ProductCard.tsx @@ -0,0 +1,51 @@ +import type { Product } from "../../../../types"; +import { Button } from "../../../shared"; +import { ProductImage } from "./ProductImage"; +import { ProductInfo } from "./ProductInfo"; +import { ProductPricing } from "./ProductPricing"; +import { StockStatus } from "./StockStatus"; + +type ProductWithUI = Product & { + description?: string; + isRecommended?: boolean; +}; + +type ProductCardProps = { + product: ProductWithUI; + remainingStock: number; + formatPrice: (price: number, productId?: string) => string; + onAddToCart: (product: ProductWithUI) => void; +}; + +export function ProductCard({ + product, + remainingStock, + formatPrice, + onAddToCart +}: ProductCardProps) { + const handleAddToCart = () => { + onAddToCart(product); + }; + + return ( +
+ + +
+ + +
+ +
+ +
+
+ ); +} diff --git a/src/basic/domains/product/components/ProductImage.tsx b/src/basic/domains/product/components/ProductImage.tsx new file mode 100644 index 00000000..9266e1d6 --- /dev/null +++ b/src/basic/domains/product/components/ProductImage.tsx @@ -0,0 +1,30 @@ +import type { Product } from "../../../../types"; +import { ImagePlaceholderIcon } from "../../../shared"; + +type ProductImageProps = { + product: Product & { isRecommended?: boolean }; +}; + +export function ProductImage({ product }: ProductImageProps) { + const maxDiscountRate = + product.discounts.length > 0 ? Math.max(...product.discounts.map((d) => d.rate)) * 100 : 0; + + return ( +
+
+ +
+ + {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{maxDiscountRate}% + + )} +
+ ); +} diff --git a/src/basic/domains/product/components/ProductInfo.tsx b/src/basic/domains/product/components/ProductInfo.tsx new file mode 100644 index 00000000..f588e106 --- /dev/null +++ b/src/basic/domains/product/components/ProductInfo.tsx @@ -0,0 +1,13 @@ +type ProductInfoProps = { + name: string; + description?: string; +}; + +export function ProductInfo({ name, description }: ProductInfoProps) { + return ( + <> +

{name}

+ {description &&

{description}

} + + ); +} diff --git a/src/basic/domains/product/components/ProductList.tsx b/src/basic/domains/product/components/ProductList.tsx new file mode 100644 index 00000000..3160e40d --- /dev/null +++ b/src/basic/domains/product/components/ProductList.tsx @@ -0,0 +1,54 @@ +import type { Product } from "../../../../types"; +import { EmptySearchResult } from "./EmptySearchResult"; +import { ProductCard } from "./ProductCard"; + +type ProductWithUI = Product & { + description?: string; + isRecommended?: boolean; +}; + +type ProductListProps = { + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + getRemainingStock: (product: Product) => number; + formatPrice: (price: number, productId?: string) => string; + addToCart: (product: ProductWithUI) => void; +}; + +export function ProductList({ + products, + filteredProducts, + debouncedSearchTerm, + getRemainingStock, + formatPrice, + addToCart +}: ProductListProps) { + const handleAddToCart = (product: ProductWithUI) => { + addToCart(product); + }; + + return ( +
+
+

전체 상품

+
총 {products.length}개 상품
+
+ {filteredProducts.length === 0 ? ( + + ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/basic/domains/product/components/ProductPricing.tsx b/src/basic/domains/product/components/ProductPricing.tsx new file mode 100644 index 00000000..55879526 --- /dev/null +++ b/src/basic/domains/product/components/ProductPricing.tsx @@ -0,0 +1,19 @@ +import type { Product } from "../../../../types"; + +type ProductPricingProps = { + product: Product; + formatPrice: (price: number, productId?: string) => string; +}; + +export function ProductPricing({ product, formatPrice }: ProductPricingProps) { + return ( +
+

{formatPrice(product.price, product.id)}

+ {product.discounts.length > 0 && ( +

+ {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% +

+ )} +
+ ); +} diff --git a/src/basic/domains/product/components/StockStatus.tsx b/src/basic/domains/product/components/StockStatus.tsx new file mode 100644 index 00000000..6d22fbd3 --- /dev/null +++ b/src/basic/domains/product/components/StockStatus.tsx @@ -0,0 +1,30 @@ +import { tv } from "tailwind-variants"; + +type StockStatusProps = { + remainingStock: number; + className?: string; +}; + +const LOW_STOCK_THRESHOLD = 5; + +const stockStatusText = tv({ + base: "text-xs", + variants: { + tone: { + normal: "text-gray-500", + low: "font-medium text-red-600" + } + }, + defaultVariants: { + tone: "normal" + } +}); + +export function StockStatus({ remainingStock, className }: StockStatusProps) { + if (remainingStock <= 0) return null; + + const tone = remainingStock <= LOW_STOCK_THRESHOLD ? "low" : "normal"; + const text = tone === "low" ? `품절임박! ${remainingStock}개 남음` : `재고 ${remainingStock}개`; + + return

{text}

; +} diff --git a/src/basic/domains/product/components/index.ts b/src/basic/domains/product/components/index.ts new file mode 100644 index 00000000..02b5f5f6 --- /dev/null +++ b/src/basic/domains/product/components/index.ts @@ -0,0 +1,7 @@ +export * from "./EmptySearchResult"; +export * from "./ProductCard"; +export * from "./ProductImage"; +export * from "./ProductInfo"; +export * from "./ProductList"; +export * from "./ProductPricing"; +export * from "./StockStatus"; diff --git a/src/basic/domains/product/index.ts b/src/basic/domains/product/index.ts index 4909c233..89e93b4a 100644 --- a/src/basic/domains/product/index.ts +++ b/src/basic/domains/product/index.ts @@ -1,3 +1,4 @@ +export * from "./components"; export * from "./constants"; export * from "./hooks"; export * from "./models"; From 9466d7b806a318c62f577accfbf856e23b467c01 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 13:25:04 +0900 Subject: [PATCH 25/35] =?UTF-8?q?feat:=20AdminPage=EC=99=80=20CartPage=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20DDD=20=EA=B5=AC=EC=A1=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/App.tsx | 34 +- src/basic/app/components/AdminTabs.tsx | 45 ++ .../components/CouponManagementSection.tsx | 60 +++ src/basic/app/components/Header.tsx | 8 +- .../components/ProductManagementSection.tsx | 70 +++ src/basic/app/components/index.ts | 4 + src/basic/app/pages/AdminPage.tsx | 506 +++--------------- .../domains/cart/components/CartItemInfo.tsx | 12 +- .../domains/cart/components/CartItemList.tsx | 6 +- .../cart/components/EmptyCartMessage.tsx | 10 - .../domains/cart/components/ItemPricing.tsx | 18 - src/basic/domains/cart/components/index.ts | 3 +- .../domains/cart/hooks/useCartActions.ts | 110 ++-- .../coupon/components/AddCouponCard.tsx | 19 + .../domains/coupon/components/CouponCard.tsx | 37 ++ .../domains/coupon/components/CouponForm.tsx | 146 +++++ src/basic/domains/coupon/components/index.ts | 3 + .../domains/coupon/hooks/useCouponActions.ts | 79 ++- src/basic/domains/coupon/index.ts | 1 + .../product/components/DiscountItem.tsx | 54 ++ .../product/components/DiscountSection.tsx | 48 ++ .../product/components/EmptySearchResult.tsx | 11 - .../product/components/ProductCard.tsx | 27 +- .../product/components/ProductFormEditor.tsx | 140 +++++ .../product/components/ProductImage.tsx | 4 +- .../product/components/ProductInfo.tsx | 13 - .../product/components/ProductList.tsx | 12 +- .../product/components/ProductPricing.tsx | 19 - .../product/components/ProductTable.tsx | 48 ++ .../product/components/ProductTableRow.tsx | 55 ++ .../domains/product/components/StockBadge.tsx | 22 + src/basic/domains/product/components/index.ts | 9 +- .../product/hooks/useProductActions.ts | 122 ++--- src/basic/domains/product/index.ts | 1 - src/basic/domains/product/models/index.ts | 1 - .../shared/components/ui/BadgeContainer.tsx | 2 +- .../shared/hooks/useLocalStorageState.ts | 6 +- src/basic/shared/hooks/useNotifications.ts | 27 +- 38 files changed, 1035 insertions(+), 757 deletions(-) create mode 100644 src/basic/app/components/AdminTabs.tsx create mode 100644 src/basic/app/components/CouponManagementSection.tsx create mode 100644 src/basic/app/components/ProductManagementSection.tsx delete mode 100644 src/basic/domains/cart/components/EmptyCartMessage.tsx delete mode 100644 src/basic/domains/cart/components/ItemPricing.tsx create mode 100644 src/basic/domains/coupon/components/AddCouponCard.tsx create mode 100644 src/basic/domains/coupon/components/CouponCard.tsx create mode 100644 src/basic/domains/coupon/components/CouponForm.tsx create mode 100644 src/basic/domains/coupon/components/index.ts create mode 100644 src/basic/domains/product/components/DiscountItem.tsx create mode 100644 src/basic/domains/product/components/DiscountSection.tsx delete mode 100644 src/basic/domains/product/components/EmptySearchResult.tsx create mode 100644 src/basic/domains/product/components/ProductFormEditor.tsx delete mode 100644 src/basic/domains/product/components/ProductInfo.tsx delete mode 100644 src/basic/domains/product/components/ProductPricing.tsx create mode 100644 src/basic/domains/product/components/ProductTable.tsx create mode 100644 src/basic/domains/product/components/ProductTableRow.tsx create mode 100644 src/basic/domains/product/components/StockBadge.tsx delete mode 100644 src/basic/domains/product/models/index.ts diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx index bee2d05c..b3fd8895 100644 --- a/src/basic/app/App.tsx +++ b/src/basic/app/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { calculateCartTotal, @@ -45,12 +45,9 @@ export function App() { const [selectedCoupon, setSelectedCoupon] = useState(null); - const formatPriceWithContext = useCallback( - (price: number, productId?: string) => { - return formatPrice(price, productId, products, cart, isAdminMode); - }, - [products, cart, isAdminMode] - ); + const formatPriceWithContext = (price: number, productId?: string) => { + return formatPrice(price, productId, products, cart, isAdminMode); + }; const [totalItemCount, setTotalItemCount] = useState(0); @@ -70,13 +67,18 @@ export function App() { addNotification }); - const applyCoupon = useCallback( - (coupon: Coupon) => { - const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; - applyCouponBase(coupon, currentTotal); - }, - [applyCouponBase, cart, selectedCoupon] - ); + const applyCoupon = (coupon: Coupon) => { + const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; + applyCouponBase(coupon, currentTotal); + }; + + const calculateItemTotalWithCart = (item: CartItem) => { + return calculateItemTotal(item, cart); + }; + + const getRemainingStockWithCart = (product: Product) => { + return getRemainingStock(product, cart); + }; useEffect(() => { const count = cart.reduce((sum, item) => sum + item.quantity, 0); @@ -108,13 +110,13 @@ export function App() { calculateItemTotal(item, cart)} + calculateItemTotal={calculateItemTotalWithCart} cart={cart} completeOrder={completeOrder} coupons={coupons} debouncedSearchTerm={debouncedSearchTerm} formatPrice={formatPriceWithContext} - getRemainingStock={(product: Product) => getRemainingStock(product, cart)} + getRemainingStock={getRemainingStockWithCart} products={products} removeFromCart={removeFromCart} selectedCoupon={selectedCoupon} diff --git a/src/basic/app/components/AdminTabs.tsx b/src/basic/app/components/AdminTabs.tsx new file mode 100644 index 00000000..f6655dc8 --- /dev/null +++ b/src/basic/app/components/AdminTabs.tsx @@ -0,0 +1,45 @@ +import { tv } from "tailwind-variants"; + +const tabButton = tv({ + base: "border-b-2 px-1 py-2 text-sm font-medium transition-colors", + variants: { + active: { + true: "border-gray-900 text-gray-900", + false: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" + } + } +}); + +type AdminTabsProps = { + activeTab: "products" | "coupons"; + onTabChange: (tab: "products" | "coupons") => void; +}; + +export function AdminTabs({ activeTab, onTabChange }: AdminTabsProps) { + const handleProductsTabClick = () => { + onTabChange("products"); + }; + + const handleCouponsTabClick = () => { + onTabChange("coupons"); + }; + + return ( +
+ +
+ ); +} diff --git a/src/basic/app/components/CouponManagementSection.tsx b/src/basic/app/components/CouponManagementSection.tsx new file mode 100644 index 00000000..6b2599ca --- /dev/null +++ b/src/basic/app/components/CouponManagementSection.tsx @@ -0,0 +1,60 @@ +import { type FormEvent } from "react"; + +import { AddCouponCard, type Coupon, CouponCard, CouponForm } from "../../domains/coupon"; + +type CouponFormType = { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +}; + +type CouponManagementSectionProps = { + coupons: Coupon[]; + couponForm: CouponFormType; + showCouponForm: boolean; + onToggleForm: () => void; + onDelete: (code: string) => void; + onFormSubmit: (e: FormEvent) => void; + onFormCancel: () => void; + onFormChange: (form: CouponFormType) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +}; + +export function CouponManagementSection({ + coupons, + couponForm, + showCouponForm, + onToggleForm, + onDelete, + onFormSubmit, + onFormCancel, + onFormChange, + addNotification +}: CouponManagementSectionProps) { + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( + + ))} + +
+ + {showCouponForm && ( + + )} +
+
+ ); +} diff --git a/src/basic/app/components/Header.tsx b/src/basic/app/components/Header.tsx index 3081948e..712dd1d7 100644 --- a/src/basic/app/components/Header.tsx +++ b/src/basic/app/components/Header.tsx @@ -1,3 +1,5 @@ +import { type ChangeEvent } from "react"; + import type { CartItem } from "../../domains/cart"; import { BadgeContainer, CartIcon, SearchInput } from "../../shared"; import { AdminToggleButton } from "./AdminToggleButton"; @@ -19,6 +21,10 @@ export function Header({ setSearchTerm, totalItemCount }: HeaderProps) { + const handleSearchChange = (e: ChangeEvent) => { + setSearchTerm(e.target.value); + }; + return (
@@ -31,7 +37,7 @@ export function Header({ setSearchTerm(e.target.value)} + onChange={handleSearchChange} placeholder="상품 검색..." color="blue" size="lg" diff --git a/src/basic/app/components/ProductManagementSection.tsx b/src/basic/app/components/ProductManagementSection.tsx new file mode 100644 index 00000000..7e146c16 --- /dev/null +++ b/src/basic/app/components/ProductManagementSection.tsx @@ -0,0 +1,70 @@ +import { type FormEvent } from "react"; + +import { + type ProductForm, + ProductFormEditor, + ProductTable, + type ProductWithUI +} from "../../domains/product"; +import { Button } from "../../shared"; + +type ProductManagementSectionProps = { + products: ProductWithUI[]; + productForm: ProductForm; + editingProduct: string | null; + showProductForm: boolean; + formatPrice: (price: number, productId?: string) => string; + onAddNew: () => void; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; + onFormSubmit: (e: FormEvent) => void; + onFormCancel: () => void; + onFormChange: (form: ProductForm) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +}; + +export function ProductManagementSection({ + products, + productForm, + editingProduct, + showProductForm, + formatPrice, + onAddNew, + onEdit, + onDelete, + onFormSubmit, + onFormCancel, + onFormChange, + addNotification +}: ProductManagementSectionProps) { + return ( +
+
+
+

상품 목록

+ +
+
+ + + + {showProductForm && ( + + )} +
+ ); +} diff --git a/src/basic/app/components/index.ts b/src/basic/app/components/index.ts index 11530f66..ae70cf87 100644 --- a/src/basic/app/components/index.ts +++ b/src/basic/app/components/index.ts @@ -1,2 +1,6 @@ +export * from "./AdminTabs"; +export * from "./AdminToggleButton"; +export * from "./CouponManagementSection"; export * from "./Header"; export * from "./NotificationList"; +export * from "./ProductManagementSection"; diff --git a/src/basic/app/pages/AdminPage.tsx b/src/basic/app/pages/AdminPage.tsx index 1161d91b..3da6c264 100644 --- a/src/basic/app/pages/AdminPage.tsx +++ b/src/basic/app/pages/AdminPage.tsx @@ -8,7 +8,8 @@ import { type ProductWithUI, useProductActions } from "../../domains/product"; -import { Button, CloseIcon, PlusIcon, SearchInput, TrashIcon } from "../../shared"; +import { CouponManagementSection, ProductManagementSection } from "../components"; +import { AdminTabs } from "../components"; type AdminPageProps = { products: ProductWithUI[]; @@ -94,459 +95,74 @@ export function AdminPage({ startEditProduct(product, setEditingProduct, setProductForm, setShowProductForm); }; + const handleAddNewProduct = () => { + setEditingProduct("new"); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] + }); + setShowProductForm(true); + }; + + const handleCancelProductForm = () => { + setEditingProduct(null); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] + }); + setShowProductForm(false); + }; + + const handleToggleCouponForm = () => { + setShowCouponForm(!showCouponForm); + }; + + const handleCancelCouponForm = () => { + setShowCouponForm(false); + }; + return (

관리자 대시보드

상품과 쿠폰을 관리할 수 있습니다

-
- -
- - {activeTab === "products" ? ( -
-
-
-

상품 목록

- -
-
-
- - - - - - - - - - - - {(activeTab === "products" ? products : products).map((product) => ( - - - - - - - - ))} - -
- 상품명 - - 가격 - - 재고 - - 설명 - - 작업 -
- {product.name} - - {formatPriceWithContext(product.price, product.id)} - - 10 - ? "bg-green-100 text-green-800" - : product.stock > 0 - ? "bg-yellow-100 text-yellow-800" - : "bg-red-100 text-red-800" - }`} - > - {product.stock}개 - - - {product.description || "-"} - - - -
-
- {showProductForm && ( -
- -

- {editingProduct === "new" ? "새 상품 추가" : "상품 수정"} -

-
-
- setProductForm({ ...productForm, name: e.target.value })} - className="shadow-sm" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value }) - } - className="shadow-sm" - /> -
-
- { - const value = e.target.value; - if (value === "" || /^\d+$/.test(value)) { - setProductForm({ - ...productForm, - price: value === "" ? 0 : parseInt(value) - }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === "") { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification("가격은 0보다 커야 합니다", "error"); - setProductForm({ ...productForm, price: 0 }); - } - }} - className="shadow-sm" - placeholder="숫자만 입력" - required - /> -
-
- { - const value = e.target.value; - if (value === "" || /^\d+$/.test(value)) { - setProductForm({ - ...productForm, - stock: value === "" ? 0 : parseInt(value) - }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === "") { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification("재고는 0보다 커야 합니다", "error"); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification("재고는 9999개를 초과할 수 없습니다", "error"); - setProductForm({ ...productForm, stock: 9999 }); - } - }} - className="shadow-sm" - placeholder="숫자만 입력" - required - /> -
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 rounded border px-2 py-1" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 rounded border px-2 py-1" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} - -
-
+ -
- - -
- -
- )} -
+ {activeTab === "products" ? ( + ) : ( -
-
-

쿠폰 관리

-
-
-
- {coupons.map((coupon) => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === "amount" - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
- -
-
- ))} - -
- -
-
- - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- setCouponForm({ ...couponForm, name: e.target.value })} - className="text-sm shadow-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }) - } - className="font-mono text-sm shadow-sm" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === "" || /^\d+$/.test(value)) { - setCouponForm({ - ...couponForm, - discountValue: value === "" ? 0 : parseInt(value) - }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === "percentage") { - if (value > 100) { - addNotification("할인율은 100%를 초과할 수 없습니다", "error"); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification( - "할인 금액은 100,000원을 초과할 수 없습니다", - "error" - ); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="text-sm shadow-sm" - placeholder={couponForm.discountType === "amount" ? "5000" : "10"} - required - /> -
-
-
- - -
-
-
- )} -
-
+ )}
); diff --git a/src/basic/domains/cart/components/CartItemInfo.tsx b/src/basic/domains/cart/components/CartItemInfo.tsx index 68cd6dd9..54133ec0 100644 --- a/src/basic/domains/cart/components/CartItemInfo.tsx +++ b/src/basic/domains/cart/components/CartItemInfo.tsx @@ -1,6 +1,5 @@ -import { CartItem } from "../types"; +import type { CartItem } from "../types"; import { CartItemHeader } from "./CartItemHeader"; -import { ItemPricing } from "./ItemPricing"; import { QuantitySelector } from "./QuantitySelector"; type CartItemInfoProps = { @@ -35,7 +34,14 @@ export function CartItemInfo({ onUpdateQuantity={updateQuantity} /> - +
+ {hasDiscount && ( + -{discountRate}% + )} +

+ {Math.round(itemTotal).toLocaleString()}원 +

+
); diff --git a/src/basic/domains/cart/components/CartItemList.tsx b/src/basic/domains/cart/components/CartItemList.tsx index ba0d2e7d..a956906f 100644 --- a/src/basic/domains/cart/components/CartItemList.tsx +++ b/src/basic/domains/cart/components/CartItemList.tsx @@ -1,7 +1,6 @@ import { ShoppingBagIcon } from "../../../shared"; import type { CartItem } from "../types"; import { CartItemInfo } from "./CartItemInfo"; -import { EmptyCartMessage } from "./EmptyCartMessage"; type CartItemListProps = { cart: CartItem[]; @@ -23,7 +22,10 @@ export function CartItemList({ 장바구니 {cart.length === 0 ? ( - +
+ +

장바구니가 비어있습니다

+
) : (
{cart.map((item) => ( diff --git a/src/basic/domains/cart/components/EmptyCartMessage.tsx b/src/basic/domains/cart/components/EmptyCartMessage.tsx deleted file mode 100644 index c227234c..00000000 --- a/src/basic/domains/cart/components/EmptyCartMessage.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { ShoppingBagIcon } from "../../../shared"; - -export function EmptyCartMessage() { - return ( -
- -

장바구니가 비어있습니다

-
- ); -} diff --git a/src/basic/domains/cart/components/ItemPricing.tsx b/src/basic/domains/cart/components/ItemPricing.tsx deleted file mode 100644 index 4e7e3c27..00000000 --- a/src/basic/domains/cart/components/ItemPricing.tsx +++ /dev/null @@ -1,18 +0,0 @@ -type ItemPricingProps = { - itemTotal: number; - hasDiscount: boolean; - discountRate: number; -}; - -export function ItemPricing({ itemTotal, hasDiscount, discountRate }: ItemPricingProps) { - return ( -
- {hasDiscount && ( - -{discountRate}% - )} -

- {Math.round(itemTotal).toLocaleString()}원 -

-
- ); -} diff --git a/src/basic/domains/cart/components/index.ts b/src/basic/domains/cart/components/index.ts index 1aeee378..36d1aeac 100644 --- a/src/basic/domains/cart/components/index.ts +++ b/src/basic/domains/cart/components/index.ts @@ -1,10 +1,9 @@ export * from "./CartItemHeader"; +export * from "./CartItemInfo"; export * from "./CartItemList"; export * from "./CartSidebar"; export * from "./CheckoutButton"; export * from "./CouponSelector"; -export * from "./EmptyCartMessage"; -export * from "./ItemPricing"; export * from "./PaymentInfoLine"; export * from "./PaymentSummary"; export * from "./QuantitySelector"; diff --git a/src/basic/domains/cart/hooks/useCartActions.ts b/src/basic/domains/cart/hooks/useCartActions.ts index 7d43f0e0..cdf87325 100644 --- a/src/basic/domains/cart/hooks/useCartActions.ts +++ b/src/basic/domains/cart/hooks/useCartActions.ts @@ -1,5 +1,4 @@ import type { Dispatch, SetStateAction } from "react"; -import { useCallback } from "react"; import type { CartItem, Product } from "../../../../types"; import { getRemainingStock } from "../utils"; @@ -19,76 +18,67 @@ export function useCartActions({ setSelectedCoupon, addNotification }: UseCartActionsParams) { - const addToCart = useCallback( - (product: Product) => { - const remainingStock = getRemainingStock(product, cart); - if (remainingStock <= 0) { - addNotification("재고가 부족합니다!", "error"); - return; - } - - setCart((prevCart) => { - const existingItem = prevCart.find((item) => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); - return prevCart; - } - - return prevCart.map((item) => - item.product.id === product.id ? { ...item, quantity: newQuantity } : item - ); + const addToCart = (product: Product) => { + const remainingStock = getRemainingStock(product, cart); + if (remainingStock <= 0) { + addNotification("재고가 부족합니다!", "error"); + return; + } + + setCart((prevCart) => { + const existingItem = prevCart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return prevCart; } - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification("장바구니에 담았습니다", "success"); - }, - [cart, addNotification, setCart] - ); - - const removeFromCart = useCallback( - (productId: string) => { - setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); - }, - [setCart] - ); - - const updateQuantity = useCallback( - (productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; + return prevCart.map((item) => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); } - const product = products.find((p) => p.id === productId); - if (!product) return; + return [...prevCart, { product, quantity: 1 }]; + }); - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, "error"); - return; - } + addNotification("장바구니에 담았습니다", "success"); + }; - setCart((prevCart) => - prevCart.map((item) => - item.product.id === productId ? { ...item, quantity: newQuantity } : item - ) - ); - }, - [products, removeFromCart, addNotification, setCart] - ); + const removeFromCart = (productId: string) => { + setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); + }; - const completeOrder = useCallback(() => { + const updateQuantity = (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } + + const product = products.find((p) => p.id === productId); + if (!product) return; + + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, "error"); + return; + } + + setCart((prevCart) => + prevCart.map((item) => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ) + ); + }; + + const completeOrder = () => { const orderNumber = `ORD-${Date.now()}`; addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); setCart([]); setSelectedCoupon(null); - }, [addNotification, setCart, setSelectedCoupon]); + }; return { addToCart, diff --git a/src/basic/domains/coupon/components/AddCouponCard.tsx b/src/basic/domains/coupon/components/AddCouponCard.tsx new file mode 100644 index 00000000..2858e653 --- /dev/null +++ b/src/basic/domains/coupon/components/AddCouponCard.tsx @@ -0,0 +1,19 @@ +import { PlusIcon } from "../../../shared"; + +type AddCouponCardProps = { + onClick: () => void; +}; + +export function AddCouponCard({ onClick }: AddCouponCardProps) { + return ( +
+ +
+ ); +} diff --git a/src/basic/domains/coupon/components/CouponCard.tsx b/src/basic/domains/coupon/components/CouponCard.tsx new file mode 100644 index 00000000..e78abebe --- /dev/null +++ b/src/basic/domains/coupon/components/CouponCard.tsx @@ -0,0 +1,37 @@ +import { TrashIcon } from "../../../shared"; +import type { Coupon } from "../types"; + +type CouponCardProps = { + coupon: Coupon; + onDelete: (code: string) => void; +}; + +export function CouponCard({ coupon, onDelete }: CouponCardProps) { + const handleDelete = () => { + onDelete(coupon.code); + }; + + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +} diff --git a/src/basic/domains/coupon/components/CouponForm.tsx b/src/basic/domains/coupon/components/CouponForm.tsx new file mode 100644 index 00000000..ebda70dd --- /dev/null +++ b/src/basic/domains/coupon/components/CouponForm.tsx @@ -0,0 +1,146 @@ +import type { ChangeEvent, FocusEvent, FormEvent } from "react"; + +import { Button, SearchInput } from "../../../shared"; + +type CouponFormType = { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +}; + +type CouponFormProps = { + couponForm: CouponFormType; + onSubmit: (e: FormEvent) => void; + onCancel: () => void; + onFormChange: (form: CouponFormType) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +}; + +export function CouponForm({ + couponForm, + onSubmit, + onCancel, + onFormChange, + addNotification +}: CouponFormProps) { + const handleNameChange = (e: ChangeEvent) => { + onFormChange({ + ...couponForm, + name: e.target.value + }); + }; + + const handleCodeChange = (e: ChangeEvent) => { + onFormChange({ + ...couponForm, + code: e.target.value.toUpperCase() + }); + }; + + const handleDiscountTypeChange = (e: ChangeEvent) => { + onFormChange({ + ...couponForm, + discountType: e.target.value as "amount" | "percentage" + }); + }; + + const handleDiscountValueChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === "" || /^\d+$/.test(value)) { + onFormChange({ + ...couponForm, + discountValue: value === "" ? 0 : parseInt(value) + }); + } + }; + + const handleDiscountValueBlur = (e: FocusEvent) => { + const value = e.target.value; + const numValue = parseInt(value) || 0; + + if (couponForm.discountType === "percentage") { + if (numValue > 100) { + addNotification("할인율은 100%를 초과할 수 없습니다", "error"); + onFormChange({ ...couponForm, discountValue: 100 }); + } else if (numValue < 0) { + onFormChange({ ...couponForm, discountValue: 0 }); + } + } else { + if (numValue > 100000) { + addNotification("할인 금액은 100,000원을 초과할 수 없습니다", "error"); + onFormChange({ ...couponForm, discountValue: 100000 }); + } else if (numValue < 0) { + onFormChange({ ...couponForm, discountValue: 0 }); + } + } + }; + + return ( +
+
+

새 쿠폰 생성

+
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/basic/domains/coupon/components/index.ts b/src/basic/domains/coupon/components/index.ts new file mode 100644 index 00000000..8d2317ef --- /dev/null +++ b/src/basic/domains/coupon/components/index.ts @@ -0,0 +1,3 @@ +export * from "./AddCouponCard"; +export * from "./CouponCard"; +export * from "./CouponForm"; diff --git a/src/basic/domains/coupon/hooks/useCouponActions.ts b/src/basic/domains/coupon/hooks/useCouponActions.ts index e6f1ce90..99528c15 100644 --- a/src/basic/domains/coupon/hooks/useCouponActions.ts +++ b/src/basic/domains/coupon/hooks/useCouponActions.ts @@ -1,5 +1,4 @@ import type { Dispatch, SetStateAction } from "react"; -import { useCallback } from "react"; import type { Coupon } from "../types"; import { validateCouponCode, validateCouponUsage } from "../utils"; @@ -19,55 +18,47 @@ export function useCouponActions({ setSelectedCoupon, addNotification }: UseCouponActionsParams) { - const addCoupon = useCallback( - (newCoupon: Coupon) => { - const validation = validateCouponCode(newCoupon.code, coupons); + const addCoupon = (newCoupon: Coupon) => { + const validation = validateCouponCode(newCoupon.code, coupons); + if (!validation.valid) { + addNotification(validation.message, "error"); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + addNotification("쿠폰이 추가되었습니다.", "success"); + }; + + const deleteCoupon = (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification("쿠폰이 삭제되었습니다.", "success"); + }; + + const applyCoupon = (coupon: Coupon, totalAmount?: number) => { + // totalAmount가 제공되지 않으면 기본 검증 없이 적용 + if (totalAmount !== undefined) { + const validation = validateCouponUsage(coupon, totalAmount); if (!validation.valid) { addNotification(validation.message, "error"); return; } - setCoupons((prev) => [...prev, newCoupon]); - addNotification("쿠폰이 추가되었습니다.", "success"); - }, - [coupons, setCoupons, addNotification] - ); - - const deleteCoupon = useCallback( - (couponCode: string) => { - setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification("쿠폰이 삭제되었습니다.", "success"); - }, - [selectedCoupon, setCoupons, setSelectedCoupon, addNotification] - ); - - const applyCoupon = useCallback( - (coupon: Coupon, totalAmount?: number) => { - // totalAmount가 제공되지 않으면 기본 검증 없이 적용 - if (totalAmount !== undefined) { - const validation = validateCouponUsage(coupon, totalAmount); - if (!validation.valid) { - addNotification(validation.message, "error"); - return; - } - } + } - setSelectedCoupon(coupon); - addNotification("쿠폰이 적용되었습니다.", "success"); - }, - [setSelectedCoupon, addNotification] - ); + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }; - const handleCouponSubmit = useCallback( - (couponForm: Coupon, resetForm: () => void, setShowForm: (show: boolean) => void) => { - addCoupon(couponForm); - resetForm(); - setShowForm(false); - }, - [addCoupon] - ); + const handleCouponSubmit = ( + couponForm: Coupon, + resetForm: () => void, + setShowForm: (show: boolean) => void + ) => { + addCoupon(couponForm); + resetForm(); + setShowForm(false); + }; return { addCoupon, diff --git a/src/basic/domains/coupon/index.ts b/src/basic/domains/coupon/index.ts index d5d9ef29..44784bb2 100644 --- a/src/basic/domains/coupon/index.ts +++ b/src/basic/domains/coupon/index.ts @@ -1,3 +1,4 @@ +export * from "./components"; export * from "./constants"; export * from "./hooks"; export * from "./types"; diff --git a/src/basic/domains/product/components/DiscountItem.tsx b/src/basic/domains/product/components/DiscountItem.tsx new file mode 100644 index 00000000..9576bdd0 --- /dev/null +++ b/src/basic/domains/product/components/DiscountItem.tsx @@ -0,0 +1,54 @@ +import type { ChangeEvent } from "react"; + +import { CloseIcon } from "../../../shared"; +import { type Discount } from "../types"; + +type DiscountItemProps = { + discount: Discount; + index: number; + onChange: (index: number, field: "quantity" | "rate", value: number) => void; + onRemove: (index: number) => void; +}; + +export function DiscountItem({ discount, index, onChange, onRemove }: DiscountItemProps) { + const handleQuantityChange = (e: ChangeEvent) => { + const value = e.target.value; + onChange(index, "quantity", parseInt(value) || 0); + }; + + const handleRateChange = (e: ChangeEvent) => { + const value = e.target.value; + onChange(index, "rate", (parseInt(value) || 0) / 100); + }; + + const handleRemove = () => { + onRemove(index); + }; + + return ( +
+ + 개 이상 구매 시 + + % 할인 + +
+ ); +} diff --git a/src/basic/domains/product/components/DiscountSection.tsx b/src/basic/domains/product/components/DiscountSection.tsx new file mode 100644 index 00000000..ad735b70 --- /dev/null +++ b/src/basic/domains/product/components/DiscountSection.tsx @@ -0,0 +1,48 @@ +import type { Discount } from "../types"; +import { DiscountItem } from "./DiscountItem"; + +type DiscountSectionProps = { + discounts: Discount[]; + onChange: (discounts: Discount[]) => void; +}; + +export function DiscountSection({ discounts, onChange }: DiscountSectionProps) { + const handleDiscountChange = (index: number, field: "quantity" | "rate", value: number) => { + const newDiscounts = [...discounts]; + newDiscounts[index][field] = value; + onChange(newDiscounts); + }; + + const handleRemoveDiscount = (index: number) => { + const newDiscounts = discounts.filter((_, i) => i !== index); + onChange(newDiscounts); + }; + + const handleAddDiscount = () => { + onChange([...discounts, { quantity: 10, rate: 0.1 }]); + }; + + return ( +
+ +
+ {discounts.map((discount, index) => ( + + ))} + +
+
+ ); +} diff --git a/src/basic/domains/product/components/EmptySearchResult.tsx b/src/basic/domains/product/components/EmptySearchResult.tsx deleted file mode 100644 index da3de347..00000000 --- a/src/basic/domains/product/components/EmptySearchResult.tsx +++ /dev/null @@ -1,11 +0,0 @@ -type EmptySearchResultProps = { - searchTerm: string; -}; - -export function EmptySearchResult({ searchTerm }: EmptySearchResultProps) { - return ( -
-

"{searchTerm}"에 대한 검색 결과가 없습니다.

-
- ); -} diff --git a/src/basic/domains/product/components/ProductCard.tsx b/src/basic/domains/product/components/ProductCard.tsx index 7d320d8a..053d1aa3 100644 --- a/src/basic/domains/product/components/ProductCard.tsx +++ b/src/basic/domains/product/components/ProductCard.tsx @@ -1,15 +1,8 @@ -import type { Product } from "../../../../types"; import { Button } from "../../../shared"; +import type { ProductWithUI } from "../types"; import { ProductImage } from "./ProductImage"; -import { ProductInfo } from "./ProductInfo"; -import { ProductPricing } from "./ProductPricing"; import { StockStatus } from "./StockStatus"; -type ProductWithUI = Product & { - description?: string; - isRecommended?: boolean; -}; - type ProductCardProps = { product: ProductWithUI; remainingStock: number; @@ -32,8 +25,22 @@ export function ProductCard({
- - +

{product.name}

+ {product.description && ( +

{product.description}

+ )} + +
+

+ {formatPrice(product.price, product.id)} +

+ {product.discounts.length > 0 && ( +

+ {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% +

+ )} +
+
diff --git a/src/basic/domains/product/components/ProductFormEditor.tsx b/src/basic/domains/product/components/ProductFormEditor.tsx new file mode 100644 index 00000000..13879bef --- /dev/null +++ b/src/basic/domains/product/components/ProductFormEditor.tsx @@ -0,0 +1,140 @@ +import { type ChangeEvent, type FocusEvent, type FormEvent } from "react"; + +import { Button, SearchInput } from "../../../shared"; +import { Discount, type ProductForm } from "../types"; +import { DiscountSection } from "./DiscountSection"; + +type ProductFormEditorProps = { + productForm: ProductForm; + editingProduct: string | null; + onSubmit: (e: FormEvent) => void; + onCancel: () => void; + onFormChange: (form: ProductForm) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +}; + +export function ProductFormEditor({ + productForm, + editingProduct, + onSubmit, + onCancel, + onFormChange, + addNotification +}: ProductFormEditorProps) { + const handleNameChange = (e: ChangeEvent) => { + onFormChange({ ...productForm, name: e.target.value }); + }; + + const handleDescriptionChange = (e: ChangeEvent) => { + onFormChange({ ...productForm, description: e.target.value }); + }; + + const handleDiscountsChange = (discounts: Discount[]) => { + onFormChange({ ...productForm, discounts }); + }; + + const handlePriceChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === "" || /^\d+$/.test(value)) { + onFormChange({ + ...productForm, + price: value === "" ? 0 : parseInt(value) + }); + } + }; + + const handlePriceBlur = (e: FocusEvent) => { + const value = e.target.value; + + if (value === "") { + onFormChange({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification("가격은 0보다 커야 합니다", "error"); + onFormChange({ ...productForm, price: 0 }); + } + }; + + const handleStockChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === "" || /^\d+$/.test(value)) { + onFormChange({ + ...productForm, + stock: value === "" ? 0 : parseInt(value) + }); + } + }; + + const handleStockBlur = (e: FocusEvent) => { + const value = e.target.value; + + if (value === "") { + onFormChange({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + addNotification("재고는 0보다 커야 합니다", "error"); + onFormChange({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + addNotification("재고는 9999개를 초과할 수 없습니다", "error"); + onFormChange({ ...productForm, stock: 9999 }); + } + }; + + return ( +
+
+

+ {editingProduct === "new" ? "새 상품 추가" : "상품 수정"} +

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + +
+ + +
+ +
+ ); +} diff --git a/src/basic/domains/product/components/ProductImage.tsx b/src/basic/domains/product/components/ProductImage.tsx index 9266e1d6..c322dd72 100644 --- a/src/basic/domains/product/components/ProductImage.tsx +++ b/src/basic/domains/product/components/ProductImage.tsx @@ -1,8 +1,8 @@ -import type { Product } from "../../../../types"; import { ImagePlaceholderIcon } from "../../../shared"; +import type { ProductWithUI } from "../types"; type ProductImageProps = { - product: Product & { isRecommended?: boolean }; + product: ProductWithUI; }; export function ProductImage({ product }: ProductImageProps) { diff --git a/src/basic/domains/product/components/ProductInfo.tsx b/src/basic/domains/product/components/ProductInfo.tsx deleted file mode 100644 index f588e106..00000000 --- a/src/basic/domains/product/components/ProductInfo.tsx +++ /dev/null @@ -1,13 +0,0 @@ -type ProductInfoProps = { - name: string; - description?: string; -}; - -export function ProductInfo({ name, description }: ProductInfoProps) { - return ( - <> -

{name}

- {description &&

{description}

} - - ); -} diff --git a/src/basic/domains/product/components/ProductList.tsx b/src/basic/domains/product/components/ProductList.tsx index 3160e40d..4b713fc1 100644 --- a/src/basic/domains/product/components/ProductList.tsx +++ b/src/basic/domains/product/components/ProductList.tsx @@ -1,12 +1,6 @@ -import type { Product } from "../../../../types"; -import { EmptySearchResult } from "./EmptySearchResult"; +import type { Product, ProductWithUI } from "../types"; import { ProductCard } from "./ProductCard"; -type ProductWithUI = Product & { - description?: string; - isRecommended?: boolean; -}; - type ProductListProps = { products: ProductWithUI[]; filteredProducts: ProductWithUI[]; @@ -35,7 +29,9 @@ export function ProductList({
총 {products.length}개 상품
{filteredProducts.length === 0 ? ( - +
+

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

+
) : (
{filteredProducts.map((product) => ( diff --git a/src/basic/domains/product/components/ProductPricing.tsx b/src/basic/domains/product/components/ProductPricing.tsx deleted file mode 100644 index 55879526..00000000 --- a/src/basic/domains/product/components/ProductPricing.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { Product } from "../../../../types"; - -type ProductPricingProps = { - product: Product; - formatPrice: (price: number, productId?: string) => string; -}; - -export function ProductPricing({ product, formatPrice }: ProductPricingProps) { - return ( -
-

{formatPrice(product.price, product.id)}

- {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% -

- )} -
- ); -} diff --git a/src/basic/domains/product/components/ProductTable.tsx b/src/basic/domains/product/components/ProductTable.tsx new file mode 100644 index 00000000..e2c88812 --- /dev/null +++ b/src/basic/domains/product/components/ProductTable.tsx @@ -0,0 +1,48 @@ +import type { ProductWithUI } from "../../product"; +import { ProductTableRow } from "./ProductTableRow"; + +type ProductTableProps = { + products: ProductWithUI[]; + formatPrice: (price: number, productId?: string) => string; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +}; + +export function ProductTable({ products, formatPrice, onEdit, onDelete }: ProductTableProps) { + return ( +
+ + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+ ); +} diff --git a/src/basic/domains/product/components/ProductTableRow.tsx b/src/basic/domains/product/components/ProductTableRow.tsx new file mode 100644 index 00000000..b47ee488 --- /dev/null +++ b/src/basic/domains/product/components/ProductTableRow.tsx @@ -0,0 +1,55 @@ +import { Button } from "../../../shared"; +import type { ProductWithUI } from "../types"; +import { StockBadge } from "./StockBadge"; + +type ProductTableRowProps = { + product: ProductWithUI; + formatPrice: (price: number, productId?: string) => string; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +}; + +export function ProductTableRow({ product, formatPrice, onEdit, onDelete }: ProductTableRowProps) { + const handleEdit = () => { + onEdit(product); + }; + + const handleDelete = () => { + onDelete(product.id); + }; + + return ( + + + {product.name} + + + {formatPrice(product.price, product.id)} + + + + + + {product.description || "-"} + + + + + + + ); +} diff --git a/src/basic/domains/product/components/StockBadge.tsx b/src/basic/domains/product/components/StockBadge.tsx new file mode 100644 index 00000000..4b2b271b --- /dev/null +++ b/src/basic/domains/product/components/StockBadge.tsx @@ -0,0 +1,22 @@ +import { tv } from "tailwind-variants"; + +const stockBadge = tv({ + base: "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium", + variants: { + level: { + high: "bg-green-100 text-green-800", + low: "bg-yellow-100 text-yellow-800", + out: "bg-red-100 text-red-800" + } + } +}); + +type StockBadgeProps = { + stock: number; +}; + +export function StockBadge({ stock }: StockBadgeProps) { + const level = stock > 10 ? "high" : stock > 0 ? "low" : "out"; + + return {stock}개; +} diff --git a/src/basic/domains/product/components/index.ts b/src/basic/domains/product/components/index.ts index 02b5f5f6..a6c680bc 100644 --- a/src/basic/domains/product/components/index.ts +++ b/src/basic/domains/product/components/index.ts @@ -1,7 +1,10 @@ -export * from "./EmptySearchResult"; +export * from "./DiscountItem"; +export * from "./DiscountSection"; export * from "./ProductCard"; +export * from "./ProductFormEditor"; export * from "./ProductImage"; -export * from "./ProductInfo"; export * from "./ProductList"; -export * from "./ProductPricing"; +export * from "./ProductTable"; +export * from "./ProductTableRow"; +export * from "./StockBadge"; export * from "./StockStatus"; diff --git a/src/basic/domains/product/hooks/useProductActions.ts b/src/basic/domains/product/hooks/useProductActions.ts index 06a60a48..09cf195a 100644 --- a/src/basic/domains/product/hooks/useProductActions.ts +++ b/src/basic/domains/product/hooks/useProductActions.ts @@ -1,5 +1,4 @@ import type { Dispatch, SetStateAction } from "react"; -import { useCallback } from "react"; import type { ProductForm, ProductWithUI } from "../types"; @@ -9,79 +8,64 @@ interface UseProductActionsParams { } export function useProductActions({ setProducts, addNotification }: UseProductActionsParams) { - const addProduct = useCallback( - (newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts((prev) => [...prev, product]); - addNotification("상품이 추가되었습니다.", "success"); - }, - [setProducts, addNotification] - ); + const addProduct = (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + setProducts((prev) => [...prev, product]); + addNotification("상품이 추가되었습니다.", "success"); + }; - const updateProduct = useCallback( - (productId: string, updates: Partial) => { - setProducts((prev) => - prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) - ); - addNotification("상품이 수정되었습니다.", "success"); - }, - [setProducts, addNotification] - ); + const updateProduct = (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + addNotification("상품이 수정되었습니다.", "success"); + }; - const deleteProduct = useCallback( - (productId: string) => { - setProducts((prev) => prev.filter((p) => p.id !== productId)); - addNotification("상품이 삭제되었습니다.", "success"); - }, - [setProducts, addNotification] - ); + const deleteProduct = (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification("상품이 삭제되었습니다.", "success"); + }; - const handleProductSubmit = useCallback( - ( - productForm: ProductForm, - editingProduct: string | null, - resetForm: () => void, - setEditingProduct: (id: string | null) => void, - setShowForm: (show: boolean) => void - ) => { - if (editingProduct && editingProduct !== "new") { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - resetForm(); + const handleProductSubmit = ( + productForm: ProductForm, + editingProduct: string | null, + resetForm: () => void, + setEditingProduct: (id: string | null) => void, + setShowForm: (show: boolean) => void + ) => { + if (editingProduct && editingProduct !== "new") { + updateProduct(editingProduct, productForm); setEditingProduct(null); - setShowForm(false); - }, - [addProduct, updateProduct] - ); - - const startEditProduct = useCallback( - ( - product: ProductWithUI, - setEditingProduct: (id: string) => void, - setProductForm: (form: ProductForm) => void, - setShowForm: (show: boolean) => void - ) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || "", - discounts: product.discounts || [] + } else { + addProduct({ + ...productForm, + discounts: productForm.discounts }); - setShowForm(true); - }, - [] - ); + } + resetForm(); + setEditingProduct(null); + setShowForm(false); + }; + + const startEditProduct = ( + product: ProductWithUI, + setEditingProduct: (id: string) => void, + setProductForm: (form: ProductForm) => void, + setShowForm: (show: boolean) => void + ) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [] + }); + setShowForm(true); + }; return { addProduct, diff --git a/src/basic/domains/product/index.ts b/src/basic/domains/product/index.ts index 89e93b4a..44784bb2 100644 --- a/src/basic/domains/product/index.ts +++ b/src/basic/domains/product/index.ts @@ -1,6 +1,5 @@ export * from "./components"; export * from "./constants"; export * from "./hooks"; -export * from "./models"; export * from "./types"; export * from "./utils"; diff --git a/src/basic/domains/product/models/index.ts b/src/basic/domains/product/models/index.ts deleted file mode 100644 index cb0ff5c3..00000000 --- a/src/basic/domains/product/models/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/src/basic/shared/components/ui/BadgeContainer.tsx b/src/basic/shared/components/ui/BadgeContainer.tsx index 7f730703..a489936d 100644 --- a/src/basic/shared/components/ui/BadgeContainer.tsx +++ b/src/basic/shared/components/ui/BadgeContainer.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren } from "react"; +import { type PropsWithChildren } from "react"; type BadgeContainerProps = PropsWithChildren<{ label: string; diff --git a/src/basic/shared/hooks/useLocalStorageState.ts b/src/basic/shared/hooks/useLocalStorageState.ts index bff52df4..43410b76 100644 --- a/src/basic/shared/hooks/useLocalStorageState.ts +++ b/src/basic/shared/hooks/useLocalStorageState.ts @@ -1,4 +1,4 @@ -import { type Dispatch, type SetStateAction, useCallback, useEffect, useState } from "react"; +import { type Dispatch, type SetStateAction, useEffect, useState } from "react"; type UseLocalStorageStateProps = { key: string; @@ -9,14 +9,14 @@ export function useLocalStorageState({ key, initialState }: UseLocalStorageStateProps): [S, Dispatch>] { - const readLocalStorage = useCallback(() => { + const readLocalStorage = () => { try { const item = localStorage.getItem(key); return item ? (JSON.parse(item) as S) : initialState; } catch { return initialState; } - }, [key, initialState]); + }; const [state, setState] = useState(readLocalStorage); diff --git a/src/basic/shared/hooks/useNotifications.ts b/src/basic/shared/hooks/useNotifications.ts index 7b8b73af..9db2fba7 100644 --- a/src/basic/shared/hooks/useNotifications.ts +++ b/src/basic/shared/hooks/useNotifications.ts @@ -1,25 +1,22 @@ -import { useCallback, useState } from "react"; +import { useState } from "react"; -import type { Notification } from "../types"; +import type { NotificationItem } from "../types"; export function useNotifications() { - const [notifications, setNotifications] = useState([]); + const [notifications, setNotifications] = useState([]); - const addNotification = useCallback( - (message: string, type: "error" | "success" | "warning" = "success") => { - const id = Date.now().toString(); - setNotifications((prev) => [...prev, { id, message, type }]); + const addNotification = (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); - setTimeout(() => { - setNotifications((prev) => prev.filter((n) => n.id !== id)); - }, 3000); - }, - [] - ); + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }; - const removeNotification = useCallback((id: string) => { + const removeNotification = (id: string) => { setNotifications((prev) => prev.filter((n) => n.id !== id)); - }, []); + }; return { notifications, From e3f82b5b43f7f8045216cd1e7c603df7f1b9d4da Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 13:26:52 +0900 Subject: [PATCH 26/35] =?UTF-8?q?chore:=20root=20types.ts=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=9B=90=EB=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/types.ts b/src/types.ts index cbb9a68b..aafe0aba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,24 @@ -export type * from "./basic/domains/cart"; -export type * from "./basic/domains/coupon"; -export type * from "./basic/domains/product"; -export type * from "./basic/shared"; +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface Discount { + quantity: number; + rate: number; +} + +export interface CartItem { + product: Product; + quantity: number; +} + +export interface Coupon { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +} From 3d635cf15db3771c94044bc2e485ae27fd0988dd Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 13:54:57 +0900 Subject: [PATCH 27/35] =?UTF-8?q?feat:=20cart=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/App.tsx | 36 ++++++-- src/basic/domains/cart/hooks/index.ts | 1 - .../domains/cart/hooks/useCartActions.ts | 89 ------------------- src/basic/domains/cart/index.ts | 2 +- .../cart/services/cartApplicationService.ts | 85 ++++++++++++++++++ .../cart/services/cartNotificationService.ts | 15 ++++ .../cart/services/cartValidationService.ts | 48 ++++++++++ src/basic/domains/cart/services/index.ts | 4 + .../domains/cart/services/orderService.ts | 13 +++ src/basic/shared/types/index.ts | 3 +- src/basic/shared/types/notification.ts | 5 ++ src/basic/shared/types/validation.ts | 4 + 12 files changed, 204 insertions(+), 101 deletions(-) delete mode 100644 src/basic/domains/cart/hooks/index.ts delete mode 100644 src/basic/domains/cart/hooks/useCartActions.ts create mode 100644 src/basic/domains/cart/services/cartApplicationService.ts create mode 100644 src/basic/domains/cart/services/cartNotificationService.ts create mode 100644 src/basic/domains/cart/services/cartValidationService.ts create mode 100644 src/basic/domains/cart/services/index.ts create mode 100644 src/basic/domains/cart/services/orderService.ts create mode 100644 src/basic/shared/types/validation.ts diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx index b3fd8895..21ae650e 100644 --- a/src/basic/app/App.tsx +++ b/src/basic/app/App.tsx @@ -3,9 +3,9 @@ import { useEffect, useState } from "react"; import { calculateCartTotal, calculateItemTotal, + cartApplicationService, type CartItem, - getRemainingStock, - useCartActions + getRemainingStock } from "../domains/cart"; import { type Coupon, INITIAL_COUPONS, useCouponActions } from "../domains/coupon"; import { @@ -51,13 +51,31 @@ export function App() { const [totalItemCount, setTotalItemCount] = useState(0); - const { addToCart, removeFromCart, updateQuantity, completeOrder } = useCartActions({ - cart, - products, - setCart, - setSelectedCoupon, - addNotification - }); + const addToCart = (product: Product) => { + cartApplicationService.addToCart(product, cart, setCart, addNotification); + }; + + const removeFromCart = (productId: string) => { + cartApplicationService.removeFromCart(productId, setCart); + }; + + const updateQuantity = (productId: string, newQuantity: number) => { + cartApplicationService.updateQuantity( + productId, + newQuantity, + products, + setCart, + addNotification + ); + }; + + const completeOrder = () => { + cartApplicationService.completeOrder( + () => setCart([]), + () => setSelectedCoupon(null), + addNotification + ); + }; const { applyCoupon: applyCouponBase } = useCouponActions({ coupons, diff --git a/src/basic/domains/cart/hooks/index.ts b/src/basic/domains/cart/hooks/index.ts deleted file mode 100644 index 6b468e64..00000000 --- a/src/basic/domains/cart/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./useCartActions"; diff --git a/src/basic/domains/cart/hooks/useCartActions.ts b/src/basic/domains/cart/hooks/useCartActions.ts deleted file mode 100644 index cdf87325..00000000 --- a/src/basic/domains/cart/hooks/useCartActions.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Dispatch, SetStateAction } from "react"; - -import type { CartItem, Product } from "../../../../types"; -import { getRemainingStock } from "../utils"; - -interface UseCartActionsParams { - cart: CartItem[]; - products: Product[]; - setCart: Dispatch>; - setSelectedCoupon: (coupon: null) => void; - addNotification: (message: string, type?: "error" | "success" | "warning") => void; -} - -export function useCartActions({ - cart, - products, - setCart, - setSelectedCoupon, - addNotification -}: UseCartActionsParams) { - const addToCart = (product: Product) => { - const remainingStock = getRemainingStock(product, cart); - if (remainingStock <= 0) { - addNotification("재고가 부족합니다!", "error"); - return; - } - - setCart((prevCart) => { - const existingItem = prevCart.find((item) => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); - return prevCart; - } - - return prevCart.map((item) => - item.product.id === product.id ? { ...item, quantity: newQuantity } : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification("장바구니에 담았습니다", "success"); - }; - - const removeFromCart = (productId: string) => { - setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); - }; - - const updateQuantity = (productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find((p) => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, "error"); - return; - } - - setCart((prevCart) => - prevCart.map((item) => - item.product.id === productId ? { ...item, quantity: newQuantity } : item - ) - ); - }; - - const completeOrder = () => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); - setCart([]); - setSelectedCoupon(null); - }; - - return { - addToCart, - removeFromCart, - updateQuantity, - completeOrder - }; -} diff --git a/src/basic/domains/cart/index.ts b/src/basic/domains/cart/index.ts index 057ddce2..57488b06 100644 --- a/src/basic/domains/cart/index.ts +++ b/src/basic/domains/cart/index.ts @@ -1,4 +1,4 @@ export * from "./components"; -export * from "./hooks"; +export * from "./services"; export * from "./types"; export * from "./utils"; diff --git a/src/basic/domains/cart/services/cartApplicationService.ts b/src/basic/domains/cart/services/cartApplicationService.ts new file mode 100644 index 00000000..4c5cd8a4 --- /dev/null +++ b/src/basic/domains/cart/services/cartApplicationService.ts @@ -0,0 +1,85 @@ +import type { CartItem, Product } from "../../../../types"; +import type { NotificationFunction } from "../../../shared"; +import { cartNotificationService, cartValidationService, orderService } from "./index"; + +type CartUpdater = (updater: (prevCart: CartItem[]) => CartItem[]) => void; + +export const cartApplicationService = { + addToCart: ( + product: Product, + cart: CartItem[], + updateCart: CartUpdater, + addNotification: NotificationFunction + ) => { + const validation = cartValidationService.validateAddToCart(product, cart); + if (!validation.valid) { + cartNotificationService.showValidationError(validation.message!, addNotification); + return; + } + + updateCart((prevCart) => { + const existingItem = prevCart.find((item) => item.product.id === product.id); + + if (existingItem) { + const quantityValidation = cartValidationService.validateQuantityIncrease( + product, + existingItem.quantity + ); + if (!quantityValidation.valid) { + cartNotificationService.showValidationError(quantityValidation.message!, addNotification); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id ? { ...item, quantity: item.quantity + 1 } : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + cartNotificationService.showAddToCartSuccess(addNotification); + }, + + removeFromCart: (productId: string, updateCart: CartUpdater) => { + updateCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); + }, + + updateQuantity: ( + productId: string, + newQuantity: number, + products: Product[], + updateCart: CartUpdater, + addNotification: NotificationFunction + ) => { + const validation = cartValidationService.validateQuantityUpdate( + productId, + newQuantity, + products + ); + if (!validation.valid) { + cartNotificationService.showValidationError(validation.message!, addNotification); + return; + } + + if (newQuantity <= 0) { + cartApplicationService.removeFromCart(productId, updateCart); + return; + } + + updateCart((prevCart) => + prevCart.map((item) => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ) + ); + }, + + completeOrder: ( + clearCart: () => void, + clearSelectedCoupon: () => void, + addNotification: NotificationFunction + ) => { + const orderNumber = orderService.processOrder(clearCart, clearSelectedCoupon); + cartNotificationService.showOrderSuccess(orderNumber, addNotification); + } +}; diff --git a/src/basic/domains/cart/services/cartNotificationService.ts b/src/basic/domains/cart/services/cartNotificationService.ts new file mode 100644 index 00000000..ab9d1da6 --- /dev/null +++ b/src/basic/domains/cart/services/cartNotificationService.ts @@ -0,0 +1,15 @@ +import type { NotificationFunction } from "../../../shared"; + +export const cartNotificationService = { + showAddToCartSuccess: (addNotification: NotificationFunction) => { + addNotification("장바구니에 담았습니다", "success"); + }, + + showValidationError: (message: string, addNotification: NotificationFunction) => { + addNotification(message, "error"); + }, + + showOrderSuccess: (orderNumber: string, addNotification: NotificationFunction) => { + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); + } +}; diff --git a/src/basic/domains/cart/services/cartValidationService.ts b/src/basic/domains/cart/services/cartValidationService.ts new file mode 100644 index 00000000..41dccd6e --- /dev/null +++ b/src/basic/domains/cart/services/cartValidationService.ts @@ -0,0 +1,48 @@ +import type { CartItem, Product } from "../../../../types"; +import type { ValidationResult } from "../../../shared"; +import { getRemainingStock } from "../utils"; + +const createValidationResult = (valid: boolean, message?: string): ValidationResult => ({ + valid, + message +}); + +const validateStockAvailability = (requiredQuantity: number, availableStock: number) => { + return requiredQuantity > availableStock + ? createValidationResult(false, `재고는 ${availableStock}개까지만 있습니다.`) + : createValidationResult(true); +}; + +const findProductById = (productId: string, products: Product[]) => { + const product = products.find((p) => p.id === productId); + return product + ? { product, error: null } + : { product: null, error: createValidationResult(false, "상품을 찾을 수 없습니다.") }; +}; + +export const cartValidationService = { + validateAddToCart: (product: Product, cart: CartItem[]) => { + const remainingStock = getRemainingStock(product, cart); + return remainingStock <= 0 + ? createValidationResult(false, "재고가 부족합니다!") + : createValidationResult(true); + }, + + validateQuantityIncrease: (product: Product, currentQuantity: number) => { + const newQuantity = currentQuantity + 1; + return validateStockAvailability(newQuantity, product.stock); + }, + + validateQuantityUpdate: (productId: string, newQuantity: number, products: Product[]) => { + if (newQuantity <= 0) { + return createValidationResult(true); + } + + const { product, error } = findProductById(productId, products); + if (error) { + return error; + } + + return validateStockAvailability(newQuantity, product!.stock); + } +}; diff --git a/src/basic/domains/cart/services/index.ts b/src/basic/domains/cart/services/index.ts new file mode 100644 index 00000000..67ff0a26 --- /dev/null +++ b/src/basic/domains/cart/services/index.ts @@ -0,0 +1,4 @@ +export * from "./cartApplicationService"; +export * from "./cartNotificationService"; +export * from "./cartValidationService"; +export * from "./orderService"; diff --git a/src/basic/domains/cart/services/orderService.ts b/src/basic/domains/cart/services/orderService.ts new file mode 100644 index 00000000..d9f799ad --- /dev/null +++ b/src/basic/domains/cart/services/orderService.ts @@ -0,0 +1,13 @@ +export const orderService = { + createOrderNumber: () => { + return `ORD-${Date.now()}`; + }, + + processOrder: (clearCart: () => void, clearSelectedCoupon: () => void) => { + const orderNumber = orderService.createOrderNumber(); + clearCart(); + clearSelectedCoupon(); + + return orderNumber; + } +}; diff --git a/src/basic/shared/types/index.ts b/src/basic/shared/types/index.ts index dd99c4d0..a67dd4c1 100644 --- a/src/basic/shared/types/index.ts +++ b/src/basic/shared/types/index.ts @@ -1 +1,2 @@ -export * from "./notification"; +export type * from "./notification"; +export type * from "./validation"; diff --git a/src/basic/shared/types/notification.ts b/src/basic/shared/types/notification.ts index 928e15b6..4871b8ae 100644 --- a/src/basic/shared/types/notification.ts +++ b/src/basic/shared/types/notification.ts @@ -3,3 +3,8 @@ export interface NotificationItem { message: string; type: "error" | "success" | "warning"; } + +export type NotificationFunction = ( + message: string, + type?: "error" | "success" | "warning" +) => void; diff --git a/src/basic/shared/types/validation.ts b/src/basic/shared/types/validation.ts new file mode 100644 index 00000000..a0ab9278 --- /dev/null +++ b/src/basic/shared/types/validation.ts @@ -0,0 +1,4 @@ +export type ValidationResult = { + valid: boolean; + message?: string; +}; From 7b7dc8c4ac82172c83272cc815e5e37ae2acd41a Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 14:02:09 +0900 Subject: [PATCH 28/35] =?UTF-8?q?fix:=20=EB=9D=BC=EB=B2=A8=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A4=91=EB=B3=B5=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/domains/coupon/components/CouponForm.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/basic/domains/coupon/components/CouponForm.tsx b/src/basic/domains/coupon/components/CouponForm.tsx index ebda70dd..c7250441 100644 --- a/src/basic/domains/coupon/components/CouponForm.tsx +++ b/src/basic/domains/coupon/components/CouponForm.tsx @@ -116,9 +116,6 @@ export function CouponForm({
- Date: Fri, 8 Aug 2025 14:03:04 +0900 Subject: [PATCH 29/35] =?UTF-8?q?feat:=20coupon=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/App.tsx | 12 +--- src/basic/app/pages/AdminPage.tsx | 27 ++++--- src/basic/domains/coupon/hooks/index.ts | 1 - .../domains/coupon/hooks/useCouponActions.ts | 69 ------------------ src/basic/domains/coupon/index.ts | 2 +- .../services/couponApplicationService.ts | 71 +++++++++++++++++++ .../services/couponNotificationService.ts | 19 +++++ .../services/couponValidationService.ts | 17 +++++ src/basic/domains/coupon/services/index.ts | 3 + 9 files changed, 132 insertions(+), 89 deletions(-) delete mode 100644 src/basic/domains/coupon/hooks/index.ts delete mode 100644 src/basic/domains/coupon/hooks/useCouponActions.ts create mode 100644 src/basic/domains/coupon/services/couponApplicationService.ts create mode 100644 src/basic/domains/coupon/services/couponNotificationService.ts create mode 100644 src/basic/domains/coupon/services/couponValidationService.ts create mode 100644 src/basic/domains/coupon/services/index.ts diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx index 21ae650e..5921f47f 100644 --- a/src/basic/app/App.tsx +++ b/src/basic/app/App.tsx @@ -7,7 +7,7 @@ import { type CartItem, getRemainingStock } from "../domains/cart"; -import { type Coupon, INITIAL_COUPONS, useCouponActions } from "../domains/coupon"; +import { type Coupon, couponApplicationService, INITIAL_COUPONS } from "../domains/coupon"; import { formatPrice, INITIAL_PRODUCTS, @@ -77,17 +77,9 @@ export function App() { ); }; - const { applyCoupon: applyCouponBase } = useCouponActions({ - coupons, - selectedCoupon, - setCoupons, - setSelectedCoupon, - addNotification - }); - const applyCoupon = (coupon: Coupon) => { const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; - applyCouponBase(coupon, currentTotal); + couponApplicationService.applyCoupon(coupon, currentTotal, setSelectedCoupon, addNotification); }; const calculateItemTotalWithCart = (item: CartItem) => { diff --git a/src/basic/app/pages/AdminPage.tsx b/src/basic/app/pages/AdminPage.tsx index 3da6c264..a9e70a4a 100644 --- a/src/basic/app/pages/AdminPage.tsx +++ b/src/basic/app/pages/AdminPage.tsx @@ -1,7 +1,7 @@ import { type Dispatch, FormEvent, type SetStateAction, useState } from "react"; import type { CartItem, Coupon } from "../../../types"; -import { useCouponActions } from "../../domains/coupon"; +import { couponApplicationService } from "../../domains/coupon"; import { formatPrice, type ProductForm, @@ -53,13 +53,24 @@ export function AdminPage({ addNotification }); - const { deleteCoupon, handleCouponSubmit } = useCouponActions({ - coupons, - selectedCoupon: null, - setCoupons, - setSelectedCoupon: () => {}, - addNotification - }); + const deleteCoupon = (couponCode: string) => { + couponApplicationService.deleteCoupon(couponCode, null, setCoupons, () => {}, addNotification); + }; + + const handleCouponSubmit = ( + couponForm: Coupon, + resetForm: () => void, + setShowForm: (show: boolean) => void + ) => { + couponApplicationService.handleCouponSubmit( + couponForm, + coupons, + setCoupons, + resetForm, + setShowForm, + addNotification + ); + }; const formatPriceWithContext = (price: number, productId?: string) => { return formatPrice(price, productId, products, cart, isAdminMode); diff --git a/src/basic/domains/coupon/hooks/index.ts b/src/basic/domains/coupon/hooks/index.ts deleted file mode 100644 index 2e2aebd6..00000000 --- a/src/basic/domains/coupon/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./useCouponActions"; diff --git a/src/basic/domains/coupon/hooks/useCouponActions.ts b/src/basic/domains/coupon/hooks/useCouponActions.ts deleted file mode 100644 index 99528c15..00000000 --- a/src/basic/domains/coupon/hooks/useCouponActions.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { Dispatch, SetStateAction } from "react"; - -import type { Coupon } from "../types"; -import { validateCouponCode, validateCouponUsage } from "../utils"; - -interface UseCouponActionsParams { - coupons: Coupon[]; - selectedCoupon: Coupon | null; - setCoupons: Dispatch>; - setSelectedCoupon: (coupon: Coupon | null) => void; - addNotification: (message: string, type?: "error" | "success" | "warning") => void; -} - -export function useCouponActions({ - coupons, - selectedCoupon, - setCoupons, - setSelectedCoupon, - addNotification -}: UseCouponActionsParams) { - const addCoupon = (newCoupon: Coupon) => { - const validation = validateCouponCode(newCoupon.code, coupons); - if (!validation.valid) { - addNotification(validation.message, "error"); - return; - } - setCoupons((prev) => [...prev, newCoupon]); - addNotification("쿠폰이 추가되었습니다.", "success"); - }; - - const deleteCoupon = (couponCode: string) => { - setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification("쿠폰이 삭제되었습니다.", "success"); - }; - - const applyCoupon = (coupon: Coupon, totalAmount?: number) => { - // totalAmount가 제공되지 않으면 기본 검증 없이 적용 - if (totalAmount !== undefined) { - const validation = validateCouponUsage(coupon, totalAmount); - if (!validation.valid) { - addNotification(validation.message, "error"); - return; - } - } - - setSelectedCoupon(coupon); - addNotification("쿠폰이 적용되었습니다.", "success"); - }; - - const handleCouponSubmit = ( - couponForm: Coupon, - resetForm: () => void, - setShowForm: (show: boolean) => void - ) => { - addCoupon(couponForm); - resetForm(); - setShowForm(false); - }; - - return { - addCoupon, - deleteCoupon, - applyCoupon, - handleCouponSubmit - }; -} diff --git a/src/basic/domains/coupon/index.ts b/src/basic/domains/coupon/index.ts index 44784bb2..6e8050bd 100644 --- a/src/basic/domains/coupon/index.ts +++ b/src/basic/domains/coupon/index.ts @@ -1,5 +1,5 @@ export * from "./components"; export * from "./constants"; -export * from "./hooks"; +export * from "./services"; export * from "./types"; export * from "./utils"; diff --git a/src/basic/domains/coupon/services/couponApplicationService.ts b/src/basic/domains/coupon/services/couponApplicationService.ts new file mode 100644 index 00000000..31538dfc --- /dev/null +++ b/src/basic/domains/coupon/services/couponApplicationService.ts @@ -0,0 +1,71 @@ +import type { NotificationFunction } from "../../../shared"; +import type { Coupon } from "../types"; +import { couponNotificationService } from "./couponNotificationService"; +import { couponValidationService } from "./couponValidationService"; + +type CouponUpdater = (updater: (prev: Coupon[]) => Coupon[]) => void; + +export const couponApplicationService = { + addCoupon: ( + newCoupon: Coupon, + existingCoupons: Coupon[], + updateCoupons: CouponUpdater, + addNotification: NotificationFunction + ) => { + const validation = couponValidationService.validateCouponCode(newCoupon.code, existingCoupons); + if (!validation.valid) { + couponNotificationService.showValidationError(validation.message!, addNotification); + return; + } + + updateCoupons((prev) => [...prev, newCoupon]); + couponNotificationService.showAddSuccess(addNotification); + }, + + deleteCoupon: ( + couponCode: string, + selectedCoupon: Coupon | null, + updateCoupons: CouponUpdater, + setSelectedCoupon: (coupon: Coupon | null) => void, + addNotification: NotificationFunction + ) => { + updateCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + + couponNotificationService.showDeleteSuccess(addNotification); + }, + + applyCoupon: ( + coupon: Coupon, + totalAmount: number | undefined, + setSelectedCoupon: (coupon: Coupon | null) => void, + addNotification: NotificationFunction + ) => { + if (totalAmount !== undefined) { + const validation = couponValidationService.validateCouponUsage(coupon, totalAmount); + if (!validation.valid) { + couponNotificationService.showValidationError(validation.message!, addNotification); + return; + } + } + + setSelectedCoupon(coupon); + couponNotificationService.showApplySuccess(addNotification); + }, + + handleCouponSubmit: ( + couponForm: Coupon, + existingCoupons: Coupon[], + updateCoupons: CouponUpdater, + resetForm: () => void, + setShowForm: (show: boolean) => void, + addNotification: NotificationFunction + ) => { + couponApplicationService.addCoupon(couponForm, existingCoupons, updateCoupons, addNotification); + resetForm(); + setShowForm(false); + } +}; diff --git a/src/basic/domains/coupon/services/couponNotificationService.ts b/src/basic/domains/coupon/services/couponNotificationService.ts new file mode 100644 index 00000000..468dc341 --- /dev/null +++ b/src/basic/domains/coupon/services/couponNotificationService.ts @@ -0,0 +1,19 @@ +import type { NotificationFunction } from "../../../shared"; + +export const couponNotificationService = { + showAddSuccess: (addNotification: NotificationFunction) => { + addNotification("쿠폰이 추가되었습니다.", "success"); + }, + + showDeleteSuccess: (addNotification: NotificationFunction) => { + addNotification("쿠폰이 삭제되었습니다.", "success"); + }, + + showApplySuccess: (addNotification: NotificationFunction) => { + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + + showValidationError: (message: string, addNotification: NotificationFunction) => { + addNotification(message, "error"); + } +}; diff --git a/src/basic/domains/coupon/services/couponValidationService.ts b/src/basic/domains/coupon/services/couponValidationService.ts new file mode 100644 index 00000000..4ff87b0c --- /dev/null +++ b/src/basic/domains/coupon/services/couponValidationService.ts @@ -0,0 +1,17 @@ +import type { ValidationResult } from "../../../shared"; +import type { Coupon } from "../types"; + +export const couponValidationService = { + validateCouponCode: (code: string, existingCoupons: Coupon[]): ValidationResult => { + const existingCoupon = existingCoupons.find((c) => c.code === code); + return existingCoupon + ? { valid: false, message: "이미 존재하는 쿠폰 코드입니다." } + : { valid: true, message: "사용 가능한 쿠폰 코드입니다." }; + }, + + validateCouponUsage: (coupon: Coupon, totalAmount: number): ValidationResult => { + return totalAmount < 10000 && coupon.discountType === "percentage" + ? { valid: false, message: "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다." } + : { valid: true, message: "쿠폰이 적용되었습니다." }; + } +}; diff --git a/src/basic/domains/coupon/services/index.ts b/src/basic/domains/coupon/services/index.ts new file mode 100644 index 00000000..539a5052 --- /dev/null +++ b/src/basic/domains/coupon/services/index.ts @@ -0,0 +1,3 @@ +export * from "./couponApplicationService"; +export * from "./couponNotificationService"; +export * from "./couponValidationService"; From 004c76e85824563a787c2d64b359a373ddd0bd13 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 14:08:02 +0900 Subject: [PATCH 30/35] =?UTF-8?q?feat:=20product=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/app/pages/AdminPage.tsx | 43 +++++++-- src/basic/domains/product/hooks/index.ts | 1 - .../product/hooks/useProductActions.ts | 77 ---------------- src/basic/domains/product/index.ts | 2 +- src/basic/domains/product/services/index.ts | 2 + .../services/productApplicationService.ts | 92 +++++++++++++++++++ .../services/productNotificationService.ts | 15 +++ 7 files changed, 146 insertions(+), 86 deletions(-) delete mode 100644 src/basic/domains/product/hooks/index.ts delete mode 100644 src/basic/domains/product/hooks/useProductActions.ts create mode 100644 src/basic/domains/product/services/index.ts create mode 100644 src/basic/domains/product/services/productApplicationService.ts create mode 100644 src/basic/domains/product/services/productNotificationService.ts diff --git a/src/basic/app/pages/AdminPage.tsx b/src/basic/app/pages/AdminPage.tsx index a9e70a4a..f84e0372 100644 --- a/src/basic/app/pages/AdminPage.tsx +++ b/src/basic/app/pages/AdminPage.tsx @@ -4,9 +4,9 @@ import type { CartItem, Coupon } from "../../../types"; import { couponApplicationService } from "../../domains/coupon"; import { formatPrice, + productApplicationService, type ProductForm, - type ProductWithUI, - useProductActions + type ProductWithUI } from "../../domains/product"; import { CouponManagementSection, ProductManagementSection } from "../components"; import { AdminTabs } from "../components"; @@ -48,10 +48,40 @@ export function AdminPage({ discountValue: 0 }); - const { deleteProduct, handleProductSubmit, startEditProduct } = useProductActions({ - setProducts, - addNotification - }); + const deleteProduct = (productId: string) => { + productApplicationService.deleteProduct(productId, setProducts, addNotification); + }; + + const handleProductSubmit = ( + productForm: ProductForm, + resetForm: () => void, + setEditingProduct: (id: string | null) => void, + setShowForm: (show: boolean) => void + ) => { + productApplicationService.handleProductSubmit( + productForm, + editingProduct, + setProducts, + resetForm, + setEditingProduct, + setShowForm, + addNotification + ); + }; + + const startEditProduct = ( + product: ProductWithUI, + setEditingProduct: (id: string) => void, + setProductForm: (form: ProductForm) => void, + setShowForm: (show: boolean) => void + ) => { + productApplicationService.startEditProduct( + product, + setEditingProduct, + setProductForm, + setShowForm + ); + }; const deleteCoupon = (couponCode: string) => { couponApplicationService.deleteCoupon(couponCode, null, setCoupons, () => {}, addNotification); @@ -80,7 +110,6 @@ export function AdminPage({ e.preventDefault(); handleProductSubmit( productForm, - editingProduct, () => setProductForm({ name: "", price: 0, stock: 0, description: "", discounts: [] }), setEditingProduct, setShowProductForm diff --git a/src/basic/domains/product/hooks/index.ts b/src/basic/domains/product/hooks/index.ts deleted file mode 100644 index 20c97741..00000000 --- a/src/basic/domains/product/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./useProductActions"; diff --git a/src/basic/domains/product/hooks/useProductActions.ts b/src/basic/domains/product/hooks/useProductActions.ts deleted file mode 100644 index 09cf195a..00000000 --- a/src/basic/domains/product/hooks/useProductActions.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { Dispatch, SetStateAction } from "react"; - -import type { ProductForm, ProductWithUI } from "../types"; - -interface UseProductActionsParams { - setProducts: Dispatch>; - addNotification: (message: string, type?: "error" | "success" | "warning") => void; -} - -export function useProductActions({ setProducts, addNotification }: UseProductActionsParams) { - const addProduct = (newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts((prev) => [...prev, product]); - addNotification("상품이 추가되었습니다.", "success"); - }; - - const updateProduct = (productId: string, updates: Partial) => { - setProducts((prev) => - prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) - ); - addNotification("상품이 수정되었습니다.", "success"); - }; - - const deleteProduct = (productId: string) => { - setProducts((prev) => prev.filter((p) => p.id !== productId)); - addNotification("상품이 삭제되었습니다.", "success"); - }; - - const handleProductSubmit = ( - productForm: ProductForm, - editingProduct: string | null, - resetForm: () => void, - setEditingProduct: (id: string | null) => void, - setShowForm: (show: boolean) => void - ) => { - if (editingProduct && editingProduct !== "new") { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - resetForm(); - setEditingProduct(null); - setShowForm(false); - }; - - const startEditProduct = ( - product: ProductWithUI, - setEditingProduct: (id: string) => void, - setProductForm: (form: ProductForm) => void, - setShowForm: (show: boolean) => void - ) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || "", - discounts: product.discounts || [] - }); - setShowForm(true); - }; - - return { - addProduct, - updateProduct, - deleteProduct, - handleProductSubmit, - startEditProduct - }; -} diff --git a/src/basic/domains/product/index.ts b/src/basic/domains/product/index.ts index 44784bb2..6e8050bd 100644 --- a/src/basic/domains/product/index.ts +++ b/src/basic/domains/product/index.ts @@ -1,5 +1,5 @@ export * from "./components"; export * from "./constants"; -export * from "./hooks"; +export * from "./services"; export * from "./types"; export * from "./utils"; diff --git a/src/basic/domains/product/services/index.ts b/src/basic/domains/product/services/index.ts new file mode 100644 index 00000000..38e141dd --- /dev/null +++ b/src/basic/domains/product/services/index.ts @@ -0,0 +1,2 @@ +export * from "./productApplicationService"; +export * from "./productNotificationService"; diff --git a/src/basic/domains/product/services/productApplicationService.ts b/src/basic/domains/product/services/productApplicationService.ts new file mode 100644 index 00000000..4315faab --- /dev/null +++ b/src/basic/domains/product/services/productApplicationService.ts @@ -0,0 +1,92 @@ +import type { NotificationFunction } from "../../../shared"; +import type { ProductForm, ProductWithUI } from "../types"; +import { productNotificationService } from "./productNotificationService"; + +type ProductUpdater = (updater: (prev: ProductWithUI[]) => ProductWithUI[]) => void; + +export const productApplicationService = { + addProduct: ( + newProduct: Omit, + updateProducts: ProductUpdater, + addNotification: NotificationFunction + ) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + + updateProducts((prev) => [...prev, product]); + productNotificationService.showAddSuccess(addNotification); + }, + + updateProduct: ( + productId: string, + updates: Partial, + updateProducts: ProductUpdater, + addNotification: NotificationFunction + ) => { + updateProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + productNotificationService.showUpdateSuccess(addNotification); + }, + + deleteProduct: ( + productId: string, + updateProducts: ProductUpdater, + addNotification: NotificationFunction + ) => { + updateProducts((prev) => prev.filter((p) => p.id !== productId)); + productNotificationService.showDeleteSuccess(addNotification); + }, + + handleProductSubmit: ( + productForm: ProductForm, + editingProduct: string | null, + updateProducts: ProductUpdater, + resetForm: () => void, + setEditingProduct: (id: string | null) => void, + setShowForm: (show: boolean) => void, + addNotification: NotificationFunction + ) => { + if (editingProduct && editingProduct !== "new") { + productApplicationService.updateProduct( + editingProduct, + productForm, + updateProducts, + addNotification + ); + setEditingProduct(null); + } else { + productApplicationService.addProduct( + { + ...productForm, + discounts: productForm.discounts + }, + updateProducts, + addNotification + ); + } + + resetForm(); + setEditingProduct(null); + setShowForm(false); + }, + + startEditProduct: ( + product: ProductWithUI, + setEditingProduct: (id: string) => void, + setProductForm: (form: ProductForm) => void, + setShowForm: (show: boolean) => void + ) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [] + }); + setShowForm(true); + } +}; diff --git a/src/basic/domains/product/services/productNotificationService.ts b/src/basic/domains/product/services/productNotificationService.ts new file mode 100644 index 00000000..76765027 --- /dev/null +++ b/src/basic/domains/product/services/productNotificationService.ts @@ -0,0 +1,15 @@ +import type { NotificationFunction } from "../../../shared"; + +export const productNotificationService = { + showAddSuccess: (addNotification: NotificationFunction) => { + addNotification("상품이 추가되었습니다.", "success"); + }, + + showUpdateSuccess: (addNotification: NotificationFunction) => { + addNotification("상품이 수정되었습니다.", "success"); + }, + + showDeleteSuccess: (addNotification: NotificationFunction) => { + addNotification("상품이 삭제되었습니다.", "success"); + } +}; From 83bccfaf1deab94ba6608167415af8aec3ac83f6 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 14:17:41 +0900 Subject: [PATCH 31/35] =?UTF-8?q?chore:=20advanced=20=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/App.tsx | 1337 ----------------- src/advanced/__tests__/origin.test.tsx | 5 +- src/advanced/app/App.tsx | 141 ++ src/advanced/app/components/AdminTabs.tsx | 45 + .../app/components/AdminToggleButton.tsx | 26 + .../components/CouponManagementSection.tsx | 60 + src/advanced/app/components/Header.tsx | 61 + .../app/components/NotificationList.tsx | 20 + .../components/ProductManagementSection.tsx | 70 + src/advanced/app/components/index.ts | 6 + src/advanced/app/index.ts | 1 + src/advanced/app/pages/AdminPage.tsx | 209 +++ src/advanced/app/pages/CartPage.tsx | 74 + src/advanced/app/pages/index.ts | 2 + .../cart/components/CartItemHeader.tsx | 22 + .../domains/cart/components/CartItemInfo.tsx | 48 + .../domains/cart/components/CartItemList.tsx | 44 + .../domains/cart/components/CartSidebar.tsx | 55 + .../cart/components/CheckoutButton.tsx | 20 + .../cart/components/CouponSelector.tsx | 50 + .../cart/components/PaymentInfoLine.tsx | 46 + .../cart/components/PaymentSummary.tsx | 47 + .../cart/components/QuantitySelector.tsx | 33 + src/advanced/domains/cart/components/index.ts | 9 + src/advanced/domains/cart/index.ts | 4 + .../cart/services/cartApplicationService.ts | 85 ++ .../cart/services/cartNotificationService.ts | 15 + .../cart/services/cartValidationService.ts | 48 + src/advanced/domains/cart/services/index.ts | 4 + .../domains/cart/services/orderService.ts | 13 + src/advanced/domains/cart/types/entities.ts | 11 + src/advanced/domains/cart/types/index.ts | 1 + .../domains/cart/utils/calculators.ts | 61 + src/advanced/domains/cart/utils/index.ts | 1 + .../coupon/components/AddCouponCard.tsx | 19 + .../domains/coupon/components/CouponCard.tsx | 37 + .../domains/coupon/components/CouponForm.tsx | 143 ++ .../domains/coupon/components/index.ts | 3 + .../domains/coupon/constants/index.ts | 1 + .../domains/coupon/constants/initialData.ts | 16 + src/advanced/domains/coupon/index.ts | 5 + .../services/couponApplicationService.ts | 71 + .../services/couponNotificationService.ts | 19 + .../services/couponValidationService.ts | 17 + src/advanced/domains/coupon/services/index.ts | 3 + src/advanced/domains/coupon/types/entities.ts | 6 + src/advanced/domains/coupon/types/index.ts | 1 + src/advanced/domains/coupon/utils/index.ts | 1 + .../domains/coupon/utils/validators.ts | 30 + .../product/components/DiscountItem.tsx | 54 + .../product/components/DiscountSection.tsx | 48 + .../product/components/ProductCard.tsx | 58 + .../product/components/ProductFormEditor.tsx | 140 ++ .../product/components/ProductImage.tsx | 30 + .../product/components/ProductList.tsx | 50 + .../product/components/ProductTable.tsx | 48 + .../product/components/ProductTableRow.tsx | 55 + .../domains/product/components/StockBadge.tsx | 22 + .../product/components/StockStatus.tsx | 30 + .../domains/product/components/index.ts | 10 + .../domains/product/constants/index.ts | 1 + .../domains/product/constants/initialData.ts | 35 + src/advanced/domains/product/index.ts | 5 + .../domains/product/services/index.ts | 2 + .../services/productApplicationService.ts | 92 ++ .../services/productNotificationService.ts | 15 + .../domains/product/types/entities.ts | 25 + src/advanced/domains/product/types/index.ts | 1 + .../domains/product/utils/formatters.ts | 35 + src/advanced/domains/product/utils/index.ts | 1 + src/advanced/main.tsx | 18 +- .../shared/components/icons/CartIcon.tsx | 16 + .../shared/components/icons/CloseIcon.tsx | 11 + .../components/icons/ImagePlaceholderIcon.tsx | 19 + .../shared/components/icons/PlusIcon.tsx | 11 + .../components/icons/ShoppingBagIcon.tsx | 22 + .../shared/components/icons/TrashIcon.tsx | 16 + src/advanced/shared/components/icons/index.ts | 6 + src/advanced/shared/components/index.ts | 2 + .../shared/components/ui/BadgeContainer.tsx | 19 + src/advanced/shared/components/ui/Button.tsx | 34 + .../shared/components/ui/Notification.tsx | 36 + .../shared/components/ui/SearchInput.tsx | 35 + src/advanced/shared/components/ui/index.ts | 4 + src/advanced/shared/hooks/index.ts | 5 + src/advanced/shared/hooks/useDebounceState.ts | 22 + src/advanced/shared/hooks/useDebounceValue.ts | 20 + .../shared/hooks/useLocalStorageState.ts | 36 + src/advanced/shared/hooks/useNotifications.ts | 26 + src/advanced/shared/hooks/useToggle.ts | 11 + src/advanced/shared/index.ts | 4 + src/advanced/shared/types/index.ts | 2 + src/advanced/shared/types/notification.ts | 10 + src/advanced/shared/types/validation.ts | 4 + src/advanced/shared/utils/index.ts | 1 + src/basic/main.tsx | 18 +- 96 files changed, 2860 insertions(+), 1351 deletions(-) delete mode 100644 src/advanced/App.tsx create mode 100644 src/advanced/app/App.tsx create mode 100644 src/advanced/app/components/AdminTabs.tsx create mode 100644 src/advanced/app/components/AdminToggleButton.tsx create mode 100644 src/advanced/app/components/CouponManagementSection.tsx create mode 100644 src/advanced/app/components/Header.tsx create mode 100644 src/advanced/app/components/NotificationList.tsx create mode 100644 src/advanced/app/components/ProductManagementSection.tsx create mode 100644 src/advanced/app/components/index.ts create mode 100644 src/advanced/app/index.ts create mode 100644 src/advanced/app/pages/AdminPage.tsx create mode 100644 src/advanced/app/pages/CartPage.tsx create mode 100644 src/advanced/app/pages/index.ts create mode 100644 src/advanced/domains/cart/components/CartItemHeader.tsx create mode 100644 src/advanced/domains/cart/components/CartItemInfo.tsx create mode 100644 src/advanced/domains/cart/components/CartItemList.tsx create mode 100644 src/advanced/domains/cart/components/CartSidebar.tsx create mode 100644 src/advanced/domains/cart/components/CheckoutButton.tsx create mode 100644 src/advanced/domains/cart/components/CouponSelector.tsx create mode 100644 src/advanced/domains/cart/components/PaymentInfoLine.tsx create mode 100644 src/advanced/domains/cart/components/PaymentSummary.tsx create mode 100644 src/advanced/domains/cart/components/QuantitySelector.tsx create mode 100644 src/advanced/domains/cart/components/index.ts create mode 100644 src/advanced/domains/cart/index.ts create mode 100644 src/advanced/domains/cart/services/cartApplicationService.ts create mode 100644 src/advanced/domains/cart/services/cartNotificationService.ts create mode 100644 src/advanced/domains/cart/services/cartValidationService.ts create mode 100644 src/advanced/domains/cart/services/index.ts create mode 100644 src/advanced/domains/cart/services/orderService.ts create mode 100644 src/advanced/domains/cart/types/entities.ts create mode 100644 src/advanced/domains/cart/types/index.ts create mode 100644 src/advanced/domains/cart/utils/calculators.ts create mode 100644 src/advanced/domains/cart/utils/index.ts create mode 100644 src/advanced/domains/coupon/components/AddCouponCard.tsx create mode 100644 src/advanced/domains/coupon/components/CouponCard.tsx create mode 100644 src/advanced/domains/coupon/components/CouponForm.tsx create mode 100644 src/advanced/domains/coupon/components/index.ts create mode 100644 src/advanced/domains/coupon/constants/index.ts create mode 100644 src/advanced/domains/coupon/constants/initialData.ts create mode 100644 src/advanced/domains/coupon/index.ts create mode 100644 src/advanced/domains/coupon/services/couponApplicationService.ts create mode 100644 src/advanced/domains/coupon/services/couponNotificationService.ts create mode 100644 src/advanced/domains/coupon/services/couponValidationService.ts create mode 100644 src/advanced/domains/coupon/services/index.ts create mode 100644 src/advanced/domains/coupon/types/entities.ts create mode 100644 src/advanced/domains/coupon/types/index.ts create mode 100644 src/advanced/domains/coupon/utils/index.ts create mode 100644 src/advanced/domains/coupon/utils/validators.ts create mode 100644 src/advanced/domains/product/components/DiscountItem.tsx create mode 100644 src/advanced/domains/product/components/DiscountSection.tsx create mode 100644 src/advanced/domains/product/components/ProductCard.tsx create mode 100644 src/advanced/domains/product/components/ProductFormEditor.tsx create mode 100644 src/advanced/domains/product/components/ProductImage.tsx create mode 100644 src/advanced/domains/product/components/ProductList.tsx create mode 100644 src/advanced/domains/product/components/ProductTable.tsx create mode 100644 src/advanced/domains/product/components/ProductTableRow.tsx create mode 100644 src/advanced/domains/product/components/StockBadge.tsx create mode 100644 src/advanced/domains/product/components/StockStatus.tsx create mode 100644 src/advanced/domains/product/components/index.ts create mode 100644 src/advanced/domains/product/constants/index.ts create mode 100644 src/advanced/domains/product/constants/initialData.ts create mode 100644 src/advanced/domains/product/index.ts create mode 100644 src/advanced/domains/product/services/index.ts create mode 100644 src/advanced/domains/product/services/productApplicationService.ts create mode 100644 src/advanced/domains/product/services/productNotificationService.ts create mode 100644 src/advanced/domains/product/types/entities.ts create mode 100644 src/advanced/domains/product/types/index.ts create mode 100644 src/advanced/domains/product/utils/formatters.ts create mode 100644 src/advanced/domains/product/utils/index.ts create mode 100644 src/advanced/shared/components/icons/CartIcon.tsx create mode 100644 src/advanced/shared/components/icons/CloseIcon.tsx create mode 100644 src/advanced/shared/components/icons/ImagePlaceholderIcon.tsx create mode 100644 src/advanced/shared/components/icons/PlusIcon.tsx create mode 100644 src/advanced/shared/components/icons/ShoppingBagIcon.tsx create mode 100644 src/advanced/shared/components/icons/TrashIcon.tsx create mode 100644 src/advanced/shared/components/icons/index.ts create mode 100644 src/advanced/shared/components/index.ts create mode 100644 src/advanced/shared/components/ui/BadgeContainer.tsx create mode 100644 src/advanced/shared/components/ui/Button.tsx create mode 100644 src/advanced/shared/components/ui/Notification.tsx create mode 100644 src/advanced/shared/components/ui/SearchInput.tsx create mode 100644 src/advanced/shared/components/ui/index.ts create mode 100644 src/advanced/shared/hooks/index.ts create mode 100644 src/advanced/shared/hooks/useDebounceState.ts create mode 100644 src/advanced/shared/hooks/useDebounceValue.ts create mode 100644 src/advanced/shared/hooks/useLocalStorageState.ts create mode 100644 src/advanced/shared/hooks/useNotifications.ts create mode 100644 src/advanced/shared/hooks/useToggle.ts create mode 100644 src/advanced/shared/index.ts create mode 100644 src/advanced/shared/types/index.ts create mode 100644 src/advanced/shared/types/notification.ts create mode 100644 src/advanced/shared/types/validation.ts create mode 100644 src/advanced/shared/utils/index.ts diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx deleted file mode 100644 index 45a2bf68..00000000 --- a/src/advanced/App.tsx +++ /dev/null @@ -1,1337 +0,0 @@ -import { FormEvent, useCallback, useEffect, useState } from "react"; - -import { CartItem, Coupon, Product } from "../types"; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: "error" | "success" | "warning"; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: "p1", - name: "상품1", - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: "최고급 품질의 프리미엄 상품입니다." - }, - { - id: "p2", - name: "상품2", - price: 20000, - stock: 20, - discounts: [{ quantity: 10, rate: 0.15 }], - description: "다양한 기능을 갖춘 실용적인 상품입니다.", - isRecommended: true - }, - { - id: "p3", - name: "상품3", - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: "대용량과 고성능을 자랑하는 상품입니다." - } -]; - -const initialCoupons: Coupon[] = [ - { - name: "5000원 할인", - code: "AMOUNT5000", - discountType: "amount", - discountValue: 5000 - }, - { - name: "10% 할인", - code: "PERCENT10", - discountType: "percentage", - discountValue: 10 - } -]; - -const App = () => { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem("products"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem("cart"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem("coupons"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: "", - price: 0, - stock: 0, - description: "", - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: "", - code: "", - discountType: "amount" as "amount" | "percentage", - discountValue: 0 - }); - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find((p) => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return "SOLD OUT"; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach((item) => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === "amount") { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round( - totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) - ); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find((item) => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback( - (message: string, type: "error" | "success" | "warning" = "success") => { - const id = Date.now().toString(); - setNotifications((prev) => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications((prev) => prev.filter((n) => n.id !== id)); - }, 3000); - }, - [] - ); - - const [totalItemCount, setTotalItemCount] = useState(0); - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem("products", JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem("coupons", JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem("cart", JSON.stringify(cart)); - } else { - localStorage.removeItem("cart"); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback( - (product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification("재고가 부족합니다!", "error"); - return; - } - - setCart((prevCart) => { - const existingItem = prevCart.find((item) => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); - return prevCart; - } - - return prevCart.map((item) => - item.product.id === product.id ? { ...item, quantity: newQuantity } : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification("장바구니에 담았습니다", "success"); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [cart, addNotification, getRemainingStock] - ); - - const removeFromCart = useCallback((productId: string) => { - setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback( - (productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find((p) => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, "error"); - return; - } - - setCart((prevCart) => - prevCart.map((item) => - item.product.id === productId ? { ...item, quantity: newQuantity } : item - ) - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [products, removeFromCart, addNotification, getRemainingStock] - ); - - const applyCoupon = useCallback( - (coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === "percentage") { - addNotification("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", "error"); - return; - } - - setSelectedCoupon(coupon); - addNotification("쿠폰이 적용되었습니다.", "success"); - }, - [addNotification, calculateCartTotal] - ); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback( - (newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts((prev) => [...prev, product]); - addNotification("상품이 추가되었습니다.", "success"); - }, - [addNotification] - ); - - const updateProduct = useCallback( - (productId: string, updates: Partial) => { - setProducts((prev) => - prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) - ); - addNotification("상품이 수정되었습니다.", "success"); - }, - [addNotification] - ); - - const deleteProduct = useCallback( - (productId: string) => { - setProducts((prev) => prev.filter((p) => p.id !== productId)); - addNotification("상품이 삭제되었습니다.", "success"); - }, - [addNotification] - ); - - const addCoupon = useCallback( - (newCoupon: Coupon) => { - const existingCoupon = coupons.find((c) => c.code === newCoupon.code); - if (existingCoupon) { - addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); - return; - } - setCoupons((prev) => [...prev, newCoupon]); - addNotification("쿠폰이 추가되었습니다.", "success"); - }, - [coupons, addNotification] - ); - - const deleteCoupon = useCallback( - (couponCode: string) => { - setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification("쿠폰이 삭제되었습니다.", "success"); - }, - [selectedCoupon, addNotification] - ); - - const handleProductSubmit = (e: FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== "new") { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: "", price: 0, stock: 0, description: "", discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: "", - code: "", - discountType: "amount", - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || "", - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter( - (product) => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && - product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; - - return ( -
- {notifications.length > 0 && ( -
- {notifications.map((notif) => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && ( -
- setSearchTerm(e.target.value)} - placeholder="상품 검색..." - className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:outline-none" - /> -
- )} -
- -
-
-
- -
- {isAdmin ? ( -
-
-

관리자 대시보드

-

상품과 쿠폰을 관리할 수 있습니다

-
-
- -
- - {activeTab === "products" ? ( -
-
-
-

상품 목록

- -
-
- -
- - - - - - - - - - - - {(activeTab === "products" ? products : products).map((product) => ( - - - - - - - - ))} - -
- 상품명 - - 가격 - - 재고 - - 설명 - - 작업 -
- {product.name} - - {formatPrice(product.price, product.id)} - - 10 - ? "bg-green-100 text-green-800" - : product.stock > 0 - ? "bg-yellow-100 text-yellow-800" - : "bg-red-100 text-red-800" - }`} - > - {product.stock}개 - - - {product.description || "-"} - - - -
-
- {showProductForm && ( -
-
-

- {editingProduct === "new" ? "새 상품 추가" : "상품 수정"} -

-
-
- - - setProductForm({ ...productForm, name: e.target.value }) - } - className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - required - /> -
-
- - - setProductForm({ ...productForm, description: e.target.value }) - } - className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - /> -
-
- - { - const value = e.target.value; - if (value === "" || /^\d+$/.test(value)) { - setProductForm({ - ...productForm, - price: value === "" ? 0 : parseInt(value) - }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === "") { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification("가격은 0보다 커야 합니다", "error"); - setProductForm({ ...productForm, price: 0 }); - } - }} - className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === "" || /^\d+$/.test(value)) { - setProductForm({ - ...productForm, - stock: value === "" ? 0 : parseInt(value) - }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === "") { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification("재고는 0보다 커야 합니다", "error"); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification("재고는 9999개를 초과할 수 없습니다", "error"); - setProductForm({ ...productForm, stock: 9999 }); - } - }} - className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - placeholder="숫자만 입력" - required - /> -
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 rounded border px-2 py-1" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 rounded border px-2 py-1" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} - -
-
- -
- - -
-
-
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {coupons.map((coupon) => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === "amount" - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
- -
-
- ))} - -
- -
-
- - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- - - setCouponForm({ ...couponForm, name: e.target.value }) - } - className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }) - } - className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === "" || /^\d+$/.test(value)) { - setCouponForm({ - ...couponForm, - discountValue: value === "" ? 0 : parseInt(value) - }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === "percentage") { - if (value > 100) { - addNotification("할인율은 100%를 초과할 수 없습니다", "error"); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification( - "할인 금액은 100,000원을 초과할 수 없습니다", - "error" - ); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" - placeholder={couponForm.discountType === "amount" ? "5000" : "10"} - required - /> -
-
-
- - -
-
-
- )} -
-
- )} -
- ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

-
총 {products.length}개 상품
-
- {filteredProducts.length === 0 ? ( -
-

- "{debouncedSearchTerm}"에 대한 검색 결과가 없습니다. -

-
- ) : ( -
- {filteredProducts.map((product) => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

- {product.description} -

- )} - - {/* 가격 정보 */} -
-

- {formatPrice(product.price, product.id)} -

- {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인{" "} - {product.discounts[0].rate * 100}% -

- )} -
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

- 품절임박! {remainingStock}개 남음 -

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

- )} -
- - {/* 장바구니 버튼 */} - -
-
- ); - })} -
- )} -
-
- -
-
-
-

- - - - 장바구니 -

- {cart.length === 0 ? ( -
- - - -

장바구니가 비어있습니다

-
- ) : ( -
- {cart.map((item) => { - const itemTotal = calculateItemTotal(item); - const originalPrice = item.product.price * item.quantity; - const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount - ? Math.round((1 - itemTotal / originalPrice) * 100) - : 0; - - return ( -
-
-

- {item.product.name} -

- -
-
-
- - - {item.quantity} - - -
-
- {hasDiscount && ( - - -{discountRate}% - - )} -

- {Math.round(itemTotal).toLocaleString()}원 -

-
-
-
- ); - })} -
- )} -
- - {cart.length > 0 && ( - <> -
-
-

쿠폰 할인

- -
- {coupons.length > 0 && ( - - )} -
- -
-

결제 정보

-
-
- 상품 금액 - - {totals.totalBeforeDiscount.toLocaleString()}원 - -
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( -
- 할인 금액 - - - - {( - totals.totalBeforeDiscount - totals.totalAfterDiscount - ).toLocaleString()} - 원 - -
- )} -
- 결제 예정 금액 - - {totals.totalAfterDiscount.toLocaleString()}원 - -
-
- - - -
-

* 실제 결제는 이루어지지 않습니다

-
-
- - )} -
-
-
- )} -
-
- ); -}; - -export default App; diff --git a/src/advanced/__tests__/origin.test.tsx b/src/advanced/__tests__/origin.test.tsx index 3f5c3d55..5f857960 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -1,8 +1,9 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { vi } from 'vitest'; -import App from '../App'; + import '../../setupTests'; +import { App } from '../app'; describe('쇼핑몰 앱 통합 테스트', () => { beforeEach(() => { diff --git a/src/advanced/app/App.tsx b/src/advanced/app/App.tsx new file mode 100644 index 00000000..5921f47f --- /dev/null +++ b/src/advanced/app/App.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; + +import { + calculateCartTotal, + calculateItemTotal, + cartApplicationService, + type CartItem, + getRemainingStock +} from "../domains/cart"; +import { type Coupon, couponApplicationService, INITIAL_COUPONS } from "../domains/coupon"; +import { + formatPrice, + INITIAL_PRODUCTS, + type Product, + type ProductWithUI +} from "../domains/product"; +import { useDebounceState, useLocalStorageState, useNotifications, useToggle } from "../shared"; +import { Header, NotificationList } from "./components"; +import { AdminPage, CartPage } from "./pages"; + +export function App() { + const [products, setProducts] = useLocalStorageState({ + key: "products", + initialState: INITIAL_PRODUCTS + }); + + const [cart, setCart] = useLocalStorageState({ + key: "cart", + initialState: [] + }); + + const [coupons, setCoupons] = useLocalStorageState({ + key: "coupons", + initialState: INITIAL_COUPONS + }); + + const [searchTerm, setSearchTerm, debouncedSearchTerm] = useDebounceState({ + delay: 500, + initialValue: "" + }); + + const { notifications, addNotification, removeNotification } = useNotifications(); + + const [isAdminMode, toggleAdminMode] = useToggle(false); + + const [selectedCoupon, setSelectedCoupon] = useState(null); + + const formatPriceWithContext = (price: number, productId?: string) => { + return formatPrice(price, productId, products, cart, isAdminMode); + }; + + const [totalItemCount, setTotalItemCount] = useState(0); + + const addToCart = (product: Product) => { + cartApplicationService.addToCart(product, cart, setCart, addNotification); + }; + + const removeFromCart = (productId: string) => { + cartApplicationService.removeFromCart(productId, setCart); + }; + + const updateQuantity = (productId: string, newQuantity: number) => { + cartApplicationService.updateQuantity( + productId, + newQuantity, + products, + setCart, + addNotification + ); + }; + + const completeOrder = () => { + cartApplicationService.completeOrder( + () => setCart([]), + () => setSelectedCoupon(null), + addNotification + ); + }; + + const applyCoupon = (coupon: Coupon) => { + const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; + couponApplicationService.applyCoupon(coupon, currentTotal, setSelectedCoupon, addNotification); + }; + + const calculateItemTotalWithCart = (item: CartItem) => { + return calculateItemTotal(item, cart); + }; + + const getRemainingStockWithCart = (product: Product) => { + return getRemainingStock(product, cart); + }; + + useEffect(() => { + const count = cart.reduce((sum, item) => sum + item.quantity, 0); + setTotalItemCount(count); + }, [cart]); + + return ( +
+
+
+ {isAdminMode ? ( + + ) : ( + + )} +
+ +
+ ); +} diff --git a/src/advanced/app/components/AdminTabs.tsx b/src/advanced/app/components/AdminTabs.tsx new file mode 100644 index 00000000..f6655dc8 --- /dev/null +++ b/src/advanced/app/components/AdminTabs.tsx @@ -0,0 +1,45 @@ +import { tv } from "tailwind-variants"; + +const tabButton = tv({ + base: "border-b-2 px-1 py-2 text-sm font-medium transition-colors", + variants: { + active: { + true: "border-gray-900 text-gray-900", + false: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" + } + } +}); + +type AdminTabsProps = { + activeTab: "products" | "coupons"; + onTabChange: (tab: "products" | "coupons") => void; +}; + +export function AdminTabs({ activeTab, onTabChange }: AdminTabsProps) { + const handleProductsTabClick = () => { + onTabChange("products"); + }; + + const handleCouponsTabClick = () => { + onTabChange("coupons"); + }; + + return ( +
+ +
+ ); +} diff --git a/src/advanced/app/components/AdminToggleButton.tsx b/src/advanced/app/components/AdminToggleButton.tsx new file mode 100644 index 00000000..be453102 --- /dev/null +++ b/src/advanced/app/components/AdminToggleButton.tsx @@ -0,0 +1,26 @@ +import { tv } from "tailwind-variants"; + +type AdminToggleButtonProps = { + isAdmin: boolean; + onToggleAdminMode: () => void; +}; + +const adminToggle = tv({ + base: "rounded px-3 py-1.5 text-sm transition-colors", + variants: { + mode: { + admin: "bg-gray-800 text-white", + cart: "text-gray-600 hover:text-gray-900" + } + } +}); + +export function AdminToggleButton({ isAdmin, onToggleAdminMode }: AdminToggleButtonProps) { + const buttonClassName = adminToggle({ mode: isAdmin ? "admin" : "cart" }); + + return ( + + ); +} diff --git a/src/advanced/app/components/CouponManagementSection.tsx b/src/advanced/app/components/CouponManagementSection.tsx new file mode 100644 index 00000000..6b2599ca --- /dev/null +++ b/src/advanced/app/components/CouponManagementSection.tsx @@ -0,0 +1,60 @@ +import { type FormEvent } from "react"; + +import { AddCouponCard, type Coupon, CouponCard, CouponForm } from "../../domains/coupon"; + +type CouponFormType = { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +}; + +type CouponManagementSectionProps = { + coupons: Coupon[]; + couponForm: CouponFormType; + showCouponForm: boolean; + onToggleForm: () => void; + onDelete: (code: string) => void; + onFormSubmit: (e: FormEvent) => void; + onFormCancel: () => void; + onFormChange: (form: CouponFormType) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +}; + +export function CouponManagementSection({ + coupons, + couponForm, + showCouponForm, + onToggleForm, + onDelete, + onFormSubmit, + onFormCancel, + onFormChange, + addNotification +}: CouponManagementSectionProps) { + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( + + ))} + +
+ + {showCouponForm && ( + + )} +
+
+ ); +} diff --git a/src/advanced/app/components/Header.tsx b/src/advanced/app/components/Header.tsx new file mode 100644 index 00000000..712dd1d7 --- /dev/null +++ b/src/advanced/app/components/Header.tsx @@ -0,0 +1,61 @@ +import { type ChangeEvent } from "react"; + +import type { CartItem } from "../../domains/cart"; +import { BadgeContainer, CartIcon, SearchInput } from "../../shared"; +import { AdminToggleButton } from "./AdminToggleButton"; + +type HeaderProps = { + isAdminMode: boolean; + onToggleAdminMode: () => void; + cart: CartItem[]; + searchTerm: string; + setSearchTerm: (term: string) => void; + totalItemCount: number; +}; + +export function Header({ + isAdminMode, + onToggleAdminMode, + cart, + searchTerm, + setSearchTerm, + totalItemCount +}: HeaderProps) { + const handleSearchChange = (e: ChangeEvent) => { + setSearchTerm(e.target.value); + }; + + return ( +
+
+
+
+

SHOP

+ {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} + {!isAdminMode && ( +
+ +
+ )} +
+ + +
+
+
+ ); +} diff --git a/src/advanced/app/components/NotificationList.tsx b/src/advanced/app/components/NotificationList.tsx new file mode 100644 index 00000000..52b37c84 --- /dev/null +++ b/src/advanced/app/components/NotificationList.tsx @@ -0,0 +1,20 @@ +import { Notification, type NotificationItem } from "../../shared"; + +interface NotificationListProps { + notifications: NotificationItem[]; + onRemove: (id: string) => void; +} + +export function NotificationList({ notifications, onRemove }: NotificationListProps) { + if (notifications.length === 0) { + return null; + } + + return ( +
+ {notifications.map((notif) => ( + + ))} +
+ ); +} diff --git a/src/advanced/app/components/ProductManagementSection.tsx b/src/advanced/app/components/ProductManagementSection.tsx new file mode 100644 index 00000000..7e146c16 --- /dev/null +++ b/src/advanced/app/components/ProductManagementSection.tsx @@ -0,0 +1,70 @@ +import { type FormEvent } from "react"; + +import { + type ProductForm, + ProductFormEditor, + ProductTable, + type ProductWithUI +} from "../../domains/product"; +import { Button } from "../../shared"; + +type ProductManagementSectionProps = { + products: ProductWithUI[]; + productForm: ProductForm; + editingProduct: string | null; + showProductForm: boolean; + formatPrice: (price: number, productId?: string) => string; + onAddNew: () => void; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; + onFormSubmit: (e: FormEvent) => void; + onFormCancel: () => void; + onFormChange: (form: ProductForm) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +}; + +export function ProductManagementSection({ + products, + productForm, + editingProduct, + showProductForm, + formatPrice, + onAddNew, + onEdit, + onDelete, + onFormSubmit, + onFormCancel, + onFormChange, + addNotification +}: ProductManagementSectionProps) { + return ( +
+
+
+

상품 목록

+ +
+
+ + + + {showProductForm && ( + + )} +
+ ); +} diff --git a/src/advanced/app/components/index.ts b/src/advanced/app/components/index.ts new file mode 100644 index 00000000..ae70cf87 --- /dev/null +++ b/src/advanced/app/components/index.ts @@ -0,0 +1,6 @@ +export * from "./AdminTabs"; +export * from "./AdminToggleButton"; +export * from "./CouponManagementSection"; +export * from "./Header"; +export * from "./NotificationList"; +export * from "./ProductManagementSection"; diff --git a/src/advanced/app/index.ts b/src/advanced/app/index.ts new file mode 100644 index 00000000..c8543026 --- /dev/null +++ b/src/advanced/app/index.ts @@ -0,0 +1 @@ +export * from "./App"; diff --git a/src/advanced/app/pages/AdminPage.tsx b/src/advanced/app/pages/AdminPage.tsx new file mode 100644 index 00000000..f84e0372 --- /dev/null +++ b/src/advanced/app/pages/AdminPage.tsx @@ -0,0 +1,209 @@ +import { type Dispatch, FormEvent, type SetStateAction, useState } from "react"; + +import type { CartItem, Coupon } from "../../../types"; +import { couponApplicationService } from "../../domains/coupon"; +import { + formatPrice, + productApplicationService, + type ProductForm, + type ProductWithUI +} from "../../domains/product"; +import { CouponManagementSection, ProductManagementSection } from "../components"; +import { AdminTabs } from "../components"; + +type AdminPageProps = { + products: ProductWithUI[]; + setProducts: Dispatch>; + coupons: Coupon[]; + setCoupons: Dispatch>; + cart: CartItem[]; + isAdminMode: boolean; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +}; + +export function AdminPage({ + products, + setProducts, + coupons, + setCoupons, + cart, + isAdminMode, + addNotification +}: AdminPageProps) { + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); + const [showProductForm, setShowProductForm] = useState(false); + const [showCouponForm, setShowCouponForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] + }); + const [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0 + }); + + const deleteProduct = (productId: string) => { + productApplicationService.deleteProduct(productId, setProducts, addNotification); + }; + + const handleProductSubmit = ( + productForm: ProductForm, + resetForm: () => void, + setEditingProduct: (id: string | null) => void, + setShowForm: (show: boolean) => void + ) => { + productApplicationService.handleProductSubmit( + productForm, + editingProduct, + setProducts, + resetForm, + setEditingProduct, + setShowForm, + addNotification + ); + }; + + const startEditProduct = ( + product: ProductWithUI, + setEditingProduct: (id: string) => void, + setProductForm: (form: ProductForm) => void, + setShowForm: (show: boolean) => void + ) => { + productApplicationService.startEditProduct( + product, + setEditingProduct, + setProductForm, + setShowForm + ); + }; + + const deleteCoupon = (couponCode: string) => { + couponApplicationService.deleteCoupon(couponCode, null, setCoupons, () => {}, addNotification); + }; + + const handleCouponSubmit = ( + couponForm: Coupon, + resetForm: () => void, + setShowForm: (show: boolean) => void + ) => { + couponApplicationService.handleCouponSubmit( + couponForm, + coupons, + setCoupons, + resetForm, + setShowForm, + addNotification + ); + }; + + const formatPriceWithContext = (price: number, productId?: string) => { + return formatPrice(price, productId, products, cart, isAdminMode); + }; + + const handleProductSubmitWrapper = (e: FormEvent) => { + e.preventDefault(); + handleProductSubmit( + productForm, + () => setProductForm({ name: "", price: 0, stock: 0, description: "", discounts: [] }), + setEditingProduct, + setShowProductForm + ); + }; + + const handleCouponSubmitWrapper = (e: FormEvent) => { + e.preventDefault(); + handleCouponSubmit( + couponForm, + () => + setCouponForm({ + name: "", + code: "", + discountType: "amount", + discountValue: 0 + }), + setShowCouponForm + ); + }; + + const startEditProductWrapper = (product: ProductWithUI) => { + startEditProduct(product, setEditingProduct, setProductForm, setShowProductForm); + }; + + const handleAddNewProduct = () => { + setEditingProduct("new"); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] + }); + setShowProductForm(true); + }; + + const handleCancelProductForm = () => { + setEditingProduct(null); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] + }); + setShowProductForm(false); + }; + + const handleToggleCouponForm = () => { + setShowCouponForm(!showCouponForm); + }; + + const handleCancelCouponForm = () => { + setShowCouponForm(false); + }; + + return ( +
+
+

관리자 대시보드

+

상품과 쿠폰을 관리할 수 있습니다

+
+ + + + {activeTab === "products" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/advanced/app/pages/CartPage.tsx b/src/advanced/app/pages/CartPage.tsx new file mode 100644 index 00000000..86daf261 --- /dev/null +++ b/src/advanced/app/pages/CartPage.tsx @@ -0,0 +1,74 @@ +import type { CartItem, Coupon, Product } from "../../../types"; +import { calculateCartTotal, CartSidebar } from "../../domains/cart"; +import { filterProducts, ProductList } from "../../domains/product"; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +type CartPageProps = { + products: ProductWithUI[]; + debouncedSearchTerm: string; + getRemainingStock: (product: Product) => number; + formatPrice: (price: number, productId?: string) => string; + addToCart: (product: ProductWithUI) => void; + cart: CartItem[]; + calculateItemTotal: (item: CartItem) => number; + updateQuantity: (productId: string, newQuantity: number) => void; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; + completeOrder: () => void; + removeFromCart: (productId: string) => void; +}; + +export function CartPage({ + addToCart, + applyCoupon, + calculateItemTotal, + cart, + completeOrder, + coupons, + debouncedSearchTerm, + formatPrice, + getRemainingStock, + products, + selectedCoupon, + setSelectedCoupon, + updateQuantity, + removeFromCart +}: CartPageProps) { + const filteredProducts = filterProducts(products, debouncedSearchTerm); + const totals = calculateCartTotal(cart, selectedCoupon); + + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/src/advanced/app/pages/index.ts b/src/advanced/app/pages/index.ts new file mode 100644 index 00000000..dfb7b6b6 --- /dev/null +++ b/src/advanced/app/pages/index.ts @@ -0,0 +1,2 @@ +export * from "./AdminPage"; +export * from "./CartPage"; diff --git a/src/advanced/domains/cart/components/CartItemHeader.tsx b/src/advanced/domains/cart/components/CartItemHeader.tsx new file mode 100644 index 00000000..305cfd30 --- /dev/null +++ b/src/advanced/domains/cart/components/CartItemHeader.tsx @@ -0,0 +1,22 @@ +import { CloseIcon } from "../../../shared"; + +type CartItemHeaderProps = { + productName: string; + productId: string; + onRemove: (productId: string) => void; +}; + +export function CartItemHeader({ productName, productId, onRemove }: CartItemHeaderProps) { + const handleRemove = () => { + onRemove(productId); + }; + + return ( +
+

{productName}

+ +
+ ); +} diff --git a/src/advanced/domains/cart/components/CartItemInfo.tsx b/src/advanced/domains/cart/components/CartItemInfo.tsx new file mode 100644 index 00000000..54133ec0 --- /dev/null +++ b/src/advanced/domains/cart/components/CartItemInfo.tsx @@ -0,0 +1,48 @@ +import type { CartItem } from "../types"; +import { CartItemHeader } from "./CartItemHeader"; +import { QuantitySelector } from "./QuantitySelector"; + +type CartItemInfoProps = { + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + item: CartItem; + itemTotal: number; +}; + +export function CartItemInfo({ + updateQuantity, + removeFromCart, + item, + itemTotal +}: CartItemInfoProps) { + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; + + return ( +
+ + +
+ + +
+ {hasDiscount && ( + -{discountRate}% + )} +

+ {Math.round(itemTotal).toLocaleString()}원 +

+
+
+
+ ); +} diff --git a/src/advanced/domains/cart/components/CartItemList.tsx b/src/advanced/domains/cart/components/CartItemList.tsx new file mode 100644 index 00000000..a956906f --- /dev/null +++ b/src/advanced/domains/cart/components/CartItemList.tsx @@ -0,0 +1,44 @@ +import { ShoppingBagIcon } from "../../../shared"; +import type { CartItem } from "../types"; +import { CartItemInfo } from "./CartItemInfo"; + +type CartItemListProps = { + cart: CartItem[]; + calculateItemTotal: (item: CartItem) => number; + updateQuantity: (productId: string, newQuantity: number) => void; + removeFromCart: (productId: string) => void; +}; + +export function CartItemList({ + cart, + calculateItemTotal, + updateQuantity, + removeFromCart +}: CartItemListProps) { + return ( +
+

+ + 장바구니 +

+ {cart.length === 0 ? ( +
+ +

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/advanced/domains/cart/components/CartSidebar.tsx b/src/advanced/domains/cart/components/CartSidebar.tsx new file mode 100644 index 00000000..ecefa997 --- /dev/null +++ b/src/advanced/domains/cart/components/CartSidebar.tsx @@ -0,0 +1,55 @@ +import type { CartItem, Coupon } from "../../../../types"; +import { CartItemList } from "./CartItemList"; +import { CouponSelector } from "./CouponSelector"; +import { PaymentSummary } from "./PaymentSummary"; + +type CartSidebarProps = { + cart: CartItem[]; + calculateItemTotal: (item: CartItem) => number; + updateQuantity: (productId: string, newQuantity: number) => void; + removeFromCart: (productId: string) => void; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + completeOrder: () => void; +}; + +export function CartSidebar({ + cart, + calculateItemTotal, + updateQuantity, + removeFromCart, + coupons, + selectedCoupon, + applyCoupon, + setSelectedCoupon, + totals, + completeOrder +}: CartSidebarProps) { + return ( +
+ + {cart.length > 0 && ( + <> + + + + )} +
+ ); +} diff --git a/src/advanced/domains/cart/components/CheckoutButton.tsx b/src/advanced/domains/cart/components/CheckoutButton.tsx new file mode 100644 index 00000000..bcf1f6eb --- /dev/null +++ b/src/advanced/domains/cart/components/CheckoutButton.tsx @@ -0,0 +1,20 @@ +import { Button } from "../../../shared"; + +type CheckoutButtonProps = { + totalAmount: number; + onCompleteOrder: () => void; +}; + +export function CheckoutButton({ totalAmount, onCompleteOrder }: CheckoutButtonProps) { + return ( + <> + + +
+

* 실제 결제는 이루어지지 않습니다

+
+ + ); +} diff --git a/src/advanced/domains/cart/components/CouponSelector.tsx b/src/advanced/domains/cart/components/CouponSelector.tsx new file mode 100644 index 00000000..29a0f45b --- /dev/null +++ b/src/advanced/domains/cart/components/CouponSelector.tsx @@ -0,0 +1,50 @@ +import type { ChangeEvent } from "react"; + +import type { Coupon } from "../../../../types"; + +type CouponSelectorProps = { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; +}; + +export function CouponSelector({ + coupons, + selectedCoupon, + applyCoupon, + setSelectedCoupon +}: CouponSelectorProps) { + const handleCouponChange = (event: ChangeEvent) => { + const coupon = coupons.find((coupon) => coupon.code === event.target.value); + if (coupon) applyCoupon(coupon); + else setSelectedCoupon(null); + }; + + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/advanced/domains/cart/components/PaymentInfoLine.tsx b/src/advanced/domains/cart/components/PaymentInfoLine.tsx new file mode 100644 index 00000000..4a80502e --- /dev/null +++ b/src/advanced/domains/cart/components/PaymentInfoLine.tsx @@ -0,0 +1,46 @@ +import { tv } from "tailwind-variants"; + +const paymentInfoLine = tv({ + slots: { + container: "flex justify-between", + label: "", + value: "" + }, + variants: { + variant: { + default: { + label: "text-gray-600", + value: "font-medium" + }, + highlighted: { + label: "text-red-500", + value: "" + }, + total: { + container: "border-t border-gray-200 py-2", + label: "font-semibold", + value: "text-lg font-bold text-gray-900" + } + } + }, + defaultVariants: { + variant: "default" + } +}); + +type PaymentInfoLineProps = { + label: string; + value: string; + variant?: "default" | "highlighted" | "total"; +}; + +export function PaymentInfoLine({ label, value, variant = "default" }: PaymentInfoLineProps) { + const { container, label: labelClass, value: valueClass } = paymentInfoLine({ variant }); + + return ( +
+ {label} + {value} +
+ ); +} diff --git a/src/advanced/domains/cart/components/PaymentSummary.tsx b/src/advanced/domains/cart/components/PaymentSummary.tsx new file mode 100644 index 00000000..dc9cd361 --- /dev/null +++ b/src/advanced/domains/cart/components/PaymentSummary.tsx @@ -0,0 +1,47 @@ +import { CheckoutButton } from "./CheckoutButton"; +import { PaymentInfoLine } from "./PaymentInfoLine"; + +type PaymentSummaryProps = { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + completeOrder: () => void; +}; + +export function PaymentSummary({ totals, completeOrder }: PaymentSummaryProps) { + const handleCompleteOrder = () => { + completeOrder(); + }; + + const discountAmount = totals.totalBeforeDiscount - totals.totalAfterDiscount; + + return ( +
+

결제 정보

+
+ + {discountAmount > 0 && ( + + )} + +
+ + +
+ ); +} diff --git a/src/advanced/domains/cart/components/QuantitySelector.tsx b/src/advanced/domains/cart/components/QuantitySelector.tsx new file mode 100644 index 00000000..ca52771e --- /dev/null +++ b/src/advanced/domains/cart/components/QuantitySelector.tsx @@ -0,0 +1,33 @@ +type QuantitySelectorProps = { + quantity: number; + productId: string; + onUpdateQuantity: (productId: string, newQuantity: number) => void; +}; + +export function QuantitySelector({ quantity, productId, onUpdateQuantity }: QuantitySelectorProps) { + const handleDecrease = () => { + onUpdateQuantity(productId, quantity - 1); + }; + + const handleIncrease = () => { + onUpdateQuantity(productId, quantity + 1); + }; + + return ( +
+ + {quantity} + +
+ ); +} diff --git a/src/advanced/domains/cart/components/index.ts b/src/advanced/domains/cart/components/index.ts new file mode 100644 index 00000000..36d1aeac --- /dev/null +++ b/src/advanced/domains/cart/components/index.ts @@ -0,0 +1,9 @@ +export * from "./CartItemHeader"; +export * from "./CartItemInfo"; +export * from "./CartItemList"; +export * from "./CartSidebar"; +export * from "./CheckoutButton"; +export * from "./CouponSelector"; +export * from "./PaymentInfoLine"; +export * from "./PaymentSummary"; +export * from "./QuantitySelector"; diff --git a/src/advanced/domains/cart/index.ts b/src/advanced/domains/cart/index.ts new file mode 100644 index 00000000..57488b06 --- /dev/null +++ b/src/advanced/domains/cart/index.ts @@ -0,0 +1,4 @@ +export * from "./components"; +export * from "./services"; +export * from "./types"; +export * from "./utils"; diff --git a/src/advanced/domains/cart/services/cartApplicationService.ts b/src/advanced/domains/cart/services/cartApplicationService.ts new file mode 100644 index 00000000..4c5cd8a4 --- /dev/null +++ b/src/advanced/domains/cart/services/cartApplicationService.ts @@ -0,0 +1,85 @@ +import type { CartItem, Product } from "../../../../types"; +import type { NotificationFunction } from "../../../shared"; +import { cartNotificationService, cartValidationService, orderService } from "./index"; + +type CartUpdater = (updater: (prevCart: CartItem[]) => CartItem[]) => void; + +export const cartApplicationService = { + addToCart: ( + product: Product, + cart: CartItem[], + updateCart: CartUpdater, + addNotification: NotificationFunction + ) => { + const validation = cartValidationService.validateAddToCart(product, cart); + if (!validation.valid) { + cartNotificationService.showValidationError(validation.message!, addNotification); + return; + } + + updateCart((prevCart) => { + const existingItem = prevCart.find((item) => item.product.id === product.id); + + if (existingItem) { + const quantityValidation = cartValidationService.validateQuantityIncrease( + product, + existingItem.quantity + ); + if (!quantityValidation.valid) { + cartNotificationService.showValidationError(quantityValidation.message!, addNotification); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id ? { ...item, quantity: item.quantity + 1 } : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + cartNotificationService.showAddToCartSuccess(addNotification); + }, + + removeFromCart: (productId: string, updateCart: CartUpdater) => { + updateCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); + }, + + updateQuantity: ( + productId: string, + newQuantity: number, + products: Product[], + updateCart: CartUpdater, + addNotification: NotificationFunction + ) => { + const validation = cartValidationService.validateQuantityUpdate( + productId, + newQuantity, + products + ); + if (!validation.valid) { + cartNotificationService.showValidationError(validation.message!, addNotification); + return; + } + + if (newQuantity <= 0) { + cartApplicationService.removeFromCart(productId, updateCart); + return; + } + + updateCart((prevCart) => + prevCart.map((item) => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ) + ); + }, + + completeOrder: ( + clearCart: () => void, + clearSelectedCoupon: () => void, + addNotification: NotificationFunction + ) => { + const orderNumber = orderService.processOrder(clearCart, clearSelectedCoupon); + cartNotificationService.showOrderSuccess(orderNumber, addNotification); + } +}; diff --git a/src/advanced/domains/cart/services/cartNotificationService.ts b/src/advanced/domains/cart/services/cartNotificationService.ts new file mode 100644 index 00000000..ab9d1da6 --- /dev/null +++ b/src/advanced/domains/cart/services/cartNotificationService.ts @@ -0,0 +1,15 @@ +import type { NotificationFunction } from "../../../shared"; + +export const cartNotificationService = { + showAddToCartSuccess: (addNotification: NotificationFunction) => { + addNotification("장바구니에 담았습니다", "success"); + }, + + showValidationError: (message: string, addNotification: NotificationFunction) => { + addNotification(message, "error"); + }, + + showOrderSuccess: (orderNumber: string, addNotification: NotificationFunction) => { + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); + } +}; diff --git a/src/advanced/domains/cart/services/cartValidationService.ts b/src/advanced/domains/cart/services/cartValidationService.ts new file mode 100644 index 00000000..41dccd6e --- /dev/null +++ b/src/advanced/domains/cart/services/cartValidationService.ts @@ -0,0 +1,48 @@ +import type { CartItem, Product } from "../../../../types"; +import type { ValidationResult } from "../../../shared"; +import { getRemainingStock } from "../utils"; + +const createValidationResult = (valid: boolean, message?: string): ValidationResult => ({ + valid, + message +}); + +const validateStockAvailability = (requiredQuantity: number, availableStock: number) => { + return requiredQuantity > availableStock + ? createValidationResult(false, `재고는 ${availableStock}개까지만 있습니다.`) + : createValidationResult(true); +}; + +const findProductById = (productId: string, products: Product[]) => { + const product = products.find((p) => p.id === productId); + return product + ? { product, error: null } + : { product: null, error: createValidationResult(false, "상품을 찾을 수 없습니다.") }; +}; + +export const cartValidationService = { + validateAddToCart: (product: Product, cart: CartItem[]) => { + const remainingStock = getRemainingStock(product, cart); + return remainingStock <= 0 + ? createValidationResult(false, "재고가 부족합니다!") + : createValidationResult(true); + }, + + validateQuantityIncrease: (product: Product, currentQuantity: number) => { + const newQuantity = currentQuantity + 1; + return validateStockAvailability(newQuantity, product.stock); + }, + + validateQuantityUpdate: (productId: string, newQuantity: number, products: Product[]) => { + if (newQuantity <= 0) { + return createValidationResult(true); + } + + const { product, error } = findProductById(productId, products); + if (error) { + return error; + } + + return validateStockAvailability(newQuantity, product!.stock); + } +}; diff --git a/src/advanced/domains/cart/services/index.ts b/src/advanced/domains/cart/services/index.ts new file mode 100644 index 00000000..67ff0a26 --- /dev/null +++ b/src/advanced/domains/cart/services/index.ts @@ -0,0 +1,4 @@ +export * from "./cartApplicationService"; +export * from "./cartNotificationService"; +export * from "./cartValidationService"; +export * from "./orderService"; diff --git a/src/advanced/domains/cart/services/orderService.ts b/src/advanced/domains/cart/services/orderService.ts new file mode 100644 index 00000000..d9f799ad --- /dev/null +++ b/src/advanced/domains/cart/services/orderService.ts @@ -0,0 +1,13 @@ +export const orderService = { + createOrderNumber: () => { + return `ORD-${Date.now()}`; + }, + + processOrder: (clearCart: () => void, clearSelectedCoupon: () => void) => { + const orderNumber = orderService.createOrderNumber(); + clearCart(); + clearSelectedCoupon(); + + return orderNumber; + } +}; diff --git a/src/advanced/domains/cart/types/entities.ts b/src/advanced/domains/cart/types/entities.ts new file mode 100644 index 00000000..184f5ea5 --- /dev/null +++ b/src/advanced/domains/cart/types/entities.ts @@ -0,0 +1,11 @@ +import type { Product } from "../../../../types"; + +export interface CartItem { + product: Product; + quantity: number; +} + +export interface CartTotals { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} diff --git a/src/advanced/domains/cart/types/index.ts b/src/advanced/domains/cart/types/index.ts new file mode 100644 index 00000000..fc8e74dd --- /dev/null +++ b/src/advanced/domains/cart/types/index.ts @@ -0,0 +1 @@ +export type * from "./entities"; diff --git a/src/advanced/domains/cart/utils/calculators.ts b/src/advanced/domains/cart/utils/calculators.ts new file mode 100644 index 00000000..502b96c6 --- /dev/null +++ b/src/advanced/domains/cart/utils/calculators.ts @@ -0,0 +1,61 @@ +import type { Coupon, Product } from "../../../../types"; +import type { CartItem } from "../types"; + +export function getMaxApplicableDiscount(item: CartItem, cart: CartItem[]) { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; +} + +export function calculateItemTotal(item: CartItem, cart: CartItem[]) { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +} + +export function calculateCartTotal(cart: CartItem[], selectedCoupon: Coupon | null) { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === "amount") { + totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount) + }; +} + +export function getRemainingStock(product: Product, cart: CartItem[]) { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +} diff --git a/src/advanced/domains/cart/utils/index.ts b/src/advanced/domains/cart/utils/index.ts new file mode 100644 index 00000000..137f47a3 --- /dev/null +++ b/src/advanced/domains/cart/utils/index.ts @@ -0,0 +1 @@ +export * from "./calculators"; diff --git a/src/advanced/domains/coupon/components/AddCouponCard.tsx b/src/advanced/domains/coupon/components/AddCouponCard.tsx new file mode 100644 index 00000000..2858e653 --- /dev/null +++ b/src/advanced/domains/coupon/components/AddCouponCard.tsx @@ -0,0 +1,19 @@ +import { PlusIcon } from "../../../shared"; + +type AddCouponCardProps = { + onClick: () => void; +}; + +export function AddCouponCard({ onClick }: AddCouponCardProps) { + return ( +
+ +
+ ); +} diff --git a/src/advanced/domains/coupon/components/CouponCard.tsx b/src/advanced/domains/coupon/components/CouponCard.tsx new file mode 100644 index 00000000..e78abebe --- /dev/null +++ b/src/advanced/domains/coupon/components/CouponCard.tsx @@ -0,0 +1,37 @@ +import { TrashIcon } from "../../../shared"; +import type { Coupon } from "../types"; + +type CouponCardProps = { + coupon: Coupon; + onDelete: (code: string) => void; +}; + +export function CouponCard({ coupon, onDelete }: CouponCardProps) { + const handleDelete = () => { + onDelete(coupon.code); + }; + + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +} diff --git a/src/advanced/domains/coupon/components/CouponForm.tsx b/src/advanced/domains/coupon/components/CouponForm.tsx new file mode 100644 index 00000000..c7250441 --- /dev/null +++ b/src/advanced/domains/coupon/components/CouponForm.tsx @@ -0,0 +1,143 @@ +import type { ChangeEvent, FocusEvent, FormEvent } from "react"; + +import { Button, SearchInput } from "../../../shared"; + +type CouponFormType = { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +}; + +type CouponFormProps = { + couponForm: CouponFormType; + onSubmit: (e: FormEvent) => void; + onCancel: () => void; + onFormChange: (form: CouponFormType) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +}; + +export function CouponForm({ + couponForm, + onSubmit, + onCancel, + onFormChange, + addNotification +}: CouponFormProps) { + const handleNameChange = (e: ChangeEvent) => { + onFormChange({ + ...couponForm, + name: e.target.value + }); + }; + + const handleCodeChange = (e: ChangeEvent) => { + onFormChange({ + ...couponForm, + code: e.target.value.toUpperCase() + }); + }; + + const handleDiscountTypeChange = (e: ChangeEvent) => { + onFormChange({ + ...couponForm, + discountType: e.target.value as "amount" | "percentage" + }); + }; + + const handleDiscountValueChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === "" || /^\d+$/.test(value)) { + onFormChange({ + ...couponForm, + discountValue: value === "" ? 0 : parseInt(value) + }); + } + }; + + const handleDiscountValueBlur = (e: FocusEvent) => { + const value = e.target.value; + const numValue = parseInt(value) || 0; + + if (couponForm.discountType === "percentage") { + if (numValue > 100) { + addNotification("할인율은 100%를 초과할 수 없습니다", "error"); + onFormChange({ ...couponForm, discountValue: 100 }); + } else if (numValue < 0) { + onFormChange({ ...couponForm, discountValue: 0 }); + } + } else { + if (numValue > 100000) { + addNotification("할인 금액은 100,000원을 초과할 수 없습니다", "error"); + onFormChange({ ...couponForm, discountValue: 100000 }); + } else if (numValue < 0) { + onFormChange({ ...couponForm, discountValue: 0 }); + } + } + }; + + return ( +
+
+

새 쿠폰 생성

+
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/advanced/domains/coupon/components/index.ts b/src/advanced/domains/coupon/components/index.ts new file mode 100644 index 00000000..8d2317ef --- /dev/null +++ b/src/advanced/domains/coupon/components/index.ts @@ -0,0 +1,3 @@ +export * from "./AddCouponCard"; +export * from "./CouponCard"; +export * from "./CouponForm"; diff --git a/src/advanced/domains/coupon/constants/index.ts b/src/advanced/domains/coupon/constants/index.ts new file mode 100644 index 00000000..b313b308 --- /dev/null +++ b/src/advanced/domains/coupon/constants/index.ts @@ -0,0 +1 @@ +export * from "./initialData"; diff --git a/src/advanced/domains/coupon/constants/initialData.ts b/src/advanced/domains/coupon/constants/initialData.ts new file mode 100644 index 00000000..2ae94127 --- /dev/null +++ b/src/advanced/domains/coupon/constants/initialData.ts @@ -0,0 +1,16 @@ +import type { Coupon } from "../types"; + +export const INITIAL_COUPONS: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000 + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10 + } +]; diff --git a/src/advanced/domains/coupon/index.ts b/src/advanced/domains/coupon/index.ts new file mode 100644 index 00000000..6e8050bd --- /dev/null +++ b/src/advanced/domains/coupon/index.ts @@ -0,0 +1,5 @@ +export * from "./components"; +export * from "./constants"; +export * from "./services"; +export * from "./types"; +export * from "./utils"; diff --git a/src/advanced/domains/coupon/services/couponApplicationService.ts b/src/advanced/domains/coupon/services/couponApplicationService.ts new file mode 100644 index 00000000..31538dfc --- /dev/null +++ b/src/advanced/domains/coupon/services/couponApplicationService.ts @@ -0,0 +1,71 @@ +import type { NotificationFunction } from "../../../shared"; +import type { Coupon } from "../types"; +import { couponNotificationService } from "./couponNotificationService"; +import { couponValidationService } from "./couponValidationService"; + +type CouponUpdater = (updater: (prev: Coupon[]) => Coupon[]) => void; + +export const couponApplicationService = { + addCoupon: ( + newCoupon: Coupon, + existingCoupons: Coupon[], + updateCoupons: CouponUpdater, + addNotification: NotificationFunction + ) => { + const validation = couponValidationService.validateCouponCode(newCoupon.code, existingCoupons); + if (!validation.valid) { + couponNotificationService.showValidationError(validation.message!, addNotification); + return; + } + + updateCoupons((prev) => [...prev, newCoupon]); + couponNotificationService.showAddSuccess(addNotification); + }, + + deleteCoupon: ( + couponCode: string, + selectedCoupon: Coupon | null, + updateCoupons: CouponUpdater, + setSelectedCoupon: (coupon: Coupon | null) => void, + addNotification: NotificationFunction + ) => { + updateCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + + couponNotificationService.showDeleteSuccess(addNotification); + }, + + applyCoupon: ( + coupon: Coupon, + totalAmount: number | undefined, + setSelectedCoupon: (coupon: Coupon | null) => void, + addNotification: NotificationFunction + ) => { + if (totalAmount !== undefined) { + const validation = couponValidationService.validateCouponUsage(coupon, totalAmount); + if (!validation.valid) { + couponNotificationService.showValidationError(validation.message!, addNotification); + return; + } + } + + setSelectedCoupon(coupon); + couponNotificationService.showApplySuccess(addNotification); + }, + + handleCouponSubmit: ( + couponForm: Coupon, + existingCoupons: Coupon[], + updateCoupons: CouponUpdater, + resetForm: () => void, + setShowForm: (show: boolean) => void, + addNotification: NotificationFunction + ) => { + couponApplicationService.addCoupon(couponForm, existingCoupons, updateCoupons, addNotification); + resetForm(); + setShowForm(false); + } +}; diff --git a/src/advanced/domains/coupon/services/couponNotificationService.ts b/src/advanced/domains/coupon/services/couponNotificationService.ts new file mode 100644 index 00000000..468dc341 --- /dev/null +++ b/src/advanced/domains/coupon/services/couponNotificationService.ts @@ -0,0 +1,19 @@ +import type { NotificationFunction } from "../../../shared"; + +export const couponNotificationService = { + showAddSuccess: (addNotification: NotificationFunction) => { + addNotification("쿠폰이 추가되었습니다.", "success"); + }, + + showDeleteSuccess: (addNotification: NotificationFunction) => { + addNotification("쿠폰이 삭제되었습니다.", "success"); + }, + + showApplySuccess: (addNotification: NotificationFunction) => { + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + + showValidationError: (message: string, addNotification: NotificationFunction) => { + addNotification(message, "error"); + } +}; diff --git a/src/advanced/domains/coupon/services/couponValidationService.ts b/src/advanced/domains/coupon/services/couponValidationService.ts new file mode 100644 index 00000000..4ff87b0c --- /dev/null +++ b/src/advanced/domains/coupon/services/couponValidationService.ts @@ -0,0 +1,17 @@ +import type { ValidationResult } from "../../../shared"; +import type { Coupon } from "../types"; + +export const couponValidationService = { + validateCouponCode: (code: string, existingCoupons: Coupon[]): ValidationResult => { + const existingCoupon = existingCoupons.find((c) => c.code === code); + return existingCoupon + ? { valid: false, message: "이미 존재하는 쿠폰 코드입니다." } + : { valid: true, message: "사용 가능한 쿠폰 코드입니다." }; + }, + + validateCouponUsage: (coupon: Coupon, totalAmount: number): ValidationResult => { + return totalAmount < 10000 && coupon.discountType === "percentage" + ? { valid: false, message: "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다." } + : { valid: true, message: "쿠폰이 적용되었습니다." }; + } +}; diff --git a/src/advanced/domains/coupon/services/index.ts b/src/advanced/domains/coupon/services/index.ts new file mode 100644 index 00000000..539a5052 --- /dev/null +++ b/src/advanced/domains/coupon/services/index.ts @@ -0,0 +1,3 @@ +export * from "./couponApplicationService"; +export * from "./couponNotificationService"; +export * from "./couponValidationService"; diff --git a/src/advanced/domains/coupon/types/entities.ts b/src/advanced/domains/coupon/types/entities.ts new file mode 100644 index 00000000..5f575011 --- /dev/null +++ b/src/advanced/domains/coupon/types/entities.ts @@ -0,0 +1,6 @@ +export interface Coupon { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +} diff --git a/src/advanced/domains/coupon/types/index.ts b/src/advanced/domains/coupon/types/index.ts new file mode 100644 index 00000000..fc8e74dd --- /dev/null +++ b/src/advanced/domains/coupon/types/index.ts @@ -0,0 +1 @@ +export type * from "./entities"; diff --git a/src/advanced/domains/coupon/utils/index.ts b/src/advanced/domains/coupon/utils/index.ts new file mode 100644 index 00000000..58564490 --- /dev/null +++ b/src/advanced/domains/coupon/utils/index.ts @@ -0,0 +1 @@ +export * from "./validators"; diff --git a/src/advanced/domains/coupon/utils/validators.ts b/src/advanced/domains/coupon/utils/validators.ts new file mode 100644 index 00000000..3fdee023 --- /dev/null +++ b/src/advanced/domains/coupon/utils/validators.ts @@ -0,0 +1,30 @@ +import type { Coupon } from "../types"; + +export function validateCouponUsage(coupon: Coupon, totalAmount: number) { + if (totalAmount < 10000 && coupon.discountType === "percentage") { + return { + valid: false, + message: "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다." + }; + } + + return { + valid: true, + message: "쿠폰이 적용되었습니다." + }; +} + +export function validateCouponCode(code: string, existingCoupons: Coupon[]) { + const existingCoupon = existingCoupons.find((c) => c.code === code); + if (existingCoupon) { + return { + valid: false, + message: "이미 존재하는 쿠폰 코드입니다." + }; + } + + return { + valid: true, + message: "사용 가능한 쿠폰 코드입니다." + }; +} diff --git a/src/advanced/domains/product/components/DiscountItem.tsx b/src/advanced/domains/product/components/DiscountItem.tsx new file mode 100644 index 00000000..9576bdd0 --- /dev/null +++ b/src/advanced/domains/product/components/DiscountItem.tsx @@ -0,0 +1,54 @@ +import type { ChangeEvent } from "react"; + +import { CloseIcon } from "../../../shared"; +import { type Discount } from "../types"; + +type DiscountItemProps = { + discount: Discount; + index: number; + onChange: (index: number, field: "quantity" | "rate", value: number) => void; + onRemove: (index: number) => void; +}; + +export function DiscountItem({ discount, index, onChange, onRemove }: DiscountItemProps) { + const handleQuantityChange = (e: ChangeEvent) => { + const value = e.target.value; + onChange(index, "quantity", parseInt(value) || 0); + }; + + const handleRateChange = (e: ChangeEvent) => { + const value = e.target.value; + onChange(index, "rate", (parseInt(value) || 0) / 100); + }; + + const handleRemove = () => { + onRemove(index); + }; + + return ( +
+ + 개 이상 구매 시 + + % 할인 + +
+ ); +} diff --git a/src/advanced/domains/product/components/DiscountSection.tsx b/src/advanced/domains/product/components/DiscountSection.tsx new file mode 100644 index 00000000..ad735b70 --- /dev/null +++ b/src/advanced/domains/product/components/DiscountSection.tsx @@ -0,0 +1,48 @@ +import type { Discount } from "../types"; +import { DiscountItem } from "./DiscountItem"; + +type DiscountSectionProps = { + discounts: Discount[]; + onChange: (discounts: Discount[]) => void; +}; + +export function DiscountSection({ discounts, onChange }: DiscountSectionProps) { + const handleDiscountChange = (index: number, field: "quantity" | "rate", value: number) => { + const newDiscounts = [...discounts]; + newDiscounts[index][field] = value; + onChange(newDiscounts); + }; + + const handleRemoveDiscount = (index: number) => { + const newDiscounts = discounts.filter((_, i) => i !== index); + onChange(newDiscounts); + }; + + const handleAddDiscount = () => { + onChange([...discounts, { quantity: 10, rate: 0.1 }]); + }; + + return ( +
+ +
+ {discounts.map((discount, index) => ( + + ))} + +
+
+ ); +} diff --git a/src/advanced/domains/product/components/ProductCard.tsx b/src/advanced/domains/product/components/ProductCard.tsx new file mode 100644 index 00000000..053d1aa3 --- /dev/null +++ b/src/advanced/domains/product/components/ProductCard.tsx @@ -0,0 +1,58 @@ +import { Button } from "../../../shared"; +import type { ProductWithUI } from "../types"; +import { ProductImage } from "./ProductImage"; +import { StockStatus } from "./StockStatus"; + +type ProductCardProps = { + product: ProductWithUI; + remainingStock: number; + formatPrice: (price: number, productId?: string) => string; + onAddToCart: (product: ProductWithUI) => void; +}; + +export function ProductCard({ + product, + remainingStock, + formatPrice, + onAddToCart +}: ProductCardProps) { + const handleAddToCart = () => { + onAddToCart(product); + }; + + return ( +
+ + +
+

{product.name}

+ {product.description && ( +

{product.description}

+ )} + +
+

+ {formatPrice(product.price, product.id)} +

+ {product.discounts.length > 0 && ( +

+ {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% +

+ )} +
+ +
+ +
+ +
+
+ ); +} diff --git a/src/advanced/domains/product/components/ProductFormEditor.tsx b/src/advanced/domains/product/components/ProductFormEditor.tsx new file mode 100644 index 00000000..13879bef --- /dev/null +++ b/src/advanced/domains/product/components/ProductFormEditor.tsx @@ -0,0 +1,140 @@ +import { type ChangeEvent, type FocusEvent, type FormEvent } from "react"; + +import { Button, SearchInput } from "../../../shared"; +import { Discount, type ProductForm } from "../types"; +import { DiscountSection } from "./DiscountSection"; + +type ProductFormEditorProps = { + productForm: ProductForm; + editingProduct: string | null; + onSubmit: (e: FormEvent) => void; + onCancel: () => void; + onFormChange: (form: ProductForm) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +}; + +export function ProductFormEditor({ + productForm, + editingProduct, + onSubmit, + onCancel, + onFormChange, + addNotification +}: ProductFormEditorProps) { + const handleNameChange = (e: ChangeEvent) => { + onFormChange({ ...productForm, name: e.target.value }); + }; + + const handleDescriptionChange = (e: ChangeEvent) => { + onFormChange({ ...productForm, description: e.target.value }); + }; + + const handleDiscountsChange = (discounts: Discount[]) => { + onFormChange({ ...productForm, discounts }); + }; + + const handlePriceChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === "" || /^\d+$/.test(value)) { + onFormChange({ + ...productForm, + price: value === "" ? 0 : parseInt(value) + }); + } + }; + + const handlePriceBlur = (e: FocusEvent) => { + const value = e.target.value; + + if (value === "") { + onFormChange({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification("가격은 0보다 커야 합니다", "error"); + onFormChange({ ...productForm, price: 0 }); + } + }; + + const handleStockChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === "" || /^\d+$/.test(value)) { + onFormChange({ + ...productForm, + stock: value === "" ? 0 : parseInt(value) + }); + } + }; + + const handleStockBlur = (e: FocusEvent) => { + const value = e.target.value; + + if (value === "") { + onFormChange({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + addNotification("재고는 0보다 커야 합니다", "error"); + onFormChange({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + addNotification("재고는 9999개를 초과할 수 없습니다", "error"); + onFormChange({ ...productForm, stock: 9999 }); + } + }; + + return ( +
+
+

+ {editingProduct === "new" ? "새 상품 추가" : "상품 수정"} +

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + +
+ + +
+ +
+ ); +} diff --git a/src/advanced/domains/product/components/ProductImage.tsx b/src/advanced/domains/product/components/ProductImage.tsx new file mode 100644 index 00000000..c322dd72 --- /dev/null +++ b/src/advanced/domains/product/components/ProductImage.tsx @@ -0,0 +1,30 @@ +import { ImagePlaceholderIcon } from "../../../shared"; +import type { ProductWithUI } from "../types"; + +type ProductImageProps = { + product: ProductWithUI; +}; + +export function ProductImage({ product }: ProductImageProps) { + const maxDiscountRate = + product.discounts.length > 0 ? Math.max(...product.discounts.map((d) => d.rate)) * 100 : 0; + + return ( +
+
+ +
+ + {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{maxDiscountRate}% + + )} +
+ ); +} diff --git a/src/advanced/domains/product/components/ProductList.tsx b/src/advanced/domains/product/components/ProductList.tsx new file mode 100644 index 00000000..4b713fc1 --- /dev/null +++ b/src/advanced/domains/product/components/ProductList.tsx @@ -0,0 +1,50 @@ +import type { Product, ProductWithUI } from "../types"; +import { ProductCard } from "./ProductCard"; + +type ProductListProps = { + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + getRemainingStock: (product: Product) => number; + formatPrice: (price: number, productId?: string) => string; + addToCart: (product: ProductWithUI) => void; +}; + +export function ProductList({ + products, + filteredProducts, + debouncedSearchTerm, + getRemainingStock, + formatPrice, + addToCart +}: ProductListProps) { + const handleAddToCart = (product: ProductWithUI) => { + addToCart(product); + }; + + return ( +
+
+

전체 상품

+
총 {products.length}개 상품
+
+ {filteredProducts.length === 0 ? ( +
+

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

+
+ ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/advanced/domains/product/components/ProductTable.tsx b/src/advanced/domains/product/components/ProductTable.tsx new file mode 100644 index 00000000..e2c88812 --- /dev/null +++ b/src/advanced/domains/product/components/ProductTable.tsx @@ -0,0 +1,48 @@ +import type { ProductWithUI } from "../../product"; +import { ProductTableRow } from "./ProductTableRow"; + +type ProductTableProps = { + products: ProductWithUI[]; + formatPrice: (price: number, productId?: string) => string; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +}; + +export function ProductTable({ products, formatPrice, onEdit, onDelete }: ProductTableProps) { + return ( +
+ + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+ ); +} diff --git a/src/advanced/domains/product/components/ProductTableRow.tsx b/src/advanced/domains/product/components/ProductTableRow.tsx new file mode 100644 index 00000000..b47ee488 --- /dev/null +++ b/src/advanced/domains/product/components/ProductTableRow.tsx @@ -0,0 +1,55 @@ +import { Button } from "../../../shared"; +import type { ProductWithUI } from "../types"; +import { StockBadge } from "./StockBadge"; + +type ProductTableRowProps = { + product: ProductWithUI; + formatPrice: (price: number, productId?: string) => string; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +}; + +export function ProductTableRow({ product, formatPrice, onEdit, onDelete }: ProductTableRowProps) { + const handleEdit = () => { + onEdit(product); + }; + + const handleDelete = () => { + onDelete(product.id); + }; + + return ( + + + {product.name} + + + {formatPrice(product.price, product.id)} + + + + + + {product.description || "-"} + + + + + + + ); +} diff --git a/src/advanced/domains/product/components/StockBadge.tsx b/src/advanced/domains/product/components/StockBadge.tsx new file mode 100644 index 00000000..4b2b271b --- /dev/null +++ b/src/advanced/domains/product/components/StockBadge.tsx @@ -0,0 +1,22 @@ +import { tv } from "tailwind-variants"; + +const stockBadge = tv({ + base: "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium", + variants: { + level: { + high: "bg-green-100 text-green-800", + low: "bg-yellow-100 text-yellow-800", + out: "bg-red-100 text-red-800" + } + } +}); + +type StockBadgeProps = { + stock: number; +}; + +export function StockBadge({ stock }: StockBadgeProps) { + const level = stock > 10 ? "high" : stock > 0 ? "low" : "out"; + + return {stock}개; +} diff --git a/src/advanced/domains/product/components/StockStatus.tsx b/src/advanced/domains/product/components/StockStatus.tsx new file mode 100644 index 00000000..6d22fbd3 --- /dev/null +++ b/src/advanced/domains/product/components/StockStatus.tsx @@ -0,0 +1,30 @@ +import { tv } from "tailwind-variants"; + +type StockStatusProps = { + remainingStock: number; + className?: string; +}; + +const LOW_STOCK_THRESHOLD = 5; + +const stockStatusText = tv({ + base: "text-xs", + variants: { + tone: { + normal: "text-gray-500", + low: "font-medium text-red-600" + } + }, + defaultVariants: { + tone: "normal" + } +}); + +export function StockStatus({ remainingStock, className }: StockStatusProps) { + if (remainingStock <= 0) return null; + + const tone = remainingStock <= LOW_STOCK_THRESHOLD ? "low" : "normal"; + const text = tone === "low" ? `품절임박! ${remainingStock}개 남음` : `재고 ${remainingStock}개`; + + return

{text}

; +} diff --git a/src/advanced/domains/product/components/index.ts b/src/advanced/domains/product/components/index.ts new file mode 100644 index 00000000..a6c680bc --- /dev/null +++ b/src/advanced/domains/product/components/index.ts @@ -0,0 +1,10 @@ +export * from "./DiscountItem"; +export * from "./DiscountSection"; +export * from "./ProductCard"; +export * from "./ProductFormEditor"; +export * from "./ProductImage"; +export * from "./ProductList"; +export * from "./ProductTable"; +export * from "./ProductTableRow"; +export * from "./StockBadge"; +export * from "./StockStatus"; diff --git a/src/advanced/domains/product/constants/index.ts b/src/advanced/domains/product/constants/index.ts new file mode 100644 index 00000000..b313b308 --- /dev/null +++ b/src/advanced/domains/product/constants/index.ts @@ -0,0 +1 @@ +export * from "./initialData"; diff --git a/src/advanced/domains/product/constants/initialData.ts b/src/advanced/domains/product/constants/initialData.ts new file mode 100644 index 00000000..11d7f1aa --- /dev/null +++ b/src/advanced/domains/product/constants/initialData.ts @@ -0,0 +1,35 @@ +import type { ProductWithUI } from "../types"; + +export const INITIAL_PRODUCTS: ProductWithUI[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 } + ], + description: "최고급 품질의 프리미엄 상품입니다." + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 } + ], + description: "대용량과 고성능을 자랑하는 상품입니다." + } +]; diff --git a/src/advanced/domains/product/index.ts b/src/advanced/domains/product/index.ts new file mode 100644 index 00000000..6e8050bd --- /dev/null +++ b/src/advanced/domains/product/index.ts @@ -0,0 +1,5 @@ +export * from "./components"; +export * from "./constants"; +export * from "./services"; +export * from "./types"; +export * from "./utils"; diff --git a/src/advanced/domains/product/services/index.ts b/src/advanced/domains/product/services/index.ts new file mode 100644 index 00000000..38e141dd --- /dev/null +++ b/src/advanced/domains/product/services/index.ts @@ -0,0 +1,2 @@ +export * from "./productApplicationService"; +export * from "./productNotificationService"; diff --git a/src/advanced/domains/product/services/productApplicationService.ts b/src/advanced/domains/product/services/productApplicationService.ts new file mode 100644 index 00000000..4315faab --- /dev/null +++ b/src/advanced/domains/product/services/productApplicationService.ts @@ -0,0 +1,92 @@ +import type { NotificationFunction } from "../../../shared"; +import type { ProductForm, ProductWithUI } from "../types"; +import { productNotificationService } from "./productNotificationService"; + +type ProductUpdater = (updater: (prev: ProductWithUI[]) => ProductWithUI[]) => void; + +export const productApplicationService = { + addProduct: ( + newProduct: Omit, + updateProducts: ProductUpdater, + addNotification: NotificationFunction + ) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + + updateProducts((prev) => [...prev, product]); + productNotificationService.showAddSuccess(addNotification); + }, + + updateProduct: ( + productId: string, + updates: Partial, + updateProducts: ProductUpdater, + addNotification: NotificationFunction + ) => { + updateProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + productNotificationService.showUpdateSuccess(addNotification); + }, + + deleteProduct: ( + productId: string, + updateProducts: ProductUpdater, + addNotification: NotificationFunction + ) => { + updateProducts((prev) => prev.filter((p) => p.id !== productId)); + productNotificationService.showDeleteSuccess(addNotification); + }, + + handleProductSubmit: ( + productForm: ProductForm, + editingProduct: string | null, + updateProducts: ProductUpdater, + resetForm: () => void, + setEditingProduct: (id: string | null) => void, + setShowForm: (show: boolean) => void, + addNotification: NotificationFunction + ) => { + if (editingProduct && editingProduct !== "new") { + productApplicationService.updateProduct( + editingProduct, + productForm, + updateProducts, + addNotification + ); + setEditingProduct(null); + } else { + productApplicationService.addProduct( + { + ...productForm, + discounts: productForm.discounts + }, + updateProducts, + addNotification + ); + } + + resetForm(); + setEditingProduct(null); + setShowForm(false); + }, + + startEditProduct: ( + product: ProductWithUI, + setEditingProduct: (id: string) => void, + setProductForm: (form: ProductForm) => void, + setShowForm: (show: boolean) => void + ) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [] + }); + setShowForm(true); + } +}; diff --git a/src/advanced/domains/product/services/productNotificationService.ts b/src/advanced/domains/product/services/productNotificationService.ts new file mode 100644 index 00000000..76765027 --- /dev/null +++ b/src/advanced/domains/product/services/productNotificationService.ts @@ -0,0 +1,15 @@ +import type { NotificationFunction } from "../../../shared"; + +export const productNotificationService = { + showAddSuccess: (addNotification: NotificationFunction) => { + addNotification("상품이 추가되었습니다.", "success"); + }, + + showUpdateSuccess: (addNotification: NotificationFunction) => { + addNotification("상품이 수정되었습니다.", "success"); + }, + + showDeleteSuccess: (addNotification: NotificationFunction) => { + addNotification("상품이 삭제되었습니다.", "success"); + } +}; diff --git a/src/advanced/domains/product/types/entities.ts b/src/advanced/domains/product/types/entities.ts new file mode 100644 index 00000000..e8b461a3 --- /dev/null +++ b/src/advanced/domains/product/types/entities.ts @@ -0,0 +1,25 @@ +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface Discount { + quantity: number; + rate: number; +} + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export interface ProductForm { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} diff --git a/src/advanced/domains/product/types/index.ts b/src/advanced/domains/product/types/index.ts new file mode 100644 index 00000000..fc8e74dd --- /dev/null +++ b/src/advanced/domains/product/types/index.ts @@ -0,0 +1 @@ +export type * from "./entities"; diff --git a/src/advanced/domains/product/utils/formatters.ts b/src/advanced/domains/product/utils/formatters.ts new file mode 100644 index 00000000..3badfe26 --- /dev/null +++ b/src/advanced/domains/product/utils/formatters.ts @@ -0,0 +1,35 @@ +import type { CartItem } from "../../cart/types"; +import { getRemainingStock } from "../../cart/utils"; +import type { ProductWithUI } from "../types"; + +export function formatPrice( + price: number, + productId?: string, + products?: ProductWithUI[], + cart?: CartItem[], + isAdmin?: boolean +) { + if (productId && products && cart) { + const product = products.find((p) => p.id === productId); + if (product && getRemainingStock(product, cart) <= 0) { + return "SOLD OUT"; + } + } + + if (isAdmin) { + return `${price.toLocaleString()}원`; + } + + return `₩${price.toLocaleString()}`; +} + +export function filterProducts(products: ProductWithUI[], searchTerm: string) { + if (!searchTerm) return products; + + const lowerSearchTerm = searchTerm.toLowerCase(); + return products.filter( + (product) => + product.name.toLowerCase().includes(lowerSearchTerm) || + (product.description && product.description.toLowerCase().includes(lowerSearchTerm)) + ); +} diff --git a/src/advanced/domains/product/utils/index.ts b/src/advanced/domains/product/utils/index.ts new file mode 100644 index 00000000..96552da5 --- /dev/null +++ b/src/advanced/domains/product/utils/index.ts @@ -0,0 +1 @@ +export * from "./formatters"; diff --git a/src/advanced/main.tsx b/src/advanced/main.tsx index 79f72b70..d08c16bb 100644 --- a/src/advanced/main.tsx +++ b/src/advanced/main.tsx @@ -1,10 +1,16 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; -import App from "./App.tsx"; +import { App } from "./app"; -ReactDOM.createRoot(document.getElementById("root")!).render( - +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error('Root element with id "root" not found'); +} + +createRoot(rootElement).render( + - + ); diff --git a/src/advanced/shared/components/icons/CartIcon.tsx b/src/advanced/shared/components/icons/CartIcon.tsx new file mode 100644 index 00000000..c4ba9a45 --- /dev/null +++ b/src/advanced/shared/components/icons/CartIcon.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from "react"; + +type CartIconProps = SVGProps; + +export function CartIcon({ className = "w-6 h-6", ...rest }: CartIconProps) { + return ( + + + + ); +} diff --git a/src/advanced/shared/components/icons/CloseIcon.tsx b/src/advanced/shared/components/icons/CloseIcon.tsx new file mode 100644 index 00000000..6e4cef6b --- /dev/null +++ b/src/advanced/shared/components/icons/CloseIcon.tsx @@ -0,0 +1,11 @@ +import type { SVGProps } from "react"; + +type CloseIconProps = SVGProps; + +export function CloseIcon({ className = "w-4 h-4", ...rest }: CloseIconProps) { + return ( + + + + ); +} diff --git a/src/advanced/shared/components/icons/ImagePlaceholderIcon.tsx b/src/advanced/shared/components/icons/ImagePlaceholderIcon.tsx new file mode 100644 index 00000000..f2090948 --- /dev/null +++ b/src/advanced/shared/components/icons/ImagePlaceholderIcon.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from "react"; + +type ImagePlaceholderIconProps = SVGProps; + +export function ImagePlaceholderIcon({ + className = "w-24 h-24", + ...rest +}: ImagePlaceholderIconProps) { + return ( + + + + ); +} diff --git a/src/advanced/shared/components/icons/PlusIcon.tsx b/src/advanced/shared/components/icons/PlusIcon.tsx new file mode 100644 index 00000000..6b014dd1 --- /dev/null +++ b/src/advanced/shared/components/icons/PlusIcon.tsx @@ -0,0 +1,11 @@ +import type { SVGProps } from "react"; + +type PlusIconProps = SVGProps; + +export function PlusIcon({ className = "w-6 h-6", ...rest }: PlusIconProps) { + return ( + + + + ); +} diff --git a/src/advanced/shared/components/icons/ShoppingBagIcon.tsx b/src/advanced/shared/components/icons/ShoppingBagIcon.tsx new file mode 100644 index 00000000..ada901f9 --- /dev/null +++ b/src/advanced/shared/components/icons/ShoppingBagIcon.tsx @@ -0,0 +1,22 @@ +import type { SVGProps } from "react"; + +type ShoppingBagIconProps = SVGProps & { + strokeWidth?: number; +}; + +export function ShoppingBagIcon({ + className = "w-5 h-5", + strokeWidth = 2, + ...rest +}: ShoppingBagIconProps) { + return ( + + + + ); +} diff --git a/src/advanced/shared/components/icons/TrashIcon.tsx b/src/advanced/shared/components/icons/TrashIcon.tsx new file mode 100644 index 00000000..d64db2f0 --- /dev/null +++ b/src/advanced/shared/components/icons/TrashIcon.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from "react"; + +type TrashIconProps = SVGProps; + +export function TrashIcon({ className = "w-5 h-5", ...rest }: TrashIconProps) { + return ( + + + + ); +} diff --git a/src/advanced/shared/components/icons/index.ts b/src/advanced/shared/components/icons/index.ts new file mode 100644 index 00000000..5394b7f8 --- /dev/null +++ b/src/advanced/shared/components/icons/index.ts @@ -0,0 +1,6 @@ +export * from "./CartIcon"; +export * from "./CloseIcon"; +export * from "./ImagePlaceholderIcon"; +export * from "./PlusIcon"; +export * from "./ShoppingBagIcon"; +export * from "./TrashIcon"; diff --git a/src/advanced/shared/components/index.ts b/src/advanced/shared/components/index.ts new file mode 100644 index 00000000..ac365264 --- /dev/null +++ b/src/advanced/shared/components/index.ts @@ -0,0 +1,2 @@ +export * from "./icons"; +export * from "./ui"; diff --git a/src/advanced/shared/components/ui/BadgeContainer.tsx b/src/advanced/shared/components/ui/BadgeContainer.tsx new file mode 100644 index 00000000..a489936d --- /dev/null +++ b/src/advanced/shared/components/ui/BadgeContainer.tsx @@ -0,0 +1,19 @@ +import { type PropsWithChildren } from "react"; + +type BadgeContainerProps = PropsWithChildren<{ + label: string; + visible: boolean; +}>; + +export function BadgeContainer({ label, visible, children }: BadgeContainerProps) { + return ( +
+ {children} + {visible && ( + + {label} + + )} +
+ ); +} diff --git a/src/advanced/shared/components/ui/Button.tsx b/src/advanced/shared/components/ui/Button.tsx new file mode 100644 index 00000000..d741d240 --- /dev/null +++ b/src/advanced/shared/components/ui/Button.tsx @@ -0,0 +1,34 @@ +import type { ComponentPropsWithRef } from "react"; +import { tv } from "tailwind-variants"; + +type ButtonProps = Omit, "size" | "color"> & { + size?: "lg" | "md" | "sm"; + color?: "primary" | "secondary" | "danger" | "dark" | "neutral" | "yellow"; +}; + +const buttonVariants = tv({ + base: "rounded font-medium transition-colors focus:outline-none disabled:cursor-not-allowed disabled:opacity-50", + variants: { + size: { + sm: "px-3 py-1.5 text-sm", + md: "px-4 py-2 text-base", + lg: "px-6 py-3 text-base" + }, + color: { + primary: "bg-indigo-600 text-white hover:bg-indigo-700 disabled:bg-indigo-300", + secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300 disabled:bg-gray-100", + danger: "bg-red-600 text-white hover:bg-red-700 disabled:bg-red-300", + dark: "bg-gray-900 text-white hover:bg-gray-800 disabled:bg-gray-400", + neutral: "bg-gray-800 text-white hover:bg-gray-700 disabled:bg-gray-400", + yellow: "bg-yellow-400 text-gray-900 hover:bg-yellow-500 disabled:bg-yellow-200" + } + }, + defaultVariants: { + size: "md", + color: "primary" + } +}); + +export function Button({ className, size, color, ...rest }: ButtonProps) { + return +
+ ); +} diff --git a/src/advanced/shared/components/ui/SearchInput.tsx b/src/advanced/shared/components/ui/SearchInput.tsx new file mode 100644 index 00000000..8da93f5e --- /dev/null +++ b/src/advanced/shared/components/ui/SearchInput.tsx @@ -0,0 +1,35 @@ +import type { ComponentPropsWithRef } from "react"; +import { tv } from "tailwind-variants"; + +type SearchInputProps = Omit, "size" | "color"> & { + label?: string; + size?: "lg" | "md"; + color?: "blue" | "indigo"; +}; + +const inputVariants = tv({ + base: "w-full border border-gray-300 py-2", + variants: { + size: { + md: "rounded-md px-3", + lg: "rounded-lg px-4" + }, + color: { + blue: "focus:border-blue-500 focus:outline-none", + indigo: "focus:border-indigo-500 focus:ring-indigo-500" + } + }, + defaultVariants: { + size: "md", + color: "indigo" + } +}); + +export function SearchInput({ className, label, size, color, ...rest }: SearchInputProps) { + return ( + <> + {label && } + + + ); +} diff --git a/src/advanced/shared/components/ui/index.ts b/src/advanced/shared/components/ui/index.ts new file mode 100644 index 00000000..7a06b0f1 --- /dev/null +++ b/src/advanced/shared/components/ui/index.ts @@ -0,0 +1,4 @@ +export * from "./BadgeContainer"; +export * from "./Button"; +export * from "./Notification"; +export * from "./SearchInput"; diff --git a/src/advanced/shared/hooks/index.ts b/src/advanced/shared/hooks/index.ts new file mode 100644 index 00000000..815d51bd --- /dev/null +++ b/src/advanced/shared/hooks/index.ts @@ -0,0 +1,5 @@ +export * from "./useDebounceState"; +export * from "./useDebounceValue"; +export * from "./useLocalStorageState"; +export * from "./useNotifications"; +export * from "./useToggle"; diff --git a/src/advanced/shared/hooks/useDebounceState.ts b/src/advanced/shared/hooks/useDebounceState.ts new file mode 100644 index 00000000..a5b9c1a6 --- /dev/null +++ b/src/advanced/shared/hooks/useDebounceState.ts @@ -0,0 +1,22 @@ +import { type Dispatch, type SetStateAction, useState } from "react"; + +import { useDebounceValue } from "./useDebounceValue"; + +type UseDebounceStateProps = { + delay: number; + initialValue: S; +}; + +export function useDebounceState({ + delay, + initialValue +}: UseDebounceStateProps): [S, Dispatch>, S] { + const [state, setState] = useState(initialValue); + + const debouncedState = useDebounceValue({ + delay, + value: state + }); + + return [state, setState, debouncedState]; +} diff --git a/src/advanced/shared/hooks/useDebounceValue.ts b/src/advanced/shared/hooks/useDebounceValue.ts new file mode 100644 index 00000000..b82e1fda --- /dev/null +++ b/src/advanced/shared/hooks/useDebounceValue.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +type UseDebounceValueProps = { + delay: number; + value: T; +}; + +export function useDebounceValue({ delay, value }: UseDebounceValueProps) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const debouncedTimeout = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(debouncedTimeout); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/advanced/shared/hooks/useLocalStorageState.ts b/src/advanced/shared/hooks/useLocalStorageState.ts new file mode 100644 index 00000000..43410b76 --- /dev/null +++ b/src/advanced/shared/hooks/useLocalStorageState.ts @@ -0,0 +1,36 @@ +import { type Dispatch, type SetStateAction, useEffect, useState } from "react"; + +type UseLocalStorageStateProps = { + key: string; + initialState: S; +}; + +export function useLocalStorageState({ + key, + initialState +}: UseLocalStorageStateProps): [S, Dispatch>] { + const readLocalStorage = () => { + try { + const item = localStorage.getItem(key); + return item ? (JSON.parse(item) as S) : initialState; + } catch { + return initialState; + } + }; + + const [state, setState] = useState(readLocalStorage); + + useEffect(() => { + const isEmpty = state === undefined || state === null; + const isEmptyObject = typeof state === "object" && Object.keys(state || {}).length === 0; + const isEmptyArray = Array.isArray(state) && state.length === 0; + + if (isEmpty || isEmptyObject || isEmptyArray) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(state)); + } + }, [state, key]); + + return [state, setState]; +} diff --git a/src/advanced/shared/hooks/useNotifications.ts b/src/advanced/shared/hooks/useNotifications.ts new file mode 100644 index 00000000..9db2fba7 --- /dev/null +++ b/src/advanced/shared/hooks/useNotifications.ts @@ -0,0 +1,26 @@ +import { useState } from "react"; + +import type { NotificationItem } from "../types"; + +export function useNotifications() { + const [notifications, setNotifications] = useState([]); + + const addNotification = (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }; + + const removeNotification = (id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }; + + return { + notifications, + addNotification, + removeNotification + }; +} diff --git a/src/advanced/shared/hooks/useToggle.ts b/src/advanced/shared/hooks/useToggle.ts new file mode 100644 index 00000000..509dd0b1 --- /dev/null +++ b/src/advanced/shared/hooks/useToggle.ts @@ -0,0 +1,11 @@ +import { useState } from "react"; + +export function useToggle(defaultValue?: boolean) { + const [value, setValue] = useState(!!defaultValue); + + const toggle = () => { + setValue((prev) => !prev); + }; + + return [value, toggle, setValue] as const; +} diff --git a/src/advanced/shared/index.ts b/src/advanced/shared/index.ts new file mode 100644 index 00000000..057ddce2 --- /dev/null +++ b/src/advanced/shared/index.ts @@ -0,0 +1,4 @@ +export * from "./components"; +export * from "./hooks"; +export * from "./types"; +export * from "./utils"; diff --git a/src/advanced/shared/types/index.ts b/src/advanced/shared/types/index.ts new file mode 100644 index 00000000..a67dd4c1 --- /dev/null +++ b/src/advanced/shared/types/index.ts @@ -0,0 +1,2 @@ +export type * from "./notification"; +export type * from "./validation"; diff --git a/src/advanced/shared/types/notification.ts b/src/advanced/shared/types/notification.ts new file mode 100644 index 00000000..4871b8ae --- /dev/null +++ b/src/advanced/shared/types/notification.ts @@ -0,0 +1,10 @@ +export interface NotificationItem { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +export type NotificationFunction = ( + message: string, + type?: "error" | "success" | "warning" +) => void; diff --git a/src/advanced/shared/types/validation.ts b/src/advanced/shared/types/validation.ts new file mode 100644 index 00000000..a0ab9278 --- /dev/null +++ b/src/advanced/shared/types/validation.ts @@ -0,0 +1,4 @@ +export type ValidationResult = { + valid: boolean; + message?: string; +}; diff --git a/src/advanced/shared/utils/index.ts b/src/advanced/shared/utils/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/advanced/shared/utils/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/basic/main.tsx b/src/basic/main.tsx index 79f72b70..d08c16bb 100644 --- a/src/basic/main.tsx +++ b/src/basic/main.tsx @@ -1,10 +1,16 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; -import App from "./App.tsx"; +import { App } from "./app"; -ReactDOM.createRoot(document.getElementById("root")!).render( - +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error('Root element with id "root" not found'); +} + +createRoot(rootElement).render( + - + ); From a4101b7eb1ed9fbe5ce008f7c6c47b2c046ae074 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 14:55:23 +0900 Subject: [PATCH 32/35] =?UTF-8?q?chore:=20jotai=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/package.json b/package.json index 84ef66a1..6c4d6dcf 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "format:check": "prettier --check ." }, "dependencies": { + "jotai": "^2.13.0", "react": "^19.1.1", "react-dom": "^19.1.1", "tailwind-variants": "^2.1.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29e94644..80938bc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + jotai: + specifier: ^2.13.0 + version: 2.13.0(@types/react@19.1.9)(react@19.1.1) react: specifier: ^19.1.1 version: 19.1.1 @@ -1095,6 +1098,24 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jotai@2.13.0: + resolution: {integrity: sha512-H43zXdanNTdpfOEJ4NVbm4hgmrctpXLZagjJNcqAywhUv+sTE7esvFjwm5oBg/ywT9Qw63lIkM6fjrhFuW8UDg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2595,6 +2616,11 @@ snapshots: isexe@2.0.0: {} + jotai@2.13.0(@types/react@19.1.9)(react@19.1.1): + optionalDependencies: + '@types/react': 19.1.9 + react: 19.1.1 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} From c3829a18daf84dbdb5c9e9a127b0cfadef155445 Mon Sep 17 00:00:00 2001 From: chan9yu Date: Fri, 8 Aug 2025 16:13:08 +0900 Subject: [PATCH 33/35] =?UTF-8?q?feat:=20=EC=A0=84=EC=97=AD=EC=83=81?= =?UTF-8?q?=ED=83=9C=EA=B4=80=EB=A6=AC=20=EA=B5=AC=EC=B6=95=20props=20dril?= =?UTF-8?q?ling=20=EC=B5=9C=EC=86=8C=ED=99=94=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/app/App.tsx | 133 +----------- .../components/CouponManagementSection.tsx | 60 ++---- src/advanced/app/components/Header.tsx | 33 ++- .../components/ProductManagementSection.tsx | 59 ++---- src/advanced/app/pages/AdminPage.tsx | 197 +----------------- src/advanced/app/pages/CartPage.tsx | 69 +----- .../domains/cart/components/CartSidebar.tsx | 36 +--- src/advanced/domains/cart/hooks/index.ts | 1 + .../domains/cart/hooks/useCartAtom.ts | 55 +++++ src/advanced/domains/cart/index.ts | 2 + src/advanced/domains/cart/store/atoms.ts | 11 + src/advanced/domains/cart/store/index.ts | 1 + .../domains/coupon/components/CouponForm.tsx | 12 +- .../domains/coupon/constants/index.ts | 1 - src/advanced/domains/coupon/hooks/index.ts | 2 + .../domains/coupon/hooks/useCouponAtom.ts | 53 +++++ .../domains/coupon/hooks/useCouponForm.ts | 56 +++++ src/advanced/domains/coupon/index.ts | 3 +- .../initialData.ts => store/atoms.ts} | 8 +- src/advanced/domains/coupon/store/index.ts | 1 + .../product/components/ProductFormEditor.tsx | 11 +- .../product/components/ProductList.tsx | 42 ++-- .../product/components/ProductTable.tsx | 2 +- .../domains/product/constants/index.ts | 1 - src/advanced/domains/product/hooks/index.ts | 2 + .../domains/product/hooks/useProductAtom.ts | 68 ++++++ .../domains/product/hooks/useProductForm.ts | 75 +++++++ src/advanced/domains/product/index.ts | 3 +- .../initialData.ts => store/atoms.ts} | 6 +- src/advanced/domains/product/store/index.ts | 1 + src/advanced/shared/hooks/useNotifications.ts | 16 +- src/advanced/shared/index.ts | 1 + src/advanced/shared/store/index.ts | 2 + .../shared/store/notificationAtoms.ts | 5 + src/advanced/shared/store/uiAtoms.ts | 5 + 35 files changed, 480 insertions(+), 553 deletions(-) create mode 100644 src/advanced/domains/cart/hooks/index.ts create mode 100644 src/advanced/domains/cart/hooks/useCartAtom.ts create mode 100644 src/advanced/domains/cart/store/atoms.ts create mode 100644 src/advanced/domains/cart/store/index.ts delete mode 100644 src/advanced/domains/coupon/constants/index.ts create mode 100644 src/advanced/domains/coupon/hooks/index.ts create mode 100644 src/advanced/domains/coupon/hooks/useCouponAtom.ts create mode 100644 src/advanced/domains/coupon/hooks/useCouponForm.ts rename src/advanced/domains/coupon/{constants/initialData.ts => store/atoms.ts} (50%) create mode 100644 src/advanced/domains/coupon/store/index.ts delete mode 100644 src/advanced/domains/product/constants/index.ts create mode 100644 src/advanced/domains/product/hooks/index.ts create mode 100644 src/advanced/domains/product/hooks/useProductAtom.ts create mode 100644 src/advanced/domains/product/hooks/useProductForm.ts rename src/advanced/domains/product/{constants/initialData.ts => store/atoms.ts} (80%) create mode 100644 src/advanced/domains/product/store/index.ts create mode 100644 src/advanced/shared/store/index.ts create mode 100644 src/advanced/shared/store/notificationAtoms.ts create mode 100644 src/advanced/shared/store/uiAtoms.ts diff --git a/src/advanced/app/App.tsx b/src/advanced/app/App.tsx index 5921f47f..fa5560eb 100644 --- a/src/advanced/app/App.tsx +++ b/src/advanced/app/App.tsx @@ -1,139 +1,18 @@ -import { useEffect, useState } from "react"; +import { useAtomValue } from "jotai"; -import { - calculateCartTotal, - calculateItemTotal, - cartApplicationService, - type CartItem, - getRemainingStock -} from "../domains/cart"; -import { type Coupon, couponApplicationService, INITIAL_COUPONS } from "../domains/coupon"; -import { - formatPrice, - INITIAL_PRODUCTS, - type Product, - type ProductWithUI -} from "../domains/product"; -import { useDebounceState, useLocalStorageState, useNotifications, useToggle } from "../shared"; +import { adminModeAtom, useNotifications } from "../shared"; import { Header, NotificationList } from "./components"; import { AdminPage, CartPage } from "./pages"; export function App() { - const [products, setProducts] = useLocalStorageState({ - key: "products", - initialState: INITIAL_PRODUCTS - }); - - const [cart, setCart] = useLocalStorageState({ - key: "cart", - initialState: [] - }); - - const [coupons, setCoupons] = useLocalStorageState({ - key: "coupons", - initialState: INITIAL_COUPONS - }); - - const [searchTerm, setSearchTerm, debouncedSearchTerm] = useDebounceState({ - delay: 500, - initialValue: "" - }); - - const { notifications, addNotification, removeNotification } = useNotifications(); - - const [isAdminMode, toggleAdminMode] = useToggle(false); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - - const formatPriceWithContext = (price: number, productId?: string) => { - return formatPrice(price, productId, products, cart, isAdminMode); - }; - - const [totalItemCount, setTotalItemCount] = useState(0); - - const addToCart = (product: Product) => { - cartApplicationService.addToCart(product, cart, setCart, addNotification); - }; - - const removeFromCart = (productId: string) => { - cartApplicationService.removeFromCart(productId, setCart); - }; - - const updateQuantity = (productId: string, newQuantity: number) => { - cartApplicationService.updateQuantity( - productId, - newQuantity, - products, - setCart, - addNotification - ); - }; - - const completeOrder = () => { - cartApplicationService.completeOrder( - () => setCart([]), - () => setSelectedCoupon(null), - addNotification - ); - }; - - const applyCoupon = (coupon: Coupon) => { - const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; - couponApplicationService.applyCoupon(coupon, currentTotal, setSelectedCoupon, addNotification); - }; - - const calculateItemTotalWithCart = (item: CartItem) => { - return calculateItemTotal(item, cart); - }; - - const getRemainingStockWithCart = (product: Product) => { - return getRemainingStock(product, cart); - }; - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); + const { notifications, removeNotification } = useNotifications(); + const isAdminMode = useAtomValue(adminModeAtom); return (
-
+
- {isAdminMode ? ( - - ) : ( - - )} + {isAdminMode ? : }
diff --git a/src/advanced/app/components/CouponManagementSection.tsx b/src/advanced/app/components/CouponManagementSection.tsx index 6b2599ca..7b00a2f1 100644 --- a/src/advanced/app/components/CouponManagementSection.tsx +++ b/src/advanced/app/components/CouponManagementSection.tsx @@ -1,37 +1,22 @@ -import { type FormEvent } from "react"; +import { + AddCouponCard, + CouponCard, + CouponForm, + useCouponAtom, + useCouponForm +} from "../../domains/coupon"; -import { AddCouponCard, type Coupon, CouponCard, CouponForm } from "../../domains/coupon"; +export function CouponManagementSection() { + const { coupons, deleteCoupon } = useCouponAtom(); + const { + couponForm, + showCouponForm, + setCouponForm, + handleSubmit, + handleToggleForm, + handleCancel + } = useCouponForm(); -type CouponFormType = { - name: string; - code: string; - discountType: "amount" | "percentage"; - discountValue: number; -}; - -type CouponManagementSectionProps = { - coupons: Coupon[]; - couponForm: CouponFormType; - showCouponForm: boolean; - onToggleForm: () => void; - onDelete: (code: string) => void; - onFormSubmit: (e: FormEvent) => void; - onFormCancel: () => void; - onFormChange: (form: CouponFormType) => void; - addNotification: (message: string, type?: "error" | "success" | "warning") => void; -}; - -export function CouponManagementSection({ - coupons, - couponForm, - showCouponForm, - onToggleForm, - onDelete, - onFormSubmit, - onFormCancel, - onFormChange, - addNotification -}: CouponManagementSectionProps) { return (
@@ -40,18 +25,17 @@ export function CouponManagementSection({
{coupons.map((coupon) => ( - + ))} - +
{showCouponForm && ( )}
diff --git a/src/advanced/app/components/Header.tsx b/src/advanced/app/components/Header.tsx index 712dd1d7..e1c76ab0 100644 --- a/src/advanced/app/components/Header.tsx +++ b/src/advanced/app/components/Header.tsx @@ -1,26 +1,15 @@ +import { useAtom, useAtomValue } from "jotai"; import { type ChangeEvent } from "react"; -import type { CartItem } from "../../domains/cart"; -import { BadgeContainer, CartIcon, SearchInput } from "../../shared"; +import { totalItemCountAtom } from "../../domains/cart"; +import { adminModeAtom, BadgeContainer, CartIcon, SearchInput, searchTermAtom } from "../../shared"; import { AdminToggleButton } from "./AdminToggleButton"; -type HeaderProps = { - isAdminMode: boolean; - onToggleAdminMode: () => void; - cart: CartItem[]; - searchTerm: string; - setSearchTerm: (term: string) => void; - totalItemCount: number; -}; +export function Header() { + const [isAdminMode, setIsAdminMode] = useAtom(adminModeAtom); + const [searchTerm, setSearchTerm] = useAtom(searchTermAtom); + const totalItemCount = useAtomValue(totalItemCountAtom); -export function Header({ - isAdminMode, - onToggleAdminMode, - cart, - searchTerm, - setSearchTerm, - totalItemCount -}: HeaderProps) { const handleSearchChange = (e: ChangeEvent) => { setSearchTerm(e.target.value); }; @@ -31,7 +20,6 @@ export function Header({

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} {!isAdminMode && (