From 10e84aaf91f2abb1f59aaaf9dc2a3f5588d8e698 Mon Sep 17 00:00:00 2001 From: esoby Date: Mon, 4 Aug 2025 22:19:59 +0900 Subject: [PATCH 01/44] =?UTF-8?q?chore:=20eslint=20&=20prettier=20rule=20?= =?UTF-8?q?=ED=8C=80=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc.cjs | 18 - .prettierrc.js | 11 + eslint.config.js | 134 ++++ package.json | 6 + pnpm-lock.yaml | 1517 +++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 1663 insertions(+), 23 deletions(-) delete mode 100644 .eslintrc.cjs create mode 100644 .prettierrc.js 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/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000..77c2218c --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,11 @@ +export default { + semi: true, + singleQuote: true, + jsxSingleQuote: true, + tabWidth: 2, + bracketSpacing: true, + bracketSameLine: false, + arrowParens: "always", + printWidth: 100, + endOfLine: "auto", +}; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..c10e6a4b --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,134 @@ +import typescript from "@typescript-eslint/eslint-plugin"; +import typescriptParser from "@typescript-eslint/parser"; +import { defineConfig } from "eslint/config"; +import prettier from "eslint-config-prettier"; +import compat from "eslint-plugin-compat"; +import importPlugin from "eslint-plugin-import"; +import eslintPluginPrettier from "eslint-plugin-prettier"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; + +export default defineConfig([ + { + ignores: ["**/node_modules/**", "dist/**", ".eslintrc.cjs"], + }, + { + files: ["**/*.{js,jsx,ts,tsx}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + parser: typescriptParser, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + tsconfigRootDir: ".", + }, + }, + plugins: { + prettier: eslintPluginPrettier, + react, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + "@typescript-eslint": typescript, + compat, + import: importPlugin, + }, + settings: { + react: { + version: "detect", + }, + browsers: + "> 0.5%, last 2 versions, not op_mini all, Firefox ESR, not dead", + }, + rules: { + // 기존 규칙 유지 + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + + // Prettier 통합 규칙 + "comma-dangle": [ + "error", + { + arrays: "always-multiline", + objects: "always-multiline", + imports: "always-multiline", + exports: "always-multiline", + functions: "never", + }, + ], + + // React 관련 규칙 + "react/prop-types": "off", + "react/react-in-jsx-scope": "off", + "react-hooks/rules-of-hooks": "error", + + // TypeScript 관련 규칙 + "@typescript-eslint/no-explicit-any": "warn", + + // 팀 컨벤션 - var 사용 금지 + "no-var": "error", + "@typescript-eslint/no-unused-vars": "error", + + // 팀 컨벤션 - 동등 연산자 (==, !=) 금지 + eqeqeq: ["error", "always", { null: "ignore" }], + + // 팀 컨벤션 - 얼리 리턴 권장 + "consistent-return": "error", + "no-else-return": ["error", { allowElseIf: false }], + + // 팀 컨벤션 - 템플릿 리터럴 규칙 + "prefer-template": "error", + + // 팀 컨벤션 - 상수는 대문자 + camelcase: [ + "error", + { + properties: "never", + ignoreDestructuring: false, + ignoreImports: false, + ignoreGlobals: false, + allow: ["^[A-Z][A-Z0-9_]*$"], + }, + ], + + // 팀 컨벤션 - 구조분해할당 권장 + "prefer-destructuring": [ + "error", + { + array: true, + object: true, + }, + { + enforceForRenamedProperties: false, + }, + ], + + // 기본 코드 품질 규칙 + "prefer-const": "error", + "object-shorthand": "error", + "no-multiple-empty-lines": ["error", { max: 1, maxEOF: 0 }], + "no-console": ["warn", { allow: ["warn", "error"] }], + "no-debugger": "warn", + "no-undef": "off", + + // import 순서 규칙 + "import/order": [ + "error", + { + groups: ["builtin", "external", ["parent", "sibling"], "index"], + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + "newlines-between": "always", + }, + ], + "import/extensions": "off", + }, + }, + prettier, +]); diff --git a/package.json b/package.json index 79034acb..534f19de 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,15 @@ "@vitejs/plugin-react-swc": "^3.11.0", "@vitest/ui": "^3.2.4", "eslint": "^9.32.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-compat": "^6.0.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-prettier": "^5.5.3", + "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "jsdom": "^26.1.0", + "prettier": "^3.6.2", "typescript": "^5.9.2", "vite": "^7.0.6", "vitest": "^3.2.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85..47126b26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,21 @@ 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-compat: + specifier: ^6.0.2 + version: 6.0.2(eslint@9.32.0) + eslint-plugin-import: + specifier: ^2.32.0 + version: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.9.2))(eslint@9.32.0) + eslint-plugin-prettier: + specifier: ^5.5.3 + version: 5.5.3(eslint-config-prettier@10.1.8(eslint@9.32.0))(eslint@9.32.0)(prettier@3.6.2) + eslint-plugin-react: + specifier: ^7.37.5 + version: 7.37.5(eslint@9.32.0) eslint-plugin-react-hooks: specifier: ^5.2.0 version: 5.2.0(eslint@9.32.0) @@ -54,6 +69,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 + prettier: + specifier: ^3.6.2 + version: 3.6.2 typescript: specifier: ^5.9.2 version: 5.9.2 @@ -343,6 +361,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@mdn/browser-compat-data@5.7.6': + resolution: {integrity: sha512-7xdrMX0Wk7grrTZQwAoy1GkvPMFoizStUoL+VmtUkAxegbCCec+3FKwOM6yc/uGU5+BEczQHXAlWiqvM8JeENg==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -355,6 +376,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} @@ -461,6 +486,9 @@ packages: cpu: [x64] os: [win32] + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@swc/core-darwin-arm64@1.13.3': resolution: {integrity: sha512-ux0Ws4pSpBTqbDS9GlVP354MekB1DwYlbxXU3VhnDr4GBcCOimpocx62x7cFJkSpEBF8bmX8+/TTCGKh4PbyXw==} engines: {node: '>=10'} @@ -583,6 +611,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/react-dom@19.1.7': resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} peerDependencies: @@ -732,10 +763,53 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-metadata-inferer@0.8.1: + resolution: {integrity: sha512-ht3Dm6Zr7SXv6t1Ra6gFo0+kLDglHGrEbYihTkcycrbHw7WCcuhBzPlJYHEsIpycaUwzsJHje+vUcxXUX4ztTA==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -749,14 +823,34 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browserslist@4.25.1: + resolution: {integrity: sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + caniuse-lite@1.0.30001731: + resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==} + chai@5.2.1: resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==} engines: {node: '>=18'} @@ -807,6 +901,26 @@ packages: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -835,28 +949,83 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.194: + resolution: {integrity: sha512-SdnWJwSUot04UR51I2oPD8kuP2VI37/CADR1OHsFOUzZIvfWJBO6q11k5P/uKNyTT3cdOsnyjkrZ+DDShqYqJA==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + es-abstract@1.24.0: + resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.2.1: + resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + esbuild@0.25.8: resolution: {integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==} engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -865,6 +1034,66 @@ 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-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-compat@6.0.2: + resolution: {integrity: sha512-1ME+YfJjmOz1blH0nPZpHgjMGK4kjgEeoYqGCqoBPQ/mGu/dJzdoP0f1C8H2jcWZjzhZjAMccbM/VdXhPORIfA==} + engines: {node: '>=18.x'} + peerDependencies: + eslint: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-prettier@5.5.3: + resolution: {integrity: sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + eslint-plugin-react-hooks@5.2.0: resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} engines: {node: '>=10'} @@ -876,6 +1105,12 @@ packages: peerDependencies: eslint: '>=8.40' + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -928,6 +1163,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -974,11 +1212,37 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -991,9 +1255,25 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -1002,6 +1282,25 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -1038,14 +1337,70 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.0: + resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} + engines: {node: '>= 0.4'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -1053,9 +1408,52 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1084,6 +1482,14 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1095,12 +1501,19 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.1.2: resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} @@ -1117,6 +1530,10 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1136,6 +1553,9 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mrmime@2.0.0: resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} engines: {node: '>=10'} @@ -1151,13 +1571,52 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + nwsapi@2.2.20: resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -1181,6 +1640,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1203,6 +1665,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1211,10 +1677,22 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + engines: {node: '>=6.0.0'} + + 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} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1227,6 +1705,9 @@ packages: peerDependencies: react: ^19.1.1 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -1238,13 +1719,30 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -1260,6 +1758,18 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1270,11 +1780,27 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.6.3: resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} hasBin: true + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1283,6 +1809,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1300,12 +1842,39 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} - strip-indent@3.0.0: - resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} - engines: {node: '>=8'} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} strip-literal@3.0.0: @@ -1319,9 +1888,17 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.11: + resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} + engines: {node: ^14.18.0 || >=16.0.0} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1373,15 +1950,44 @@ packages: peerDependencies: typescript: '>=4.8.4' + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} hasBin: true + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1478,6 +2084,22 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1709,6 +2331,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} + '@mdn/browser-compat-data@5.7.6': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1721,6 +2345,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@pkgr/core@0.2.9': {} + '@polka/url@1.0.0-next.28': {} '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -1785,6 +2411,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.46.2': optional: true + '@rtsao/scc@1.1.0': {} + '@swc/core-darwin-arm64@1.13.3': optional: true @@ -1886,6 +2514,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/json5@0.0.29': {} + '@types/react-dom@19.1.7(@types/react@19.1.9)': dependencies: '@types/react': 19.1.9 @@ -2083,8 +2713,85 @@ snapshots: aria-query@5.3.2: {} + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-metadata-inferer@0.8.1: + dependencies: + '@mdn/browser-compat-data': 5.7.6 + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + balanced-match@1.0.2: {} brace-expansion@1.1.11: @@ -2100,10 +2807,36 @@ snapshots: dependencies: fill-range: 7.1.1 + browserslist@4.25.1: + dependencies: + caniuse-lite: 1.0.30001731 + electron-to-chromium: 1.5.194 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.25.1) + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} + caniuse-lite@1.0.30001731: {} + chai@5.2.1: dependencies: assertion-error: 2.0.1 @@ -2159,6 +2892,28 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + debug@4.3.7: dependencies: ms: 2.1.3 @@ -2173,16 +2928,141 @@ snapshots: deep-is@0.1.4: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + dequal@2.0.3: {} + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.194: {} + entities@4.5.0: {} + es-abstract@1.24.0: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.19 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + safe-array-concat: 1.1.3 + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + esbuild@0.25.8: optionalDependencies: '@esbuild/aix-ppc64': 0.25.8 @@ -2212,10 +3092,84 @@ snapshots: '@esbuild/win32-ia32': 0.25.8 '@esbuild/win32-x64': 0.25.8 + escalade@3.2.0: {} + escape-string-regexp@1.0.5: {} escape-string-regexp@4.0.0: {} + eslint-config-prettier@10.1.8(eslint@9.32.0): + dependencies: + eslint: 9.32.0 + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 1.22.10 + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.32.0): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.9.2) + eslint: 9.32.0 + eslint-import-resolver-node: 0.3.9 + transitivePeerDependencies: + - supports-color + + eslint-plugin-compat@6.0.2(eslint@9.32.0): + dependencies: + '@mdn/browser-compat-data': 5.7.6 + ast-metadata-inferer: 0.8.1 + browserslist: 4.25.1 + caniuse-lite: 1.0.30001731 + eslint: 9.32.0 + find-up: 5.0.0 + globals: 15.15.0 + lodash.memoize: 4.1.2 + semver: 7.6.3 + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.9.2))(eslint@9.32.0): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.32.0 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.32.0) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.9.2) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-prettier@5.5.3(eslint-config-prettier@10.1.8(eslint@9.32.0))(eslint@9.32.0)(prettier@3.6.2): + dependencies: + eslint: 9.32.0 + prettier: 3.6.2 + prettier-linter-helpers: 1.0.0 + synckit: 0.11.11 + optionalDependencies: + eslint-config-prettier: 10.1.8(eslint@9.32.0) + eslint-plugin-react-hooks@5.2.0(eslint@9.32.0): dependencies: eslint: 9.32.0 @@ -2224,6 +3178,28 @@ snapshots: dependencies: eslint: 9.32.0 + eslint-plugin-react@7.37.5(eslint@9.32.0): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.1 + eslint: 9.32.0 + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -2299,6 +3275,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-diff@1.3.0: {} + fast-glob@3.3.2: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2347,9 +3325,50 @@ snapshots: flatted@3.3.3: {} + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2360,12 +3379,41 @@ snapshots: globals@14.0.0: {} + globals@15.15.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + graphemer@1.4.0: {} + has-bigints@1.1.0: {} + has-flag@3.0.0: {} has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -2401,18 +3449,134 @@ snapshots: indent-string@4.0.0: {} + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-extglob@2.1.1: {} + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.0: + dependencies: + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-number@7.0.0: {} is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.19 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + isexe@2.0.0: {} + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -2454,6 +3618,17 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -2467,10 +3642,16 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.memoize@4.1.2: {} + lodash.merge@4.6.2: {} lodash@4.17.21: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.1.2: {} loupe@3.2.0: {} @@ -2483,6 +3664,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + math-intrinsics@1.1.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -2500,6 +3683,8 @@ snapshots: dependencies: brace-expansion: 2.0.1 + minimist@1.2.8: {} + mrmime@2.0.0: {} ms@2.1.3: {} @@ -2508,8 +3693,52 @@ snapshots: natural-compare@1.4.0: {} + node-releases@2.0.19: {} + nwsapi@2.2.20: {} + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2519,6 +3748,12 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -2539,6 +3774,8 @@ snapshots: path-key@3.1.1: {} + path-parse@1.0.7: {} + pathe@2.0.3: {} pathval@2.0.0: {} @@ -2551,6 +3788,8 @@ snapshots: picomatch@4.0.3: {} + possible-typed-array-names@1.1.0: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2559,12 +3798,24 @@ snapshots: prelude-ls@1.2.1: {} + prettier-linter-helpers@1.0.0: + dependencies: + fast-diff: 1.3.0 + + prettier@3.6.2: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 ansi-styles: 5.2.0 react-is: 17.0.2 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -2574,6 +3825,8 @@ snapshots: react: 19.1.1 scheduler: 0.26.0 + react-is@16.13.1: {} + react-is@17.0.2: {} react@19.1.1: {} @@ -2583,10 +3836,42 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + regenerator-runtime@0.14.1: {} + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + resolve-from@4.0.0: {} + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.5: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + reusify@1.0.4: {} rollup@4.46.2: @@ -2621,6 +3906,25 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + safer-buffer@2.1.2: {} saxes@6.0.0: @@ -2629,14 +3933,66 @@ snapshots: scheduler@0.26.0: {} + semver@6.3.1: {} + semver@7.6.3: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} sirv@3.0.1: @@ -2651,6 +4007,57 @@ snapshots: std-env@3.9.0: {} + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.0 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-bom@3.0.0: {} + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -2669,8 +4076,14 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + synckit@0.11.11: + dependencies: + '@pkgr/core': 0.2.9 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -2710,12 +4123,65 @@ snapshots: dependencies: typescript: 5.9.2 + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + typescript@5.9.2: {} + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + update-browserslist-db@1.1.3(browserslist@4.25.1): + dependencies: + browserslist: 4.25.1 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -2811,6 +4277,47 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.0 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@2.0.2: dependencies: isexe: 2.0.0 From df9e3f805abafb67af09f6ae2f522eb8758d7bf7 Mon Sep 17 00:00:00 2001 From: esoby Date: Mon, 4 Aug 2025 23:33:06 +0900 Subject: [PATCH 02/44] =?UTF-8?q?refactor:=20basic=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=97=90=20lint=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.tsx | 1540 +++++++++++++++++++++++++------------------- src/basic/main.tsx | 9 +- 2 files changed, 880 insertions(+), 669 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1..626a53bb 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; + import { CartItem, Coupon, Product } from '../types'; interface ProductWithUI extends Product { @@ -21,20 +22,18 @@ const initialProducts: ProductWithUI[] = [ stock: 20, discounts: [ { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } + { quantity: 20, rate: 0.2 }, ], - description: '최고급 품질의 프리미엄 상품입니다.' + description: '최고급 품질의 프리미엄 상품입니다.', }, { id: 'p2', name: '상품2', price: 20000, stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], + discounts: [{ quantity: 10, rate: 0.15 }], description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true + isRecommended: true, }, { id: 'p3', @@ -43,10 +42,10 @@ const initialProducts: ProductWithUI[] = [ stock: 20, discounts: [ { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } + { quantity: 30, rate: 0.25 }, ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } + description: '대용량과 고성능을 자랑하는 상품입니다.', + }, ]; const initialCoupons: Coupon[] = [ @@ -54,18 +53,17 @@ const initialCoupons: Coupon[] = [ name: '5000원 할인', code: 'AMOUNT5000', discountType: 'amount', - discountValue: 5000 + discountValue: 5000, }, { name: '10% 할인', code: 'PERCENT10', discountType: 'percentage', - discountValue: 10 - } + discountValue: 10, + }, ]; const App = () => { - const [products, setProducts] = useState(() => { const saved = localStorage.getItem('products'); if (saved) { @@ -118,20 +116,19 @@ const App = () => { price: 0, stock: 0, description: '', - discounts: [] as Array<{ quantity: number; rate: number }> + discounts: [] as Array<{ quantity: number; rate: number }>, }); const [couponForm, setCouponForm] = useState({ name: '', code: '', discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 + 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'; } @@ -140,25 +137,25 @@ const App = () => { 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,7 +163,7 @@ const App = () => { const { price } = item.product; const { quantity } = item; const discount = getMaxApplicableDiscount(item); - + return Math.round(price * quantity * (1 - discount)); }; @@ -177,7 +174,7 @@ 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); @@ -187,34 +184,38 @@ const App = () => { 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), + ); } } return { totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) + totalAfterDiscount: Math.round(totalAfterDiscount), }; }; 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); @@ -244,76 +245,81 @@ 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'); + }, + [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, + ), + ); + }, + [products, removeFromCart, addNotification, getRemainingStock], + ); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal().totalAfterDiscount; - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + 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()}`; @@ -322,48 +328,59 @@ const App = () => { 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 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: React.FormEvent) => { e.preventDefault(); @@ -373,7 +390,7 @@ const App = () => { } else { addProduct({ ...productForm, - discounts: productForm.discounts + discounts: productForm.discounts, }); } setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); @@ -388,7 +405,7 @@ const App = () => { name: '', code: '', discountType: 'amount', - discountValue: 0 + discountValue: 0, }); setShowCouponForm(false); }; @@ -400,7 +417,7 @@ const App = () => { price: product.price, stock: product.stock, description: product.description || '', - discounts: product.discounts || [] + discounts: product.discounts || [], }); setShowProductForm(true); }; @@ -408,74 +425,91 @@ 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

+
+
+
+
+

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" + placeholder='상품 검색...' + className='w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500' />
)}
-
-
+
{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 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 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" - placeholder="숫자만 입력" - required - /> + 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; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + price: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const { value } = e.target; + 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; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + stock: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const { value } = e.target; + 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 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border' + 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 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='%' + /> + % 할인 + +
+ ))} + +
+
+ +
-
-
- -
- - -
- -
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {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 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; + 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 + /> +
+
+
+ + +
+
+
+ )} +
)}
) : ( -
-
+
+
{/* 상품 목록 */}
-
-

전체 상품

-
- 총 {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 +1251,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 +1331,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..b2677542 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'; + +import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( , -) +); From 7bf504e59108fe87c564d3c9beef7012126d163a Mon Sep 17 00:00:00 2001 From: esoby Date: Tue, 5 Aug 2025 00:31:22 +0900 Subject: [PATCH 03/44] =?UTF-8?q?refactor:=20ui=20=EC=BD=94=EB=93=9C=20sec?= =?UTF-8?q?tion=20=EA=B8=B0=EC=A4=80=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 956 ++------------------- src/basic/components/AdminPage.tsx | 530 ++++++++++++ src/basic/components/CartPage.tsx | 320 +++++++ src/basic/components/Header.tsx | 64 ++ src/basic/components/NotificationPanel.tsx | 35 + 5 files changed, 1018 insertions(+), 887 deletions(-) create mode 100644 src/basic/components/AdminPage.tsx create mode 100644 src/basic/components/CartPage.tsx create mode 100644 src/basic/components/Header.tsx create mode 100644 src/basic/components/NotificationPanel.tsx diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 626a53bb..3a3cd604 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,6 +1,10 @@ import { useState, useCallback, useEffect } from 'react'; import { CartItem, Coupon, Product } from '../types'; +import { AdminPage } from './components/AdminPage'; +import { CartPage } from './components/CartPage'; +import { Header } from './components/Header'; +import { NotificationPanel } from './components/NotificationPanel'; interface ProductWithUI extends Product { description?: string; @@ -435,896 +439,74 @@ const App = () => { return (
- {notifications.length > 0 && ( -
- {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' - /> -
- )} -
- -
-
-
- + +
{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 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; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ - ...productForm, - price: value === '' ? 0 : parseInt(value), - }); - } - }} - onBlur={(e) => { - const { value } = e.target; - 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; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ - ...productForm, - stock: value === '' ? 0 : parseInt(value), - }); - } - }} - onBlur={(e) => { - const { value } = e.target; - 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 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border' - 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='%' - /> - % 할인 - -
- ))} - -
-
- -
- - -
-
-
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {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; - 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 - /> -
-
-
- - -
-
-
- )} -
-
- )} -
+ ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

-
총 {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/components/AdminPage.tsx b/src/basic/components/AdminPage.tsx new file mode 100644 index 00000000..1b47e364 --- /dev/null +++ b/src/basic/components/AdminPage.tsx @@ -0,0 +1,530 @@ +// TODO: 관리자 페이지 컴포넌트 +// 힌트: +// 1. 탭 UI로 상품 관리와 쿠폰 관리 분리 +// 2. 상품 추가/수정/삭제 기능 +// 3. 쿠폰 생성 기능 +// 4. 할인 규칙 설정 +// +// 필요한 hooks: +// - useProducts: 상품 CRUD +// - useCoupons: 쿠폰 CRUD +// +// 하위 컴포넌트: +// - ProductForm: 새 상품 추가 폼 +// - ProductAccordion: 상품 정보 표시 및 수정 +// - CouponForm: 새 쿠폰 추가 폼 +// - CouponList: 쿠폰 목록 표시 + +export function AdminPage({ + activeTab, + setActiveTab, + products, + deleteProduct, + startEditProduct, + handleProductSubmit, + coupons, + deleteCoupon, + handleCouponSubmit, + editingProduct, + setEditingProduct, + showProductForm, + setShowProductForm, + productForm, + setProductForm, + showCouponForm, + setShowCouponForm, + couponForm, + setCouponForm, + formatPrice, + addNotification, +}) { + 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 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; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + price: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const { value } = e.target; + 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; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + stock: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const { value } = e.target; + 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 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border' + 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='%' + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ )} +
+ ) : ( +
+
+

쿠폰 관리

+
+
+
+ {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; + 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 + /> +
+
+
+ + +
+
+
+ )} +
+
+ )} +
+ ); +} diff --git a/src/basic/components/CartPage.tsx b/src/basic/components/CartPage.tsx new file mode 100644 index 00000000..1e55154e --- /dev/null +++ b/src/basic/components/CartPage.tsx @@ -0,0 +1,320 @@ +// TODO: 장바구니 페이지 컴포넌트 +// 힌트: +// 1. 상품 목록 표시 (검색 기능 포함) +// 2. 장바구니 관리 +// 3. 쿠폰 적용 +// 4. 주문 처리 +// +// 필요한 hooks: +// - useProducts: 상품 목록 관리 +// - useCart: 장바구니 상태 관리 +// - useCoupons: 쿠폰 목록 관리 +// - useDebounce: 검색어 디바운싱 +// +// 하위 컴포넌트: +// - SearchBar: 검색 입력 +// - ProductList: 상품 목록 표시 +// - Cart: 장바구니 표시 및 결제 + +export function CartPage({ + products, + filteredProducts, + cart, + coupons, + selectedCoupon, + debouncedSearchTerm, + totals, + getRemainingStock, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + setSelectedCoupon, + calculateItemTotal, + completeOrder, + formatPrice, +}) { + 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/components/Header.tsx b/src/basic/components/Header.tsx new file mode 100644 index 00000000..9ce933e5 --- /dev/null +++ b/src/basic/components/Header.tsx @@ -0,0 +1,64 @@ +export const Header = ({ + isAdmin, + searchTerm, + setSearchTerm, + setIsAdmin, + cart, + totalItemCount, +}) => { + return ( +
+
+
+
+

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' + /> +
+ )} +
+ +
+
+
+ ); +}; diff --git a/src/basic/components/NotificationPanel.tsx b/src/basic/components/NotificationPanel.tsx new file mode 100644 index 00000000..bb8506b7 --- /dev/null +++ b/src/basic/components/NotificationPanel.tsx @@ -0,0 +1,35 @@ +export const NotificationPanel = ({ notifications, setNotifications }) => { + return ( + notifications.length > 0 && ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ) + ); +}; From 2af9f010d8808822590e76204fc924cd5ab883d6 Mon Sep 17 00:00:00 2001 From: esoby Date: Tue, 5 Aug 2025 00:56:02 +0900 Subject: [PATCH 04/44] =?UTF-8?q?refactor:=20product=20CRUD=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EA=B4=80=EB=A6=AC=20=ED=9B=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 106 +++++-------------------------- src/basic/hooks/useProducts.ts | 112 +++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 92 deletions(-) create mode 100644 src/basic/hooks/useProducts.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 3a3cd604..355414bc 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -5,6 +5,7 @@ import { AdminPage } from './components/AdminPage'; import { CartPage } from './components/CartPage'; import { Header } from './components/Header'; import { NotificationPanel } from './components/NotificationPanel'; +import { useProducts } from './hooks/useProducts'; interface ProductWithUI extends Product { description?: string; @@ -17,41 +18,6 @@ interface Notification { 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원 할인', @@ -68,17 +34,19 @@ const initialCoupons: Coupon[] = [ ]; const App = () => { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); + 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 { products, addProduct, updateProduct, deleteProduct } = useProducts({ addNotification }); const [cart, setCart] = useState(() => { const saved = localStorage.getItem('cart'); @@ -207,18 +175,6 @@ const App = () => { 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(() => { @@ -226,10 +182,6 @@ const App = () => { setTotalItemCount(count); }, [cart]); - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - useEffect(() => { localStorage.setItem('coupons', JSON.stringify(coupons)); }, [coupons]); @@ -332,36 +284,6 @@ const App = () => { 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); diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 00000000..d4ecda7e --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,112 @@ +// TODO: 상품 관리 Hook +// 힌트: +// 1. 상품 목록 상태 관리 (localStorage 연동 고려) +// 2. 상품 CRUD 작업 +// 3. 재고 업데이트 +// 4. 할인 규칙 추가/삭제 +// +// 반환할 값: +// - products: 상품 배열 +// - updateProduct: 상품 정보 수정 +// - addProduct: 새 상품 추가 +// - updateProductStock: 재고 수정 +// - addProductDiscount: 할인 규칙 추가 +// - removeProductDiscount: 할인 규칙 삭제 + +import { useState, useCallback, useEffect } from 'react'; + +import { Product } from '../../types'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +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 const useProducts = ({ addNotification }) => { + const [products, setProducts] = useState(() => { + const saved = localStorage.getItem('products'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialProducts; + } + } + return initialProducts; + }); + + useEffect(() => { + localStorage.setItem('products', JSON.stringify(products)); + }, [products]); + + 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], + ); + + return { + products, + addProduct, + updateProduct, + deleteProduct, + }; +}; From 0ee5466004e0a0471bd54e1997f76a688ba53a69 Mon Sep 17 00:00:00 2001 From: esoby Date: Tue, 5 Aug 2025 01:25:01 +0900 Subject: [PATCH 05/44] =?UTF-8?q?refactor:=20cart=20&=20coupon=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A1=9C=EC=A7=81=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.tsx | 248 ++++------------------------------ src/basic/hooks/useCart.ts | 223 ++++++++++++++++++++++++++++++ src/basic/hooks/useCoupons.ts | 75 ++++++++++ 3 files changed, 322 insertions(+), 224 deletions(-) create mode 100644 src/basic/hooks/useCart.ts create mode 100644 src/basic/hooks/useCoupons.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 355414bc..f608792f 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,10 +1,12 @@ import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { Product } from '../types'; import { AdminPage } from './components/AdminPage'; import { CartPage } from './components/CartPage'; import { Header } from './components/Header'; import { NotificationPanel } from './components/NotificationPanel'; +import { useCart } from './hooks/useCart'; +import { useCoupons } from './hooks/useCoupons'; import { useProducts } from './hooks/useProducts'; interface ProductWithUI extends Product { @@ -18,21 +20,6 @@ interface Notification { type: 'error' | 'success' | 'warning'; } -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000, - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10, - }, -]; - const App = () => { const addNotification = useCallback( (message: string, type: 'error' | 'success' | 'warning' = 'success') => { @@ -45,34 +32,30 @@ const App = () => { }, [], ); - const { products, addProduct, updateProduct, deleteProduct } = useProducts({ addNotification }); - 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 { + cart, + selectedCoupon, + setSelectedCoupon, + totalItemCount, + setTotalItemCount, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + calculateItemTotal, + calculateCartTotal, + getRemainingStock, + clearCart, + } = useCart({ products, addNotification }); + + const { coupons, addCoupon, deleteCoupon } = useCoupons({ + selectedCoupon, + setSelectedCoupon, + addNotification, }); - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [notifications, setNotifications] = useState([]); const [showCouponForm, setShowCouponForm] = useState(false); @@ -105,7 +88,6 @@ const App = () => { return 'SOLD OUT'; } } - if (isAdmin) { return `${price.toLocaleString()}원`; } @@ -113,87 +95,6 @@ const 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)); - }; - - 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 [totalItemCount, setTotalItemCount] = useState(0); - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - 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); @@ -201,113 +102,12 @@ const 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'); - }, - [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, - ), - ); - }, - [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); + clearCart(); }, [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: React.FormEvent) => { e.preventDefault(); if (editingProduct && editingProduct !== 'new') { diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 00000000..1407023d --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,223 @@ +// TODO: 장바구니 관리 Hook +// 힌트: +// 1. 장바구니 상태 관리 (localStorage 연동) +// 2. 상품 추가/삭제/수량 변경 +// 3. 쿠폰 적용 +// 4. 총액 계산 +// 5. 재고 확인 +// +// 사용할 모델 함수: +// - cartModel.addItemToCart +// - cartModel.removeItemFromCart +// - cartModel.updateCartItemQuantity +// - cartModel.calculateCartTotal +// - cartModel.getRemainingStock +// +// 반환할 값: +// - cart: 장바구니 아이템 배열 +// - selectedCoupon: 선택된 쿠폰 +// - addToCart: 상품 추가 함수 +// - removeFromCart: 상품 제거 함수 +// - updateQuantity: 수량 변경 함수 +// - applyCoupon: 쿠폰 적용 함수 +// - calculateTotal: 총액 계산 함수 +// - getRemainingStock: 재고 확인 함수 +// - clearCart: 장바구니 비우기 함수 + +import { useState, useCallback, useEffect } from 'react'; + +import { CartItem, Coupon, Product } from '../../types'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export function useCart({ products, addNotification }) { + const [cart, setCart] = useState(() => { + const saved = localStorage.getItem('cart'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return []; + } + } + return []; + }); + + const [selectedCoupon, setSelectedCoupon] = useState(null); + const [totalItemCount, setTotalItemCount] = useState(0); + + useEffect(() => { + const count = cart.reduce((sum, item) => sum + item.quantity, 0); + setTotalItemCount(count); + }, [cart]); + + useEffect(() => { + if (cart.length > 0) { + localStorage.setItem('cart', JSON.stringify(cart)); + } else { + localStorage.removeItem('cart'); + } + }, [cart]); + + 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)); + }; + + 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 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'); + }, + [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, + ), + ); + }, + [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 clearCart = () => { + setCart([]); + setSelectedCoupon(null); + }; + + return { + cart, + selectedCoupon, + setSelectedCoupon, + totalItemCount, + setTotalItemCount, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + calculateItemTotal, + calculateCartTotal, + getRemainingStock, + clearCart, + }; +} diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 00000000..07003c54 --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,75 @@ +// TODO: 쿠폰 관리 Hook +// 힌트: +// 1. 쿠폰 목록 상태 관리 (localStorage 연동 고려) +// 2. 쿠폰 추가/삭제 +// +// 반환할 값: +// - coupons: 쿠폰 배열 +// - addCoupon: 새 쿠폰 추가 +// - removeCoupon: 쿠폰 삭제 +import { useState, useCallback, useEffect } from 'react'; + +import { Coupon } from '../../types'; + +const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; + +export function useCoupons({ selectedCoupon, setSelectedCoupon, addNotification }) { + const [coupons, setCoupons] = useState(() => { + const saved = localStorage.getItem('coupons'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialCoupons; + } + } + return initialCoupons; + }); + + useEffect(() => { + localStorage.setItem('coupons', JSON.stringify(coupons)); + }, [coupons]); + + 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], + ); + + return { + coupons, + addCoupon, + deleteCoupon, + }; +} From 78d70d5f22c6617a05795f90223913ef2df0c9e9 Mon Sep 17 00:00:00 2001 From: esoby Date: Tue, 5 Aug 2025 01:34:25 +0900 Subject: [PATCH 06/44] =?UTF-8?q?refactor:=20notification=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20hook=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 21 +++------------------ src/basic/hooks/useNotifications.ts | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 18 deletions(-) create mode 100644 src/basic/hooks/useNotifications.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index f608792f..bde32cae 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -7,6 +7,7 @@ import { Header } from './components/Header'; import { NotificationPanel } from './components/NotificationPanel'; import { useCart } from './hooks/useCart'; import { useCoupons } from './hooks/useCoupons'; +import { useNotifications } from './hooks/useNotifications'; import { useProducts } from './hooks/useProducts'; interface ProductWithUI extends Product { @@ -14,24 +15,9 @@ interface ProductWithUI extends Product { isRecommended?: boolean; } -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - const App = () => { - 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 { notifications, setNotifications, addNotification } = useNotifications(); + const { products, addProduct, updateProduct, deleteProduct } = useProducts({ addNotification }); const { @@ -57,7 +43,6 @@ const App = () => { }); 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); diff --git a/src/basic/hooks/useNotifications.ts b/src/basic/hooks/useNotifications.ts new file mode 100644 index 00000000..28849ad7 --- /dev/null +++ b/src/basic/hooks/useNotifications.ts @@ -0,0 +1,24 @@ +import { useState, useCallback } from 'react'; + +interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export const 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); + }, + [], + ); + return { notifications, setNotifications, addNotification }; +}; From 6bcb75dd2d876b720e0aceea59966cfe79417fbd Mon Sep 17 00:00:00 2001 From: esoby Date: Tue, 5 Aug 2025 01:38:49 +0900 Subject: [PATCH 07/44] =?UTF-8?q?refactor:=20totalItemCount=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=ED=97=A4=EB=8D=94=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=EB=A1=9C=20=EC=A7=80=EC=97=AD=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 3 --- src/basic/components/Header.tsx | 18 ++++++++++-------- src/basic/hooks/useCart.ts | 8 -------- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index bde32cae..6b512eb6 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -24,8 +24,6 @@ const App = () => { cart, selectedCoupon, setSelectedCoupon, - totalItemCount, - setTotalItemCount, addToCart, removeFromCart, updateQuantity, @@ -153,7 +151,6 @@ const App = () => { searchTerm={searchTerm} setSearchTerm={setSearchTerm} cart={cart} - totalItemCount={totalItemCount} />
{isAdmin ? ( diff --git a/src/basic/components/Header.tsx b/src/basic/components/Header.tsx index 9ce933e5..90ecfa73 100644 --- a/src/basic/components/Header.tsx +++ b/src/basic/components/Header.tsx @@ -1,11 +1,13 @@ -export const Header = ({ - isAdmin, - searchTerm, - setSearchTerm, - setIsAdmin, - cart, - totalItemCount, -}) => { +import { useState, useEffect } from 'react'; + +export const Header = ({ isAdmin, searchTerm, setSearchTerm, setIsAdmin, cart }) => { + const [totalItemCount, setTotalItemCount] = useState(0); + + useEffect(() => { + const count = cart.reduce((sum, item) => sum + item.quantity, 0); + setTotalItemCount(count); + }, [cart]); + return (
diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts index 1407023d..e0b6d973 100644 --- a/src/basic/hooks/useCart.ts +++ b/src/basic/hooks/useCart.ts @@ -47,12 +47,6 @@ export function useCart({ products, addNotification }) { }); const [selectedCoupon, setSelectedCoupon] = useState(null); - const [totalItemCount, setTotalItemCount] = useState(0); - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); useEffect(() => { if (cart.length > 0) { @@ -209,8 +203,6 @@ export function useCart({ products, addNotification }) { cart, selectedCoupon, setSelectedCoupon, - totalItemCount, - setTotalItemCount, addToCart, removeFromCart, updateQuantity, From 344901dd06592eaf317daa503ae9646c284210a9 Mon Sep 17 00:00:00 2001 From: kimsh Date: Tue, 5 Aug 2025 16:57:35 +0900 Subject: [PATCH 08/44] =?UTF-8?q?refactor:=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=83=81=EC=88=98=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/constants/index.ts | 55 ++++++++++++++++++++++++++++++++++ src/basic/hooks/useCoupons.ts | 15 +--------- src/basic/hooks/useProducts.ts | 34 +-------------------- 3 files changed, 57 insertions(+), 47 deletions(-) create mode 100644 src/basic/constants/index.ts diff --git a/src/basic/constants/index.ts b/src/basic/constants/index.ts new file mode 100644 index 00000000..840ad88b --- /dev/null +++ b/src/basic/constants/index.ts @@ -0,0 +1,55 @@ +import { Product, Coupon } from '../../types'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export 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 const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; \ No newline at end of file diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts index 07003c54..3dbcbee5 100644 --- a/src/basic/hooks/useCoupons.ts +++ b/src/basic/hooks/useCoupons.ts @@ -10,21 +10,8 @@ import { useState, useCallback, useEffect } from 'react'; import { Coupon } from '../../types'; +import { initialCoupons } from '../constants'; -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000, - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10, - }, -]; export function useCoupons({ selectedCoupon, setSelectedCoupon, addNotification }) { const [coupons, setCoupons] = useState(() => { diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts index d4ecda7e..5d653bae 100644 --- a/src/basic/hooks/useProducts.ts +++ b/src/basic/hooks/useProducts.ts @@ -16,45 +16,13 @@ import { useState, useCallback, useEffect } from 'react'; import { Product } from '../../types'; +import { initialProducts } from '../constants'; interface ProductWithUI extends Product { description?: string; isRecommended?: boolean; } -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 const useProducts = ({ addNotification }) => { const [products, setProducts] = useState(() => { From ce0cc0f4d9d0ab71e54195b069c0e260f76de1c0 Mon Sep 17 00:00:00 2001 From: kimsh Date: Tue, 5 Aug 2025 17:20:16 +0900 Subject: [PATCH 09/44] =?UTF-8?q?refactor:=20admin=20page=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20ui=20=EC=83=81=ED=83=9C=20app=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 88 ++--------------------------- src/basic/components/AdminPage.tsx | 89 ++++++++++++++++++++++++------ 2 files changed, 76 insertions(+), 101 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 6b512eb6..8ad1a683 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,6 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; -import { Product } from '../types'; import { AdminPage } from './components/AdminPage'; import { CartPage } from './components/CartPage'; import { Header } from './components/Header'; @@ -10,11 +9,6 @@ import { useCoupons } from './hooks/useCoupons'; import { useNotifications } from './hooks/useNotifications'; import { useProducts } from './hooks/useProducts'; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - const App = () => { const { notifications, setNotifications, addNotification } = useNotifications(); @@ -41,29 +35,10 @@ const App = () => { }); 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, 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); @@ -91,45 +66,6 @@ const App = () => { clearCart(); }, [addNotification]); - const handleProductSubmit = (e: React.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: React.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(); @@ -155,32 +91,16 @@ const App = () => {
{isAdmin ? ( ('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: [] as Array<{ quantity: number; rate: number }>, + }); + + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0, + }); + + const handleProductSubmit = (e: React.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: React.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); + }; return (
From 13d73eb5a72d03920e39183fabb7d890b0eb6d2a Mon Sep 17 00:00:00 2001 From: kimsh Date: Tue, 5 Aug 2025 17:51:14 +0900 Subject: [PATCH 10/44] =?UTF-8?q?refactor:=20cartpage=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=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.tsx | 11 - src/basic/components/CartPage.tsx | 302 ++---------------- .../components/cart-page/OrderSummary.tsx | 188 +++++++++++ .../components/cart-page/ProductList.tsx | 117 +++++++ 4 files changed, 327 insertions(+), 291 deletions(-) create mode 100644 src/basic/components/cart-page/OrderSummary.tsx create mode 100644 src/basic/components/cart-page/ProductList.tsx diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 8ad1a683..2a813be5 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -66,18 +66,8 @@ const App = () => { clearCart(); }, [addNotification]); - 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 (
@@ -112,7 +102,6 @@ const App = () => { cart={cart} coupons={coupons} // --- 파생 데이터 --- - filteredProducts={filteredProducts} totals={totals} // --- UI 상태 --- selectedCoupon={selectedCoupon} diff --git a/src/basic/components/CartPage.tsx b/src/basic/components/CartPage.tsx index 1e55154e..373e4888 100644 --- a/src/basic/components/CartPage.tsx +++ b/src/basic/components/CartPage.tsx @@ -16,9 +16,11 @@ // - ProductList: 상품 목록 표시 // - Cart: 장바구니 표시 및 결제 +import { OrderSummary } from './cart-page/OrderSummary'; +import { ProductList } from './cart-page/ProductList'; + export function CartPage({ products, - filteredProducts, cart, coupons, selectedCoupon, @@ -36,285 +38,25 @@ 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/components/cart-page/OrderSummary.tsx b/src/basic/components/cart-page/OrderSummary.tsx new file mode 100644 index 00000000..dd6b710d --- /dev/null +++ b/src/basic/components/cart-page/OrderSummary.tsx @@ -0,0 +1,188 @@ +export const OrderSummary = ({ + cart, + calculateItemTotal, + removeFromCart, + updateQuantity, + coupons, + selectedCoupon, + applyCoupon, + setSelectedCoupon, + totals, + completeOrder, +}) => { + return ( +
+
+
+

+ + + + 장바구니 +

+ {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/components/cart-page/ProductList.tsx b/src/basic/components/cart-page/ProductList.tsx new file mode 100644 index 00000000..5d2983ea --- /dev/null +++ b/src/basic/components/cart-page/ProductList.tsx @@ -0,0 +1,117 @@ +export const ProductList = ({products, debouncedSearchTerm, getRemainingStock, formatPrice, addToCart}) => { + const filteredProducts = debouncedSearchTerm + ? products.filter( + (product) => + product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())), + ) + : products; + + 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}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); + })} +
+ )} +
+
+ ) +} \ No newline at end of file From c4a2cfa2eca9f4597639b4ca29a201800288ccd6 Mon Sep 17 00:00:00 2001 From: kimsh Date: Tue, 5 Aug 2025 18:02:25 +0900 Subject: [PATCH 11/44] =?UTF-8?q?refactor:=20header=20search=20bar=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/components/Header.tsx | 14 ++------------ src/basic/components/header/SearchBar.tsx | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 12 deletions(-) create mode 100644 src/basic/components/header/SearchBar.tsx diff --git a/src/basic/components/Header.tsx b/src/basic/components/Header.tsx index 90ecfa73..aea61aac 100644 --- a/src/basic/components/Header.tsx +++ b/src/basic/components/Header.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { SearchBar } from './header/SearchBar'; export const Header = ({ isAdmin, searchTerm, setSearchTerm, setIsAdmin, cart }) => { const [totalItemCount, setTotalItemCount] = useState(0); @@ -14,18 +15,7 @@ export const Header = ({ isAdmin, searchTerm, setSearchTerm, setIsAdmin, cart })

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' - /> -
- )} + {!isAdmin && }
+
+ ); +}; diff --git a/src/basic/components/cart-page/ProductList.tsx b/src/basic/components/cart-page/ProductList.tsx index 5d2983ea..340817d6 100644 --- a/src/basic/components/cart-page/ProductList.tsx +++ b/src/basic/components/cart-page/ProductList.tsx @@ -1,117 +1,49 @@ -export const ProductList = ({products, debouncedSearchTerm, getRemainingStock, formatPrice, addToCart}) => { - const filteredProducts = debouncedSearchTerm - ? products.filter( - (product) => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && - product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())), - ) - : products; +import { ProductItem } from './ProductItem'; - 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}% -

- )} -
+export const ProductList = ({ + products, + debouncedSearchTerm, + getRemainingStock, + formatPrice, + addToCart, +}) => { + const filteredProducts = debouncedSearchTerm + ? products.filter( + (product) => + product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())), + ) + : products; - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

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

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

재고 {remainingStock}개

- )} -
- - {/* 장바구니 버튼 */} - -
-
- ); - })} -
- )} -
-
- ) -} \ No newline at end of file + return ( +
+ {/* 상품 목록 */} +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(product); + return ( + + ); + })} +
+ )} +
+
+ ); +}; From fd4c3275251fdfd1e0cde10b0b28e1f7c85e16fe Mon Sep 17 00:00:00 2001 From: kimsh Date: Tue, 5 Aug 2025 18:51:34 +0900 Subject: [PATCH 13/44] =?UTF-8?q?refactor:=20product=20=EB=B9=84=EC=A6=88?= =?UTF-8?q?=EB=8B=88=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=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/hooks/useProducts.ts | 88 ++++++++++++++++------------------ src/basic/models/product.ts | 70 +++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 47 deletions(-) create mode 100644 src/basic/models/product.ts diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts index 5d653bae..0b6805f3 100644 --- a/src/basic/hooks/useProducts.ts +++ b/src/basic/hooks/useProducts.ts @@ -1,40 +1,16 @@ -// TODO: 상품 관리 Hook -// 힌트: -// 1. 상품 목록 상태 관리 (localStorage 연동 고려) -// 2. 상품 CRUD 작업 -// 3. 재고 업데이트 -// 4. 할인 규칙 추가/삭제 -// -// 반환할 값: -// - products: 상품 배열 -// - updateProduct: 상품 정보 수정 -// - addProduct: 새 상품 추가 -// - updateProductStock: 재고 수정 -// - addProductDiscount: 할인 규칙 추가 -// - removeProductDiscount: 할인 규칙 삭제 - import { useState, useCallback, useEffect } from 'react'; - -import { Product } from '../../types'; +import { Product, Discount } from '../../types'; import { initialProducts } from '../constants'; +import * as productModel from '../models/product'; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - - -export const useProducts = ({ addNotification }) => { - const [products, setProducts] = useState(() => { +export const useProducts = ({ + addNotification, +}: { + addNotification: (message: string, type: string) => void; +}) => { + const [products, setProducts] = useState(() => { const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; + return saved ? JSON.parse(saved) : initialProducts; }); useEffect(() => { @@ -42,33 +18,48 @@ export const useProducts = ({ addNotification }) => { }, [products]); const addProduct = useCallback( - (newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}`, - }; - setProducts((prev) => [...prev, product]); + (newProduct: Omit) => { + setProducts(productModel.addProduct(products, newProduct)); addNotification('상품이 추가되었습니다.', 'success'); }, - [addNotification], + [products, addNotification], ); const updateProduct = useCallback( - (productId: string, updates: Partial) => { - setProducts((prev) => - prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)), - ); + (productId: string, updates: Partial) => { + setProducts(productModel.updateProduct(products, productId, updates)); addNotification('상품이 수정되었습니다.', 'success'); }, - [addNotification], + [products, addNotification], ); const deleteProduct = useCallback( (productId: string) => { - setProducts((prev) => prev.filter((p) => p.id !== productId)); + setProducts(productModel.deleteProduct(products, productId)); addNotification('상품이 삭제되었습니다.', 'success'); }, - [addNotification], + [products, addNotification], + ); + + const updateProductStock = useCallback( + (productId: string, newStock: number) => { + setProducts(productModel.updateProductStock(products, productId, newStock)); + }, + [products], + ); + + const addProductDiscount = useCallback( + (productId: string, newDiscount: Discount) => { + setProducts(productModel.addProductDiscount(products, productId, newDiscount)); + }, + [products], + ); + + const removeProductDiscount = useCallback( + (productId: string, discountQuantity: number) => { + setProducts(productModel.removeProductDiscount(products, productId, discountQuantity)); + }, + [products], ); return { @@ -76,5 +67,8 @@ export const useProducts = ({ addNotification }) => { addProduct, updateProduct, deleteProduct, + updateProductStock, + addProductDiscount, + removeProductDiscount, }; }; diff --git a/src/basic/models/product.ts b/src/basic/models/product.ts new file mode 100644 index 00000000..a8e38c9e --- /dev/null +++ b/src/basic/models/product.ts @@ -0,0 +1,70 @@ +import { Product, Discount } from '../../types'; + +export const addProduct = (products: Product[], newProduct: Omit): Product[] => { + const productWithId: Product = { + ...newProduct, + id: `p${Date.now()}`, + }; + return [...products, productWithId]; +}; + +export const updateProduct = ( + products: Product[], + productId: string, + updates: Partial, +): Product[] => { + return products.map((p) => (p.id === productId ? { ...p, ...updates } : p)); +}; + +export const deleteProduct = (products: Product[], productId: string): Product[] => { + return products.filter((p) => p.id !== productId); +}; + +export const updateProductStock = ( + products: Product[], + productId: string, + newStock: number, +): Product[] => { + return products.map((p) => (p.id === productId ? { ...p, stock: newStock } : p)); +}; + +export const addProductDiscount = ( + products: Product[], + productId: string, + newDiscount: Discount, +): Product[] => { + return products.map((p) => { + if (p.id === productId) { + const existingDiscountIndex = p.discounts.findIndex( + (d) => d.quantity === newDiscount.quantity, + ); + const newDiscounts = [...p.discounts]; + + if (existingDiscountIndex > -1) { + newDiscounts[existingDiscountIndex] = newDiscount; + } else { + newDiscounts.push(newDiscount); + } + newDiscounts.sort((a, b) => a.quantity - b.quantity); + + return { ...p, discounts: newDiscounts }; + } + return p; + }); +}; + +export const removeProductDiscount = ( + products: Product[], + productId: string, + discountQuantity: number, +): Product[] => { + return products.map((p) => { + if (p.id === productId) { + return { + ...p, + discounts: p.discounts.filter((d) => d.quantity !== discountQuantity), + }; + } + return p; + }); +}; From d8c9e6d18013db95949020c625ec84f081df7d5d Mon Sep 17 00:00:00 2001 From: kimsh Date: Wed, 6 Aug 2025 17:13:42 +0900 Subject: [PATCH 14/44] =?UTF-8?q?refactor:=20cart=20=EB=B9=84=EC=A6=88?= =?UTF-8?q?=EB=8B=88=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20model=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=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/models/cart.ts | 92 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/basic/models/cart.ts diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts new file mode 100644 index 00000000..0a160501 --- /dev/null +++ b/src/basic/models/cart.ts @@ -0,0 +1,92 @@ +import { CartItem, Coupon, Product } from '../../types'; + +// 적용 가능한 최대 할인율 계산 +const getMaxApplicableDiscount = (item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + return discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); +}; + +// 개별 아이템의 할인 적용 후 총액 계산 +export const calculateItemTotal = (item: CartItem): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item); + + return Math.round(price * quantity * (1 - discount)); +}; + +// 남은 재고 계산 +export const getRemainingStock = (product: Product, cart: CartItem[]): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); +}; + +// 상품 추가 +export const addItemToCart = (cart: CartItem[], product: Product): CartItem[] => { + const existingItem = cart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + return cart.map((item) => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item, + ); + } + return [...cart, { product, quantity: 1 }]; +}; + +// 상품 제거 +export const removeItemFromCart = (cart: CartItem[], productId: string): CartItem[] => { + return cart.filter((item) => item.product.id !== productId); +}; + +// 상품 수량 변경 +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + newQuantity: number, +): CartItem[] => { + if (newQuantity <= 0) { + return removeItemFromCart(cart, productId); + } + + return cart.map((item) => + item.product.id === productId ? { ...item, quantity: newQuantity } : item, + ); +}; + +// 장바구니 총액 계산 (할인 전/후, 할인액) +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null, +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + const totalBeforeDiscount = cart.reduce( + (sum, item) => sum + item.product.price * item.quantity, + 0, + ); + + let totalAfterDiscount = cart.reduce((sum, item) => sum + calculateItemTotal(item), 0); + + 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), + }; +}; From 776d791ec37cdd3f5e54db3e30b4150f648366be Mon Sep 17 00:00:00 2001 From: kimsh Date: Wed, 6 Aug 2025 17:17:55 +0900 Subject: [PATCH 15/44] =?UTF-8?q?refactor:=20useCart=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=20=EC=A0=81=EC=9A=A9=20&=20=EC=BF=A0?= =?UTF-8?q?=ED=8F=B0=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A0=A8=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/hooks/useCart.ts | 199 ++++++------------------------------- 1 file changed, 31 insertions(+), 168 deletions(-) diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts index e0b6d973..53103694 100644 --- a/src/basic/hooks/useCart.ts +++ b/src/basic/hooks/useCart.ts @@ -1,214 +1,77 @@ -// TODO: 장바구니 관리 Hook -// 힌트: -// 1. 장바구니 상태 관리 (localStorage 연동) -// 2. 상품 추가/삭제/수량 변경 -// 3. 쿠폰 적용 -// 4. 총액 계산 -// 5. 재고 확인 -// -// 사용할 모델 함수: -// - cartModel.addItemToCart -// - cartModel.removeItemFromCart -// - cartModel.updateCartItemQuantity -// - cartModel.calculateCartTotal -// - cartModel.getRemainingStock -// -// 반환할 값: -// - cart: 장바구니 아이템 배열 -// - selectedCoupon: 선택된 쿠폰 -// - addToCart: 상품 추가 함수 -// - removeFromCart: 상품 제거 함수 -// - updateQuantity: 수량 변경 함수 -// - applyCoupon: 쿠폰 적용 함수 -// - calculateTotal: 총액 계산 함수 -// - getRemainingStock: 재고 확인 함수 -// - clearCart: 장바구니 비우기 함수 - import { useState, useCallback, useEffect } from 'react'; +import { CartItem, Product } from '../../types'; +import * as cartModel from '../models/cart'; -import { CartItem, Coupon, Product } from '../../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; +interface UseCartProps { + products: Product[]; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; } -export function useCart({ products, addNotification }) { +export function useCart({ products, addNotification }: UseCartProps) { const [cart, setCart] = useState(() => { const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; + return saved ? JSON.parse(saved) : []; }); - const [selectedCoupon, setSelectedCoupon] = useState(null); - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } + localStorage.setItem('cart', JSON.stringify(cart)); }, [cart]); - 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)); - }; - - 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 getRemainingStock = useCallback( + (product: Product): number => { + return cartModel.getRemainingStock(product, cart); + }, + [cart], + ); const addToCart = useCallback( - (product: ProductWithUI) => { + (product: Product) => { 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 }]; - }); - + const newCart = cartModel.addItemToCart(cart, product); + setCart(newCart); addNotification('장바구니에 담았습니다', 'success'); }, [cart, addNotification, getRemainingStock], ); - const removeFromCart = useCallback((productId: string) => { - setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); - }, []); + const removeFromCart = useCallback( + (productId: string) => { + const newCart = cartModel.removeItemFromCart(cart, productId); + setCart(newCart); + }, + [cart], + ); 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, getRemainingStock], - ); - - const applyCoupon = useCallback( - (coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); return; } - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); + const newCart = cartModel.updateCartItemQuantity(cart, productId, newQuantity); + setCart(newCart); }, - [addNotification, calculateCartTotal], + [cart, products, addNotification], ); - const clearCart = () => { + const clearCart = useCallback(() => { setCart([]); - setSelectedCoupon(null); - }; + }, []); return { cart, - selectedCoupon, - setSelectedCoupon, addToCart, removeFromCart, updateQuantity, - applyCoupon, - calculateItemTotal, - calculateCartTotal, getRemainingStock, clearCart, }; From 761254ad838325c4ed936977f4b601ba62869eb3 Mon Sep 17 00:00:00 2001 From: kimsh Date: Wed, 6 Aug 2025 17:18:42 +0900 Subject: [PATCH 16/44] =?UTF-8?q?refactor:=20useCoupons=20hook=EC=97=90=20?= =?UTF-8?q?selected=20coupon=20=EA=B4=80=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/hooks/useCoupons.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts index 3dbcbee5..3d410ad5 100644 --- a/src/basic/hooks/useCoupons.ts +++ b/src/basic/hooks/useCoupons.ts @@ -12,8 +12,7 @@ import { useState, useCallback, useEffect } from 'react'; import { Coupon } from '../../types'; import { initialCoupons } from '../constants'; - -export function useCoupons({ selectedCoupon, setSelectedCoupon, addNotification }) { +export function useCoupons({ addNotification }) { const [coupons, setCoupons] = useState(() => { const saved = localStorage.getItem('coupons'); if (saved) { @@ -26,10 +25,21 @@ export function useCoupons({ selectedCoupon, setSelectedCoupon, addNotification return initialCoupons; }); + const [selectedCoupon, setSelectedCoupon] = useState(null); + useEffect(() => { localStorage.setItem('coupons', JSON.stringify(coupons)); }, [coupons]); + const applyCoupon = (coupon) => { + if (coupon) { + setSelectedCoupon(coupon); + addNotification(`${coupon.name} 쿠폰이 적용되었습니다.`); + } else { + setSelectedCoupon(null); + } + }; + const addCoupon = useCallback( (newCoupon: Coupon) => { const existingCoupon = coupons.find((c) => c.code === newCoupon.code); @@ -58,5 +68,7 @@ export function useCoupons({ selectedCoupon, setSelectedCoupon, addNotification coupons, addCoupon, deleteCoupon, + selectedCoupon, + applyCoupon, }; } From 8a87944b4adb6d66250b1765a07c894b941da493 Mon Sep 17 00:00:00 2001 From: kimsh Date: Wed, 6 Aug 2025 17:27:27 +0900 Subject: [PATCH 17/44] =?UTF-8?q?refactor:=20cart=20=EC=88=9C=EC=88=98=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20utils=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/models/cart.ts | 24 ++---------------------- src/basic/utils/cart.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 22 deletions(-) create mode 100644 src/basic/utils/cart.ts diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts index 0a160501..4eb7d4c1 100644 --- a/src/basic/models/cart.ts +++ b/src/basic/models/cart.ts @@ -1,25 +1,5 @@ import { CartItem, Coupon, Product } from '../../types'; - -// 적용 가능한 최대 할인율 계산 -const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - return discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); -}; - -// 개별 아이템의 할인 적용 후 총액 계산 -export const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); -}; +import { calculateItemTotal } from '../utils/cart'; // 남은 재고 계산 export const getRemainingStock = (product: Product, cart: CartItem[]): number => { @@ -73,7 +53,7 @@ export const calculateCartTotal = ( 0, ); - let totalAfterDiscount = cart.reduce((sum, item) => sum + calculateItemTotal(item), 0); + let totalAfterDiscount = cart.reduce((sum, item) => sum + calculateItemTotal(item, cart), 0); if (selectedCoupon) { if (selectedCoupon.discountType === 'amount') { diff --git a/src/basic/utils/cart.ts b/src/basic/utils/cart.ts new file mode 100644 index 00000000..2fa76211 --- /dev/null +++ b/src/basic/utils/cart.ts @@ -0,0 +1,28 @@ +import { CartItem } from '../../types'; + +export const getItemDiscountRate = (item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + return discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); +}; + +export const getBulkDiscountRate = (cart: CartItem[]): number => { + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + return hasBulkPurchase ? 0.05 : 0; +}; + +export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => { + const { price } = item.product; + const { quantity } = item; + + const itemDiscount = getItemDiscountRate(item); + const bulkDiscount = getBulkDiscountRate(cart); + + const totalDiscount = Math.min(itemDiscount + bulkDiscount, 0.5); + return Math.round(price * quantity * (1 - totalDiscount)); +}; From 79dfd380df8a089515c896960c5f527fad53ffea Mon Sep 17 00:00:00 2001 From: kimsh Date: Wed, 6 Aug 2025 17:38:51 +0900 Subject: [PATCH 18/44] =?UTF-8?q?refactor:=20useCart=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 50 ++++++------------- src/basic/components/CartPage.tsx | 10 ++-- .../components/cart-page/OrderSummary.tsx | 19 ++++--- 3 files changed, 33 insertions(+), 46 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 2a813be5..628f3394 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { AdminPage } from './components/AdminPage'; import { CartPage } from './components/CartPage'; @@ -9,35 +9,26 @@ import { useCoupons } from './hooks/useCoupons'; import { useNotifications } from './hooks/useNotifications'; import { useProducts } from './hooks/useProducts'; +import * as cartModel from './models/cart'; + const App = () => { + const [isAdmin, setIsAdmin] = useState(false); + + const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + const { notifications, setNotifications, addNotification } = useNotifications(); const { products, addProduct, updateProduct, deleteProduct } = useProducts({ addNotification }); - const { - cart, - selectedCoupon, - setSelectedCoupon, - addToCart, - removeFromCart, - updateQuantity, - applyCoupon, - calculateItemTotal, - calculateCartTotal, - getRemainingStock, - clearCart, - } = useCart({ products, addNotification }); - - const { coupons, addCoupon, deleteCoupon } = useCoupons({ - selectedCoupon, - setSelectedCoupon, + const { cart, addToCart, removeFromCart, updateQuantity, getRemainingStock, clearCart } = useCart( + { products, addNotification }, + ); + const { coupons, addCoupon, selectedCoupon, applyCoupon, deleteCoupon } = useCoupons({ addNotification, }); - const [isAdmin, setIsAdmin] = useState(false); - - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + const totals = cartModel.calculateCartTotal(cart, selectedCoupon); const formatPrice = (price: number, productId?: string): string => { if (productId) { @@ -60,14 +51,6 @@ const App = () => { return () => clearTimeout(timer); }, [searchTerm]); - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - clearCart(); - }, [addNotification]); - - const totals = calculateCartTotal(); - return (
@@ -110,14 +93,13 @@ const App = () => { addToCart={addToCart} removeFromCart={removeFromCart} updateQuantity={updateQuantity} + getRemainingStock={getRemainingStock} // --- 쿠폰 관련 핸들러 --- applyCoupon={applyCoupon} - setSelectedCoupon={setSelectedCoupon} // --- 주문 관련 핸들러 --- - completeOrder={completeOrder} + addNotification={addNotification} + clearCart={clearCart} // --- 계산 및 포맷팅 유틸 함수 --- - getRemainingStock={getRemainingStock} - calculateItemTotal={calculateItemTotal} formatPrice={formatPrice} /> )} diff --git a/src/basic/components/CartPage.tsx b/src/basic/components/CartPage.tsx index 373e4888..03f5b864 100644 --- a/src/basic/components/CartPage.tsx +++ b/src/basic/components/CartPage.tsx @@ -31,9 +31,8 @@ export function CartPage({ removeFromCart, updateQuantity, applyCoupon, - setSelectedCoupon, - calculateItemTotal, - completeOrder, + addNotification, + clearCart, formatPrice, }) { return ( @@ -47,15 +46,14 @@ export function CartPage({ />
); diff --git a/src/basic/components/cart-page/OrderSummary.tsx b/src/basic/components/cart-page/OrderSummary.tsx index dd6b710d..7bb70e5c 100644 --- a/src/basic/components/cart-page/OrderSummary.tsx +++ b/src/basic/components/cart-page/OrderSummary.tsx @@ -1,15 +1,23 @@ +import { useCallback } from 'react'; +import { calculateItemTotal } from '../../utils/cart'; + export const OrderSummary = ({ cart, - calculateItemTotal, removeFromCart, updateQuantity, coupons, selectedCoupon, applyCoupon, - setSelectedCoupon, totals, - completeOrder, + addNotification, + clearCart, }) => { + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + clearCart(); + }, [addNotification]); + return (
@@ -45,7 +53,7 @@ export const OrderSummary = ({ ) : (
{cart.map((item) => { - const itemTotal = calculateItemTotal(item); + const itemTotal = calculateItemTotal(item, cart); const originalPrice = item.product.price * item.quantity; const hasDiscount = itemTotal < originalPrice; const discountRate = hasDiscount @@ -126,8 +134,7 @@ export const OrderSummary = ({ value={selectedCoupon?.code || ''} onChange={(e) => { const coupon = coupons.find((c) => c.code === e.target.value); - if (coupon) applyCoupon(coupon); - else setSelectedCoupon(null); + applyCoupon(coupon); }} > From bb4370e1fc514791945fa51f2f6c2434ccea55c2 Mon Sep 17 00:00:00 2001 From: kimsh Date: Wed, 6 Aug 2025 17:54:50 +0900 Subject: [PATCH 19/44] =?UTF-8?q?refactor:=20format=20price=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/utils/product.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/basic/utils/product.ts diff --git a/src/basic/utils/product.ts b/src/basic/utils/product.ts new file mode 100644 index 00000000..b259becc --- /dev/null +++ b/src/basic/utils/product.ts @@ -0,0 +1,6 @@ +export const formatPrice = (price: number, options = { isAdmin: false }): string => { + if (options.isAdmin) { + return `${price.toLocaleString()}원`; + } + return `₩${price.toLocaleString()}`; +}; From e5e8d500f2af261c65ec1348c2bd43ac29a7b41f Mon Sep 17 00:00:00 2001 From: kimsh Date: Wed, 6 Aug 2025 17:55:24 +0900 Subject: [PATCH 20/44] =?UTF-8?q?refactor:=20format=20price=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=ED=95=A8=EC=88=98=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.tsx | 33 +------ src/basic/components/AdminPage.tsx | 98 +++++++++---------- src/basic/components/CartPage.tsx | 20 ++-- .../components/cart-page/ProductItem.tsx | 6 +- .../components/cart-page/ProductList.tsx | 9 +- 5 files changed, 66 insertions(+), 100 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 628f3394..2240d9a5 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -30,20 +30,6 @@ const App = () => { const totals = cartModel.calculateCartTotal(cart, selectedCoupon); - 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()}`; - }; - useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchTerm(searchTerm); @@ -64,43 +50,30 @@ const App = () => {
{isAdmin ? ( ) : ( )}
diff --git a/src/basic/components/AdminPage.tsx b/src/basic/components/AdminPage.tsx index 7c616623..5845d105 100644 --- a/src/basic/components/AdminPage.tsx +++ b/src/basic/components/AdminPage.tsx @@ -15,28 +15,28 @@ // - CouponForm: 새 쿠폰 추가 폼 // - CouponList: 쿠폰 목록 표시 -import { useState } from "react"; +import { useState } from 'react'; import { Product } from '../../types'; +import { formatPrice } from '../utils/product'; interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; + description?: string; + isRecommended?: boolean; } export function AdminPage({ products, coupons, - addProduct, - updateProduct, + addProduct, + updateProduct, deleteProduct, - addCoupon, + addCoupon, deleteCoupon, - formatPrice, addNotification, -}) { - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); -const [showProductForm, setShowProductForm] = useState(false); -const [showCouponForm, setShowCouponForm] = useState(false); +}) { + 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({ @@ -54,45 +54,45 @@ const [showCouponForm, setShowCouponForm] = useState(false); discountValue: 0, }); - const handleProductSubmit = (e: React.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 handleProductSubmit = (e: React.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: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0, - }); - setShowCouponForm(false); - }; + const handleCouponSubmit = (e: React.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 startEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], + }); + setShowProductForm(true); + }; return (
@@ -176,7 +176,7 @@ const [showCouponForm, setShowCouponForm] = useState(false); {product.name} - {formatPrice(product.price, product.id)} + {formatPrice(product.price, { isAdmin: true })} @@ -41,19 +40,18 @@ export function CartPage({ products={products} debouncedSearchTerm={debouncedSearchTerm} getRemainingStock={getRemainingStock} - formatPrice={formatPrice} addToCart={addToCart} />
); diff --git a/src/basic/components/cart-page/ProductItem.tsx b/src/basic/components/cart-page/ProductItem.tsx index f265954c..977c534b 100644 --- a/src/basic/components/cart-page/ProductItem.tsx +++ b/src/basic/components/cart-page/ProductItem.tsx @@ -1,4 +1,6 @@ -export const ProductItem = ({ product, remainingStock, formatPrice, addToCart }) => { +import { formatPrice } from '../../utils/product'; + +export const ProductItem = ({ product, remainingStock, addToCart }) => { return (

- {formatPrice(product.price, product.id)} + {remainingStock <= 0 ? 'SOLD OUT' : formatPrice(product.price)}

{product.discounts.length > 0 && (

diff --git a/src/basic/components/cart-page/ProductList.tsx b/src/basic/components/cart-page/ProductList.tsx index 340817d6..41a442d2 100644 --- a/src/basic/components/cart-page/ProductList.tsx +++ b/src/basic/components/cart-page/ProductList.tsx @@ -1,12 +1,6 @@ import { ProductItem } from './ProductItem'; -export const ProductList = ({ - products, - debouncedSearchTerm, - getRemainingStock, - formatPrice, - addToCart, -}) => { +export const ProductList = ({ products, debouncedSearchTerm, getRemainingStock, addToCart }) => { const filteredProducts = debouncedSearchTerm ? products.filter( (product) => @@ -36,7 +30,6 @@ export const ProductList = ({ ); From 73385e63beed34d5beb628c38c9ccdc7f020c42f Mon Sep 17 00:00:00 2001 From: kimsh Date: Wed, 6 Aug 2025 17:58:31 +0900 Subject: [PATCH 21/44] =?UTF-8?q?refactor:=20format=20utils=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/components/AdminPage.tsx | 2 +- src/basic/components/cart-page/ProductItem.tsx | 2 +- src/basic/utils/{product.ts => formatters.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/basic/utils/{product.ts => formatters.ts} (100%) diff --git a/src/basic/components/AdminPage.tsx b/src/basic/components/AdminPage.tsx index 5845d105..22a6f907 100644 --- a/src/basic/components/AdminPage.tsx +++ b/src/basic/components/AdminPage.tsx @@ -17,7 +17,7 @@ import { useState } from 'react'; import { Product } from '../../types'; -import { formatPrice } from '../utils/product'; +import { formatPrice } from '../utils/formatters'; interface ProductWithUI extends Product { description?: string; diff --git a/src/basic/components/cart-page/ProductItem.tsx b/src/basic/components/cart-page/ProductItem.tsx index 977c534b..a2ccbacb 100644 --- a/src/basic/components/cart-page/ProductItem.tsx +++ b/src/basic/components/cart-page/ProductItem.tsx @@ -1,4 +1,4 @@ -import { formatPrice } from '../../utils/product'; +import { formatPrice } from '../../utils/formatters'; export const ProductItem = ({ product, remainingStock, addToCart }) => { return ( diff --git a/src/basic/utils/product.ts b/src/basic/utils/formatters.ts similarity index 100% rename from src/basic/utils/product.ts rename to src/basic/utils/formatters.ts From fd3177385683e14e9711a7685f989bf34538a541 Mon Sep 17 00:00:00 2001 From: kimsh Date: Wed, 6 Aug 2025 18:40:39 +0900 Subject: [PATCH 22/44] =?UTF-8?q?refactor:=20=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=ED=9B=85=20useStore=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 39 +++++++++--------- src/basic/components/NotificationPanel.tsx | 4 +- src/basic/hooks/useCoupons.ts | 19 ++++----- src/basic/hooks/useNotifications.ts | 8 +--- src/basic/hooks/useProducts.ts | 10 ++--- src/basic/hooks/useStore.ts | 46 ++++++++++++++++++++++ src/types.ts | 6 +++ 7 files changed, 89 insertions(+), 43 deletions(-) create mode 100644 src/basic/hooks/useStore.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 2240d9a5..c5e12ad6 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -4,12 +4,7 @@ import { AdminPage } from './components/AdminPage'; import { CartPage } from './components/CartPage'; import { Header } from './components/Header'; import { NotificationPanel } from './components/NotificationPanel'; -import { useCart } from './hooks/useCart'; -import { useCoupons } from './hooks/useCoupons'; -import { useNotifications } from './hooks/useNotifications'; -import { useProducts } from './hooks/useProducts'; - -import * as cartModel from './models/cart'; +import { useStore } from './hooks/useStore'; const App = () => { const [isAdmin, setIsAdmin] = useState(false); @@ -17,18 +12,26 @@ const App = () => { const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - const { notifications, setNotifications, addNotification } = useNotifications(); - - const { products, addProduct, updateProduct, deleteProduct } = useProducts({ addNotification }); - - const { cart, addToCart, removeFromCart, updateQuantity, getRemainingStock, clearCart } = useCart( - { products, addNotification }, - ); - const { coupons, addCoupon, selectedCoupon, applyCoupon, deleteCoupon } = useCoupons({ + const { + notifications, addNotification, - }); - - const totals = cartModel.calculateCartTotal(cart, selectedCoupon); + products, + addProduct, + updateProduct, + deleteProduct, + cart, + addToCart, + removeFromCart, + updateQuantity, + getRemainingStock, + clearCart, + coupons, + addCoupon, + selectedCoupon, + applyCoupon, + deleteCoupon, + totals, + } = useStore(); useEffect(() => { const timer = setTimeout(() => { @@ -39,7 +42,7 @@ const App = () => { return (

- +
{ +export const NotificationPanel = ({ notifications, addNotification }) => { return ( notifications.length > 0 && (
@@ -15,7 +15,7 @@ export const NotificationPanel = ({ notifications, setNotifications }) => { > {notif.message} -
- ))} - -
-
+ -
- - -
- -
+ {showProductForm && ( + { + setEditingProduct(null); + setShowProductForm(false); + }} + editingProduct={editingProduct} + addNotification={addNotification} + /> )}
) : ( @@ -409,173 +162,20 @@ export function AdminPage({

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
- -
-
- ))} - -
- -
-
+ setShowCouponForm(!showCouponForm)} + /> {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; - 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 - /> -
-
-
- - -
-
-
+ setShowCouponForm(false)} + addNotification={addNotification} + /> )}
diff --git a/src/basic/components/admin-page/CouponForm.tsx b/src/basic/components/admin-page/CouponForm.tsx new file mode 100644 index 00000000..9ed08438 --- /dev/null +++ b/src/basic/components/admin-page/CouponForm.tsx @@ -0,0 +1,109 @@ +export const CouponForm = ({ + onSubmit, + couponForm, + setCouponForm, + closeCouponForm, + addNotification, +}) => { + return ( +
+
+

새 쿠폰 생성

+
+
+ + 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; + 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 + /> +
+
+
+ + +
+
+
+ ); +}; diff --git a/src/basic/components/admin-page/CouponList.tsx b/src/basic/components/admin-page/CouponList.tsx new file mode 100644 index 00000000..18d9ff15 --- /dev/null +++ b/src/basic/components/admin-page/CouponList.tsx @@ -0,0 +1,51 @@ +export const CouponList = ({ coupons, deleteCoupon, toggleCouponForm }) => { + return ( +
+ {coupons.map((coupon) => ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + +
+ +
+
+ ); +}; diff --git a/src/basic/components/admin-page/ProductForm.tsx b/src/basic/components/admin-page/ProductForm.tsx new file mode 100644 index 00000000..d1181969 --- /dev/null +++ b/src/basic/components/admin-page/ProductForm.tsx @@ -0,0 +1,188 @@ +export const ProductForm = ({ + productForm, + setProductForm, + onSubmit, + onCancel, + editingProduct, + addNotification, +}) => { + return ( +
+
+

+ {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; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + price: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const { value } = e.target; + 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; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + stock: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const { value } = e.target; + 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 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border' + 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='%' + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/basic/components/admin-page/ProductTable.tsx b/src/basic/components/admin-page/ProductTable.tsx new file mode 100644 index 00000000..13789c28 --- /dev/null +++ b/src/basic/components/admin-page/ProductTable.tsx @@ -0,0 +1,71 @@ +import { formatPrice } from '../../utils/formatters'; + +export const ProductTable = ({ activeTab, products, onEdit, onDelete }) => { + return ( +
+ + + + + + + + + + + + {(activeTab === 'products' ? products : products).map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {formatPrice(product.price, { isAdmin: true })} + + 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 || '-'} + + + +
+
+ ); +}; From 289708e9d690057055b8aa35f1a3ad6e379be906 Mon Sep 17 00:00:00 2001 From: esoby Date: Thu, 7 Aug 2025 23:45:07 +0900 Subject: [PATCH 29/44] =?UTF-8?q?chore:=20=EB=B0=B0=ED=8F=AC=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 --- package.json | 2 + pnpm-lock.yaml | 205 +++++++++++++++++++++++++++++++++++++++++++++++++ vite.config.ts | 16 ++-- 3 files changed, 218 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 534f19de..fe233fd8 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "start:origin": "vite serve --open ./index.origin.html", "start:basic": "vite serve --open ./index.basic.html", "start:advanced": "vite serve --open ./index.advanced.html", + "deploy": "vite build && cp dist/index.advanced.html dist/index.html && gh-pages -d dist", "test": "vitest", "test:origin": "vitest src/origin", "test:basic": "vitest src/basic", @@ -16,6 +17,7 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "gh-pages": "^6.3.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47126b26..6cfe394f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + gh-pages: + specifier: ^6.3.0 + version: 6.3.0 react: specifier: ^19.1.1 version: 19.1.1 @@ -771,6 +774,10 @@ packages: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + array.prototype.findlast@1.2.5: resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} @@ -806,6 +813,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -880,6 +890,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -961,6 +978,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -978,6 +999,9 @@ packages: electron-to-chromium@1.5.194: resolution: {integrity: sha512-SdnWJwSUot04UR51I2oPD8kuP2VI37/CADR1OHsFOUzZIvfWJBO6q11k5P/uKNyTT3cdOsnyjkrZ+DDShqYqJA==} + email-addresses@5.0.0: + resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -1194,10 +1218,26 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filename-reserved-regex@2.0.0: + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} + + filenamify@4.3.0: + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1216,6 +1256,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fs-extra@11.3.1: + resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} + engines: {node: '>=14.14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1243,6 +1287,11 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} + gh-pages@6.3.0: + resolution: {integrity: sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==} + engines: {node: '>=10'} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1263,10 +1312,17 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1486,6 +1542,9 @@ packages: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -1497,6 +1556,10 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1530,6 +1593,10 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1617,14 +1684,26 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1643,6 +1722,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1665,6 +1748,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -1832,6 +1919,10 @@ packages: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1880,6 +1971,10 @@ packages: strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + strip-outer@1.0.1: + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -1944,6 +2039,10 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -1982,6 +2081,10 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true @@ -2729,6 +2832,8 @@ snapshots: is-string: 1.1.1 math-intrinsics: 1.1.0 + array-union@2.1.0: {} + array.prototype.findlast@1.2.5: dependencies: call-bind: 1.0.8 @@ -2788,6 +2893,8 @@ snapshots: async-function@1.0.0: {} + async@3.2.6: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -2870,6 +2977,10 @@ snapshots: color-name@1.1.4: {} + commander@13.1.0: {} + + commondir@1.0.1: {} + concat-map@0.0.1: {} cross-spawn@7.0.6: @@ -2942,6 +3053,10 @@ snapshots: dequal@2.0.3: {} + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -2958,6 +3073,8 @@ snapshots: electron-to-chromium@1.5.194: {} + email-addresses@5.0.0: {} + entities@4.5.0: {} es-abstract@1.24.0: @@ -3307,10 +3424,29 @@ snapshots: dependencies: flat-cache: 4.0.1 + filename-reserved-regex@2.0.0: {} + + filenamify@4.3.0: + dependencies: + filename-reserved-regex: 2.0.0 + strip-outer: 1.0.1 + trim-repeated: 1.0.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -3329,6 +3465,12 @@ snapshots: dependencies: is-callable: 1.2.7 + fs-extra@11.3.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + fsevents@2.3.3: optional: true @@ -3369,6 +3511,16 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 + gh-pages@6.3.0: + dependencies: + async: 3.2.6 + commander: 13.1.0 + email-addresses: 5.0.0 + filenamify: 4.3.0 + find-cache-dir: 3.3.2 + fs-extra: 11.3.1 + globby: 11.1.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -3386,8 +3538,19 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + graphemer@1.4.0: {} has-bigints@1.1.0: {} @@ -3622,6 +3785,12 @@ snapshots: dependencies: minimist: 1.2.8 + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -3638,6 +3807,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3664,6 +3837,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -3754,14 +3931,24 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -3776,6 +3963,8 @@ snapshots: path-parse@1.0.7: {} + path-type@4.0.0: {} + pathe@2.0.3: {} pathval@2.0.0: {} @@ -3788,6 +3977,10 @@ snapshots: picomatch@4.0.3: {} + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + possible-typed-array-names@1.1.0: {} postcss@8.5.6: @@ -4001,6 +4194,8 @@ snapshots: mrmime: 2.0.0 totalist: 3.0.1 + slash@3.0.0: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} @@ -4068,6 +4263,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -4119,6 +4318,10 @@ snapshots: dependencies: punycode: 2.3.1 + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + ts-api-utils@2.1.0(typescript@5.9.2): dependencies: typescript: 5.9.2 @@ -4176,6 +4379,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + universalify@2.0.1: {} + update-browserslist-db@1.1.3(browserslist@4.25.1): dependencies: browserslist: 4.25.1 diff --git a/vite.config.ts b/vite.config.ts index e6c4016b..1853325d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,22 @@ -import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; -import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; +import { defineConfig } from 'vite'; +import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; export default mergeConfig( defineConfig({ + base: '/front_6th_chapter2-2/', + build: { + rollupOptions: { + input: 'index.advanced.html', + }, + }, plugins: [react()], }), defineTestConfig({ test: { globals: true, environment: 'jsdom', - setupFiles: './src/setupTests.ts' + setupFiles: './src/setupTests.ts', }, - }) -) + }), +); From 18552b4a6189c250d5f0726b08db3ecb0dd21585 Mon Sep 17 00:00:00 2001 From: esoby Date: Fri, 8 Aug 2025 00:05:35 +0900 Subject: [PATCH 30/44] =?UTF-8?q?refactor:=20dismissnoti=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/hooks/useNotifications.ts | 7 ++++++- src/basic/hooks/useStore.ts | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/basic/hooks/useNotifications.ts b/src/basic/hooks/useNotifications.ts index bc94e06c..75a1ea38 100644 --- a/src/basic/hooks/useNotifications.ts +++ b/src/basic/hooks/useNotifications.ts @@ -16,5 +16,10 @@ export const useNotifications = () => { }, [], ); - return { notifications, addNotification }; + + const dismissNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return { notifications, addNotification, dismissNotification }; }; diff --git a/src/basic/hooks/useStore.ts b/src/basic/hooks/useStore.ts index b28f3389..60b63bb1 100644 --- a/src/basic/hooks/useStore.ts +++ b/src/basic/hooks/useStore.ts @@ -7,7 +7,7 @@ import { useProducts } from './useProducts'; import * as cartModel from '../models/cart'; export const useStore = () => { - const { notifications, addNotification } = useNotifications(); + const { notifications, addNotification, dismissNotification } = useNotifications(); const { products, addProduct, updateProduct, deleteProduct } = useProducts({ addNotification }); @@ -26,6 +26,7 @@ export const useStore = () => { return { notifications, addNotification, + dismissNotification, products, addProduct, updateProduct, From 60698ddbc3575affc2ddbb8fe123cb5aa7cc7c08 Mon Sep 17 00:00:00 2001 From: esoby Date: Fri, 8 Aug 2025 01:03:03 +0900 Subject: [PATCH 31/44] =?UTF-8?q?refactor:=20product=20interface=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/types.ts b/src/types.ts index 7beefac1..57aecc40 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,8 @@ export interface Product { price: number; stock: number; discounts: Discount[]; + description?: string; + isRecommended?: boolean; } export interface Discount { From 11f06dd664870c75a76b2ee8a1897759b82541f3 Mon Sep 17 00:00:00 2001 From: esoby Date: Fri, 8 Aug 2025 01:14:11 +0900 Subject: [PATCH 32/44] =?UTF-8?q?refactor:=20props=20interface=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 3 +- src/basic/components/AdminPage.tsx | 20 ++-- src/basic/components/CartPage.tsx | 40 ++++---- src/basic/components/Header.tsx | 12 ++- src/basic/components/NotificationPanel.tsx | 14 ++- .../components/admin-page/CouponForm.tsx | 12 ++- .../components/admin-page/CouponList.tsx | 10 +- .../components/admin-page/ProductForm.tsx | 13 ++- .../components/admin-page/ProductTable.tsx | 10 +- .../components/cart-page/OrderSummary.tsx | 21 +++- .../components/cart-page/ProductItem.tsx | 9 +- .../components/cart-page/ProductList.tsx | 15 ++- src/basic/constants/index.ts | 95 +++++++++---------- src/basic/hooks/useCart.ts | 1 + src/types.ts | 15 +++ 15 files changed, 199 insertions(+), 91 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 2a398be1..a9f586a6 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -14,6 +14,7 @@ const App = () => { const { notifications, addNotification, + dismissNotification, products, addProduct, updateProduct, @@ -34,7 +35,7 @@ const App = () => { return (
- +
) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; + addCoupon: (newCoupon: Coupon) => void; + deleteCoupon: (couponCode: string) => void; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; } export function AdminPage({ @@ -20,13 +26,13 @@ export function AdminPage({ addCoupon, deleteCoupon, 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({ + const [productForm, setProductForm] = useState({ name: '', price: 0, stock: 0, @@ -69,7 +75,7 @@ export function AdminPage({ setShowCouponForm(false); }; - const editProduct = (product: ProductWithUI) => { + const editProduct = (product: Product) => { setEditingProduct(product.id); setProductForm({ name: product.name, diff --git a/src/basic/components/CartPage.tsx b/src/basic/components/CartPage.tsx index 40141014..ab00a982 100644 --- a/src/basic/components/CartPage.tsx +++ b/src/basic/components/CartPage.tsx @@ -1,24 +1,26 @@ -// TODO: 장바구니 페이지 컴포넌트 -// 힌트: -// 1. 상품 목록 표시 (검색 기능 포함) -// 2. 장바구니 관리 -// 3. 쿠폰 적용 -// 4. 주문 처리 -// -// 필요한 hooks: -// - useProducts: 상품 목록 관리 -// - useCart: 장바구니 상태 관리 -// - useCoupons: 쿠폰 목록 관리 -// - useDebounce: 검색어 디바운싱 -// -// 하위 컴포넌트: -// - SearchBar: 검색 입력 -// - ProductList: 상품 목록 표시 -// - Cart: 장바구니 표시 및 결제 - +import { CartItem, Coupon, Product } from '../../types'; import { OrderSummary } from './cart-page/OrderSummary'; import { ProductList } from './cart-page/ProductList'; +interface CartPageProps { + products: Product[]; + cart: CartItem[]; + coupons: Coupon[]; + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + searchTerm: string; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + addToCart: (product: Product) => void; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + getRemainingStock: (product: Product) => number; + clearCart: () => void; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + export function CartPage({ products, cart, @@ -33,7 +35,7 @@ export function CartPage({ getRemainingStock, clearCart, addNotification, -}) { +}: CartPageProps) { return (
>; + setIsAdmin: React.Dispatch>; + cart: CartItem[]; +} -export const Header = ({ isAdmin, searchTerm, setSearchTerm, setIsAdmin, cart }) => { +export const Header = ({ isAdmin, searchTerm, setSearchTerm, setIsAdmin, cart }: HeaderProps) => { const [totalItemCount, setTotalItemCount] = useState(0); useEffect(() => { diff --git a/src/basic/components/NotificationPanel.tsx b/src/basic/components/NotificationPanel.tsx index 811296a4..51c77845 100644 --- a/src/basic/components/NotificationPanel.tsx +++ b/src/basic/components/NotificationPanel.tsx @@ -1,4 +1,11 @@ -export const NotificationPanel = ({ notifications, addNotification }) => { +import { Notification } from '../../types'; + +interface NotificationPanelProps { + notifications: Notification[]; + onDismiss: (id: string) => void; +} + +export const NotificationPanel = ({ notifications, onDismiss }: NotificationPanelProps) => { return ( notifications.length > 0 && (
@@ -14,10 +21,7 @@ export const NotificationPanel = ({ notifications, addNotification }) => { }`} > {notif.message} - -
- ))} - -
-
- -
- - -
- -
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {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 - /> -
-
-
- - -
-
-
- )} -
-
- )} -
+ ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

-
- 총 {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; \ No newline at end of file +export default App; diff --git a/src/advanced/components/AdminPage.tsx b/src/advanced/components/AdminPage.tsx new file mode 100644 index 00000000..b709be07 --- /dev/null +++ b/src/advanced/components/AdminPage.tsx @@ -0,0 +1,191 @@ +import { useState } from 'react'; + +import { Product, Coupon, ProductFormData } from '../../types'; +import { CouponForm } from './admin-page/CouponForm'; +import { CouponList } from './admin-page/CouponList'; +import { ProductForm } from './admin-page/ProductForm'; +import { ProductTable } from './admin-page/ProductTable'; + +interface AdminPageProps { + products: Product[]; + coupons: Coupon[]; + addProduct: (newProduct: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; + addCoupon: (newCoupon: Coupon) => void; + deleteCoupon: (couponCode: string) => void; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export function AdminPage({ + products, + coupons, + addProduct, + updateProduct, + deleteProduct, + addCoupon, + deleteCoupon, + 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: [] as Array<{ quantity: number; rate: number }>, + }); + + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0, + }); + + const handleProductSubmit = (e: React.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: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + setShowCouponForm(false); + }; + + const editProduct = (product: Product) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], + }); + setShowProductForm(true); + }; + return ( +
+
+

관리자 대시보드

+

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

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

상품 목록

+ +
+
+ + + + {showProductForm && ( + { + setEditingProduct(null); + setShowProductForm(false); + }} + editingProduct={editingProduct} + addNotification={addNotification} + /> + )} +
+ ) : ( +
+
+

쿠폰 관리

+
+
+ setShowCouponForm(!showCouponForm)} + /> + + {showCouponForm && ( + setShowCouponForm(false)} + addNotification={addNotification} + /> + )} +
+
+ )} +
+ ); +} diff --git a/src/advanced/components/CartPage.tsx b/src/advanced/components/CartPage.tsx new file mode 100644 index 00000000..ab00a982 --- /dev/null +++ b/src/advanced/components/CartPage.tsx @@ -0,0 +1,60 @@ +import { CartItem, Coupon, Product } from '../../types'; +import { OrderSummary } from './cart-page/OrderSummary'; +import { ProductList } from './cart-page/ProductList'; + +interface CartPageProps { + products: Product[]; + cart: CartItem[]; + coupons: Coupon[]; + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + searchTerm: string; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + addToCart: (product: Product) => void; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + getRemainingStock: (product: Product) => number; + clearCart: () => void; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export function CartPage({ + products, + cart, + coupons, + totals, + searchTerm, + selectedCoupon, + applyCoupon, + addToCart, + removeFromCart, + updateQuantity, + getRemainingStock, + clearCart, + addNotification, +}: CartPageProps) { + return ( +
+ + +
+ ); +} diff --git a/src/advanced/components/Header.tsx b/src/advanced/components/Header.tsx new file mode 100644 index 00000000..fb030691 --- /dev/null +++ b/src/advanced/components/Header.tsx @@ -0,0 +1,55 @@ +import { useState, useEffect } from 'react'; + +import { SearchBar } from './header/SearchBar'; +import { CartIcon } from './icons'; +import { CartItem } from '../../types'; + +interface HeaderProps { + isAdmin: boolean; + searchTerm: string; + setSearchTerm: React.Dispatch>; + setIsAdmin: React.Dispatch>; + cart: CartItem[]; +} + +export const Header = ({ isAdmin, searchTerm, setSearchTerm, setIsAdmin, cart }: HeaderProps) => { + const [totalItemCount, setTotalItemCount] = useState(0); + + useEffect(() => { + const count = cart.reduce((sum, item) => sum + item.quantity, 0); + setTotalItemCount(count); + }, [cart]); + + return ( +
+
+
+
+

SHOP

+ {!isAdmin && } +
+ +
+
+
+ ); +}; diff --git a/src/advanced/components/NotificationPanel.tsx b/src/advanced/components/NotificationPanel.tsx new file mode 100644 index 00000000..d6081ac1 --- /dev/null +++ b/src/advanced/components/NotificationPanel.tsx @@ -0,0 +1,33 @@ +import { CloseIcon } from './icons'; +import { Notification } from '../../types'; + +interface NotificationPanelProps { + notifications: Notification[]; + onDismiss: (id: string) => void; +} + +export const NotificationPanel = ({ notifications, onDismiss }: NotificationPanelProps) => { + return ( + notifications.length > 0 && ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ) + ); +}; diff --git a/src/advanced/components/admin-page/CouponForm.tsx b/src/advanced/components/admin-page/CouponForm.tsx new file mode 100644 index 00000000..f8ffa6f2 --- /dev/null +++ b/src/advanced/components/admin-page/CouponForm.tsx @@ -0,0 +1,119 @@ +import { CouponFormData } from '../../../types'; + +interface CouponFormProps { + onSubmit: (e: React.FormEvent) => void; + couponForm: CouponFormData; + setCouponForm: React.Dispatch>; + closeCouponForm: () => void; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export const CouponForm = ({ + onSubmit, + couponForm, + setCouponForm, + closeCouponForm, + addNotification, +}: CouponFormProps) => { + return ( +
+
+

새 쿠폰 생성

+
+
+ + 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; + 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 + /> +
+
+
+ + +
+
+
+ ); +}; diff --git a/src/advanced/components/admin-page/CouponList.tsx b/src/advanced/components/admin-page/CouponList.tsx new file mode 100644 index 00000000..3d752bb9 --- /dev/null +++ b/src/advanced/components/admin-page/CouponList.tsx @@ -0,0 +1,51 @@ +import { Coupon } from '../../../types'; +import { PlusIcon, TrashIcon } from '../icons'; + +interface CouponListProps { + coupons: Coupon[]; + deleteCoupon: (couponCode: string) => void; + toggleCouponForm: () => void; +} + +export const CouponList = ({ coupons, deleteCoupon, toggleCouponForm }: CouponListProps) => { + return ( +
+ {coupons.map((coupon) => ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + +
+ +
+
+ ); +}; diff --git a/src/advanced/components/admin-page/ProductForm.tsx b/src/advanced/components/admin-page/ProductForm.tsx new file mode 100644 index 00000000..32e9e1ef --- /dev/null +++ b/src/advanced/components/admin-page/ProductForm.tsx @@ -0,0 +1,193 @@ +import { ProductFormData } from '../../../types'; +import { ChevronDownIcon } from '../icons'; + +interface ProductFormProps { + productForm: ProductFormData; + setProductForm: React.Dispatch>; + onSubmit: (e: React.FormEvent) => void; + onCancel: () => void; + editingProduct: string | null; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export const ProductForm = ({ + productForm, + setProductForm, + onSubmit, + onCancel, + editingProduct, + addNotification, +}: ProductFormProps) => { + return ( +
+
+

+ {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; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + price: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const { value } = e.target; + 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; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + stock: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const { value } = e.target; + 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 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border' + 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='%' + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/advanced/components/admin-page/ProductTable.tsx b/src/advanced/components/admin-page/ProductTable.tsx new file mode 100644 index 00000000..90699fda --- /dev/null +++ b/src/advanced/components/admin-page/ProductTable.tsx @@ -0,0 +1,79 @@ +import { Product } from '../../../types'; +import { formatPrice } from '../../utils/formatters'; + +interface ProductTableProps { + activeTab: 'products' | 'coupons'; + products: Product[]; + onEdit: (product: Product) => void; + onDelete: (productId: string) => void; +} + +export const ProductTable = ({ activeTab, products, onEdit, onDelete }: ProductTableProps) => { + return ( +
+ + + + + + + + + + + + {(activeTab === 'products' ? products : products).map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {formatPrice(product.price, { isAdmin: true })} + + 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 || '-'} + + + +
+
+ ); +}; diff --git a/src/advanced/components/cart-page/OrderSummary.tsx b/src/advanced/components/cart-page/OrderSummary.tsx new file mode 100644 index 00000000..44270ae1 --- /dev/null +++ b/src/advanced/components/cart-page/OrderSummary.tsx @@ -0,0 +1,182 @@ +import { useCallback } from 'react'; + +import { CartItem, Coupon } from '../../../types'; +import { calculateItemTotal } from '../../utils/cart'; +import { BagIcon, CloseIcon } from '../icons'; + +interface OrderSummaryProps { + cart: CartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; + clearCart: () => void; +} + +export const OrderSummary = ({ + cart, + removeFromCart, + updateQuantity, + coupons, + selectedCoupon, + applyCoupon, + totals, + addNotification, + clearCart, +}: OrderSummaryProps) => { + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + clearCart(); + }, [addNotification]); + + return ( +
+
+
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = calculateItemTotal(item, cart); + 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/advanced/components/cart-page/ProductItem.tsx b/src/advanced/components/cart-page/ProductItem.tsx new file mode 100644 index 00000000..41603494 --- /dev/null +++ b/src/advanced/components/cart-page/ProductItem.tsx @@ -0,0 +1,76 @@ +import { Product } from '../../../types'; +import { formatPrice } from '../../utils/formatters'; +import { ProductIcon } from '../icons'; + +interface ProductItemProps { + product: Product; + remainingStock: number; + addToCart: (product: Product) => void; +} + +export const ProductItem = ({ product, remainingStock, addToCart }: ProductItemProps) => { + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

{product.description}

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

+ {remainingStock <= 0 ? 'SOLD OUT' : formatPrice(product.price)} +

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

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

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

품절임박! {remainingStock}개 남음

+ )} + {remainingStock > 5 &&

재고 {remainingStock}개

} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/advanced/components/cart-page/ProductList.tsx b/src/advanced/components/cart-page/ProductList.tsx new file mode 100644 index 00000000..73a61a79 --- /dev/null +++ b/src/advanced/components/cart-page/ProductList.tsx @@ -0,0 +1,55 @@ +import { ProductItem } from './ProductItem'; +import { Product } from '../../../types'; + +interface ProductListProps { + products: Product[]; + searchTerm: string; + getRemainingStock: (product: Product) => number; + addToCart: (product: Product) => void; +} + +export const ProductList = ({ + products, + searchTerm, + getRemainingStock, + addToCart, +}: ProductListProps) => { + const filteredProducts = searchTerm + ? products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(searchTerm.toLowerCase())), + ) + : products; + + return ( +
+ {/* 상품 목록 */} +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(product); + return ( + + ); + })} +
+ )} +
+
+ ); +}; diff --git a/src/advanced/components/header/SearchBar.tsx b/src/advanced/components/header/SearchBar.tsx new file mode 100644 index 00000000..d660afa2 --- /dev/null +++ b/src/advanced/components/header/SearchBar.tsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; + +import { useDebounce } from '../../utils/hooks/useDebounce'; + +interface SearchBarProps { + searchTerm: string; + setSearchTerm: (value: string) => void; +} + +export const SearchBar = ({ searchTerm, setSearchTerm }: SearchBarProps) => { + const [inputValue, setInputValue] = useState(''); + + const debouncedValue = useDebounce(inputValue, 500); + + useEffect(() => { + setSearchTerm(debouncedValue); + }, [debouncedValue, setSearchTerm]); + + useEffect(() => { + setInputValue(searchTerm); + }, [searchTerm]); + + return ( +
+ setInputValue(e.target.value)} + placeholder='상품 검색...' + className='w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500' + /> +
+ ); +}; diff --git a/src/advanced/components/icons/index.tsx b/src/advanced/components/icons/index.tsx new file mode 100644 index 00000000..e2347b15 --- /dev/null +++ b/src/advanced/components/icons/index.tsx @@ -0,0 +1,77 @@ +interface IconProps extends React.SVGProps {} + +export const CartIcon = (props: IconProps) => ( + + + +); + +export const BagIcon = (props: IconProps) => ( + + + +); + +export const CloseIcon = (props: IconProps) => ( + + + +); + +export const PlusIcon = (props: IconProps) => ( + + + +); + +export const TrashIcon = (props: IconProps) => ( + + + +); + +export const ChevronDownIcon = (props: IconProps) => ( + + + +); + +export const ChevronUpIcon = (props: IconProps) => ; + +export const ProductIcon = (props: IconProps) => ( + + + +); diff --git a/src/advanced/constants/index.ts b/src/advanced/constants/index.ts new file mode 100644 index 00000000..ed10ea2a --- /dev/null +++ b/src/advanced/constants/index.ts @@ -0,0 +1,50 @@ +import { Product, Coupon } from '../../types'; + +export const initialProducts: Product[] = [ + { + 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 const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts new file mode 100644 index 00000000..17bcd731 --- /dev/null +++ b/src/advanced/hooks/useCart.ts @@ -0,0 +1,79 @@ +import { useState, useCallback, useEffect } from 'react'; + +import { CartItem, Product } from '../../types'; +import * as cartModel from '../models/cart'; + +interface UseCartProps { + products: Product[]; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export function useCart({ products, addNotification }: UseCartProps) { + const [cart, setCart] = useState(() => { + const saved = localStorage.getItem('cart'); + return saved ? JSON.parse(saved) : []; + }); + + useEffect(() => { + localStorage.setItem('cart', JSON.stringify(cart)); + }, [cart]); + + const getRemainingStock = useCallback( + (product: Product): number => { + return cartModel.getRemainingStock(product, cart); + }, + [cart], + ); + + const addToCart = useCallback( + (product: Product) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + addNotification('재고가 부족합니다!', 'error'); + return; + } + + const newCart = cartModel.addItemToCart(cart, product); + setCart(newCart); + addNotification('장바구니에 담았습니다', 'success'); + }, + [cart, addNotification, getRemainingStock], + ); + + const removeFromCart = useCallback( + (productId: string) => { + const newCart = cartModel.removeItemFromCart(cart, productId); + setCart(newCart); + }, + [cart], + ); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + const product = products.find((p) => p.id === productId); + if (!product) return; + + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + return; + } + + const newCart = cartModel.updateCartItemQuantity(cart, productId, newQuantity); + setCart(newCart); + }, + [cart, products, addNotification], + ); + + const clearCart = useCallback(() => { + setCart([]); + }, []); + + return { + cart, + addToCart, + removeFromCart, + updateQuantity, + getRemainingStock, + clearCart, + }; +} diff --git a/src/advanced/hooks/useCoupons.ts b/src/advanced/hooks/useCoupons.ts new file mode 100644 index 00000000..a602b05d --- /dev/null +++ b/src/advanced/hooks/useCoupons.ts @@ -0,0 +1,57 @@ +import { useState, useCallback, useEffect } from 'react'; + +import { Coupon } from '../../types'; +import * as couponModel from '../models/coupon'; + +interface UseCouponsProps { + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export function useCoupons({ addNotification }: UseCouponsProps) { + const [coupons, setCoupons] = useState(couponModel.loadCouponsFromStorage); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + useEffect(() => { + couponModel.saveCouponsToStorage(coupons); + }, [coupons]); + + const applyCoupon = useCallback( + (coupon: Coupon | null) => { + setSelectedCoupon(coupon); + if (coupon) { + addNotification(`${coupon.name} 쿠폰이 적용되었습니다.`); + } + }, + [addNotification], + ); + + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const newCoupons = couponModel.addNewCoupon(coupons, newCoupon); + setCoupons(newCoupons); + addNotification('쿠폰이 추가되었습니다.', 'success'); + }, + [coupons, addNotification], + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + const newCoupons = couponModel.removeCouponByCode(coupons, couponCode); + setCoupons(newCoupons); + + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }, + [coupons, selectedCoupon, addNotification], + ); + + return { + coupons, + selectedCoupon, + applyCoupon, + addCoupon, + deleteCoupon, + }; +} diff --git a/src/advanced/hooks/useNotifications.ts b/src/advanced/hooks/useNotifications.ts new file mode 100644 index 00000000..915b1842 --- /dev/null +++ b/src/advanced/hooks/useNotifications.ts @@ -0,0 +1,28 @@ +import { useState, useCallback } from 'react'; + +import { Notification } from '../../types'; + +export const useNotifications = (timeout = 3000) => { + const [notifications, setNotifications] = useState([]); + + const dismissNotification = useCallback( + (id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, + [setNotifications], + ); + + const addNotification = useCallback( + (message: string, type: 'error' | 'success' | 'warning' = 'success') => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + dismissNotification(id); + }, timeout); + }, + [dismissNotification, timeout], + ); + + return { notifications, addNotification, dismissNotification }; +}; diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts new file mode 100644 index 00000000..fb3af752 --- /dev/null +++ b/src/advanced/hooks/useProducts.ts @@ -0,0 +1,76 @@ +import { useCallback } from 'react'; + +import { Product, Discount } from '../../types'; +import { initialProducts } from '../constants'; +import * as discountModel from '../models/discount'; +import * as productModel from '../models/product'; +import { useLocalStorage } from '../utils/hooks/useLocalStorage'; + +interface UseProductsProps { + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export const useProducts = ({ addNotification }: UseProductsProps) => { + const [products, setProducts] = useLocalStorage('products', initialProducts); + + const addProduct = useCallback( + (newProduct: Omit) => { + setProducts((prevProducts) => productModel.addProduct(prevProducts, newProduct)); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [setProducts, addNotification], + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prevProducts) => productModel.updateProduct(prevProducts, productId, updates)); + addNotification('상품이 수정되었습니다.', 'success'); + }, + [setProducts, addNotification], + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prevProducts) => productModel.deleteProduct(prevProducts, productId)); + addNotification('상품이 삭제되었습니다.', 'success'); + }, + [setProducts, addNotification], + ); + + const updateProductStock = useCallback( + (productId: string, newStock: number) => { + setProducts((prevProducts) => + productModel.updateProductStock(prevProducts, productId, newStock), + ); + }, + [setProducts], + ); + + const addProductDiscount = useCallback( + (productId: string, newDiscount: Discount) => { + setProducts((prevProducts) => + discountModel.addProductDiscount(prevProducts, productId, newDiscount), + ); + }, + [setProducts], + ); + + const removeProductDiscount = useCallback( + (productId: string, discountQuantity: number) => { + setProducts((prevProducts) => + discountModel.removeProductDiscount(prevProducts, productId, discountQuantity), + ); + }, + [setProducts], + ); + + return { + products, + addProduct, + updateProduct, + deleteProduct, + updateProductStock, + addProductDiscount, + removeProductDiscount, + }; +}; diff --git a/src/advanced/hooks/useStore.ts b/src/advanced/hooks/useStore.ts new file mode 100644 index 00000000..60b63bb1 --- /dev/null +++ b/src/advanced/hooks/useStore.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react'; + +import { useCart } from './useCart'; +import { useCoupons } from './useCoupons'; +import { useNotifications } from './useNotifications'; +import { useProducts } from './useProducts'; +import * as cartModel from '../models/cart'; + +export const useStore = () => { + const { notifications, addNotification, dismissNotification } = useNotifications(); + + const { products, addProduct, updateProduct, deleteProduct } = useProducts({ addNotification }); + + const { cart, addToCart, removeFromCart, updateQuantity, getRemainingStock, clearCart } = useCart( + { products, addNotification }, + ); + + const { coupons, addCoupon, selectedCoupon, applyCoupon, deleteCoupon } = useCoupons({ + addNotification, + }); + + const totals = useMemo(() => { + return cartModel.calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + return { + notifications, + addNotification, + dismissNotification, + products, + addProduct, + updateProduct, + deleteProduct, + cart, + addToCart, + removeFromCart, + updateQuantity, + getRemainingStock, + clearCart, + coupons, + addCoupon, + selectedCoupon, + applyCoupon, + deleteCoupon, + totals, + }; +}; diff --git a/src/advanced/main.tsx b/src/advanced/main.tsx index e63eef4a..b2677542 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'; + +import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( , -) +); diff --git a/src/advanced/models/cart.ts b/src/advanced/models/cart.ts new file mode 100644 index 00000000..4eb7d4c1 --- /dev/null +++ b/src/advanced/models/cart.ts @@ -0,0 +1,72 @@ +import { CartItem, Coupon, Product } from '../../types'; +import { calculateItemTotal } from '../utils/cart'; + +// 남은 재고 계산 +export const getRemainingStock = (product: Product, cart: CartItem[]): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); +}; + +// 상품 추가 +export const addItemToCart = (cart: CartItem[], product: Product): CartItem[] => { + const existingItem = cart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + return cart.map((item) => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item, + ); + } + return [...cart, { product, quantity: 1 }]; +}; + +// 상품 제거 +export const removeItemFromCart = (cart: CartItem[], productId: string): CartItem[] => { + return cart.filter((item) => item.product.id !== productId); +}; + +// 상품 수량 변경 +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + newQuantity: number, +): CartItem[] => { + if (newQuantity <= 0) { + return removeItemFromCart(cart, productId); + } + + return cart.map((item) => + item.product.id === productId ? { ...item, quantity: newQuantity } : item, + ); +}; + +// 장바구니 총액 계산 (할인 전/후, 할인액) +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null, +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + const totalBeforeDiscount = cart.reduce( + (sum, item) => sum + item.product.price * item.quantity, + 0, + ); + + let totalAfterDiscount = cart.reduce((sum, item) => sum + calculateItemTotal(item, cart), 0); + + 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), + }; +}; diff --git a/src/advanced/models/coupon.ts b/src/advanced/models/coupon.ts new file mode 100644 index 00000000..c8848be9 --- /dev/null +++ b/src/advanced/models/coupon.ts @@ -0,0 +1,30 @@ +import { Coupon } from '../../types'; +import { initialCoupons } from '../constants'; + +export const loadCouponsFromStorage = (): Coupon[] => { + const saved = localStorage.getItem('coupons'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialCoupons; + } + } + return initialCoupons; +}; + +export const saveCouponsToStorage = (coupons: Coupon[]): void => { + localStorage.setItem('coupons', JSON.stringify(coupons)); +}; + +export const addNewCoupon = (coupons: Coupon[], newCoupon: Coupon): Coupon[] => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + throw new Error('이미 존재하는 쿠폰 코드입니다.'); + } + return [...coupons, newCoupon]; +}; + +export const removeCouponByCode = (coupons: Coupon[], couponCode: string): Coupon[] => { + return coupons.filter((c) => c.code !== couponCode); +}; diff --git a/src/advanced/models/discount.ts b/src/advanced/models/discount.ts new file mode 100644 index 00000000..4617ece6 --- /dev/null +++ b/src/advanced/models/discount.ts @@ -0,0 +1,42 @@ +import { Product, Discount } from '../../types'; + +export const addProductDiscount = ( + products: Product[], + productId: string, + newDiscount: Discount, +): Product[] => { + return products.map((p) => { + if (p.id === productId) { + const existingDiscountIndex = p.discounts.findIndex( + (d) => d.quantity === newDiscount.quantity, + ); + const newDiscounts = [...p.discounts]; + + if (existingDiscountIndex > -1) { + newDiscounts[existingDiscountIndex] = newDiscount; + } else { + newDiscounts.push(newDiscount); + } + newDiscounts.sort((a, b) => a.quantity - b.quantity); + + return { ...p, discounts: newDiscounts }; + } + return p; + }); +}; + +export const removeProductDiscount = ( + products: Product[], + productId: string, + discountQuantity: number, +): Product[] => { + return products.map((p) => { + if (p.id === productId) { + return { + ...p, + discounts: p.discounts.filter((d) => d.quantity !== discountQuantity), + }; + } + return p; + }); +}; diff --git a/src/advanced/models/product.ts b/src/advanced/models/product.ts new file mode 100644 index 00000000..21d2d8ba --- /dev/null +++ b/src/advanced/models/product.ts @@ -0,0 +1,29 @@ +import { Product } from '../../types'; + +export const addProduct = (products: Product[], newProduct: Omit): Product[] => { + const productWithId: Product = { + ...newProduct, + id: `p${Date.now()}`, + }; + return [...products, productWithId]; +}; + +export const updateProduct = ( + products: Product[], + productId: string, + updates: Partial, +): Product[] => { + return products.map((p) => (p.id === productId ? { ...p, ...updates } : p)); +}; + +export const deleteProduct = (products: Product[], productId: string): Product[] => { + return products.filter((p) => p.id !== productId); +}; + +export const updateProductStock = ( + products: Product[], + productId: string, + newStock: number, +): Product[] => { + return products.map((p) => (p.id === productId ? { ...p, stock: newStock } : p)); +}; diff --git a/src/advanced/types.ts b/src/advanced/types.ts new file mode 100644 index 00000000..2525934e --- /dev/null +++ b/src/advanced/types.ts @@ -0,0 +1,51 @@ +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; + description?: string; + isRecommended?: boolean; +} + +export interface Discount { + quantity: number; + rate: number; +} + +export interface CartItem { + productId: string; + quantity: number; +} + +export interface Coupon { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} + +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} + +export interface CouponFormData { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} + +export interface CartItemWithDetail extends CartItem { + product: Product; +} diff --git a/src/advanced/utils/cart.ts b/src/advanced/utils/cart.ts new file mode 100644 index 00000000..2fa76211 --- /dev/null +++ b/src/advanced/utils/cart.ts @@ -0,0 +1,28 @@ +import { CartItem } from '../../types'; + +export const getItemDiscountRate = (item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + return discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); +}; + +export const getBulkDiscountRate = (cart: CartItem[]): number => { + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + return hasBulkPurchase ? 0.05 : 0; +}; + +export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => { + const { price } = item.product; + const { quantity } = item; + + const itemDiscount = getItemDiscountRate(item); + const bulkDiscount = getBulkDiscountRate(cart); + + const totalDiscount = Math.min(itemDiscount + bulkDiscount, 0.5); + return Math.round(price * quantity * (1 - totalDiscount)); +}; diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts new file mode 100644 index 00000000..71f649fa --- /dev/null +++ b/src/advanced/utils/formatters.ts @@ -0,0 +1,20 @@ +export const formatPrice = (price: number, options: { isAdmin?: boolean } = {}): string => { + const formattedNumber = price.toLocaleString('ko-KR'); + + if (options.isAdmin) { + return `${formattedNumber}원`; + } + return `₩${formattedNumber}`; +}; + +export const formatDate = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +}; + +export const formatPercentage = (rate: number): string => { + return `${rate * 100}%`; +}; diff --git a/src/advanced/utils/hooks/useDebounce.ts b/src/advanced/utils/hooks/useDebounce.ts new file mode 100644 index 00000000..e3706e6b --- /dev/null +++ b/src/advanced/utils/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/advanced/utils/hooks/useLocalStorage.ts b/src/advanced/utils/hooks/useLocalStorage.ts new file mode 100644 index 00000000..6f307365 --- /dev/null +++ b/src/advanced/utils/hooks/useLocalStorage.ts @@ -0,0 +1,43 @@ +import { useState, useCallback } from 'react'; + +export function useLocalStorage( + key: string, + initialValue: T, +): [T, (value: T | ((val: T) => T)) => void] { + const [storedValue, setStoredValue] = useState(() => { + if (typeof window === 'undefined') { + return initialValue; + } + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(`Error parsing localStorage key "${key}":`, error); + return initialValue; + } + }); + + const setValue = useCallback( + (value: T | ((val: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value; + + setStoredValue(valueToStore); + + if ( + valueToStore === undefined || + (Array.isArray(valueToStore) && valueToStore.length === 0) + ) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } + } catch (error) { + console.error(`Error setting localStorage key "${key}":`, error); + } + }, + [key, storedValue], + ); + + return [storedValue, setValue]; +} diff --git a/src/advanced/utils/validators.ts b/src/advanced/utils/validators.ts new file mode 100644 index 00000000..3dd600c2 --- /dev/null +++ b/src/advanced/utils/validators.ts @@ -0,0 +1,16 @@ +export const isValidCouponCode = (code: string): boolean => { + const regex = /^[A-Z0-9]{4,12}$/; + return regex.test(code); +}; + +export const isValidStock = (stock: number): boolean => { + return Number.isInteger(stock) && stock >= 0; +}; + +export const isValidPrice = (price: number): boolean => { + return typeof price === 'number' && price > 0; +}; + +export const extractNumbers = (value: string): string => { + return value.replace(/\D/g, ''); +}; From 3bde99acd21b917fc444ce46204fbfe4a74e6d92 Mon Sep 17 00:00:00 2001 From: esoby Date: Fri, 8 Aug 2025 05:25:40 +0900 Subject: [PATCH 38/44] =?UTF-8?q?refactor:=20atoms=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/atoms/cartAtoms.ts | 31 +++++++++++++++++++++++++++++ src/advanced/atoms/couponsAtoms.ts | 8 ++++++++ src/advanced/atoms/index.ts | 4 ++++ src/advanced/atoms/productsAtoms.ts | 6 ++++++ src/advanced/atoms/uiAtoms.ts | 27 +++++++++++++++++++++++++ 5 files changed, 76 insertions(+) create mode 100644 src/advanced/atoms/cartAtoms.ts create mode 100644 src/advanced/atoms/couponsAtoms.ts create mode 100644 src/advanced/atoms/index.ts create mode 100644 src/advanced/atoms/productsAtoms.ts create mode 100644 src/advanced/atoms/uiAtoms.ts diff --git a/src/advanced/atoms/cartAtoms.ts b/src/advanced/atoms/cartAtoms.ts new file mode 100644 index 00000000..8930e247 --- /dev/null +++ b/src/advanced/atoms/cartAtoms.ts @@ -0,0 +1,31 @@ +import { atom } from 'jotai'; + +import { CartItem, CartItemWithDetail } from '../types'; +import { selectedCouponAtom } from './couponsAtoms'; +import { productsAtom } from './productsAtoms'; +import { calculateCartTotal } from '../models/cart'; + +export const cartAtom = atom[]>([]); + +export const cartWithDetailsAtom = atom((get): CartItemWithDetail[] => { + const cart = get(cartAtom); + const products = get(productsAtom); + + return cart + .map((item) => { + const product = products.find((p) => p.id === item.productId); + return product ? { ...item, product } : null; + }) + .filter((item): item is CartItemWithDetail => item !== null); +}); + +export const totalItemCountAtom = atom((get) => { + const cart = get(cartAtom); + return cart.reduce((sum, item) => sum + item.quantity, 0); +}); + +export const totalsAtom = atom((get) => { + const cartWithDetails = get(cartWithDetailsAtom); + const selectedCoupon = get(selectedCouponAtom); + return calculateCartTotal(cartWithDetails, selectedCoupon); +}); diff --git a/src/advanced/atoms/couponsAtoms.ts b/src/advanced/atoms/couponsAtoms.ts new file mode 100644 index 00000000..1c3a20f6 --- /dev/null +++ b/src/advanced/atoms/couponsAtoms.ts @@ -0,0 +1,8 @@ +import { atom } from 'jotai'; + +import { initialCoupons } from '../constants'; +import { Coupon } from '../types'; + +export const couponsAtom = atom(initialCoupons); + +export const selectedCouponAtom = atom(null); diff --git a/src/advanced/atoms/index.ts b/src/advanced/atoms/index.ts new file mode 100644 index 00000000..775902cc --- /dev/null +++ b/src/advanced/atoms/index.ts @@ -0,0 +1,4 @@ +export * from './productsAtoms'; +export * from './cartAtoms'; +export * from './couponsAtoms'; +export * from './uiAtoms'; diff --git a/src/advanced/atoms/productsAtoms.ts b/src/advanced/atoms/productsAtoms.ts new file mode 100644 index 00000000..74762711 --- /dev/null +++ b/src/advanced/atoms/productsAtoms.ts @@ -0,0 +1,6 @@ +import { atom } from 'jotai'; + +import { initialProducts } from '../constants'; +import { Product } from '../types'; + +export const productsAtom = atom(initialProducts); diff --git a/src/advanced/atoms/uiAtoms.ts b/src/advanced/atoms/uiAtoms.ts new file mode 100644 index 00000000..39a2e433 --- /dev/null +++ b/src/advanced/atoms/uiAtoms.ts @@ -0,0 +1,27 @@ +import { atom } from 'jotai'; + +import { Notification } from '../types'; + +export const notificationsAtom = atom([]); + +export const addNotificationAtom = atom( + null, + (_, set, update: { message: string; type?: Notification['type'] }) => { + const id = Date.now().toString(); + const newNotification: Notification = { + id, + message: update.message, + type: update.type || 'success', + }; + + set(notificationsAtom, (prev) => [...prev, newNotification]); + + setTimeout(() => { + set(notificationsAtom, (prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, +); + +export const isAdminAtom = atom(false); + +export const searchTermAtom = atom(''); From 33a472a5b9c8322243630653648208eb3d4808f2 Mon Sep 17 00:00:00 2001 From: esoby Date: Fri, 8 Aug 2025 11:47:25 +0900 Subject: [PATCH 39/44] =?UTF-8?q?refactor:=20jotai=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/App.tsx | 71 +--------- src/advanced/atoms/cartAtoms.ts | 10 +- src/advanced/atoms/couponsAtoms.ts | 2 +- src/advanced/atoms/productsAtoms.ts | 2 +- src/advanced/atoms/uiAtoms.ts | 2 +- src/advanced/components/AdminPage.tsx | 130 ++++-------------- src/advanced/components/CartPage.tsx | 55 +------- src/advanced/components/Header.tsx | 24 +--- src/advanced/components/NotificationPanel.tsx | 56 ++++---- .../components/admin-page/CouponForm.tsx | 23 +++- .../components/admin-page/CouponList.tsx | 12 +- .../components/admin-page/ProductForm.tsx | 32 ++--- .../components/admin-page/ProductTable.tsx | 12 +- .../components/cart-page/OrderSummary.tsx | 41 ++---- .../components/cart-page/ProductItem.tsx | 12 +- .../components/cart-page/ProductList.tsx | 29 ++-- src/advanced/components/header/SearchBar.tsx | 10 +- src/advanced/constants/index.ts | 16 +++ src/advanced/hooks/useCart.ts | 29 ++-- src/advanced/hooks/useCoupons.ts | 44 ++++-- src/advanced/hooks/useNotifications.ts | 28 ---- src/advanced/hooks/useProducts.ts | 77 +++++++++-- src/advanced/hooks/useStore.ts | 47 ------- src/advanced/types.ts | 51 ------- src/advanced/utils/hooks/useLocalStorage.ts | 43 ------ 25 files changed, 277 insertions(+), 581 deletions(-) delete mode 100644 src/advanced/hooks/useNotifications.ts delete mode 100644 src/advanced/hooks/useStore.ts delete mode 100644 src/advanced/types.ts delete mode 100644 src/advanced/utils/hooks/useLocalStorage.ts diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a9f586a6..3818e0d8 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,78 +1,19 @@ -import { useState } from 'react'; +import { useAtom } from 'jotai'; +import { isAdminAtom } from './atoms'; import { AdminPage } from './components/AdminPage'; import { CartPage } from './components/CartPage'; import { Header } from './components/Header'; import { NotificationPanel } from './components/NotificationPanel'; -import { useStore } from './hooks/useStore'; const App = () => { - const [isAdmin, setIsAdmin] = useState(false); - - const [searchTerm, setSearchTerm] = useState(''); - - const { - notifications, - addNotification, - dismissNotification, - products, - addProduct, - updateProduct, - deleteProduct, - cart, - addToCart, - removeFromCart, - updateQuantity, - getRemainingStock, - clearCart, - coupons, - addCoupon, - selectedCoupon, - applyCoupon, - deleteCoupon, - totals, - } = useStore(); + const [isAdmin] = useAtom(isAdminAtom); return (
- -
-
- {isAdmin ? ( - - ) : ( - - )} -
+ +
+
{isAdmin ? : }
); }; diff --git a/src/advanced/atoms/cartAtoms.ts b/src/advanced/atoms/cartAtoms.ts index 8930e247..d448494e 100644 --- a/src/advanced/atoms/cartAtoms.ts +++ b/src/advanced/atoms/cartAtoms.ts @@ -1,22 +1,22 @@ import { atom } from 'jotai'; -import { CartItem, CartItemWithDetail } from '../types'; import { selectedCouponAtom } from './couponsAtoms'; import { productsAtom } from './productsAtoms'; +import { CartItem } from '../../types'; import { calculateCartTotal } from '../models/cart'; -export const cartAtom = atom[]>([]); +export const cartAtom = atom([]); -export const cartWithDetailsAtom = atom((get): CartItemWithDetail[] => { +export const cartWithDetailsAtom = atom((get): CartItem[] => { const cart = get(cartAtom); const products = get(productsAtom); return cart .map((item) => { - const product = products.find((p) => p.id === item.productId); + const product = products.find((p) => p.id === item.product.id); return product ? { ...item, product } : null; }) - .filter((item): item is CartItemWithDetail => item !== null); + .filter((item): item is CartItem => item !== null); }); export const totalItemCountAtom = atom((get) => { diff --git a/src/advanced/atoms/couponsAtoms.ts b/src/advanced/atoms/couponsAtoms.ts index 1c3a20f6..49d589ce 100644 --- a/src/advanced/atoms/couponsAtoms.ts +++ b/src/advanced/atoms/couponsAtoms.ts @@ -1,7 +1,7 @@ import { atom } from 'jotai'; +import { Coupon } from '../../types'; import { initialCoupons } from '../constants'; -import { Coupon } from '../types'; export const couponsAtom = atom(initialCoupons); diff --git a/src/advanced/atoms/productsAtoms.ts b/src/advanced/atoms/productsAtoms.ts index 74762711..db06849c 100644 --- a/src/advanced/atoms/productsAtoms.ts +++ b/src/advanced/atoms/productsAtoms.ts @@ -1,6 +1,6 @@ import { atom } from 'jotai'; +import { Product } from '../../types'; import { initialProducts } from '../constants'; -import { Product } from '../types'; export const productsAtom = atom(initialProducts); diff --git a/src/advanced/atoms/uiAtoms.ts b/src/advanced/atoms/uiAtoms.ts index 39a2e433..525cbec8 100644 --- a/src/advanced/atoms/uiAtoms.ts +++ b/src/advanced/atoms/uiAtoms.ts @@ -1,6 +1,6 @@ import { atom } from 'jotai'; -import { Notification } from '../types'; +import { Notification } from '../../types'; export const notificationsAtom = atom([]); diff --git a/src/advanced/components/AdminPage.tsx b/src/advanced/components/AdminPage.tsx index b709be07..d65964ad 100644 --- a/src/advanced/components/AdminPage.tsx +++ b/src/advanced/components/AdminPage.tsx @@ -1,91 +1,37 @@ import { useState } from 'react'; -import { Product, Coupon, ProductFormData } from '../../types'; +import { initialProductForm } from '../constants'; import { CouponForm } from './admin-page/CouponForm'; import { CouponList } from './admin-page/CouponList'; import { ProductForm } from './admin-page/ProductForm'; import { ProductTable } from './admin-page/ProductTable'; +import { useCoupons } from '../hooks/useCoupons'; +import { useProducts } from '../hooks/useProducts'; -interface AdminPageProps { - products: Product[]; - coupons: Coupon[]; - addProduct: (newProduct: Omit) => void; - updateProduct: (productId: string, updates: Partial) => void; - deleteProduct: (productId: string) => void; - addCoupon: (newCoupon: Coupon) => void; - deleteCoupon: (couponCode: string) => void; - addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; -} - -export function AdminPage({ - products, - coupons, - addProduct, - updateProduct, - deleteProduct, - addCoupon, - deleteCoupon, - addNotification, -}: AdminPageProps) { +export function AdminPage() { 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: [] as Array<{ quantity: number; rate: number }>, - }); + const { + showProductForm, + startAddProduct, + startEditProduct, + productForm, + editingProduct, + setShowProductForm, + setEditingProduct, + handleProductSubmit, + setProductForm, + } = useProducts(); - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0, - }); + const { couponForm, setCouponForm, handleCouponSubmit, showCouponForm, setShowCouponForm } = + useCoupons(); - const handleProductSubmit = (e: React.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: [] }); + const handleClickCancel = () => { setEditingProduct(null); setShowProductForm(false); + setProductForm(initialProductForm); }; - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0, - }); - setShowCouponForm(false); - }; - - const editProduct = (product: Product) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [], - }); - setShowProductForm(true); - }; return (
@@ -123,17 +69,7 @@ export function AdminPage({

상품 목록

- + {showProductForm && ( { - setEditingProduct(null); - setShowProductForm(false); - }} editingProduct={editingProduct} - addNotification={addNotification} + onSubmit={handleProductSubmit} + onCancel={handleClickCancel} /> )} @@ -168,19 +95,14 @@ export function AdminPage({

쿠폰 관리

- setShowCouponForm(!showCouponForm)} - /> + {showCouponForm && ( setShowCouponForm(false)} - addNotification={addNotification} + onSubmit={handleCouponSubmit} + setShowCouponForm={setShowCouponForm} /> )}
diff --git a/src/advanced/components/CartPage.tsx b/src/advanced/components/CartPage.tsx index ab00a982..580f867d 100644 --- a/src/advanced/components/CartPage.tsx +++ b/src/advanced/components/CartPage.tsx @@ -1,60 +1,11 @@ -import { CartItem, Coupon, Product } from '../../types'; import { OrderSummary } from './cart-page/OrderSummary'; import { ProductList } from './cart-page/ProductList'; -interface CartPageProps { - products: Product[]; - cart: CartItem[]; - coupons: Coupon[]; - totals: { - totalBeforeDiscount: number; - totalAfterDiscount: number; - }; - searchTerm: string; - selectedCoupon: Coupon | null; - applyCoupon: (coupon: Coupon) => void; - addToCart: (product: Product) => void; - removeFromCart: (productId: string) => void; - updateQuantity: (productId: string, newQuantity: number) => void; - getRemainingStock: (product: Product) => number; - clearCart: () => void; - addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; -} - -export function CartPage({ - products, - cart, - coupons, - totals, - searchTerm, - selectedCoupon, - applyCoupon, - addToCart, - removeFromCart, - updateQuantity, - getRemainingStock, - clearCart, - addNotification, -}: CartPageProps) { +export function CartPage() { return (
- - + +
); } diff --git a/src/advanced/components/Header.tsx b/src/advanced/components/Header.tsx index fb030691..20119112 100644 --- a/src/advanced/components/Header.tsx +++ b/src/advanced/components/Header.tsx @@ -1,25 +1,15 @@ -import { useState, useEffect } from 'react'; +import { useAtom } from 'jotai'; +import { isAdminAtom, searchTermAtom, cartAtom, totalItemCountAtom } from '../atoms'; import { SearchBar } from './header/SearchBar'; import { CartIcon } from './icons'; -import { CartItem } from '../../types'; -interface HeaderProps { - isAdmin: boolean; - searchTerm: string; - setSearchTerm: React.Dispatch>; - setIsAdmin: React.Dispatch>; - cart: CartItem[]; -} - -export const Header = ({ isAdmin, searchTerm, setSearchTerm, setIsAdmin, cart }: HeaderProps) => { - const [totalItemCount, setTotalItemCount] = useState(0); - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); +export const Header = () => { + const [isAdmin, setIsAdmin] = useAtom(isAdminAtom); + const [searchTerm, setSearchTerm] = useAtom(searchTermAtom); + const [cart] = useAtom(cartAtom); + const [totalItemCount] = useAtom(totalItemCountAtom); return (
diff --git a/src/advanced/components/NotificationPanel.tsx b/src/advanced/components/NotificationPanel.tsx index d6081ac1..719838f4 100644 --- a/src/advanced/components/NotificationPanel.tsx +++ b/src/advanced/components/NotificationPanel.tsx @@ -1,33 +1,37 @@ +import { useAtom } from 'jotai'; + import { CloseIcon } from './icons'; -import { Notification } from '../../types'; +import { notificationsAtom } from '../atoms'; + +export const NotificationPanel = () => { + const [notifications, setNotifications] = useAtom(notificationsAtom); -interface NotificationPanelProps { - notifications: Notification[]; - onDismiss: (id: string) => void; -} + if (notifications.length === 0) { + return null; + } -export const NotificationPanel = ({ notifications, onDismiss }: NotificationPanelProps) => { return ( - notifications.length > 0 && ( -
- {notifications.map((notif) => ( -
+ {notifications.map((notif) => ( +
+ {notif.message} + -
- ))} -
- ) + + +
+ ))} +
); }; diff --git a/src/advanced/components/admin-page/CouponForm.tsx b/src/advanced/components/admin-page/CouponForm.tsx index f8ffa6f2..a4102bc8 100644 --- a/src/advanced/components/admin-page/CouponForm.tsx +++ b/src/advanced/components/admin-page/CouponForm.tsx @@ -1,20 +1,23 @@ +import { useSetAtom } from 'jotai'; + import { CouponFormData } from '../../../types'; +import { addNotificationAtom } from '../../atoms'; interface CouponFormProps { onSubmit: (e: React.FormEvent) => void; couponForm: CouponFormData; setCouponForm: React.Dispatch>; - closeCouponForm: () => void; - addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; + setShowCouponForm: React.Dispatch>; } export const CouponForm = ({ onSubmit, couponForm, setCouponForm, - closeCouponForm, - addNotification, + setShowCouponForm, }: CouponFormProps) => { + const addNotification = useSetAtom(addNotificationAtom); + return (
@@ -78,14 +81,20 @@ export const CouponForm = ({ const value = parseInt(e.target.value) || 0; if (couponForm.discountType === 'percentage') { if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); + addNotification({ + message: '할인율은 100%를 초과할 수 없습니다', + type: 'error', + }); setCouponForm({ ...couponForm, discountValue: 100 }); } else if (value < 0) { setCouponForm({ ...couponForm, discountValue: 0 }); } } else { if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); + addNotification({ + message: '할인 금액은 100,000원을 초과할 수 없습니다', + type: 'error', + }); setCouponForm({ ...couponForm, discountValue: 100000 }); } else if (value < 0) { setCouponForm({ ...couponForm, discountValue: 0 }); @@ -101,7 +110,7 @@ export const CouponForm = ({