From 83ba80075d65475a3ac79db319e44a1ac52e66f1 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 4 Aug 2025 20:54:17 +0900 Subject: [PATCH 01/34] feat: add prettier & eslint settings --- .eslintrc.cjs | 146 ++++- .prettierrc | 11 + package.json | 13 +- pnpm-lock.yaml | 1514 +++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 1658 insertions(+), 26 deletions(-) create mode 100644 .prettierrc diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d6c95379..d97ccd0b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,18 +1,130 @@ -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 }, - ], +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/.prettierrc b/.prettierrc new file mode 100644 index 00000000..5c8a57f0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "singleQuote": true, + "jsxSingleQuote": true, + "tabWidth": 2, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "printWidth": 100, + "endOfLine": "auto" +} diff --git a/package.json b/package.json index 79034acb..448e0e9d 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,17 @@ "@typescript-eslint/parser": "^8.38.0", "@vitejs/plugin-react-swc": "^3.11.0", "@vitest/ui": "^3.2.4", - "eslint": "^9.32.0", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", "jsdom": "^26.1.0", "typescript": "^5.9.2", "vite": "^7.0.6", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "eslint": "^9.32.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85..cf40a22d 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) @@ -343,6 +358,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 +373,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 +483,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 +608,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 +760,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 +820,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 +898,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 +946,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 +1031,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 +1102,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 +1160,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 +1209,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 +1252,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 +1279,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 +1334,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 +1405,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 +1479,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 +1498,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 +1527,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 +1550,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 +1568,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 +1637,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 +1662,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 +1674,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 +1702,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 +1716,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 +1755,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 +1777,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 +1806,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 +1839,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 +1885,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 +1947,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 +2081,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 +2328,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 +2342,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 +2408,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 +2511,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 +2710,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 +2804,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 +2889,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 +2925,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 +3089,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 +3175,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 +3272,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 +3322,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 +3376,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 +3446,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 +3615,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 +3639,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 +3661,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + math-intrinsics@1.1.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -2500,6 +3680,8 @@ snapshots: dependencies: brace-expansion: 2.0.1 + minimist@1.2.8: {} + mrmime@2.0.0: {} ms@2.1.3: {} @@ -2508,8 +3690,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 +3745,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 +3771,8 @@ snapshots: path-key@3.1.1: {} + path-parse@1.0.7: {} + pathe@2.0.3: {} pathval@2.0.0: {} @@ -2551,6 +3785,8 @@ snapshots: picomatch@4.0.3: {} + possible-typed-array-names@1.1.0: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2559,12 +3795,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 +3822,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 +3833,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 +3903,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 +3930,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 +4004,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 +4073,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 +4120,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 +4274,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 32fc0719187ff92e523e27cf2fad29a62d919fbd Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 4 Aug 2025 21:45:12 +0900 Subject: [PATCH 02/34] =?UTF-8?q?feat:=20admin=20dashboard=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 | 1193 ++++++++++------------------ src/basic/constants/mocks.ts | 55 ++ src/basic/entities/index.ts | 0 src/basic/ui/admin-dashboard.tsx | 574 +++++++++++++ src/basic/ui/index.ts | 3 + src/basic/ui/notification-item.tsx | 37 + src/basic/ui/search-input.tsx | 22 + 7 files changed, 1110 insertions(+), 774 deletions(-) create mode 100644 src/basic/constants/mocks.ts create mode 100644 src/basic/entities/index.ts create mode 100644 src/basic/ui/admin-dashboard.tsx create mode 100644 src/basic/ui/index.ts create mode 100644 src/basic/ui/notification-item.tsx create mode 100644 src/basic/ui/search-input.tsx diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1..077c323c 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,10 +1,7 @@ import { useState, useCallback, useEffect } from 'react'; import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} +import { initialCoupons, initialProducts, ProductWithUI } from './constants/mocks'; +import { AdminDashboard, NotificationItem, SearchInput } from './ui'; interface Notification { id: string; @@ -12,60 +9,7 @@ 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원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; - const App = () => { - const [products, setProducts] = useState(() => { const saved = localStorage.getItem('products'); if (saved) { @@ -118,20 +62,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 +83,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 +109,7 @@ const App = () => { const { price } = item.product; const { quantity } = item; const discount = getMaxApplicableDiscount(item); - + return Math.round(price * quantity * (1 - discount)); }; @@ -177,7 +120,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 +130,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 +191,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); - 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; + 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 +274,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,10 +336,16 @@ const App = () => { } else { addProduct({ ...productForm, - discounts: productForm.discounts + discounts: productForm.discounts, }); } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + }); setEditingProduct(null); setShowProductForm(false); }; @@ -388,7 +357,7 @@ const App = () => { name: '', code: '', discountType: 'amount', - discountValue: 0 + discountValue: 0, }); setShowCouponForm(false); }; @@ -400,7 +369,7 @@ const App = () => { price: product.price, stock: product.stock, description: product.description || '', - discounts: product.discounts || [] + discounts: product.discounts || [], }); setShowProductForm(true); }; @@ -408,74 +377,63 @@ 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 ( -
+
+ {/* Notification */} {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
+ {notifications.map((notif) => ( + - {notif.message} - -
+ notif={notif} + onClose={() => setNotifications((prev) => prev.filter((n) => n.id !== notif.id))} + /> ))}
)} -
-
-
-
-

SHOP

+ {/* Header */} +
+
+
+
+

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 && }
-
-
+ {/* Main */} +
{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) }); - } - }} - 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" - 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.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 - /> -
-
-
- - -
-
-
- )} -
-
- )} -
+ // Admin Dashboard + ) : ( -
-
+ // User Dashboard +
+
{/* 상품 목록 */}
-
-

전체 상품

-
- 총 {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 +686,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 +766,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/constants/mocks.ts b/src/basic/constants/mocks.ts new file mode 100644 index 00000000..ffc79005 --- /dev/null +++ b/src/basic/constants/mocks.ts @@ -0,0 +1,55 @@ +import { Coupon, Product } from '../../types'; + +export 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, + }, +]; diff --git a/src/basic/entities/index.ts b/src/basic/entities/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/basic/ui/admin-dashboard.tsx b/src/basic/ui/admin-dashboard.tsx new file mode 100644 index 00000000..911cd6fd --- /dev/null +++ b/src/basic/ui/admin-dashboard.tsx @@ -0,0 +1,574 @@ +import { useState } from 'react'; +import { ProductWithUI } from '../constants/mocks'; +import { Coupon } from '../../types'; + +interface AdminDashboardProps { + products: ProductWithUI[]; + coupons: Coupon[]; + productForm: Omit; + showProductForm: boolean; + couponForm: Coupon; + showCouponForm: boolean; + + formatPrice: (price: number, productId?: string) => string; + setProductForm: (product: any) => void; + setShowProductForm: (show: boolean) => void; + setCouponForm: (coupon: Coupon) => void; + setShowCouponForm: (show: boolean) => void; + + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; + handleProductSubmit: (e: React.FormEvent) => void; + deleteCoupon: (couponCode: string) => void; + handleCouponSubmit: (e: React.FormEvent) => void; +} + +export function AdminDashboard({ + products, + coupons, + formatPrice, + productForm, + showProductForm, + couponForm, + showCouponForm, + setProductForm, + setShowProductForm, + setCouponForm, + setShowCouponForm, + addNotification, + startEditProduct, + deleteProduct, + handleProductSubmit, + deleteCoupon, + handleCouponSubmit, +}: AdminDashboardProps) { + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [editingProduct, setEditingProduct] = useState(null); + 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.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), + }); + } + }} + 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' + 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.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 + /> +
+
+
+ + +
+
+
+ )} +
+
+ )} +
+ ); +} diff --git a/src/basic/ui/index.ts b/src/basic/ui/index.ts new file mode 100644 index 00000000..2c61aca1 --- /dev/null +++ b/src/basic/ui/index.ts @@ -0,0 +1,3 @@ +export * from './notification-item'; +export * from './search-input'; +export * from './admin-dashboard'; diff --git a/src/basic/ui/notification-item.tsx b/src/basic/ui/notification-item.tsx new file mode 100644 index 00000000..2c482f24 --- /dev/null +++ b/src/basic/ui/notification-item.tsx @@ -0,0 +1,37 @@ +interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +type NotificationItemProps = { + notif: Notification; + onClose: () => void; +}; + +export function NotificationItem({ notif, onClose }: NotificationItemProps) { + return ( +
+ {notif.message} + +
+ ); +} diff --git a/src/basic/ui/search-input.tsx b/src/basic/ui/search-input.tsx new file mode 100644 index 00000000..1458e6f6 --- /dev/null +++ b/src/basic/ui/search-input.tsx @@ -0,0 +1,22 @@ +interface SearchInputProps { + searchTerm: string; + onChange: (searchTerm: string) => void; +} + +export function SearchInput({ searchTerm, onChange }: SearchInputProps) { + const handleChange = (e: React.ChangeEvent) => { + onChange(e.target.value); + }; + + return ( +
+ +
+ ); +} From 0ed8f3acffc8d86abaf99e36e8db5a607d23cfd6 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 4 Aug 2025 21:48:19 +0900 Subject: [PATCH 03/34] =?UTF-8?q?feat:=20product=20list=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 | 114 +++----------------------------- src/basic/ui/index.ts | 1 + src/basic/ui/product-list.tsx | 121 ++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 105 deletions(-) create mode 100644 src/basic/ui/product-list.tsx diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 077c323c..9ac2820f 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,7 +1,7 @@ import { useState, useCallback, useEffect } from 'react'; import { CartItem, Coupon, Product } from '../types'; import { initialCoupons, initialProducts, ProductWithUI } from './constants/mocks'; -import { AdminDashboard, NotificationItem, SearchInput } from './ui'; +import { AdminDashboard, NotificationItem, ProductList, SearchInput } from './ui'; interface Notification { id: string; @@ -468,113 +468,17 @@ const App = () => { handleCouponSubmit={handleCouponSubmit} /> ) : ( - // User Dashboard
{/* 상품 목록 */} -
-
-

전체 상품

-
총 {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}개

- )} -
- - {/* 장바구니 버튼 */} - -
-
- ); - })} -
- )} -
+
diff --git a/src/basic/ui/index.ts b/src/basic/ui/index.ts index 2c61aca1..49757b56 100644 --- a/src/basic/ui/index.ts +++ b/src/basic/ui/index.ts @@ -1,3 +1,4 @@ export * from './notification-item'; export * from './search-input'; export * from './admin-dashboard'; +export * from './product-list'; diff --git a/src/basic/ui/product-list.tsx b/src/basic/ui/product-list.tsx new file mode 100644 index 00000000..aa8c907a --- /dev/null +++ b/src/basic/ui/product-list.tsx @@ -0,0 +1,121 @@ +import { ProductWithUI } from '../constants/mocks'; + +interface ProductListProps { + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + getRemainingStock: (product: ProductWithUI) => number; + formatPrice: (price: number, productId?: string) => string; + addToCart: (product: ProductWithUI) => void; +} + +export function ProductList({ + products, + filteredProducts, + debouncedSearchTerm, + getRemainingStock, + formatPrice, + addToCart, +}: ProductListProps) { + 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}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); + })} +
+ )} +
+ ); +} From 72687c8dfdf648be853ee0c5a343be05216da16c Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 4 Aug 2025 21:55:13 +0900 Subject: [PATCH 04/34] =?UTF-8?q?feat:=20header,=20cart,=20payments,=20cou?= =?UTF-8?q?pons=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 | 253 +++++--------------------------------- src/basic/ui/cart.tsx | 105 ++++++++++++++++ src/basic/ui/coupons.tsx | 41 ++++++ src/basic/ui/header.tsx | 66 ++++++++++ src/basic/ui/index.ts | 5 +- src/basic/ui/payments.tsx | 46 +++++++ 6 files changed, 294 insertions(+), 222 deletions(-) create mode 100644 src/basic/ui/cart.tsx create mode 100644 src/basic/ui/coupons.tsx create mode 100644 src/basic/ui/header.tsx create mode 100644 src/basic/ui/payments.tsx diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 9ac2820f..5f3f5b13 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,7 +1,15 @@ import { useState, useCallback, useEffect } from 'react'; import { CartItem, Coupon, Product } from '../types'; import { initialCoupons, initialProducts, ProductWithUI } from './constants/mocks'; -import { AdminDashboard, NotificationItem, ProductList, SearchInput } from './ui'; +import { + AdminDashboard, + Cart, + Coupons, + Header, + NotificationItem, + Payments, + ProductList, +} from './ui'; interface Notification { id: string; @@ -50,7 +58,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); const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); @@ -399,52 +406,14 @@ const App = () => { ))}
)} - {/* Header */} -
-
-
-
-

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && } -
- -
-
-
- - {/* Main */} +
{isAdmin ? ( // Admin Dashboard @@ -483,182 +452,24 @@ const App = () => {
-
-

- - - - 장바구니 -

- {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 */} + {cart.length > 0 && ( <> -
-
-

쿠폰 할인

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

결제 정보

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

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

-
-
+ + {/* Payment */} + )}
diff --git a/src/basic/ui/cart.tsx b/src/basic/ui/cart.tsx new file mode 100644 index 00000000..1d2a879f --- /dev/null +++ b/src/basic/ui/cart.tsx @@ -0,0 +1,105 @@ +import { CartItem } from '../../types'; + +interface CartProps { + cart: CartItem[]; + calculateItemTotal: (item: CartItem) => number; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +} + +export function Cart({ cart, calculateItemTotal, removeFromCart, updateQuantity }: CartProps) { + 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()}원 +

+
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/basic/ui/coupons.tsx b/src/basic/ui/coupons.tsx new file mode 100644 index 00000000..cb2be31d --- /dev/null +++ b/src/basic/ui/coupons.tsx @@ -0,0 +1,41 @@ +import { Coupon } from '../../types'; + +interface CouponsProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; +} + +export function Coupons({ coupons, selectedCoupon, applyCoupon, setSelectedCoupon }: CouponsProps) { + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/basic/ui/header.tsx b/src/basic/ui/header.tsx new file mode 100644 index 00000000..ab939456 --- /dev/null +++ b/src/basic/ui/header.tsx @@ -0,0 +1,66 @@ +import { CartItem } from '../../types'; +import { SearchInput } from './search-input'; + +interface HeaderProps { + isAdmin: boolean; + searchTerm: string; + setSearchTerm: (term: string) => void; + setIsAdmin: (isAdmin: boolean) => void; + cart: CartItem[]; + totalItemCount: number; +} + +export function Header({ + isAdmin, + searchTerm, + setSearchTerm, + setIsAdmin, + cart, + totalItemCount, +}: HeaderProps) { + return ( +
+
+
+
+

SHOP

+ {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} + {!isAdmin && } +
+ +
+
+
+ ); +} diff --git a/src/basic/ui/index.ts b/src/basic/ui/index.ts index 49757b56..7e2eee42 100644 --- a/src/basic/ui/index.ts +++ b/src/basic/ui/index.ts @@ -1,4 +1,7 @@ export * from './notification-item'; -export * from './search-input'; export * from './admin-dashboard'; export * from './product-list'; +export * from './cart'; +export * from './coupons'; +export * from './payments'; +export * from './header'; diff --git a/src/basic/ui/payments.tsx b/src/basic/ui/payments.tsx new file mode 100644 index 00000000..be4aafb4 --- /dev/null +++ b/src/basic/ui/payments.tsx @@ -0,0 +1,46 @@ +interface PaymentsProps { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + completeOrder: () => void; +} + +export function Payments({ totals, completeOrder }: PaymentsProps) { + return ( +
+

결제 정보

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

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

+
+
+ ); +} From 9ad7d0e2f01146d426dd594399d0e5ee40b61df4 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 4 Aug 2025 22:28:22 +0900 Subject: [PATCH 05/34] =?UTF-8?q?feat:=20icon=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/entities/index.ts | 0 src/basic/ui/admin-dashboard.tsx | 38 +++-------------------------- src/basic/ui/cart.tsx | 33 +++---------------------- src/basic/ui/header.tsx | 15 ++---------- src/basic/ui/icons/cart.tsx | 12 +++++++++ src/basic/ui/icons/close.tsx | 7 ++++++ src/basic/ui/icons/delete.tsx | 12 +++++++++ src/basic/ui/icons/empty-bag.tsx | 17 +++++++++++++ src/basic/ui/icons/index.ts | 7 ++++++ src/basic/ui/icons/picture.tsx | 12 +++++++++ src/basic/ui/icons/plus.tsx | 7 ++++++ src/basic/ui/icons/shopping-bag.tsx | 12 +++++++++ src/basic/ui/product-list.tsx | 15 ++---------- 13 files changed, 98 insertions(+), 89 deletions(-) delete mode 100644 src/basic/entities/index.ts create mode 100644 src/basic/ui/icons/cart.tsx create mode 100644 src/basic/ui/icons/close.tsx create mode 100644 src/basic/ui/icons/delete.tsx create mode 100644 src/basic/ui/icons/empty-bag.tsx create mode 100644 src/basic/ui/icons/index.ts create mode 100644 src/basic/ui/icons/picture.tsx create mode 100644 src/basic/ui/icons/plus.tsx create mode 100644 src/basic/ui/icons/shopping-bag.tsx diff --git a/src/basic/entities/index.ts b/src/basic/entities/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/basic/ui/admin-dashboard.tsx b/src/basic/ui/admin-dashboard.tsx index 911cd6fd..38cea13e 100644 --- a/src/basic/ui/admin-dashboard.tsx +++ b/src/basic/ui/admin-dashboard.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { ProductWithUI } from '../constants/mocks'; import { Coupon } from '../../types'; +import { CloseIcon, DeleteIcon, PlusIcon } from './icons'; interface AdminDashboardProps { products: ProductWithUI[]; @@ -312,19 +313,7 @@ export function AdminDashboard({ }} className='text-red-600 hover:text-red-800' > - - - +
))} @@ -400,19 +389,7 @@ export function AdminDashboard({ onClick={() => deleteCoupon(coupon.code)} className='text-gray-400 hover:text-red-600 transition-colors' > - - - +
@@ -423,14 +400,7 @@ export function AdminDashboard({ onClick={() => setShowCouponForm(!showCouponForm)} className='text-gray-400 hover:text-gray-600 flex flex-col items-center' > - - - +

새 쿠폰 추가

diff --git a/src/basic/ui/cart.tsx b/src/basic/ui/cart.tsx index 1d2a879f..10d0f72b 100644 --- a/src/basic/ui/cart.tsx +++ b/src/basic/ui/cart.tsx @@ -1,4 +1,5 @@ import { CartItem } from '../../types'; +import { CloseIcon, EmptyBagIcon, ShoppingBagIcon } from './icons'; interface CartProps { cart: CartItem[]; @@ -11,31 +12,12 @@ export function Cart({ cart, calculateItemTotal, removeFromCart, updateQuantity return (

- - - + 장바구니

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

장바구니가 비어있습니다

) : ( @@ -56,14 +38,7 @@ export function Cart({ cart, calculateItemTotal, removeFromCart, updateQuantity onClick={() => removeFromCart(item.product.id)} className='text-gray-400 hover:text-red-500 ml-2' > - - - +
diff --git a/src/basic/ui/header.tsx b/src/basic/ui/header.tsx index ab939456..083b0320 100644 --- a/src/basic/ui/header.tsx +++ b/src/basic/ui/header.tsx @@ -1,4 +1,5 @@ import { CartItem } from '../../types'; +import { CartIcon } from './icons'; import { SearchInput } from './search-input'; interface HeaderProps { @@ -38,19 +39,7 @@ export function Header({ {!isAdmin && (
- - - + {cart.length > 0 && ( {totalItemCount} diff --git a/src/basic/ui/icons/cart.tsx b/src/basic/ui/icons/cart.tsx new file mode 100644 index 00000000..4660006d --- /dev/null +++ b/src/basic/ui/icons/cart.tsx @@ -0,0 +1,12 @@ +export function CartIcon() { + return ( + + + + ); +} diff --git a/src/basic/ui/icons/close.tsx b/src/basic/ui/icons/close.tsx new file mode 100644 index 00000000..3c9f9d11 --- /dev/null +++ b/src/basic/ui/icons/close.tsx @@ -0,0 +1,7 @@ +export function CloseIcon() { + return ( + + + + ); +} diff --git a/src/basic/ui/icons/delete.tsx b/src/basic/ui/icons/delete.tsx new file mode 100644 index 00000000..24c47340 --- /dev/null +++ b/src/basic/ui/icons/delete.tsx @@ -0,0 +1,12 @@ +export function DeleteIcon() { + return ( + + + + ); +} diff --git a/src/basic/ui/icons/empty-bag.tsx b/src/basic/ui/icons/empty-bag.tsx new file mode 100644 index 00000000..dace96b9 --- /dev/null +++ b/src/basic/ui/icons/empty-bag.tsx @@ -0,0 +1,17 @@ +export function EmptyBagIcon() { + return ( + + + + ); +} diff --git a/src/basic/ui/icons/index.ts b/src/basic/ui/icons/index.ts new file mode 100644 index 00000000..bb17aeba --- /dev/null +++ b/src/basic/ui/icons/index.ts @@ -0,0 +1,7 @@ +export * from './cart'; +export * from './close'; +export * from './delete'; +export * from './empty-bag'; +export * from './plus'; +export * from './shopping-bag'; +export * from './picture'; diff --git a/src/basic/ui/icons/picture.tsx b/src/basic/ui/icons/picture.tsx new file mode 100644 index 00000000..3eb22d76 --- /dev/null +++ b/src/basic/ui/icons/picture.tsx @@ -0,0 +1,12 @@ +export function PictureIcon() { + return ( + + + + ); +} diff --git a/src/basic/ui/icons/plus.tsx b/src/basic/ui/icons/plus.tsx new file mode 100644 index 00000000..dc8e0202 --- /dev/null +++ b/src/basic/ui/icons/plus.tsx @@ -0,0 +1,7 @@ +export function PlusIcon() { + return ( + + + + ); +} diff --git a/src/basic/ui/icons/shopping-bag.tsx b/src/basic/ui/icons/shopping-bag.tsx new file mode 100644 index 00000000..a24291c0 --- /dev/null +++ b/src/basic/ui/icons/shopping-bag.tsx @@ -0,0 +1,12 @@ +export function ShoppingBagIcon() { + return ( + + + + ); +} diff --git a/src/basic/ui/product-list.tsx b/src/basic/ui/product-list.tsx index aa8c907a..03357bae 100644 --- a/src/basic/ui/product-list.tsx +++ b/src/basic/ui/product-list.tsx @@ -1,4 +1,5 @@ import { ProductWithUI } from '../constants/mocks'; +import { PictureIcon } from './icons'; interface ProductListProps { products: ProductWithUI[]; @@ -40,19 +41,7 @@ export function ProductList({ {/* 상품 이미지 영역 (placeholder) */}
- - - +
{product.isRecommended && ( From 93ea5796b8031b6b36c3f5ef6b7b0e5fa9dc7f1a Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 4 Aug 2025 23:22:29 +0900 Subject: [PATCH 06/34] =?UTF-8?q?feat:=20cart,=20coupons,=20products=20?= =?UTF-8?q?=EB=B3=84=20entities=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 | 69 +++++++------------ src/basic/entities/cart/index.ts | 0 src/basic/entities/coupons/hooks/index.ts | 1 + .../entities/coupons/hooks/useCoupons.ts | 37 ++++++++++ src/basic/entities/coupons/index.ts | 1 + src/basic/entities/products/hooks/index.ts | 1 + .../entities/products/hooks/useProducts.ts | 35 ++++++++++ src/basic/entities/products/index.ts | 1 + 8 files changed, 99 insertions(+), 46 deletions(-) create mode 100644 src/basic/entities/cart/index.ts create mode 100644 src/basic/entities/coupons/hooks/index.ts create mode 100644 src/basic/entities/coupons/hooks/useCoupons.ts create mode 100644 src/basic/entities/coupons/index.ts create mode 100644 src/basic/entities/products/hooks/index.ts create mode 100644 src/basic/entities/products/hooks/useProducts.ts create mode 100644 src/basic/entities/products/index.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 5f3f5b13..781e1342 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -10,6 +10,8 @@ import { Payments, ProductList, } from './ui'; +import { useCoupons } from './entities/coupons'; +import { useProducts } from './entities/products'; interface Notification { id: string; @@ -18,18 +20,7 @@ interface Notification { } const App = () => { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - + const [isAdmin, setIsAdmin] = useState(false); const [cart, setCart] = useState(() => { const saved = localStorage.getItem('cart'); if (saved) { @@ -41,43 +32,29 @@ const App = () => { } return []; }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [showProductForm, setShowProductForm] = useState(false); + const { + coupons, + setCoupons, + selectedCoupon, + setSelectedCoupon, + showCouponForm, + setShowCouponForm, + couponForm, + setCouponForm, + } = useCoupons(); 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 { + products, + setProducts, + productForm, + editingProduct, + showProductForm, + setShowProductForm, + setEditingProduct, + setProductForm, + } = useProducts(); const formatPrice = (price: number, productId?: string): string => { if (productId) { diff --git a/src/basic/entities/cart/index.ts b/src/basic/entities/cart/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/basic/entities/coupons/hooks/index.ts b/src/basic/entities/coupons/hooks/index.ts new file mode 100644 index 00000000..89953ffb --- /dev/null +++ b/src/basic/entities/coupons/hooks/index.ts @@ -0,0 +1 @@ +export * from './useCoupons'; diff --git a/src/basic/entities/coupons/hooks/useCoupons.ts b/src/basic/entities/coupons/hooks/useCoupons.ts new file mode 100644 index 00000000..98e9dbe4 --- /dev/null +++ b/src/basic/entities/coupons/hooks/useCoupons.ts @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import { Coupon } from '../../../../types'; +import { initialCoupons } from '../../../constants/mocks'; + +export function useCoupons() { + const [coupons, setCoupons] = useState(() => { + const saved = localStorage.getItem('coupons'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialCoupons; + } + } + return initialCoupons; + }); + const [selectedCoupon, setSelectedCoupon] = useState(null); + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0, + }); + + return { + coupons, + selectedCoupon, + couponForm, + showCouponForm, + + setCoupons, + setSelectedCoupon, + setShowCouponForm, + setCouponForm, + }; +} diff --git a/src/basic/entities/coupons/index.ts b/src/basic/entities/coupons/index.ts new file mode 100644 index 00000000..4cc90d02 --- /dev/null +++ b/src/basic/entities/coupons/index.ts @@ -0,0 +1 @@ +export * from './hooks'; diff --git a/src/basic/entities/products/hooks/index.ts b/src/basic/entities/products/hooks/index.ts new file mode 100644 index 00000000..a53137e0 --- /dev/null +++ b/src/basic/entities/products/hooks/index.ts @@ -0,0 +1 @@ +export * from './useProducts'; diff --git a/src/basic/entities/products/hooks/useProducts.ts b/src/basic/entities/products/hooks/useProducts.ts new file mode 100644 index 00000000..dadadfc5 --- /dev/null +++ b/src/basic/entities/products/hooks/useProducts.ts @@ -0,0 +1,35 @@ +import { useState } from 'react'; +import { initialProducts, ProductWithUI } from '../../../constants/mocks'; + +export function useProducts() { + const [products, setProducts] = useState(() => { + const saved = localStorage.getItem('products'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialProducts; + } + } + return initialProducts; + }); + const [editingProduct, setEditingProduct] = useState(null); + const [showProductForm, setShowProductForm] = useState(false); + const [productForm, setProductForm] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] as Array<{ quantity: number; rate: number }>, + }); + return { + products, + setProducts, + editingProduct, + setEditingProduct, + showProductForm, + setShowProductForm, + productForm, + setProductForm, + }; +} diff --git a/src/basic/entities/products/index.ts b/src/basic/entities/products/index.ts new file mode 100644 index 00000000..4cc90d02 --- /dev/null +++ b/src/basic/entities/products/index.ts @@ -0,0 +1 @@ +export * from './hooks'; From f8f2c796dad323994b1aae145903e73c76ad56e8 Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 6 Aug 2025 01:18:45 +0900 Subject: [PATCH 07/34] feat: set basicUrl & path --- package.json | 15 ++++++++------- pnpm-lock.yaml | 47 ++++++++++++++++++++++++++++++++--------------- tsconfig.app.json | 8 +++++++- tsconfig.json | 8 +++++++- vite.config.ts | 10 ++++++++-- 5 files changed, 62 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index 448e0e9d..e930cdb9 100644 --- a/package.json +++ b/package.json @@ -23,23 +23,24 @@ "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.2.0", "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", "@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/parser": "^8.38.0", "@vitejs/plugin-react-swc": "^3.11.0", "@vitest/ui": "^3.2.4", - "jsdom": "^26.1.0", - "typescript": "^5.9.2", - "vite": "^7.0.6", - "vitest": "^3.2.4", "eslint": "^9.32.0", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", "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": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "jsdom": "^26.1.0", + "typescript": "^5.9.2", + "vite": "^7.0.6", + "vitest": "^3.2.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf40a22d..21d291ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@types/node': + specifier: ^24.2.0 + version: 24.2.0 '@types/react': specifier: ^19.1.9 version: 19.1.9 @@ -38,7 +41,7 @@ importers: version: 8.38.0(eslint@9.32.0)(typescript@5.9.2) '@vitejs/plugin-react-swc': specifier: ^3.11.0 - version: 3.11.0(vite@7.0.6) + version: 3.11.0(vite@7.0.6(@types/node@24.2.0)) '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) @@ -74,10 +77,10 @@ importers: version: 5.9.2 vite: specifier: ^7.0.6 - version: 7.0.6 + version: 7.0.6(@types/node@24.2.0) vitest: specifier: ^3.2.4 - version: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) + version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(jsdom@26.1.0) packages: @@ -611,6 +614,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/node@24.2.0': + resolution: {integrity: sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==} + '@types/react-dom@19.1.7': resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} peerDependencies: @@ -1979,6 +1985,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@7.10.0: + resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true @@ -2513,6 +2522,10 @@ snapshots: '@types/json5@0.0.29': {} + '@types/node@24.2.0': + dependencies: + undici-types: 7.10.0 + '@types/react-dom@19.1.7(@types/react@19.1.9)': dependencies: '@types/react': 19.1.9 @@ -2614,11 +2627,11 @@ snapshots: '@typescript-eslint/types': 8.38.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6)': + '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6(@types/node@24.2.0))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 '@swc/core': 1.13.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@24.2.0) transitivePeerDependencies: - '@swc/helpers' @@ -2630,13 +2643,13 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.0.6)': + '@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.2.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.0.6 + vite: 7.0.6(@types/node@24.2.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -2667,7 +2680,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) + vitest: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(jsdom@26.1.0) '@vitest/utils@3.2.4': dependencies: @@ -4173,6 +4186,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@7.10.0: {} + update-browserslist-db@1.1.3(browserslist@4.25.1): dependencies: browserslist: 4.25.1 @@ -4183,13 +4198,13 @@ snapshots: dependencies: punycode: 2.3.1 - vite-node@3.2.4: + vite-node@3.2.4(@types/node@24.2.0): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@24.2.0) transitivePeerDependencies: - '@types/node' - jiti @@ -4204,7 +4219,7 @@ snapshots: - tsx - yaml - vite@7.0.6: + vite@7.0.6(@types/node@24.2.0): dependencies: esbuild: 0.25.8 fdir: 6.4.6(picomatch@4.0.3) @@ -4213,13 +4228,14 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.14 optionalDependencies: + '@types/node': 24.2.0 fsevents: 2.3.3 - vitest@3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0): + vitest@3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)(jsdom@26.1.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.0.6) + '@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.2.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -4237,10 +4253,11 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.6 - vite-node: 3.2.4 + vite: 7.0.6(@types/node@24.2.0) + vite-node: 3.2.4(@types/node@24.2.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 24.2.0 '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 26.1.0 transitivePeerDependencies: diff --git a/tsconfig.app.json b/tsconfig.app.json index d739292a..80b644cd 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -21,7 +21,13 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + + /* Paths */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } }, "include": ["src"] } diff --git a/tsconfig.json b/tsconfig.json index ea9d0cd8..8884f4d2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,5 +7,11 @@ { "path": "./tsconfig.node.json" } - ] + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } } diff --git a/vite.config.ts b/vite.config.ts index e6c4016b..810b25b6 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 path from 'path'; export default mergeConfig( defineConfig({ plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, }), defineTestConfig({ test: { globals: true, environment: 'jsdom', - setupFiles: './src/setupTests.ts' + setupFiles: './src/setupTests.ts', }, }) -) +); From 5d1ec457cf18b905031a2abce298854dd4e31b97 Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 6 Aug 2025 22:06:33 +0900 Subject: [PATCH 08/34] =?UTF-8?q?feat:=20utils=20=ED=95=A8=EC=88=98=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 | 140 +++++++----------- src/basic/entities/cart/index.ts | 1 + src/basic/entities/cart/model/cart.ts | 26 ++++ src/basic/entities/cart/model/index.ts | 1 + .../entities/coupons/hooks/useCoupons.ts | 4 +- .../entities/products/hooks/useProducts.ts | 2 +- src/basic/utils/calculator.ts | 59 ++++++++ src/basic/utils/filter.ts | 13 ++ src/basic/utils/format.ts | 52 +++++++ src/basic/utils/index.ts | 5 + src/basic/utils/inventory.ts | 8 + src/basic/utils/validator.ts | 30 ++++ 12 files changed, 249 insertions(+), 92 deletions(-) create mode 100644 src/basic/entities/cart/model/cart.ts create mode 100644 src/basic/entities/cart/model/index.ts create mode 100644 src/basic/utils/calculator.ts create mode 100644 src/basic/utils/filter.ts create mode 100644 src/basic/utils/format.ts create mode 100644 src/basic/utils/index.ts create mode 100644 src/basic/utils/inventory.ts create mode 100644 src/basic/utils/validator.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 781e1342..210c55d0 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect } from 'react'; import { CartItem, Coupon, Product } from '../types'; -import { initialCoupons, initialProducts, ProductWithUI } from './constants/mocks'; +import { ProductWithUI } from './constants/mocks'; import { AdminDashboard, Cart, @@ -12,6 +12,15 @@ import { } from './ui'; import { useCoupons } from './entities/coupons'; import { useProducts } from './entities/products'; +import { + formatPriceWithStock, + calculateItemTotal, + calculateCartTotal, + getRemainingStock, + filterProducts, + validateCouponApplication, + validateCouponCode, +} from './utils'; interface Notification { id: string; @@ -56,82 +65,41 @@ const App = () => { setProductForm, } = useProducts(); - 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'; + const formatPrice = useCallback( + (price: number, productId?: string): string => { + if (productId) { + return formatPriceWithStock(price, productId, products, cart, isAdmin); } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; + if (isAdmin) { + return `${price.toLocaleString()}원`; + } - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); + return `₩${price.toLocaleString()}`; + }, + [products, cart, isAdmin] + ); - return Math.round(price * quantity * (1 - discount)); - }; + const getItemTotal = useCallback( + (item: CartItem): number => { + return calculateItemTotal(item, cart); + }, + [cart] + ); - const calculateCartTotal = (): { + const getCartTotal = useCallback((): { 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 calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); - return remaining; - }; + const getStock = useCallback( + (product: Product): number => { + return getRemainingStock(product, cart); + }, + [cart] + ); const addNotification = useCallback( (message: string, type: 'error' | 'success' | 'warning' = 'success') => { @@ -177,7 +145,7 @@ const App = () => { const addToCart = useCallback( (product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); + const remainingStock = getStock(product); if (remainingStock <= 0) { addNotification('재고가 부족합니다!', 'error'); return; @@ -204,7 +172,7 @@ const App = () => { addNotification('장바구니에 담았습니다', 'success'); }, - [cart, addNotification, getRemainingStock] + [getStock, addNotification] ); const removeFromCart = useCallback((productId: string) => { @@ -233,22 +201,23 @@ const App = () => { ) ); }, - [products, removeFromCart, addNotification, getRemainingStock] + [products, removeFromCart, addNotification] ); const applyCoupon = useCallback( (coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; + const currentTotal = getCartTotal().totalAfterDiscount; + const validation = validateCouponApplication(coupon, currentTotal); - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); + if (!validation.isValid) { + addNotification(validation.errorMessage!, 'error'); return; } setSelectedCoupon(coupon); addNotification('쿠폰이 적용되었습니다.', 'success'); }, - [addNotification, calculateCartTotal] + [addNotification, getCartTotal] ); const completeOrder = useCallback(() => { @@ -290,9 +259,9 @@ const App = () => { const addCoupon = useCallback( (newCoupon: Coupon) => { - const existingCoupon = coupons.find((c) => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); + const validation = validateCouponCode(newCoupon.code, coupons); + if (!validation.isValid) { + addNotification(validation.errorMessage!, 'error'); return; } setCoupons((prev) => [...prev, newCoupon]); @@ -358,16 +327,9 @@ const App = () => { setShowProductForm(true); }; - const totals = calculateCartTotal(); + const totals = getCartTotal(); - const filteredProducts = debouncedSearchTerm - ? products.filter( - (product) => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && - product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const filteredProducts = filterProducts(products, debouncedSearchTerm); return (
@@ -421,7 +383,7 @@ const App = () => { products={products} filteredProducts={filteredProducts} debouncedSearchTerm={debouncedSearchTerm} - getRemainingStock={getRemainingStock} + getRemainingStock={getStock} formatPrice={formatPrice} addToCart={addToCart} /> @@ -432,7 +394,7 @@ const App = () => { {/* Cart */} diff --git a/src/basic/entities/cart/index.ts b/src/basic/entities/cart/index.ts index e69de29b..9f8ccadd 100644 --- a/src/basic/entities/cart/index.ts +++ b/src/basic/entities/cart/index.ts @@ -0,0 +1 @@ +export * from './model'; diff --git a/src/basic/entities/cart/model/cart.ts b/src/basic/entities/cart/model/cart.ts new file mode 100644 index 00000000..71acac2f --- /dev/null +++ b/src/basic/entities/cart/model/cart.ts @@ -0,0 +1,26 @@ +import { CartItem } from '@/types'; + +const getMaxApplicableDiscount = (item: CartItem, hasBulkPurchase: boolean): 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); + + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; +}; + +export const calculateItemTotal = (item: CartItem, hasBulkPurchase: boolean): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, hasBulkPurchase); + + return Math.round(price * quantity * (1 - discount)); +}; diff --git a/src/basic/entities/cart/model/index.ts b/src/basic/entities/cart/model/index.ts new file mode 100644 index 00000000..cbcb0736 --- /dev/null +++ b/src/basic/entities/cart/model/index.ts @@ -0,0 +1 @@ +export * from './cart'; diff --git a/src/basic/entities/coupons/hooks/useCoupons.ts b/src/basic/entities/coupons/hooks/useCoupons.ts index 98e9dbe4..ef0d2bd8 100644 --- a/src/basic/entities/coupons/hooks/useCoupons.ts +++ b/src/basic/entities/coupons/hooks/useCoupons.ts @@ -1,6 +1,6 @@ +import { initialCoupons } from '@/basic/constants/mocks'; +import { Coupon } from '@/types'; import { useState } from 'react'; -import { Coupon } from '../../../../types'; -import { initialCoupons } from '../../../constants/mocks'; export function useCoupons() { const [coupons, setCoupons] = useState(() => { diff --git a/src/basic/entities/products/hooks/useProducts.ts b/src/basic/entities/products/hooks/useProducts.ts index dadadfc5..8bb0791d 100644 --- a/src/basic/entities/products/hooks/useProducts.ts +++ b/src/basic/entities/products/hooks/useProducts.ts @@ -1,5 +1,5 @@ +import { initialProducts, ProductWithUI } from '@/basic/constants/mocks'; import { useState } from 'react'; -import { initialProducts, ProductWithUI } from '../../../constants/mocks'; export function useProducts() { const [products, setProducts] = useState(() => { diff --git a/src/basic/utils/calculator.ts b/src/basic/utils/calculator.ts new file mode 100644 index 00000000..8fa93996 --- /dev/null +++ b/src/basic/utils/calculator.ts @@ -0,0 +1,59 @@ +import { CartItem, Coupon } from '../../types'; + +const getMaxApplicableDiscount = (item: CartItem, cart: 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; +}; + +export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon?: Coupon | null +): { + 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, cart); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === 'amount') { + totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; diff --git a/src/basic/utils/filter.ts b/src/basic/utils/filter.ts new file mode 100644 index 00000000..56f7fd7e --- /dev/null +++ b/src/basic/utils/filter.ts @@ -0,0 +1,13 @@ +import { ProductWithUI } from '../constants/mocks'; + +export const filterProducts = (products: ProductWithUI[], searchTerm: string): ProductWithUI[] => { + if (!searchTerm) { + return products; + } + + return products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (product.description && product.description.toLowerCase().includes(searchTerm.toLowerCase())) + ); +}; diff --git a/src/basic/utils/format.ts b/src/basic/utils/format.ts new file mode 100644 index 00000000..3e13b023 --- /dev/null +++ b/src/basic/utils/format.ts @@ -0,0 +1,52 @@ +import { Product, CartItem } from '../../types'; + +const SOLD_OUT_MESSAGE = 'SOLD OUT'; + +interface PriceFormatOptions { + showSymbol?: boolean; + isSoldOut?: boolean; + isAdmin?: boolean; +} + +export const formatPrice = ( + price: number, + options: PriceFormatOptions = { + showSymbol: true, + isSoldOut: false, + isAdmin: false, + } +): string => { + if (options.isSoldOut) { + return SOLD_OUT_MESSAGE; + } + + if (options.isAdmin) { + return `${price.toLocaleString()}원`; + } + + if (options.showSymbol) { + return `₩${price.toLocaleString()}`; + } + + return price.toLocaleString(); +}; + +export const formatPriceWithStock = ( + price: number, + productId: string, + products: Product[], + cart: CartItem[], + isAdmin: boolean = false +): string => { + const product = products.find((p) => p.id === productId); + if (product) { + const cartItem = cart.find((item) => item.product.id === product.id); + const remainingStock = product.stock - (cartItem?.quantity || 0); + + if (remainingStock <= 0) { + return SOLD_OUT_MESSAGE; + } + } + + return formatPrice(price, { isAdmin, showSymbol: !isAdmin }); +}; diff --git a/src/basic/utils/index.ts b/src/basic/utils/index.ts new file mode 100644 index 00000000..d7a870c9 --- /dev/null +++ b/src/basic/utils/index.ts @@ -0,0 +1,5 @@ +export * from './format'; +export * from './calculator'; +export * from './inventory'; +export * from './filter'; +export * from './validator'; diff --git a/src/basic/utils/inventory.ts b/src/basic/utils/inventory.ts new file mode 100644 index 00000000..56ff7582 --- /dev/null +++ b/src/basic/utils/inventory.ts @@ -0,0 +1,8 @@ +import { CartItem, Product } from '../../types'; + +export const getRemainingStock = (product: Product, cart: CartItem[]): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +}; diff --git a/src/basic/utils/validator.ts b/src/basic/utils/validator.ts new file mode 100644 index 00000000..306c72f7 --- /dev/null +++ b/src/basic/utils/validator.ts @@ -0,0 +1,30 @@ +import { Coupon } from '../../types'; + +export const validateCouponApplication = ( + coupon: Coupon, + cartTotalAfterDiscount: number +): { isValid: boolean; errorMessage?: string } => { + if (cartTotalAfterDiscount < 10000 && coupon.discountType === 'percentage') { + return { + isValid: false, + errorMessage: 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + }; + } + + return { isValid: true }; +}; + +export const validateCouponCode = ( + newCouponCode: string, + existingCoupons: Coupon[] +): { isValid: boolean; errorMessage?: string } => { + const existingCoupon = existingCoupons.find((c) => c.code === newCouponCode); + if (existingCoupon) { + return { + isValid: false, + errorMessage: '이미 존재하는 쿠폰 코드입니다.', + }; + } + + return { isValid: true }; +}; From bd7aa8cdb106534e29293971bcca0883a760024b Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 6 Aug 2025 22:26:29 +0900 Subject: [PATCH 09/34] feat: add useLocalStorage --- src/basic/App.tsx | 29 ++----------------- .../entities/coupons/hooks/useCoupons.ts | 13 ++------- .../entities/products/hooks/useProducts.ts | 13 ++------- src/basic/hooks/index.ts | 1 + src/basic/hooks/use-local-storage/index.ts | 28 ++++++++++++++++++ 5 files changed, 35 insertions(+), 49 deletions(-) create mode 100644 src/basic/hooks/index.ts create mode 100644 src/basic/hooks/use-local-storage/index.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 210c55d0..56a3f871 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -21,6 +21,7 @@ import { validateCouponApplication, validateCouponCode, } from './utils'; +import { useLocalStorage } from './hooks'; interface Notification { id: string; @@ -30,17 +31,7 @@ interface Notification { const App = () => { const [isAdmin, setIsAdmin] = useState(false); - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); + const [cart, setCart] = useLocalStorage('cart', []); const [notifications, setNotifications] = useState([]); const { coupons, @@ -120,22 +111,6 @@ const App = () => { setTotalItemCount(count); }, [cart]); - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchTerm(searchTerm); diff --git a/src/basic/entities/coupons/hooks/useCoupons.ts b/src/basic/entities/coupons/hooks/useCoupons.ts index ef0d2bd8..8bce6c89 100644 --- a/src/basic/entities/coupons/hooks/useCoupons.ts +++ b/src/basic/entities/coupons/hooks/useCoupons.ts @@ -1,19 +1,10 @@ import { initialCoupons } from '@/basic/constants/mocks'; +import { useLocalStorage } from '@/basic/hooks'; import { Coupon } from '@/types'; import { useState } from 'react'; export function useCoupons() { - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); + const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); const [selectedCoupon, setSelectedCoupon] = useState(null); const [showCouponForm, setShowCouponForm] = useState(false); const [couponForm, setCouponForm] = useState({ diff --git a/src/basic/entities/products/hooks/useProducts.ts b/src/basic/entities/products/hooks/useProducts.ts index 8bb0791d..dfc48e2b 100644 --- a/src/basic/entities/products/hooks/useProducts.ts +++ b/src/basic/entities/products/hooks/useProducts.ts @@ -1,18 +1,9 @@ import { initialProducts, ProductWithUI } from '@/basic/constants/mocks'; +import { useLocalStorage } from '@/basic/hooks'; import { useState } from 'react'; export function useProducts() { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); + const [products, setProducts] = useLocalStorage('products', initialProducts); const [editingProduct, setEditingProduct] = useState(null); const [showProductForm, setShowProductForm] = useState(false); const [productForm, setProductForm] = useState({ diff --git a/src/basic/hooks/index.ts b/src/basic/hooks/index.ts new file mode 100644 index 00000000..87c1953d --- /dev/null +++ b/src/basic/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-local-storage'; diff --git a/src/basic/hooks/use-local-storage/index.ts b/src/basic/hooks/use-local-storage/index.ts new file mode 100644 index 00000000..04976f9f --- /dev/null +++ b/src/basic/hooks/use-local-storage/index.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((prev: T) => T)) => void] { + const [value, setValue] = useState(() => { + try { + const saved = localStorage.getItem(key); + if (saved) { + return JSON.parse(saved); + } + } catch (error) { + console.warn(`[오류] 로컬 스토리지 파싱 실패: "${key}"`, error); + } + return initialValue; + }); + + useEffect(() => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.warn(`[오류] 로컬 스토리지 저장 실패: "${key}"`, error); + } + }, [value, key]); + + return [value, setValue]; +} From 0a72bd1f790c9834a24cf27fe3fbffe44a1a08d9 Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 6 Aug 2025 22:32:19 +0900 Subject: [PATCH 10/34] feat: add useNotifications --- src/basic/App.tsx | 24 +++--------------- src/basic/hooks/index.ts | 1 + src/basic/hooks/use-notifications/index.ts | 29 ++++++++++++++++++++++ 3 files changed, 33 insertions(+), 21 deletions(-) create mode 100644 src/basic/hooks/use-notifications/index.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 56a3f871..d73e1694 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -21,18 +21,12 @@ import { validateCouponApplication, validateCouponCode, } from './utils'; -import { useLocalStorage } from './hooks'; - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} +import { useLocalStorage, useNotifications } from './hooks'; const App = () => { const [isAdmin, setIsAdmin] = useState(false); const [cart, setCart] = useLocalStorage('cart', []); - const [notifications, setNotifications] = useState([]); + const { notifications, addNotification, removeNotification } = useNotifications(); const { coupons, setCoupons, @@ -92,18 +86,6 @@ const App = () => { [cart] ); - 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(() => { @@ -315,7 +297,7 @@ const App = () => { setNotifications((prev) => prev.filter((n) => n.id !== notif.id))} + onClose={() => removeNotification(notif.id)} /> ))}
diff --git a/src/basic/hooks/index.ts b/src/basic/hooks/index.ts index 87c1953d..f8dd2730 100644 --- a/src/basic/hooks/index.ts +++ b/src/basic/hooks/index.ts @@ -1 +1,2 @@ export * from './use-local-storage'; +export * from './use-notifications'; diff --git a/src/basic/hooks/use-notifications/index.ts b/src/basic/hooks/use-notifications/index.ts new file mode 100644 index 00000000..3ecf0709 --- /dev/null +++ b/src/basic/hooks/use-notifications/index.ts @@ -0,0 +1,29 @@ +import { useCallback, useState } from 'react'; + +interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export function useNotifications() { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: 'error' | 'success' | 'warning' = 'success') => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); + + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return { notifications, addNotification, removeNotification }; +} From 53e8c18b29f68a0673e3050ceefad6afa8c9d47a Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 6 Aug 2025 22:51:02 +0900 Subject: [PATCH 11/34] feat: add useCart --- src/basic/App.tsx | 103 +++--------------- src/basic/hooks/index.ts | 1 + src/basic/hooks/use-cart.ts | 93 ++++++++++++++++ .../index.ts => use-local-storage.ts} | 0 .../index.ts => use-notifications.ts} | 0 5 files changed, 110 insertions(+), 87 deletions(-) create mode 100644 src/basic/hooks/use-cart.ts rename src/basic/hooks/{use-local-storage/index.ts => use-local-storage.ts} (100%) rename src/basic/hooks/{use-notifications/index.ts => use-notifications.ts} (100%) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index d73e1694..3f938bed 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -21,12 +21,21 @@ import { validateCouponApplication, validateCouponCode, } from './utils'; -import { useLocalStorage, useNotifications } from './hooks'; +import { useCart, useLocalStorage, useNotifications } from './hooks'; const App = () => { const [isAdmin, setIsAdmin] = useState(false); - const [cart, setCart] = useLocalStorage('cart', []); const { notifications, addNotification, removeNotification } = useNotifications(); + const { + products, + setProducts, + productForm, + editingProduct, + showProductForm, + setShowProductForm, + setEditingProduct, + setProductForm, + } = useProducts(); const { coupons, setCoupons, @@ -37,18 +46,13 @@ const App = () => { couponForm, setCouponForm, } = useCoupons(); + const { cart, addToCart, removeFromCart, updateQuantity, completeOrder, getStock } = useCart({ + products, + addNotification, + setSelectedCoupon, + }); const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - const { - products, - setProducts, - productForm, - editingProduct, - showProductForm, - setShowProductForm, - setEditingProduct, - setProductForm, - } = useProducts(); const formatPrice = useCallback( (price: number, productId?: string): string => { @@ -79,13 +83,6 @@ const App = () => { return calculateCartTotal(cart, selectedCoupon); }, [cart, selectedCoupon]); - const getStock = useCallback( - (product: Product): number => { - return getRemainingStock(product, cart); - }, - [cart] - ); - const [totalItemCount, setTotalItemCount] = useState(0); useEffect(() => { @@ -100,67 +97,6 @@ const App = () => { return () => clearTimeout(timer); }, [searchTerm]); - const addToCart = useCallback( - (product: ProductWithUI) => { - const remainingStock = getStock(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'); - }, - [getStock, addNotification] - ); - - 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] - ); - const applyCoupon = useCallback( (coupon: Coupon) => { const currentTotal = getCartTotal().totalAfterDiscount; @@ -177,13 +113,6 @@ const App = () => { [addNotification, getCartTotal] ); - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - const addProduct = useCallback( (newProduct: Omit) => { const product: ProductWithUI = { diff --git a/src/basic/hooks/index.ts b/src/basic/hooks/index.ts index f8dd2730..f6efe6d9 100644 --- a/src/basic/hooks/index.ts +++ b/src/basic/hooks/index.ts @@ -1,2 +1,3 @@ export * from './use-local-storage'; export * from './use-notifications'; +export * from './use-cart'; diff --git a/src/basic/hooks/use-cart.ts b/src/basic/hooks/use-cart.ts new file mode 100644 index 00000000..09976680 --- /dev/null +++ b/src/basic/hooks/use-cart.ts @@ -0,0 +1,93 @@ +import { CartItem, Coupon, Product } from '@/types'; +import { useLocalStorage } from './use-local-storage'; +import { useCallback, useState } from 'react'; +import { ProductWithUI } from '../constants/mocks'; +import { getRemainingStock, validateCouponApplication } from '../utils'; + +interface UseCartProps { + products: ProductWithUI[]; + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; + setSelectedCoupon: (coupon: Coupon | null) => void; +} + +export function useCart({ products, addNotification, setSelectedCoupon }: UseCartProps) { + const [cart, setCart] = useLocalStorage('cart', []); + const [totalItemCount, setTotalItemCount] = useState(0); + + const getStock = useCallback( + (product: Product): number => { + return getRemainingStock(product, cart); + }, + [cart] + ); + + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getStock(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'); + }, + [getStock, addNotification] + ); + + 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] + ); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + setCart([]); + setSelectedCoupon(null); + }, [addNotification]); + + return { cart, addToCart, removeFromCart, updateQuantity, completeOrder, getStock }; +} diff --git a/src/basic/hooks/use-local-storage/index.ts b/src/basic/hooks/use-local-storage.ts similarity index 100% rename from src/basic/hooks/use-local-storage/index.ts rename to src/basic/hooks/use-local-storage.ts diff --git a/src/basic/hooks/use-notifications/index.ts b/src/basic/hooks/use-notifications.ts similarity index 100% rename from src/basic/hooks/use-notifications/index.ts rename to src/basic/hooks/use-notifications.ts From afab62881646c3d728ff8abb2aec6402d272d5ff Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 6 Aug 2025 23:53:39 +0900 Subject: [PATCH 12/34] feat: add useDebounceValue & useTotalItemCount --- src/basic/App.tsx | 26 ++++++++++--------------- src/basic/hooks/index.ts | 2 ++ src/basic/hooks/use-debounce-value.ts | 12 ++++++++++++ src/basic/hooks/use-total-item-count.ts | 6 ++++++ 4 files changed, 30 insertions(+), 16 deletions(-) create mode 100644 src/basic/hooks/use-debounce-value.ts create mode 100644 src/basic/hooks/use-total-item-count.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 3f938bed..697830f8 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -21,7 +21,13 @@ import { validateCouponApplication, validateCouponCode, } from './utils'; -import { useCart, useLocalStorage, useNotifications } from './hooks'; +import { + useCart, + useDebounceValue, + useLocalStorage, + useNotifications, + useTotalItemCount, +} from './hooks'; const App = () => { const [isAdmin, setIsAdmin] = useState(false); @@ -51,8 +57,10 @@ const App = () => { addNotification, setSelectedCoupon, }); + const totalItemCount = useTotalItemCount(cart); const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + + const debouncedSearchTerm = useDebounceValue(searchTerm, 500); const formatPrice = useCallback( (price: number, productId?: string): string => { @@ -83,20 +91,6 @@ const App = () => { return calculateCartTotal(cart, selectedCoupon); }, [cart, selectedCoupon]); - const [totalItemCount, setTotalItemCount] = useState(0); - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - const applyCoupon = useCallback( (coupon: Coupon) => { const currentTotal = getCartTotal().totalAfterDiscount; diff --git a/src/basic/hooks/index.ts b/src/basic/hooks/index.ts index f6efe6d9..59a82973 100644 --- a/src/basic/hooks/index.ts +++ b/src/basic/hooks/index.ts @@ -1,3 +1,5 @@ export * from './use-local-storage'; export * from './use-notifications'; export * from './use-cart'; +export * from './use-debounce-value'; +export * from './use-total-item-count'; diff --git a/src/basic/hooks/use-debounce-value.ts b/src/basic/hooks/use-debounce-value.ts new file mode 100644 index 00000000..f1d68b12 --- /dev/null +++ b/src/basic/hooks/use-debounce-value.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from 'react'; + +export function useDebounceValue(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/basic/hooks/use-total-item-count.ts b/src/basic/hooks/use-total-item-count.ts new file mode 100644 index 00000000..51511745 --- /dev/null +++ b/src/basic/hooks/use-total-item-count.ts @@ -0,0 +1,6 @@ +import { useMemo } from 'react'; +import { CartItem } from '@/types'; + +export function useTotalItemCount(cart: CartItem[]) { + return useMemo(() => cart.reduce((sum, item) => sum + item.quantity, 0), [cart]); +} From 6608d5bdcd20f68e9a619cdaebef2540766b555f Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 7 Aug 2025 00:34:38 +0900 Subject: [PATCH 13/34] =?UTF-8?q?fix:=20calculateCartTotal=20=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=EC=83=81=ED=95=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 697830f8..2a9ea08d 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -77,23 +77,9 @@ const App = () => { [products, cart, isAdmin] ); - const getItemTotal = useCallback( - (item: CartItem): number => { - return calculateItemTotal(item, cart); - }, - [cart] - ); - - const getCartTotal = useCallback((): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - return calculateCartTotal(cart, selectedCoupon); - }, [cart, selectedCoupon]); - const applyCoupon = useCallback( (coupon: Coupon) => { - const currentTotal = getCartTotal().totalAfterDiscount; + const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; const validation = validateCouponApplication(coupon, currentTotal); if (!validation.isValid) { @@ -104,7 +90,7 @@ const App = () => { setSelectedCoupon(coupon); addNotification('쿠폰이 적용되었습니다.', 'success'); }, - [addNotification, getCartTotal] + [addNotification, cart, selectedCoupon] ); const addProduct = useCallback( @@ -207,7 +193,7 @@ const App = () => { setShowProductForm(true); }; - const totals = getCartTotal(); + const totals = calculateCartTotal(cart, selectedCoupon); const filteredProducts = filterProducts(products, debouncedSearchTerm); @@ -274,7 +260,7 @@ const App = () => { {/* Cart */} calculateItemTotal(item, cart)} removeFromCart={removeFromCart} updateQuantity={updateQuantity} /> From 56687b3452028559905777ad1e1ff464ff5bbaf1 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 7 Aug 2025 00:36:57 +0900 Subject: [PATCH 14/34] =?UTF-8?q?fix:=20=EC=83=81=EC=88=98=20=EC=A0=95?= =?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 | 31 +++++++------------------------ src/basic/constants/forms.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 24 deletions(-) create mode 100644 src/basic/constants/forms.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 2a9ea08d..b9450648 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,5 +1,5 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState, useCallback } from 'react'; +import { Coupon } from '../types'; import { ProductWithUI } from './constants/mocks'; import { AdminDashboard, @@ -16,18 +16,12 @@ import { formatPriceWithStock, calculateItemTotal, calculateCartTotal, - getRemainingStock, filterProducts, validateCouponApplication, validateCouponCode, } from './utils'; -import { - useCart, - useDebounceValue, - useLocalStorage, - useNotifications, - useTotalItemCount, -} from './hooks'; +import { useCart, useDebounceValue, useNotifications, useTotalItemCount } from './hooks'; +import { INITIAL_PRODUCT_FORM, INITIAL_COUPON_FORM, EDITING_STATES } from './constants/forms'; const App = () => { const [isAdmin, setIsAdmin] = useState(false); @@ -149,7 +143,7 @@ const App = () => { const handleProductSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { + if (editingProduct && editingProduct !== EDITING_STATES.NEW) { updateProduct(editingProduct, productForm); setEditingProduct(null); } else { @@ -158,13 +152,7 @@ const App = () => { discounts: productForm.discounts, }); } - setProductForm({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [], - }); + setProductForm(INITIAL_PRODUCT_FORM); setEditingProduct(null); setShowProductForm(false); }; @@ -172,12 +160,7 @@ const App = () => { const handleCouponSubmit = (e: React.FormEvent) => { e.preventDefault(); addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0, - }); + setCouponForm(INITIAL_COUPON_FORM); setShowCouponForm(false); }; diff --git a/src/basic/constants/forms.ts b/src/basic/constants/forms.ts new file mode 100644 index 00000000..986456fe --- /dev/null +++ b/src/basic/constants/forms.ts @@ -0,0 +1,18 @@ +export const INITIAL_PRODUCT_FORM = { + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] as Array<{ quantity: number; rate: number }>, +}; + +export const INITIAL_COUPON_FORM = { + name: '', + code: '', + discountType: 'amount' as const, + discountValue: 0, +}; + +export const EDITING_STATES = { + NEW: 'new', +} as const; From 1de2362d53efcad7798222543f0f8940f8e285f8 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 7 Aug 2025 00:42:21 +0900 Subject: [PATCH 15/34] =?UTF-8?q?feat:=20user-dashboard=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 | 66 ++++++++----------------- src/basic/ui/index.ts | 2 + src/basic/ui/user-dashboard.tsx | 85 +++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 47 deletions(-) create mode 100644 src/basic/ui/user-dashboard.tsx diff --git a/src/basic/App.tsx b/src/basic/App.tsx index b9450648..80910cf2 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,15 +1,7 @@ import { useState, useCallback } from 'react'; import { Coupon } from '../types'; import { ProductWithUI } from './constants/mocks'; -import { - AdminDashboard, - Cart, - Coupons, - Header, - NotificationItem, - Payments, - ProductList, -} from './ui'; +import { AdminDashboard, Header, NotificationItem, UserDashboard } from './ui'; import { useCoupons } from './entities/coupons'; import { useProducts } from './entities/products'; import { @@ -225,44 +217,24 @@ const App = () => { handleCouponSubmit={handleCouponSubmit} /> ) : ( -
-
- {/* 상품 목록 */} - -
- -
-
- {/* Cart */} - calculateItemTotal(item, cart)} - removeFromCart={removeFromCart} - updateQuantity={updateQuantity} - /> - - {cart.length > 0 && ( - <> - - {/* Payment */} - - - )} -
-
-
+ calculateItemTotal(item, cart)} + coupons={coupons} + selectedCoupon={selectedCoupon} + applyCoupon={applyCoupon} + setSelectedCoupon={setSelectedCoupon} + totals={totals} + completeOrder={completeOrder} + /> )}

diff --git a/src/basic/ui/index.ts b/src/basic/ui/index.ts index 7e2eee42..107ef0d0 100644 --- a/src/basic/ui/index.ts +++ b/src/basic/ui/index.ts @@ -1,7 +1,9 @@ export * from './notification-item'; export * from './admin-dashboard'; +export * from './user-dashboard'; export * from './product-list'; export * from './cart'; export * from './coupons'; export * from './payments'; export * from './header'; +export * from './user-dashboard'; diff --git a/src/basic/ui/user-dashboard.tsx b/src/basic/ui/user-dashboard.tsx new file mode 100644 index 00000000..a59e0434 --- /dev/null +++ b/src/basic/ui/user-dashboard.tsx @@ -0,0 +1,85 @@ +import { CartItem, Coupon, Product } from '@/types'; +import { ProductWithUI } from '../constants/mocks'; +import { Cart, Coupons, Payments, ProductList } from './index'; + +interface UserDashboardProps { + // 상품 + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + formatPrice: (price: number, productId?: string) => string; + + // 장바구니 + cart: CartItem[]; + addToCart: (product: ProductWithUI) => void; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + getStock: (product: Product) => number; + calculateItemTotal: (item: CartItem) => number; + + // 쿠폰 & 결제 + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; + totals: { totalBeforeDiscount: number; totalAfterDiscount: number }; + completeOrder: () => void; +} + +export function UserDashboard({ + products, + filteredProducts, + debouncedSearchTerm, + formatPrice, + cart, + addToCart, + removeFromCart, + updateQuantity, + getStock, + calculateItemTotal, + coupons, + selectedCoupon, + applyCoupon, + setSelectedCoupon, + totals, + completeOrder, +}: UserDashboardProps) { + return ( +
+
+ +
+ +
+
+ + + {cart.length > 0 && ( + <> + + {/* Payment */} + + + )} +
+
+
+ ); +} From 797596d7458d1daa1e4220d9e03d4196a13ecbb4 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 7 Aug 2025 22:50:05 +0900 Subject: [PATCH 16/34] =?UTF-8?q?feat:=20product,=20coupon=20=ED=9B=85=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 66 ++----------------- .../entities/coupons/hooks/useCoupons.ts | 36 +++++++++- .../entities/products/hooks/useProducts.ts | 42 +++++++++++- 3 files changed, 81 insertions(+), 63 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 80910cf2..ddac75ba 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -10,7 +10,6 @@ import { calculateCartTotal, filterProducts, validateCouponApplication, - validateCouponCode, } from './utils'; import { useCart, useDebounceValue, useNotifications, useTotalItemCount } from './hooks'; import { INITIAL_PRODUCT_FORM, INITIAL_COUPON_FORM, EDITING_STATES } from './constants/forms'; @@ -20,24 +19,27 @@ const App = () => { const { notifications, addNotification, removeNotification } = useNotifications(); const { products, - setProducts, productForm, editingProduct, showProductForm, setShowProductForm, setEditingProduct, setProductForm, - } = useProducts(); + addProduct, + updateProduct, + deleteProduct, + } = useProducts({ addNotification }); const { coupons, - setCoupons, selectedCoupon, setSelectedCoupon, showCouponForm, setShowCouponForm, couponForm, setCouponForm, - } = useCoupons(); + addCoupon, + deleteCoupon, + } = useCoupons({ addNotification }); const { cart, addToCart, removeFromCart, updateQuantity, completeOrder, getStock } = useCart({ products, addNotification, @@ -79,60 +81,6 @@ const App = () => { [addNotification, cart, selectedCoupon] ); - 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 validation = validateCouponCode(newCoupon.code, coupons); - if (!validation.isValid) { - addNotification(validation.errorMessage!, '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 !== EDITING_STATES.NEW) { diff --git a/src/basic/entities/coupons/hooks/useCoupons.ts b/src/basic/entities/coupons/hooks/useCoupons.ts index 8bce6c89..c926f5f6 100644 --- a/src/basic/entities/coupons/hooks/useCoupons.ts +++ b/src/basic/entities/coupons/hooks/useCoupons.ts @@ -1,9 +1,14 @@ import { initialCoupons } from '@/basic/constants/mocks'; import { useLocalStorage } from '@/basic/hooks'; import { Coupon } from '@/types'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; +import { validateCouponCode } from '@/basic/utils'; -export function useCoupons() { +interface UseCouponsProps { + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; +} + +export function useCoupons({ addNotification }: UseCouponsProps) { const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); const [selectedCoupon, setSelectedCoupon] = useState(null); const [showCouponForm, setShowCouponForm] = useState(false); @@ -14,6 +19,30 @@ export function useCoupons() { discountValue: 0, }); + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const validation = validateCouponCode(newCoupon.code, coupons); + if (!validation.isValid) { + addNotification(validation.errorMessage!, 'error'); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + addNotification('쿠폰이 추가되었습니다.', 'success'); + }, + [coupons, addNotification, setCoupons] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }, + [selectedCoupon, addNotification, setCoupons] + ); + return { coupons, selectedCoupon, @@ -24,5 +53,8 @@ export function useCoupons() { setSelectedCoupon, setShowCouponForm, setCouponForm, + + addCoupon, + deleteCoupon, }; } diff --git a/src/basic/entities/products/hooks/useProducts.ts b/src/basic/entities/products/hooks/useProducts.ts index dfc48e2b..8c946b75 100644 --- a/src/basic/entities/products/hooks/useProducts.ts +++ b/src/basic/entities/products/hooks/useProducts.ts @@ -1,8 +1,12 @@ import { initialProducts, ProductWithUI } from '@/basic/constants/mocks'; import { useLocalStorage } from '@/basic/hooks'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; -export function useProducts() { +interface UseProductsProps { + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; +} + +export function useProducts({ addNotification }: UseProductsProps) { const [products, setProducts] = useLocalStorage('products', initialProducts); const [editingProduct, setEditingProduct] = useState(null); const [showProductForm, setShowProductForm] = useState(false); @@ -13,6 +17,37 @@ export function useProducts() { description: '', discounts: [] as Array<{ quantity: number; rate: number }>, }); + + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [addNotification, setProducts] + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + addNotification('상품이 수정되었습니다.', 'success'); + }, + [addNotification, setProducts] + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification('상품이 삭제되었습니다.', 'success'); + }, + [addNotification, setProducts] + ); + return { products, setProducts, @@ -22,5 +57,8 @@ export function useProducts() { setShowProductForm, productForm, setProductForm, + addProduct, + updateProduct, + deleteProduct, }; } From 34143737cc08ad87a38ae999039050476c10dc61 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 7 Aug 2025 23:17:56 +0900 Subject: [PATCH 17/34] =?UTF-8?q?feat:=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=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 | 60 ++++--------------- .../entities/coupons/hooks/useCoupons.ts | 25 ++++---- .../entities/products/hooks/useProducts.ts | 44 +++++++++++--- 3 files changed, 61 insertions(+), 68 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index ddac75ba..93f6c872 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,6 +1,5 @@ -import { useState, useCallback } from 'react'; -import { Coupon } from '../types'; -import { ProductWithUI } from './constants/mocks'; +import { useState, useCallback, useMemo } from 'react'; +import { Coupon } from '@/types'; import { AdminDashboard, Header, NotificationItem, UserDashboard } from './ui'; import { useCoupons } from './entities/coupons'; import { useProducts } from './entities/products'; @@ -12,7 +11,6 @@ import { validateCouponApplication, } from './utils'; import { useCart, useDebounceValue, useNotifications, useTotalItemCount } from './hooks'; -import { INITIAL_PRODUCT_FORM, INITIAL_COUPON_FORM, EDITING_STATES } from './constants/forms'; const App = () => { const [isAdmin, setIsAdmin] = useState(false); @@ -20,25 +18,23 @@ const App = () => { const { products, productForm, - editingProduct, showProductForm, setShowProductForm, - setEditingProduct, setProductForm, - addProduct, - updateProduct, deleteProduct, + startEditProduct, + handleProductSubmit, } = useProducts({ addNotification }); const { coupons, selectedCoupon, - setSelectedCoupon, + couponForm, showCouponForm, + setSelectedCoupon, setShowCouponForm, - couponForm, setCouponForm, - addCoupon, deleteCoupon, + handleCouponSubmit, } = useCoupons({ addNotification }); const { cart, addToCart, removeFromCart, updateQuantity, completeOrder, getStock } = useCart({ products, @@ -81,44 +77,12 @@ const App = () => { [addNotification, cart, selectedCoupon] ); - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== EDITING_STATES.NEW) { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts, - }); - } - setProductForm(INITIAL_PRODUCT_FORM); - setEditingProduct(null); - setShowProductForm(false); - }; + const totals = useMemo(() => calculateCartTotal(cart, selectedCoupon), [cart, selectedCoupon]); - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm(INITIAL_COUPON_FORM); - 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(cart, selectedCoupon); - - const filteredProducts = filterProducts(products, debouncedSearchTerm); + const filteredProducts = useMemo( + () => filterProducts(products, debouncedSearchTerm), + [products, debouncedSearchTerm] + ); return (
diff --git a/src/basic/entities/coupons/hooks/useCoupons.ts b/src/basic/entities/coupons/hooks/useCoupons.ts index c926f5f6..d0238367 100644 --- a/src/basic/entities/coupons/hooks/useCoupons.ts +++ b/src/basic/entities/coupons/hooks/useCoupons.ts @@ -3,6 +3,7 @@ import { useLocalStorage } from '@/basic/hooks'; import { Coupon } from '@/types'; import { useCallback, useState } from 'react'; import { validateCouponCode } from '@/basic/utils'; +import { INITIAL_COUPON_FORM } from '@/basic/constants/forms'; interface UseCouponsProps { addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; @@ -12,12 +13,7 @@ export function useCoupons({ addNotification }: UseCouponsProps) { const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); const [selectedCoupon, setSelectedCoupon] = useState(null); const [showCouponForm, setShowCouponForm] = useState(false); - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0, - }); + const [couponForm, setCouponForm] = useState(INITIAL_COUPON_FORM); const addCoupon = useCallback( (newCoupon: Coupon) => { @@ -43,18 +39,25 @@ export function useCoupons({ addNotification }: UseCouponsProps) { [selectedCoupon, addNotification, setCoupons] ); + const handleCouponSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm(INITIAL_COUPON_FORM); + setShowCouponForm(false); + }, + [couponForm, addCoupon] + ); + return { coupons, selectedCoupon, couponForm, showCouponForm, - - setCoupons, - setSelectedCoupon, setShowCouponForm, setCouponForm, - - addCoupon, + setSelectedCoupon, deleteCoupon, + handleCouponSubmit, }; } diff --git a/src/basic/entities/products/hooks/useProducts.ts b/src/basic/entities/products/hooks/useProducts.ts index 8c946b75..a60f1b66 100644 --- a/src/basic/entities/products/hooks/useProducts.ts +++ b/src/basic/entities/products/hooks/useProducts.ts @@ -1,6 +1,7 @@ import { initialProducts, ProductWithUI } from '@/basic/constants/mocks'; import { useLocalStorage } from '@/basic/hooks'; import { useCallback, useState } from 'react'; +import { INITIAL_PRODUCT_FORM, EDITING_STATES } from '@/basic/constants/forms'; interface UseProductsProps { addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; @@ -10,13 +11,7 @@ export function useProducts({ addNotification }: UseProductsProps) { const [products, setProducts] = useLocalStorage('products', initialProducts); const [editingProduct, setEditingProduct] = useState(null); const [showProductForm, setShowProductForm] = useState(false); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }>, - }); + const [productForm, setProductForm] = useState(INITIAL_PRODUCT_FORM); const addProduct = useCallback( (newProduct: Omit) => { @@ -48,11 +43,39 @@ export function useProducts({ addNotification }: UseProductsProps) { [addNotification, setProducts] ); + const startEditProduct = useCallback((product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], + }); + setShowProductForm(true); + }, []); + + const handleProductSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + if (editingProduct && editingProduct !== EDITING_STATES.NEW) { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProduct(productForm); + } + + setProductForm(INITIAL_PRODUCT_FORM); + setEditingProduct(null); + setShowProductForm(false); + }, + [editingProduct, productForm, updateProduct, addProduct] + ); + return { products, setProducts, - editingProduct, - setEditingProduct, showProductForm, setShowProductForm, productForm, @@ -60,5 +83,8 @@ export function useProducts({ addNotification }: UseProductsProps) { addProduct, updateProduct, deleteProduct, + + startEditProduct, + handleProductSubmit, }; } From 72314259118d2543c8429836488b87f60871499f Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 7 Aug 2025 23:55:48 +0900 Subject: [PATCH 18/34] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20format=20=EC=B6=94=EC=83=81=ED=99=94=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 43 +++---------------- .../entities/coupons/hooks/useCoupons.ts | 21 ++++++++- src/basic/ui/admin-dashboard.tsx | 8 ++-- src/basic/ui/product-list.tsx | 9 ++-- src/basic/ui/user-dashboard.tsx | 6 +-- src/basic/utils/format.ts | 22 ---------- 6 files changed, 38 insertions(+), 71 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 93f6c872..0a1060b0 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -3,13 +3,7 @@ import { Coupon } from '@/types'; import { AdminDashboard, Header, NotificationItem, UserDashboard } from './ui'; import { useCoupons } from './entities/coupons'; import { useProducts } from './entities/products'; -import { - formatPriceWithStock, - calculateItemTotal, - calculateCartTotal, - filterProducts, - validateCouponApplication, -} from './utils'; +import { calculateItemTotal, calculateCartTotal, filterProducts } from './utils'; import { useCart, useDebounceValue, useNotifications, useTotalItemCount } from './hooks'; const App = () => { @@ -35,6 +29,7 @@ const App = () => { setCouponForm, deleteCoupon, handleCouponSubmit, + applyCoupon: applyCouponFromHook, } = useCoupons({ addNotification }); const { cart, addToCart, removeFromCart, updateQuantity, completeOrder, getStock } = useCart({ products, @@ -46,35 +41,9 @@ const App = () => { const debouncedSearchTerm = useDebounceValue(searchTerm, 500); - const formatPrice = useCallback( - (price: number, productId?: string): string => { - if (productId) { - return formatPriceWithStock(price, productId, products, cart, isAdmin); - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }, - [products, cart, isAdmin] - ); - const applyCoupon = useCallback( - (coupon: Coupon) => { - const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; - const validation = validateCouponApplication(coupon, currentTotal); - - if (!validation.isValid) { - addNotification(validation.errorMessage!, 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, - [addNotification, cart, selectedCoupon] + (coupon: Coupon) => applyCouponFromHook(coupon, cart), + [applyCouponFromHook, cart] ); const totals = useMemo(() => calculateCartTotal(cart, selectedCoupon), [cart, selectedCoupon]); @@ -112,7 +81,7 @@ const App = () => { { products={products} filteredProducts={filteredProducts} debouncedSearchTerm={debouncedSearchTerm} - formatPrice={formatPrice} + isAdmin={isAdmin} cart={cart} addToCart={addToCart} removeFromCart={removeFromCart} diff --git a/src/basic/entities/coupons/hooks/useCoupons.ts b/src/basic/entities/coupons/hooks/useCoupons.ts index d0238367..022f2728 100644 --- a/src/basic/entities/coupons/hooks/useCoupons.ts +++ b/src/basic/entities/coupons/hooks/useCoupons.ts @@ -1,8 +1,8 @@ import { initialCoupons } from '@/basic/constants/mocks'; import { useLocalStorage } from '@/basic/hooks'; -import { Coupon } from '@/types'; +import { Coupon, CartItem } from '@/types'; import { useCallback, useState } from 'react'; -import { validateCouponCode } from '@/basic/utils'; +import { validateCouponCode, calculateCartTotal, validateCouponApplication } from '@/basic/utils'; import { INITIAL_COUPON_FORM } from '@/basic/constants/forms'; interface UseCouponsProps { @@ -49,6 +49,22 @@ export function useCoupons({ addNotification }: UseCouponsProps) { [couponForm, addCoupon] ); + const applyCoupon = useCallback( + (coupon: Coupon, cart: CartItem[]) => { + const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; + const validation = validateCouponApplication(coupon, currentTotal); + + if (!validation.isValid) { + addNotification(validation.errorMessage!, 'error'); + return; + } + + setSelectedCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, + [addNotification, selectedCoupon] + ); + return { coupons, selectedCoupon, @@ -59,5 +75,6 @@ export function useCoupons({ addNotification }: UseCouponsProps) { setSelectedCoupon, deleteCoupon, handleCouponSubmit, + applyCoupon, }; } diff --git a/src/basic/ui/admin-dashboard.tsx b/src/basic/ui/admin-dashboard.tsx index 38cea13e..09fc3a1a 100644 --- a/src/basic/ui/admin-dashboard.tsx +++ b/src/basic/ui/admin-dashboard.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { ProductWithUI } from '../constants/mocks'; import { Coupon } from '../../types'; import { CloseIcon, DeleteIcon, PlusIcon } from './icons'; +import { formatPrice } from '../utils'; interface AdminDashboardProps { products: ProductWithUI[]; @@ -10,8 +11,7 @@ interface AdminDashboardProps { showProductForm: boolean; couponForm: Coupon; showCouponForm: boolean; - - formatPrice: (price: number, productId?: string) => string; + isAdmin: boolean; setProductForm: (product: any) => void; setShowProductForm: (show: boolean) => void; setCouponForm: (coupon: Coupon) => void; @@ -28,7 +28,7 @@ interface AdminDashboardProps { export function AdminDashboard({ products, coupons, - formatPrice, + isAdmin, productForm, showProductForm, couponForm, @@ -129,7 +129,7 @@ export function AdminDashboard({ {product.name} - {formatPrice(product.price, product.id)} + {formatPrice(product.price, { isAdmin })} number; - formatPrice: (price: number, productId?: string) => string; + isAdmin: boolean; addToCart: (product: ProductWithUI) => void; } @@ -15,7 +16,7 @@ export function ProductList({ filteredProducts, debouncedSearchTerm, getRemainingStock, - formatPrice, + isAdmin, addToCart, }: ProductListProps) { return ( @@ -65,7 +66,9 @@ export function ProductList({ {/* 가격 정보 */}

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

{product.discounts.length > 0 && (

diff --git a/src/basic/ui/user-dashboard.tsx b/src/basic/ui/user-dashboard.tsx index a59e0434..565f5b77 100644 --- a/src/basic/ui/user-dashboard.tsx +++ b/src/basic/ui/user-dashboard.tsx @@ -7,7 +7,7 @@ interface UserDashboardProps { products: ProductWithUI[]; filteredProducts: ProductWithUI[]; debouncedSearchTerm: string; - formatPrice: (price: number, productId?: string) => string; + isAdmin: boolean; // 장바구니 cart: CartItem[]; @@ -30,7 +30,7 @@ export function UserDashboard({ products, filteredProducts, debouncedSearchTerm, - formatPrice, + isAdmin, cart, addToCart, removeFromCart, @@ -52,7 +52,7 @@ export function UserDashboard({ filteredProducts={filteredProducts} debouncedSearchTerm={debouncedSearchTerm} getRemainingStock={getStock} - formatPrice={formatPrice} + isAdmin={isAdmin} addToCart={addToCart} />

diff --git a/src/basic/utils/format.ts b/src/basic/utils/format.ts index 3e13b023..5d6da778 100644 --- a/src/basic/utils/format.ts +++ b/src/basic/utils/format.ts @@ -1,5 +1,3 @@ -import { Product, CartItem } from '../../types'; - const SOLD_OUT_MESSAGE = 'SOLD OUT'; interface PriceFormatOptions { @@ -30,23 +28,3 @@ export const formatPrice = ( return price.toLocaleString(); }; - -export const formatPriceWithStock = ( - price: number, - productId: string, - products: Product[], - cart: CartItem[], - isAdmin: boolean = false -): string => { - const product = products.find((p) => p.id === productId); - if (product) { - const cartItem = cart.find((item) => item.product.id === product.id); - const remainingStock = product.stock - (cartItem?.quantity || 0); - - if (remainingStock <= 0) { - return SOLD_OUT_MESSAGE; - } - } - - return formatPrice(price, { isAdmin, showSymbol: !isAdmin }); -}; From a657465a302f0eb56909caa60acab55dc32ff2f8 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 8 Aug 2025 00:04:51 +0900 Subject: [PATCH 19/34] =?UTF-8?q?fix:=20calculateItemTotal=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 13 +++---------- src/basic/ui/cart.tsx | 6 +++--- src/basic/ui/user-dashboard.tsx | 3 --- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 0a1060b0..f1a26930 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,9 +1,8 @@ -import { useState, useCallback, useMemo } from 'react'; -import { Coupon } from '@/types'; +import { useState, useMemo } from 'react'; import { AdminDashboard, Header, NotificationItem, UserDashboard } from './ui'; import { useCoupons } from './entities/coupons'; import { useProducts } from './entities/products'; -import { calculateItemTotal, calculateCartTotal, filterProducts } from './utils'; +import { calculateCartTotal, filterProducts } from './utils'; import { useCart, useDebounceValue, useNotifications, useTotalItemCount } from './hooks'; const App = () => { @@ -41,11 +40,6 @@ const App = () => { const debouncedSearchTerm = useDebounceValue(searchTerm, 500); - const applyCoupon = useCallback( - (coupon: Coupon) => applyCouponFromHook(coupon, cart), - [applyCouponFromHook, cart] - ); - const totals = useMemo(() => calculateCartTotal(cart, selectedCoupon), [cart, selectedCoupon]); const filteredProducts = useMemo( @@ -108,10 +102,9 @@ const App = () => { removeFromCart={removeFromCart} updateQuantity={updateQuantity} getStock={getStock} - calculateItemTotal={(item) => calculateItemTotal(item, cart)} coupons={coupons} selectedCoupon={selectedCoupon} - applyCoupon={applyCoupon} + applyCoupon={(coupon) => applyCouponFromHook(coupon, cart)} setSelectedCoupon={setSelectedCoupon} totals={totals} completeOrder={completeOrder} diff --git a/src/basic/ui/cart.tsx b/src/basic/ui/cart.tsx index 10d0f72b..87184306 100644 --- a/src/basic/ui/cart.tsx +++ b/src/basic/ui/cart.tsx @@ -1,14 +1,14 @@ import { CartItem } from '../../types'; import { CloseIcon, EmptyBagIcon, ShoppingBagIcon } from './icons'; +import { calculateItemTotal } from '../utils'; interface CartProps { cart: CartItem[]; - calculateItemTotal: (item: CartItem) => number; removeFromCart: (productId: string) => void; updateQuantity: (productId: string, quantity: number) => void; } -export function Cart({ cart, calculateItemTotal, removeFromCart, updateQuantity }: CartProps) { +export function Cart({ cart, removeFromCart, updateQuantity }: CartProps) { return (

@@ -23,7 +23,7 @@ export function Cart({ cart, calculateItemTotal, removeFromCart, updateQuantity ) : (
{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 diff --git a/src/basic/ui/user-dashboard.tsx b/src/basic/ui/user-dashboard.tsx index 565f5b77..64e9235b 100644 --- a/src/basic/ui/user-dashboard.tsx +++ b/src/basic/ui/user-dashboard.tsx @@ -15,7 +15,6 @@ interface UserDashboardProps { removeFromCart: (productId: string) => void; updateQuantity: (productId: string, newQuantity: number) => void; getStock: (product: Product) => number; - calculateItemTotal: (item: CartItem) => number; // 쿠폰 & 결제 coupons: Coupon[]; @@ -36,7 +35,6 @@ export function UserDashboard({ removeFromCart, updateQuantity, getStock, - calculateItemTotal, coupons, selectedCoupon, applyCoupon, @@ -61,7 +59,6 @@ export function UserDashboard({
From a4f212608a74ef6211a2b2a1696cd1c9c30726a5 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 8 Aug 2025 00:46:40 +0900 Subject: [PATCH 20/34] =?UTF-8?q?feat:=20model=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 16 ++- .../entities/coupons/hooks/useCoupons.ts | 13 +- src/basic/hooks/use-cart.ts | 8 +- src/basic/models/cart.ts | 115 ++++++++++++++++++ src/basic/models/coupon.ts | 74 +++++++++++ src/basic/models/product.ts | 60 +++++++++ src/basic/ui/cart.tsx | 6 +- 7 files changed, 276 insertions(+), 16 deletions(-) create mode 100644 src/basic/models/cart.ts create mode 100644 src/basic/models/coupon.ts create mode 100644 src/basic/models/product.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index f1a26930..8ba2ffab 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -2,7 +2,8 @@ import { useState, useMemo } from 'react'; import { AdminDashboard, Header, NotificationItem, UserDashboard } from './ui'; import { useCoupons } from './entities/coupons'; import { useProducts } from './entities/products'; -import { calculateCartTotal, filterProducts } from './utils'; +import { CartModel } from './models/cart'; +import { ProductModel } from './models/product'; import { useCart, useDebounceValue, useNotifications, useTotalItemCount } from './hooks'; const App = () => { @@ -40,12 +41,15 @@ const App = () => { const debouncedSearchTerm = useDebounceValue(searchTerm, 500); - const totals = useMemo(() => calculateCartTotal(cart, selectedCoupon), [cart, selectedCoupon]); + const totals = useMemo(() => { + const cartModel = new CartModel(cart); + return cartModel.calculateTotal(selectedCoupon || undefined); + }, [cart, selectedCoupon]); - const filteredProducts = useMemo( - () => filterProducts(products, debouncedSearchTerm), - [products, debouncedSearchTerm] - ); + const filteredProducts = useMemo(() => { + const productModel = new ProductModel(products); + return productModel.filter(debouncedSearchTerm); + }, [products, debouncedSearchTerm]); return (
diff --git a/src/basic/entities/coupons/hooks/useCoupons.ts b/src/basic/entities/coupons/hooks/useCoupons.ts index 022f2728..17534777 100644 --- a/src/basic/entities/coupons/hooks/useCoupons.ts +++ b/src/basic/entities/coupons/hooks/useCoupons.ts @@ -2,7 +2,8 @@ import { initialCoupons } from '@/basic/constants/mocks'; import { useLocalStorage } from '@/basic/hooks'; import { Coupon, CartItem } from '@/types'; import { useCallback, useState } from 'react'; -import { validateCouponCode, calculateCartTotal, validateCouponApplication } from '@/basic/utils'; +import { CouponModel } from '@/basic/models/coupon'; +import { CartModel } from '@/basic/models/cart'; import { INITIAL_COUPON_FORM } from '@/basic/constants/forms'; interface UseCouponsProps { @@ -17,7 +18,8 @@ export function useCoupons({ addNotification }: UseCouponsProps) { const addCoupon = useCallback( (newCoupon: Coupon) => { - const validation = validateCouponCode(newCoupon.code, coupons); + const couponModel = new CouponModel(coupons); + const validation = couponModel.validateCouponCode(newCoupon.code); if (!validation.isValid) { addNotification(validation.errorMessage!, 'error'); return; @@ -51,8 +53,11 @@ export function useCoupons({ addNotification }: UseCouponsProps) { const applyCoupon = useCallback( (coupon: Coupon, cart: CartItem[]) => { - const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; - const validation = validateCouponApplication(coupon, currentTotal); + const cartModel = new CartModel(cart); + const couponModel = new CouponModel(); + + const currentTotal = cartModel.calculateTotal(selectedCoupon || undefined).totalAfterDiscount; + const validation = couponModel.validateCouponApplication(coupon, currentTotal); if (!validation.isValid) { addNotification(validation.errorMessage!, 'error'); diff --git a/src/basic/hooks/use-cart.ts b/src/basic/hooks/use-cart.ts index 09976680..9106239a 100644 --- a/src/basic/hooks/use-cart.ts +++ b/src/basic/hooks/use-cart.ts @@ -1,8 +1,8 @@ import { CartItem, Coupon, Product } from '@/types'; import { useLocalStorage } from './use-local-storage'; -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { ProductWithUI } from '../constants/mocks'; -import { getRemainingStock, validateCouponApplication } from '../utils'; +import { CartModel } from '../models/cart'; interface UseCartProps { products: ProductWithUI[]; @@ -12,11 +12,11 @@ interface UseCartProps { export function useCart({ products, addNotification, setSelectedCoupon }: UseCartProps) { const [cart, setCart] = useLocalStorage('cart', []); - const [totalItemCount, setTotalItemCount] = useState(0); const getStock = useCallback( (product: Product): number => { - return getRemainingStock(product, cart); + const cartModel = new CartModel(cart); + return cartModel.getRemainingStock(product); }, [cart] ); diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts new file mode 100644 index 00000000..34279ecd --- /dev/null +++ b/src/basic/models/cart.ts @@ -0,0 +1,115 @@ +import { CartItem, Product, Coupon } from '@/types'; + +export class CartModel { + constructor(private items: CartItem[] = []) {} + + get cartItems(): CartItem[] { + return [...this.items]; + } + + get itemCount(): number { + return this.items.reduce((count, item) => count + item.quantity, 0); + } + + calculateItemTotal(item: CartItem): number { + const { price } = item.product; + const { quantity } = item; + const discount = this.getMaxApplicableDiscount(item); + + return Math.round(price * quantity * (1 - discount)); + } + + private 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 = this.items.some((cartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); + } + + return baseDiscount; + } + + calculateTotal(coupon?: Coupon) { + const totalBeforeDiscount = this.items.reduce((total, item) => { + return total + item.product.price * item.quantity; + }, 0); + + const totalAfterItemDiscounts = this.items.reduce((total, item) => { + return total + this.calculateItemTotal(item); + }, 0); + + let totalAfterDiscount = totalAfterItemDiscounts; + + if (coupon) { + if (coupon.discountType === 'percentage') { + totalAfterDiscount = totalAfterItemDiscounts * (1 - coupon.discountValue / 100); + } else if (coupon.discountType === 'amount') { + totalAfterDiscount = totalAfterItemDiscounts - coupon.discountValue; + } + totalAfterDiscount = Math.max(0, totalAfterDiscount); + } + + const totalDiscount = totalBeforeDiscount - totalAfterDiscount; + + return { + totalBeforeDiscount, + totalAfterDiscount, + totalDiscount, + }; + } + + addItem(product: Product): CartModel { + const existingItemIndex = this.items.findIndex((item) => item.product.id === product.id); + + let newItems; + if (existingItemIndex >= 0) { + newItems = [...this.items]; + newItems[existingItemIndex] = { + ...newItems[existingItemIndex], + quantity: newItems[existingItemIndex].quantity + 1, + }; + } else { + newItems = [...this.items, { product, quantity: 1 }]; + } + + return new CartModel(newItems); + } + + updateItemQuantity(productId: string, quantity: number): CartModel { + if (quantity <= 0) { + return this.removeItem(productId); + } + + const newItems = this.items.map((item) => + item.product.id === productId ? { ...item, quantity } : item + ); + + return new CartModel(newItems); + } + + removeItem(productId: string): CartModel { + const newItems = this.items.filter((item) => item.product.id !== productId); + return new CartModel(newItems); + } + + getRemainingStock(product: Product): number { + const cartItem = this.items.find((item) => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); + } + + clear(): CartModel { + return new CartModel([]); + } + + hasProduct(productId: string): boolean { + return this.items.some((item) => item.product.id === productId); + } +} diff --git a/src/basic/models/coupon.ts b/src/basic/models/coupon.ts new file mode 100644 index 00000000..dd564401 --- /dev/null +++ b/src/basic/models/coupon.ts @@ -0,0 +1,74 @@ +import { Coupon } from '@/types'; + +export class CouponModel { + constructor(private coupons: Coupon[] = []) {} + + get couponList(): Coupon[] { + return [...this.coupons]; + } + + get count(): number { + return this.coupons.length; + } + + validateCouponCode(code: string) { + if (!code.trim()) { + return { + isValid: false, + errorMessage: '쿠폰 코드를 입력해주세요.', + }; + } + + const isDuplicate = this.coupons.some((coupon) => coupon.code === code); + if (isDuplicate) { + return { + isValid: false, + errorMessage: '이미 존재하는 쿠폰 코드입니다.', + }; + } + + return { isValid: true }; + } + + validateCouponApplication(coupon: Coupon, currentTotal: number) { + const minimumAmount = 10000; + if (currentTotal < minimumAmount) { + return { + isValid: false, + errorMessage: `${minimumAmount.toLocaleString()}원 이상 구매 시 쿠폰을 사용할 수 있습니다.`, + }; + } + + if (coupon.discountType === 'amount' && coupon.discountValue >= currentTotal) { + return { + isValid: false, + errorMessage: '쿠폰 할인 금액이 주문 금액보다 클 수 없습니다.', + }; + } + + return { isValid: true }; + } + + add(newCoupon: Coupon): CouponModel { + return new CouponModel([...this.coupons, newCoupon]); + } + + remove(couponCode: string): CouponModel { + const newCoupons = this.coupons.filter((coupon) => coupon.code !== couponCode); + return new CouponModel(newCoupons); + } + + findByCode(code: string): Coupon | undefined { + return this.coupons.find((coupon) => coupon.code === code); + } + + findByDiscountType(type: 'amount' | 'percentage'): Coupon[] { + return this.coupons.filter((coupon) => coupon.discountType === type); + } + + getApplicableCoupons(currentTotal: number): Coupon[] { + return this.coupons.filter( + (coupon) => this.validateCouponApplication(coupon, currentTotal).isValid + ); + } +} diff --git a/src/basic/models/product.ts b/src/basic/models/product.ts new file mode 100644 index 00000000..30f07190 --- /dev/null +++ b/src/basic/models/product.ts @@ -0,0 +1,60 @@ +import { ProductWithUI } from '../constants/mocks'; + +export class ProductModel { + constructor(private products: ProductWithUI[] = []) {} + + get productList(): ProductWithUI[] { + return [...this.products]; + } + + get count(): number { + return this.products.length; + } + + filter(searchTerm: string): ProductWithUI[] { + if (!searchTerm.trim()) { + return this.products; + } + + const lowercaseSearch = searchTerm.toLowerCase().trim(); + return this.products.filter( + (product) => + product.name.toLowerCase().includes(lowercaseSearch) || + product.description?.toLowerCase().includes(lowercaseSearch) + ); + } + + add(newProduct: Omit): ProductModel { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + return new ProductModel([...this.products, product]); + } + + update(productId: string, updates: Partial): ProductModel { + const newProducts = this.products.map((product) => + product.id === productId ? { ...product, ...updates } : product + ); + return new ProductModel(newProducts); + } + + remove(productId: string): ProductModel { + const newProducts = this.products.filter((product) => product.id !== productId); + return new ProductModel(newProducts); + } + + findById(productId: string): ProductWithUI | undefined { + return this.products.find((product) => product.id === productId); + } + + getAvailableProducts(): ProductWithUI[] { + return this.products.filter((product) => product.stock > 0); + } + + findByPriceRange(minPrice: number, maxPrice: number): ProductWithUI[] { + return this.products.filter( + (product) => product.price >= minPrice && product.price <= maxPrice + ); + } +} diff --git a/src/basic/ui/cart.tsx b/src/basic/ui/cart.tsx index 87184306..85253677 100644 --- a/src/basic/ui/cart.tsx +++ b/src/basic/ui/cart.tsx @@ -1,6 +1,6 @@ import { CartItem } from '../../types'; import { CloseIcon, EmptyBagIcon, ShoppingBagIcon } from './icons'; -import { calculateItemTotal } from '../utils'; +import { CartModel } from '../models/cart'; interface CartProps { cart: CartItem[]; @@ -9,6 +9,8 @@ interface CartProps { } export function Cart({ cart, removeFromCart, updateQuantity }: CartProps) { + const cartModel = new CartModel(cart); + return (

@@ -23,7 +25,7 @@ export function Cart({ cart, removeFromCart, updateQuantity }: CartProps) { ) : (
{cart.map((item) => { - const itemTotal = calculateItemTotal(item, cart); + const itemTotal = cartModel.calculateItemTotal(item); const originalPrice = item.product.price * item.quantity; const hasDiscount = itemTotal < originalPrice; const discountRate = hasDiscount From ace3a25176f71af5ad7ced12472f6aa435c5c023 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 8 Aug 2025 00:51:52 +0900 Subject: [PATCH 21/34] =?UTF-8?q?fix:=20pages=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 4 ++-- src/basic/{ui => pages}/admin-dashboard.tsx | 2 +- src/basic/pages/index.ts | 2 ++ src/basic/{ui => pages}/user-dashboard.tsx | 8 ++------ src/basic/ui/index.ts | 3 --- 5 files changed, 7 insertions(+), 12 deletions(-) rename src/basic/{ui => pages}/admin-dashboard.tsx (99%) create mode 100644 src/basic/pages/index.ts rename src/basic/{ui => pages}/user-dashboard.tsx (90%) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 8ba2ffab..a2cf5e1f 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,10 +1,11 @@ import { useState, useMemo } from 'react'; -import { AdminDashboard, Header, NotificationItem, UserDashboard } from './ui'; +import { Header, NotificationItem } from './ui'; import { useCoupons } from './entities/coupons'; import { useProducts } from './entities/products'; import { CartModel } from './models/cart'; import { ProductModel } from './models/product'; import { useCart, useDebounceValue, useNotifications, useTotalItemCount } from './hooks'; +import { AdminDashboard, UserDashboard } from './pages'; const App = () => { const [isAdmin, setIsAdmin] = useState(false); @@ -75,7 +76,6 @@ const App = () => { />
{isAdmin ? ( - // Admin Dashboard
- + {cart.length > 0 && ( <> diff --git a/src/basic/ui/index.ts b/src/basic/ui/index.ts index 107ef0d0..32a2e9c9 100644 --- a/src/basic/ui/index.ts +++ b/src/basic/ui/index.ts @@ -1,9 +1,6 @@ export * from './notification-item'; -export * from './admin-dashboard'; -export * from './user-dashboard'; export * from './product-list'; export * from './cart'; export * from './coupons'; export * from './payments'; export * from './header'; -export * from './user-dashboard'; From 8dece110434763732a3c0e965705b2787aeca5ed Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 8 Aug 2025 00:56:36 +0900 Subject: [PATCH 22/34] =?UTF-8?q?fix:=20model=20export=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 3 +-- src/basic/entities/coupons/hooks/useCoupons.ts | 5 ++--- src/basic/models/index.ts | 3 +++ src/basic/pages/admin-dashboard.tsx | 2 +- src/basic/ui/coupons.tsx | 2 +- src/basic/ui/header.tsx | 2 +- 6 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 src/basic/models/index.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a2cf5e1f..9d22004a 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -2,8 +2,7 @@ import { useState, useMemo } from 'react'; import { Header, NotificationItem } from './ui'; import { useCoupons } from './entities/coupons'; import { useProducts } from './entities/products'; -import { CartModel } from './models/cart'; -import { ProductModel } from './models/product'; +import { CartModel, ProductModel } from './models'; import { useCart, useDebounceValue, useNotifications, useTotalItemCount } from './hooks'; import { AdminDashboard, UserDashboard } from './pages'; diff --git a/src/basic/entities/coupons/hooks/useCoupons.ts b/src/basic/entities/coupons/hooks/useCoupons.ts index 17534777..e8588bf5 100644 --- a/src/basic/entities/coupons/hooks/useCoupons.ts +++ b/src/basic/entities/coupons/hooks/useCoupons.ts @@ -2,8 +2,7 @@ import { initialCoupons } from '@/basic/constants/mocks'; import { useLocalStorage } from '@/basic/hooks'; import { Coupon, CartItem } from '@/types'; import { useCallback, useState } from 'react'; -import { CouponModel } from '@/basic/models/coupon'; -import { CartModel } from '@/basic/models/cart'; +import { CouponModel, CartModel } from '@/basic/models'; import { INITIAL_COUPON_FORM } from '@/basic/constants/forms'; interface UseCouponsProps { @@ -55,7 +54,7 @@ export function useCoupons({ addNotification }: UseCouponsProps) { (coupon: Coupon, cart: CartItem[]) => { const cartModel = new CartModel(cart); const couponModel = new CouponModel(); - + const currentTotal = cartModel.calculateTotal(selectedCoupon || undefined).totalAfterDiscount; const validation = couponModel.validateCouponApplication(coupon, currentTotal); diff --git a/src/basic/models/index.ts b/src/basic/models/index.ts new file mode 100644 index 00000000..0b6b7572 --- /dev/null +++ b/src/basic/models/index.ts @@ -0,0 +1,3 @@ +export * from './cart'; +export * from './coupon'; +export * from './product'; diff --git a/src/basic/pages/admin-dashboard.tsx b/src/basic/pages/admin-dashboard.tsx index 51e90e41..44831f91 100644 --- a/src/basic/pages/admin-dashboard.tsx +++ b/src/basic/pages/admin-dashboard.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { ProductWithUI } from '../constants/mocks'; -import { Coupon } from '../../types'; +import { Coupon } from '@/types'; import { CloseIcon, DeleteIcon, PlusIcon } from '../ui/icons'; import { formatPrice } from '../utils'; diff --git a/src/basic/ui/coupons.tsx b/src/basic/ui/coupons.tsx index cb2be31d..83a4160d 100644 --- a/src/basic/ui/coupons.tsx +++ b/src/basic/ui/coupons.tsx @@ -1,4 +1,4 @@ -import { Coupon } from '../../types'; +import { Coupon } from '@/types'; interface CouponsProps { coupons: Coupon[]; diff --git a/src/basic/ui/header.tsx b/src/basic/ui/header.tsx index 083b0320..11d5cff7 100644 --- a/src/basic/ui/header.tsx +++ b/src/basic/ui/header.tsx @@ -1,4 +1,4 @@ -import { CartItem } from '../../types'; +import { CartItem } from '@/types'; import { CartIcon } from './icons'; import { SearchInput } from './search-input'; From 473a2e77ca85aea150a59bfef56186592aefbd00 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 8 Aug 2025 07:30:05 +0900 Subject: [PATCH 23/34] =?UTF-8?q?feat:=20advanced=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20context=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/App.tsx | 1159 +---------------- src/advanced/constants/forms.ts | 18 + src/advanced/constants/mocks.ts | 55 + src/advanced/contexts/AppProvider.tsx | 23 + src/advanced/contexts/CartContext.tsx | 129 ++ src/advanced/contexts/CouponContext.tsx | 102 ++ src/advanced/contexts/NotificationContext.tsx | 32 + src/advanced/contexts/ProductContext.tsx | 117 ++ src/advanced/contexts/index.ts | 5 + src/advanced/entities/cart/index.ts | 1 + src/advanced/entities/cart/model/cart.ts | 26 + src/advanced/entities/cart/model/index.ts | 1 + src/advanced/entities/coupons/hooks/index.ts | 1 + .../entities/coupons/hooks/useCoupons.ts | 84 ++ src/advanced/entities/coupons/index.ts | 1 + src/advanced/entities/products/hooks/index.ts | 1 + .../entities/products/hooks/useProducts.ts | 90 ++ src/advanced/entities/products/index.ts | 1 + src/advanced/hooks/index.ts | 5 + src/advanced/hooks/use-cart.ts | 93 ++ src/advanced/hooks/use-debounce-value.ts | 12 + src/advanced/hooks/use-local-storage.ts | 28 + src/advanced/hooks/use-notifications.ts | 29 + src/advanced/hooks/use-total-item-count.ts | 6 + src/advanced/models/cart.ts | 115 ++ src/advanced/models/coupon.ts | 74 ++ src/advanced/models/index.ts | 3 + src/advanced/models/product.ts | 60 + src/advanced/pages/admin-dashboard.tsx | 530 ++++++++ src/advanced/pages/index.ts | 2 + src/advanced/pages/user-dashboard.tsx | 55 + src/advanced/ui/cart.tsx | 82 ++ src/advanced/ui/coupons.tsx | 41 + src/advanced/ui/header.tsx | 55 + src/advanced/ui/icons/cart.tsx | 12 + src/advanced/ui/icons/close.tsx | 7 + src/advanced/ui/icons/delete.tsx | 12 + src/advanced/ui/icons/empty-bag.tsx | 17 + src/advanced/ui/icons/index.ts | 7 + src/advanced/ui/icons/picture.tsx | 12 + src/advanced/ui/icons/plus.tsx | 7 + src/advanced/ui/icons/shopping-bag.tsx | 12 + src/advanced/ui/index.ts | 6 + src/advanced/ui/notification-item.tsx | 37 + src/advanced/ui/payments.tsx | 46 + src/advanced/ui/product-list.tsx | 113 ++ src/advanced/ui/search-input.tsx | 22 + src/advanced/utils/calculator.ts | 59 + src/advanced/utils/filter.ts | 13 + src/advanced/utils/format.ts | 30 + src/advanced/utils/index.ts | 5 + src/advanced/utils/inventory.ts | 8 + src/advanced/utils/validator.ts | 30 + 53 files changed, 2387 insertions(+), 1104 deletions(-) create mode 100644 src/advanced/constants/forms.ts create mode 100644 src/advanced/constants/mocks.ts create mode 100644 src/advanced/contexts/AppProvider.tsx create mode 100644 src/advanced/contexts/CartContext.tsx create mode 100644 src/advanced/contexts/CouponContext.tsx create mode 100644 src/advanced/contexts/NotificationContext.tsx create mode 100644 src/advanced/contexts/ProductContext.tsx create mode 100644 src/advanced/contexts/index.ts create mode 100644 src/advanced/entities/cart/index.ts create mode 100644 src/advanced/entities/cart/model/cart.ts create mode 100644 src/advanced/entities/cart/model/index.ts create mode 100644 src/advanced/entities/coupons/hooks/index.ts create mode 100644 src/advanced/entities/coupons/hooks/useCoupons.ts create mode 100644 src/advanced/entities/coupons/index.ts create mode 100644 src/advanced/entities/products/hooks/index.ts create mode 100644 src/advanced/entities/products/hooks/useProducts.ts create mode 100644 src/advanced/entities/products/index.ts create mode 100644 src/advanced/hooks/index.ts create mode 100644 src/advanced/hooks/use-cart.ts create mode 100644 src/advanced/hooks/use-debounce-value.ts create mode 100644 src/advanced/hooks/use-local-storage.ts create mode 100644 src/advanced/hooks/use-notifications.ts create mode 100644 src/advanced/hooks/use-total-item-count.ts create mode 100644 src/advanced/models/cart.ts create mode 100644 src/advanced/models/coupon.ts create mode 100644 src/advanced/models/index.ts create mode 100644 src/advanced/models/product.ts create mode 100644 src/advanced/pages/admin-dashboard.tsx create mode 100644 src/advanced/pages/index.ts create mode 100644 src/advanced/pages/user-dashboard.tsx create mode 100644 src/advanced/ui/cart.tsx create mode 100644 src/advanced/ui/coupons.tsx create mode 100644 src/advanced/ui/header.tsx create mode 100644 src/advanced/ui/icons/cart.tsx create mode 100644 src/advanced/ui/icons/close.tsx create mode 100644 src/advanced/ui/icons/delete.tsx create mode 100644 src/advanced/ui/icons/empty-bag.tsx create mode 100644 src/advanced/ui/icons/index.ts create mode 100644 src/advanced/ui/icons/picture.tsx create mode 100644 src/advanced/ui/icons/plus.tsx create mode 100644 src/advanced/ui/icons/shopping-bag.tsx create mode 100644 src/advanced/ui/index.ts create mode 100644 src/advanced/ui/notification-item.tsx create mode 100644 src/advanced/ui/payments.tsx create mode 100644 src/advanced/ui/product-list.tsx create mode 100644 src/advanced/ui/search-input.tsx create mode 100644 src/advanced/utils/calculator.ts create mode 100644 src/advanced/utils/filter.ts create mode 100644 src/advanced/utils/format.ts create mode 100644 src/advanced/utils/index.ts create mode 100644 src/advanced/utils/inventory.ts create mode 100644 src/advanced/utils/validator.ts diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1..9a7d5438 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1124 +1,75 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; - -const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); +import { useState, useMemo } from 'react'; +import { Header, NotificationItem } from './ui'; +import { CartModel, ProductModel } from './models'; +import { useDebounceValue, useTotalItemCount } from './hooks'; +import { AdminDashboard, UserDashboard } from './pages'; +import { useNotifications, useProducts, useCoupons, useCart, AppProvider } from './contexts'; + +function AppContent() { const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); + const { notifications, removeNotification } = useNotifications(); + const { products } = useProducts(); + const { selectedCoupon } = useCoupons(); + const { cart } = useCart(); + const totalItemCount = useTotalItemCount(cart); const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } + const debouncedSearchTerm = useDebounceValue(searchTerm, 500); - 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; - } + const totals = useMemo(() => { + const cartModel = new CartModel(cart); + return cartModel.calculateTotal(selectedCoupon || undefined); + }, [cart, selectedCoupon]); - 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); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: 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(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const filteredProducts = useMemo(() => { + const productModel = new ProductModel(products); + return productModel.filter(debouncedSearchTerm); + }, [products, debouncedSearchTerm]); return ( -
+
+ {/* Notification */} {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
+ {notifications.map((notif) => ( + - {notif.message} - -
+ notif={notif} + onClose={() => removeNotification(notif.id)} + /> ))}
)} -
-
-
-
-

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.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) }); - } - }} - 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" - 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.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()}원 -
-
- - - -
-

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

-
-
- - )} -
-
-
+ )}
); +} + +const App = () => { + return ( + + + + ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/advanced/constants/forms.ts b/src/advanced/constants/forms.ts new file mode 100644 index 00000000..986456fe --- /dev/null +++ b/src/advanced/constants/forms.ts @@ -0,0 +1,18 @@ +export const INITIAL_PRODUCT_FORM = { + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] as Array<{ quantity: number; rate: number }>, +}; + +export const INITIAL_COUPON_FORM = { + name: '', + code: '', + discountType: 'amount' as const, + discountValue: 0, +}; + +export const EDITING_STATES = { + NEW: 'new', +} as const; diff --git a/src/advanced/constants/mocks.ts b/src/advanced/constants/mocks.ts new file mode 100644 index 00000000..ffc79005 --- /dev/null +++ b/src/advanced/constants/mocks.ts @@ -0,0 +1,55 @@ +import { Coupon, Product } from '../../types'; + +export 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, + }, +]; diff --git a/src/advanced/contexts/AppProvider.tsx b/src/advanced/contexts/AppProvider.tsx new file mode 100644 index 00000000..e6273f38 --- /dev/null +++ b/src/advanced/contexts/AppProvider.tsx @@ -0,0 +1,23 @@ +import { ReactNode } from 'react'; +import { NotificationProvider } from './NotificationContext'; +import { ProductProvider } from './ProductContext'; +import { CouponProvider } from './CouponContext'; +import { CartProvider } from './CartContext'; + +interface AppProviderProps { + children: ReactNode; +} + +export function AppProvider({ children }: AppProviderProps) { + return ( + + + + + {children} + + + + + ); +} \ No newline at end of file diff --git a/src/advanced/contexts/CartContext.tsx b/src/advanced/contexts/CartContext.tsx new file mode 100644 index 00000000..05cf9ae4 --- /dev/null +++ b/src/advanced/contexts/CartContext.tsx @@ -0,0 +1,129 @@ +import { createContext, useContext, useCallback, ReactNode } from 'react'; +import { CartItem, Product } from '@/types'; +import { useLocalStorage } from '../hooks/use-local-storage'; +import { ProductWithUI } from '../constants/mocks'; +import { CartModel } from '../models/cart'; +import { useNotifications } from './NotificationContext'; +import { useProducts } from './ProductContext'; +import { useCoupons } from './CouponContext'; + +interface CartContextType { + cart: CartItem[]; + addToCart: (product: ProductWithUI) => void; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + completeOrder: () => void; + getStock: (product: Product) => number; +} + +const CartContext = createContext(undefined); + +interface CartProviderProps { + children: ReactNode; +} + +export function CartProvider({ children }: CartProviderProps) { + const [cart, setCart] = useLocalStorage('cart', []); + const { products } = useProducts(); + const { addNotification } = useNotifications(); + const { setSelectedCoupon } = useCoupons(); + + const getStock = useCallback( + (product: Product): number => { + const cartModel = new CartModel(cart); + return cartModel.getRemainingStock(product); + }, + [cart] + ); + + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getStock(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'); + }, + [getStock, setCart, addNotification] + ); + + const removeFromCart = useCallback( + (productId: string) => { + setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); + }, + [setCart] + ); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } + + const product = products.find((p) => p.id === productId); + if (!product) return; + + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); + return; + } + + setCart((prevCart) => + prevCart.map((item) => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ) + ); + }, + [products, removeFromCart, setCart, addNotification] + ); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + setCart([]); + setSelectedCoupon(null); + }, [setCart, addNotification, setSelectedCoupon]); + + const value: CartContextType = { + cart, + addToCart, + removeFromCart, + updateQuantity, + completeOrder, + getStock, + }; + + return {children}; +} + +export function useCart() { + const context = useContext(CartContext); + if (context === undefined) { + throw new Error('useCart must be used within a CartProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/advanced/contexts/CouponContext.tsx b/src/advanced/contexts/CouponContext.tsx new file mode 100644 index 00000000..bf6630df --- /dev/null +++ b/src/advanced/contexts/CouponContext.tsx @@ -0,0 +1,102 @@ +import { createContext, useContext, ReactNode, useCallback, useState } from 'react'; +import { initialCoupons } from '../constants/mocks'; +import { useLocalStorage } from '../hooks/use-local-storage'; +import { Coupon } from '@/types'; +import { CouponModel } from '../models'; +import { INITIAL_COUPON_FORM } from '../constants/forms'; +import { useNotifications } from './NotificationContext'; + +interface CouponContextType { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + couponForm: Coupon; + showCouponForm: boolean; + setShowCouponForm: (show: boolean) => void; + setCouponForm: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; + deleteCoupon: (couponCode: string) => void; + handleCouponSubmit: (e: React.FormEvent) => void; + applyCoupon: (coupon: Coupon) => void; +} + +const CouponContext = createContext(undefined); + +interface CouponProviderProps { + children: ReactNode; +} + +export function CouponProvider({ children }: CouponProviderProps) { + const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); + const [selectedCoupon, setSelectedCoupon] = useState(null); + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState(INITIAL_COUPON_FORM); + const { addNotification } = useNotifications(); + + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const couponModel = new CouponModel(coupons); + const validation = couponModel.validateCouponCode(newCoupon.code); + if (!validation.isValid) { + addNotification(validation.errorMessage!, 'error'); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + addNotification('쿠폰이 추가되었습니다.', 'success'); + }, + [coupons, addNotification, setCoupons] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }, + [selectedCoupon, addNotification, setCoupons] + ); + + const handleCouponSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm(INITIAL_COUPON_FORM); + setShowCouponForm(false); + }, + [couponForm, addCoupon] + ); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + // Note: This creates a circular dependency issue, so we'll need to restructure this + // For now, let's implement a simpler version that just sets the coupon + setSelectedCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, + [addNotification, setSelectedCoupon] + ); + + const value: CouponContextType = { + coupons, + selectedCoupon, + couponForm, + showCouponForm, + setShowCouponForm, + setCouponForm, + setSelectedCoupon, + deleteCoupon, + handleCouponSubmit, + applyCoupon, + }; + + return {children}; +} + +export function useCoupons() { + const context = useContext(CouponContext); + if (context === undefined) { + throw new Error('useCoupons must be used within a CouponProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/advanced/contexts/NotificationContext.tsx b/src/advanced/contexts/NotificationContext.tsx new file mode 100644 index 00000000..52d75cf3 --- /dev/null +++ b/src/advanced/contexts/NotificationContext.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext, ReactNode } from 'react'; +import { useNotifications as useNotificationsHook } from '../hooks/use-notifications'; + +interface NotificationContextType { + notifications: Array<{ id: string; message: string; type: 'error' | 'success' | 'warning' }>; + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; + removeNotification: (id: string) => void; +} + +const NotificationContext = createContext(undefined); + +interface NotificationProviderProps { + children: ReactNode; +} + +export function NotificationProvider({ children }: NotificationProviderProps) { + const notificationLogic = useNotificationsHook(); + + return ( + + {children} + + ); +} + +export function useNotifications() { + const context = useContext(NotificationContext); + if (context === undefined) { + throw new Error('useNotifications must be used within a NotificationProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/advanced/contexts/ProductContext.tsx b/src/advanced/contexts/ProductContext.tsx new file mode 100644 index 00000000..7f9c141d --- /dev/null +++ b/src/advanced/contexts/ProductContext.tsx @@ -0,0 +1,117 @@ +import { createContext, useContext, ReactNode, useCallback, useState } from 'react'; +import { initialProducts, ProductWithUI } from '../constants/mocks'; +import { useLocalStorage } from '../hooks/use-local-storage'; +import { INITIAL_PRODUCT_FORM, EDITING_STATES } from '../constants/forms'; +import { useNotifications } from './NotificationContext'; + +interface ProductContextType { + products: ProductWithUI[]; + setProducts: (products: ProductWithUI[] | ((prev: ProductWithUI[]) => ProductWithUI[])) => void; + showProductForm: boolean; + setShowProductForm: (show: boolean) => void; + productForm: Omit; + setProductForm: (form: any) => void; + addProduct: (newProduct: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; + startEditProduct: (product: ProductWithUI) => void; + handleProductSubmit: (e: React.FormEvent) => void; +} + +const ProductContext = createContext(undefined); + +interface ProductProviderProps { + children: ReactNode; +} + +export function ProductProvider({ children }: ProductProviderProps) { + const [products, setProducts] = useLocalStorage('products', initialProducts); + const [editingProduct, setEditingProduct] = useState(null); + const [showProductForm, setShowProductForm] = useState(false); + const [productForm, setProductForm] = useState(INITIAL_PRODUCT_FORM); + const { addNotification } = useNotifications(); + + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [addNotification, setProducts] + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + addNotification('상품이 수정되었습니다.', 'success'); + }, + [addNotification, setProducts] + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification('상품이 삭제되었습니다.', 'success'); + }, + [addNotification, setProducts] + ); + + const startEditProduct = useCallback((product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], + }); + setShowProductForm(true); + }, []); + + const handleProductSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + if (editingProduct && editingProduct !== EDITING_STATES.NEW) { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProduct(productForm); + } + + setProductForm(INITIAL_PRODUCT_FORM); + setEditingProduct(null); + setShowProductForm(false); + }, + [editingProduct, productForm, updateProduct, addProduct] + ); + + const value: ProductContextType = { + products, + setProducts, + showProductForm, + setShowProductForm, + productForm, + setProductForm, + addProduct, + updateProduct, + deleteProduct, + startEditProduct, + handleProductSubmit, + }; + + return {children}; +} + +export function useProducts() { + const context = useContext(ProductContext); + if (context === undefined) { + throw new Error('useProducts must be used within a ProductProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/advanced/contexts/index.ts b/src/advanced/contexts/index.ts new file mode 100644 index 00000000..4cddf5e9 --- /dev/null +++ b/src/advanced/contexts/index.ts @@ -0,0 +1,5 @@ +export { NotificationProvider, useNotifications } from './NotificationContext'; +export { ProductProvider, useProducts } from './ProductContext'; +export { CouponProvider, useCoupons } from './CouponContext'; +export { CartProvider, useCart } from './CartContext'; +export { AppProvider } from './AppProvider'; \ No newline at end of file diff --git a/src/advanced/entities/cart/index.ts b/src/advanced/entities/cart/index.ts new file mode 100644 index 00000000..9f8ccadd --- /dev/null +++ b/src/advanced/entities/cart/index.ts @@ -0,0 +1 @@ +export * from './model'; diff --git a/src/advanced/entities/cart/model/cart.ts b/src/advanced/entities/cart/model/cart.ts new file mode 100644 index 00000000..71acac2f --- /dev/null +++ b/src/advanced/entities/cart/model/cart.ts @@ -0,0 +1,26 @@ +import { CartItem } from '@/types'; + +const getMaxApplicableDiscount = (item: CartItem, hasBulkPurchase: boolean): 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); + + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; +}; + +export const calculateItemTotal = (item: CartItem, hasBulkPurchase: boolean): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, hasBulkPurchase); + + return Math.round(price * quantity * (1 - discount)); +}; diff --git a/src/advanced/entities/cart/model/index.ts b/src/advanced/entities/cart/model/index.ts new file mode 100644 index 00000000..cbcb0736 --- /dev/null +++ b/src/advanced/entities/cart/model/index.ts @@ -0,0 +1 @@ +export * from './cart'; diff --git a/src/advanced/entities/coupons/hooks/index.ts b/src/advanced/entities/coupons/hooks/index.ts new file mode 100644 index 00000000..89953ffb --- /dev/null +++ b/src/advanced/entities/coupons/hooks/index.ts @@ -0,0 +1 @@ +export * from './useCoupons'; diff --git a/src/advanced/entities/coupons/hooks/useCoupons.ts b/src/advanced/entities/coupons/hooks/useCoupons.ts new file mode 100644 index 00000000..e8588bf5 --- /dev/null +++ b/src/advanced/entities/coupons/hooks/useCoupons.ts @@ -0,0 +1,84 @@ +import { initialCoupons } from '@/basic/constants/mocks'; +import { useLocalStorage } from '@/basic/hooks'; +import { Coupon, CartItem } from '@/types'; +import { useCallback, useState } from 'react'; +import { CouponModel, CartModel } from '@/basic/models'; +import { INITIAL_COUPON_FORM } from '@/basic/constants/forms'; + +interface UseCouponsProps { + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; +} + +export function useCoupons({ addNotification }: UseCouponsProps) { + const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); + const [selectedCoupon, setSelectedCoupon] = useState(null); + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState(INITIAL_COUPON_FORM); + + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const couponModel = new CouponModel(coupons); + const validation = couponModel.validateCouponCode(newCoupon.code); + if (!validation.isValid) { + addNotification(validation.errorMessage!, 'error'); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + addNotification('쿠폰이 추가되었습니다.', 'success'); + }, + [coupons, addNotification, setCoupons] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }, + [selectedCoupon, addNotification, setCoupons] + ); + + const handleCouponSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm(INITIAL_COUPON_FORM); + setShowCouponForm(false); + }, + [couponForm, addCoupon] + ); + + const applyCoupon = useCallback( + (coupon: Coupon, cart: CartItem[]) => { + const cartModel = new CartModel(cart); + const couponModel = new CouponModel(); + + const currentTotal = cartModel.calculateTotal(selectedCoupon || undefined).totalAfterDiscount; + const validation = couponModel.validateCouponApplication(coupon, currentTotal); + + if (!validation.isValid) { + addNotification(validation.errorMessage!, 'error'); + return; + } + + setSelectedCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, + [addNotification, selectedCoupon] + ); + + return { + coupons, + selectedCoupon, + couponForm, + showCouponForm, + setShowCouponForm, + setCouponForm, + setSelectedCoupon, + deleteCoupon, + handleCouponSubmit, + applyCoupon, + }; +} diff --git a/src/advanced/entities/coupons/index.ts b/src/advanced/entities/coupons/index.ts new file mode 100644 index 00000000..4cc90d02 --- /dev/null +++ b/src/advanced/entities/coupons/index.ts @@ -0,0 +1 @@ +export * from './hooks'; diff --git a/src/advanced/entities/products/hooks/index.ts b/src/advanced/entities/products/hooks/index.ts new file mode 100644 index 00000000..a53137e0 --- /dev/null +++ b/src/advanced/entities/products/hooks/index.ts @@ -0,0 +1 @@ +export * from './useProducts'; diff --git a/src/advanced/entities/products/hooks/useProducts.ts b/src/advanced/entities/products/hooks/useProducts.ts new file mode 100644 index 00000000..a60f1b66 --- /dev/null +++ b/src/advanced/entities/products/hooks/useProducts.ts @@ -0,0 +1,90 @@ +import { initialProducts, ProductWithUI } from '@/basic/constants/mocks'; +import { useLocalStorage } from '@/basic/hooks'; +import { useCallback, useState } from 'react'; +import { INITIAL_PRODUCT_FORM, EDITING_STATES } from '@/basic/constants/forms'; + +interface UseProductsProps { + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; +} + +export function useProducts({ addNotification }: UseProductsProps) { + const [products, setProducts] = useLocalStorage('products', initialProducts); + const [editingProduct, setEditingProduct] = useState(null); + const [showProductForm, setShowProductForm] = useState(false); + const [productForm, setProductForm] = useState(INITIAL_PRODUCT_FORM); + + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [addNotification, setProducts] + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + addNotification('상품이 수정되었습니다.', 'success'); + }, + [addNotification, setProducts] + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification('상품이 삭제되었습니다.', 'success'); + }, + [addNotification, setProducts] + ); + + const startEditProduct = useCallback((product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], + }); + setShowProductForm(true); + }, []); + + const handleProductSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + if (editingProduct && editingProduct !== EDITING_STATES.NEW) { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProduct(productForm); + } + + setProductForm(INITIAL_PRODUCT_FORM); + setEditingProduct(null); + setShowProductForm(false); + }, + [editingProduct, productForm, updateProduct, addProduct] + ); + + return { + products, + setProducts, + showProductForm, + setShowProductForm, + productForm, + setProductForm, + addProduct, + updateProduct, + deleteProduct, + + startEditProduct, + handleProductSubmit, + }; +} diff --git a/src/advanced/entities/products/index.ts b/src/advanced/entities/products/index.ts new file mode 100644 index 00000000..4cc90d02 --- /dev/null +++ b/src/advanced/entities/products/index.ts @@ -0,0 +1 @@ +export * from './hooks'; diff --git a/src/advanced/hooks/index.ts b/src/advanced/hooks/index.ts new file mode 100644 index 00000000..59a82973 --- /dev/null +++ b/src/advanced/hooks/index.ts @@ -0,0 +1,5 @@ +export * from './use-local-storage'; +export * from './use-notifications'; +export * from './use-cart'; +export * from './use-debounce-value'; +export * from './use-total-item-count'; diff --git a/src/advanced/hooks/use-cart.ts b/src/advanced/hooks/use-cart.ts new file mode 100644 index 00000000..9106239a --- /dev/null +++ b/src/advanced/hooks/use-cart.ts @@ -0,0 +1,93 @@ +import { CartItem, Coupon, Product } from '@/types'; +import { useLocalStorage } from './use-local-storage'; +import { useCallback } from 'react'; +import { ProductWithUI } from '../constants/mocks'; +import { CartModel } from '../models/cart'; + +interface UseCartProps { + products: ProductWithUI[]; + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; + setSelectedCoupon: (coupon: Coupon | null) => void; +} + +export function useCart({ products, addNotification, setSelectedCoupon }: UseCartProps) { + const [cart, setCart] = useLocalStorage('cart', []); + + const getStock = useCallback( + (product: Product): number => { + const cartModel = new CartModel(cart); + return cartModel.getRemainingStock(product); + }, + [cart] + ); + + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getStock(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'); + }, + [getStock, addNotification] + ); + + 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] + ); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + setCart([]); + setSelectedCoupon(null); + }, [addNotification]); + + return { cart, addToCart, removeFromCart, updateQuantity, completeOrder, getStock }; +} diff --git a/src/advanced/hooks/use-debounce-value.ts b/src/advanced/hooks/use-debounce-value.ts new file mode 100644 index 00000000..f1d68b12 --- /dev/null +++ b/src/advanced/hooks/use-debounce-value.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from 'react'; + +export function useDebounceValue(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/hooks/use-local-storage.ts b/src/advanced/hooks/use-local-storage.ts new file mode 100644 index 00000000..04976f9f --- /dev/null +++ b/src/advanced/hooks/use-local-storage.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((prev: T) => T)) => void] { + const [value, setValue] = useState(() => { + try { + const saved = localStorage.getItem(key); + if (saved) { + return JSON.parse(saved); + } + } catch (error) { + console.warn(`[오류] 로컬 스토리지 파싱 실패: "${key}"`, error); + } + return initialValue; + }); + + useEffect(() => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.warn(`[오류] 로컬 스토리지 저장 실패: "${key}"`, error); + } + }, [value, key]); + + return [value, setValue]; +} diff --git a/src/advanced/hooks/use-notifications.ts b/src/advanced/hooks/use-notifications.ts new file mode 100644 index 00000000..3ecf0709 --- /dev/null +++ b/src/advanced/hooks/use-notifications.ts @@ -0,0 +1,29 @@ +import { useCallback, useState } from 'react'; + +interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export function useNotifications() { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: 'error' | 'success' | 'warning' = 'success') => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); + + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return { notifications, addNotification, removeNotification }; +} diff --git a/src/advanced/hooks/use-total-item-count.ts b/src/advanced/hooks/use-total-item-count.ts new file mode 100644 index 00000000..51511745 --- /dev/null +++ b/src/advanced/hooks/use-total-item-count.ts @@ -0,0 +1,6 @@ +import { useMemo } from 'react'; +import { CartItem } from '@/types'; + +export function useTotalItemCount(cart: CartItem[]) { + return useMemo(() => cart.reduce((sum, item) => sum + item.quantity, 0), [cart]); +} diff --git a/src/advanced/models/cart.ts b/src/advanced/models/cart.ts new file mode 100644 index 00000000..34279ecd --- /dev/null +++ b/src/advanced/models/cart.ts @@ -0,0 +1,115 @@ +import { CartItem, Product, Coupon } from '@/types'; + +export class CartModel { + constructor(private items: CartItem[] = []) {} + + get cartItems(): CartItem[] { + return [...this.items]; + } + + get itemCount(): number { + return this.items.reduce((count, item) => count + item.quantity, 0); + } + + calculateItemTotal(item: CartItem): number { + const { price } = item.product; + const { quantity } = item; + const discount = this.getMaxApplicableDiscount(item); + + return Math.round(price * quantity * (1 - discount)); + } + + private 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 = this.items.some((cartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); + } + + return baseDiscount; + } + + calculateTotal(coupon?: Coupon) { + const totalBeforeDiscount = this.items.reduce((total, item) => { + return total + item.product.price * item.quantity; + }, 0); + + const totalAfterItemDiscounts = this.items.reduce((total, item) => { + return total + this.calculateItemTotal(item); + }, 0); + + let totalAfterDiscount = totalAfterItemDiscounts; + + if (coupon) { + if (coupon.discountType === 'percentage') { + totalAfterDiscount = totalAfterItemDiscounts * (1 - coupon.discountValue / 100); + } else if (coupon.discountType === 'amount') { + totalAfterDiscount = totalAfterItemDiscounts - coupon.discountValue; + } + totalAfterDiscount = Math.max(0, totalAfterDiscount); + } + + const totalDiscount = totalBeforeDiscount - totalAfterDiscount; + + return { + totalBeforeDiscount, + totalAfterDiscount, + totalDiscount, + }; + } + + addItem(product: Product): CartModel { + const existingItemIndex = this.items.findIndex((item) => item.product.id === product.id); + + let newItems; + if (existingItemIndex >= 0) { + newItems = [...this.items]; + newItems[existingItemIndex] = { + ...newItems[existingItemIndex], + quantity: newItems[existingItemIndex].quantity + 1, + }; + } else { + newItems = [...this.items, { product, quantity: 1 }]; + } + + return new CartModel(newItems); + } + + updateItemQuantity(productId: string, quantity: number): CartModel { + if (quantity <= 0) { + return this.removeItem(productId); + } + + const newItems = this.items.map((item) => + item.product.id === productId ? { ...item, quantity } : item + ); + + return new CartModel(newItems); + } + + removeItem(productId: string): CartModel { + const newItems = this.items.filter((item) => item.product.id !== productId); + return new CartModel(newItems); + } + + getRemainingStock(product: Product): number { + const cartItem = this.items.find((item) => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); + } + + clear(): CartModel { + return new CartModel([]); + } + + hasProduct(productId: string): boolean { + return this.items.some((item) => item.product.id === productId); + } +} diff --git a/src/advanced/models/coupon.ts b/src/advanced/models/coupon.ts new file mode 100644 index 00000000..dd564401 --- /dev/null +++ b/src/advanced/models/coupon.ts @@ -0,0 +1,74 @@ +import { Coupon } from '@/types'; + +export class CouponModel { + constructor(private coupons: Coupon[] = []) {} + + get couponList(): Coupon[] { + return [...this.coupons]; + } + + get count(): number { + return this.coupons.length; + } + + validateCouponCode(code: string) { + if (!code.trim()) { + return { + isValid: false, + errorMessage: '쿠폰 코드를 입력해주세요.', + }; + } + + const isDuplicate = this.coupons.some((coupon) => coupon.code === code); + if (isDuplicate) { + return { + isValid: false, + errorMessage: '이미 존재하는 쿠폰 코드입니다.', + }; + } + + return { isValid: true }; + } + + validateCouponApplication(coupon: Coupon, currentTotal: number) { + const minimumAmount = 10000; + if (currentTotal < minimumAmount) { + return { + isValid: false, + errorMessage: `${minimumAmount.toLocaleString()}원 이상 구매 시 쿠폰을 사용할 수 있습니다.`, + }; + } + + if (coupon.discountType === 'amount' && coupon.discountValue >= currentTotal) { + return { + isValid: false, + errorMessage: '쿠폰 할인 금액이 주문 금액보다 클 수 없습니다.', + }; + } + + return { isValid: true }; + } + + add(newCoupon: Coupon): CouponModel { + return new CouponModel([...this.coupons, newCoupon]); + } + + remove(couponCode: string): CouponModel { + const newCoupons = this.coupons.filter((coupon) => coupon.code !== couponCode); + return new CouponModel(newCoupons); + } + + findByCode(code: string): Coupon | undefined { + return this.coupons.find((coupon) => coupon.code === code); + } + + findByDiscountType(type: 'amount' | 'percentage'): Coupon[] { + return this.coupons.filter((coupon) => coupon.discountType === type); + } + + getApplicableCoupons(currentTotal: number): Coupon[] { + return this.coupons.filter( + (coupon) => this.validateCouponApplication(coupon, currentTotal).isValid + ); + } +} diff --git a/src/advanced/models/index.ts b/src/advanced/models/index.ts new file mode 100644 index 00000000..0b6b7572 --- /dev/null +++ b/src/advanced/models/index.ts @@ -0,0 +1,3 @@ +export * from './cart'; +export * from './coupon'; +export * from './product'; diff --git a/src/advanced/models/product.ts b/src/advanced/models/product.ts new file mode 100644 index 00000000..30f07190 --- /dev/null +++ b/src/advanced/models/product.ts @@ -0,0 +1,60 @@ +import { ProductWithUI } from '../constants/mocks'; + +export class ProductModel { + constructor(private products: ProductWithUI[] = []) {} + + get productList(): ProductWithUI[] { + return [...this.products]; + } + + get count(): number { + return this.products.length; + } + + filter(searchTerm: string): ProductWithUI[] { + if (!searchTerm.trim()) { + return this.products; + } + + const lowercaseSearch = searchTerm.toLowerCase().trim(); + return this.products.filter( + (product) => + product.name.toLowerCase().includes(lowercaseSearch) || + product.description?.toLowerCase().includes(lowercaseSearch) + ); + } + + add(newProduct: Omit): ProductModel { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + return new ProductModel([...this.products, product]); + } + + update(productId: string, updates: Partial): ProductModel { + const newProducts = this.products.map((product) => + product.id === productId ? { ...product, ...updates } : product + ); + return new ProductModel(newProducts); + } + + remove(productId: string): ProductModel { + const newProducts = this.products.filter((product) => product.id !== productId); + return new ProductModel(newProducts); + } + + findById(productId: string): ProductWithUI | undefined { + return this.products.find((product) => product.id === productId); + } + + getAvailableProducts(): ProductWithUI[] { + return this.products.filter((product) => product.stock > 0); + } + + findByPriceRange(minPrice: number, maxPrice: number): ProductWithUI[] { + return this.products.filter( + (product) => product.price >= minPrice && product.price <= maxPrice + ); + } +} diff --git a/src/advanced/pages/admin-dashboard.tsx b/src/advanced/pages/admin-dashboard.tsx new file mode 100644 index 00000000..0eb62981 --- /dev/null +++ b/src/advanced/pages/admin-dashboard.tsx @@ -0,0 +1,530 @@ +import { useState } from 'react'; +import { CloseIcon, DeleteIcon, PlusIcon } from '../ui/icons'; +import { formatPrice } from '../utils'; +import { useProducts, useCoupons, useNotifications } from '../contexts'; + +interface AdminDashboardProps { + isAdmin: boolean; +} + +export function AdminDashboard({ isAdmin }: AdminDashboardProps) { + const { + products, + productForm, + showProductForm, + setProductForm, + setShowProductForm, + startEditProduct, + deleteProduct, + handleProductSubmit, + } = useProducts(); + + const { + coupons, + couponForm, + showCouponForm, + setShowCouponForm, + setCouponForm, + deleteCoupon, + handleCouponSubmit, + } = useCoupons(); + + const { addNotification } = useNotifications(); + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [editingProduct, setEditingProduct] = useState(null); + return ( +
+
+

관리자 대시보드

+

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

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

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {(activeTab === 'products' ? products : products).map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {formatPrice(product.price, { isAdmin })} + + 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), + }); + } + }} + 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' + 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.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 + /> +
+
+
+ + +
+
+
+ )} +
+
+ )} +
+ ); +} diff --git a/src/advanced/pages/index.ts b/src/advanced/pages/index.ts new file mode 100644 index 00000000..64add449 --- /dev/null +++ b/src/advanced/pages/index.ts @@ -0,0 +1,2 @@ +export * from './admin-dashboard'; +export * from './user-dashboard'; diff --git a/src/advanced/pages/user-dashboard.tsx b/src/advanced/pages/user-dashboard.tsx new file mode 100644 index 00000000..078f576f --- /dev/null +++ b/src/advanced/pages/user-dashboard.tsx @@ -0,0 +1,55 @@ +import { Coupon } from '@/types'; +import { ProductWithUI } from '../constants/mocks'; +import { Cart, Coupons, Payments, ProductList } from '../ui/index'; +import { useProducts, useCoupons, useCart } from '../contexts'; + +interface UserDashboardProps { + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + isAdmin: boolean; + totals: { totalBeforeDiscount: number; totalAfterDiscount: number }; +} + +export function UserDashboard({ + filteredProducts, + debouncedSearchTerm, + isAdmin, + totals, +}: UserDashboardProps) { + const { products } = useProducts(); + const { coupons, selectedCoupon, setSelectedCoupon, applyCoupon } = useCoupons(); + const { cart, addToCart, removeFromCart, updateQuantity, getStock, completeOrder } = useCart(); + return ( +
+
+ +
+ +
+
+ + + {cart.length > 0 && ( + <> + + {/* Payment */} + + + )} +
+
+
+ ); +} diff --git a/src/advanced/ui/cart.tsx b/src/advanced/ui/cart.tsx new file mode 100644 index 00000000..85253677 --- /dev/null +++ b/src/advanced/ui/cart.tsx @@ -0,0 +1,82 @@ +import { CartItem } from '../../types'; +import { CloseIcon, EmptyBagIcon, ShoppingBagIcon } from './icons'; +import { CartModel } from '../models/cart'; + +interface CartProps { + cart: CartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +} + +export function Cart({ cart, removeFromCart, updateQuantity }: CartProps) { + const cartModel = new CartModel(cart); + + return ( +
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = cartModel.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()}원 +

+
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/advanced/ui/coupons.tsx b/src/advanced/ui/coupons.tsx new file mode 100644 index 00000000..83a4160d --- /dev/null +++ b/src/advanced/ui/coupons.tsx @@ -0,0 +1,41 @@ +import { Coupon } from '@/types'; + +interface CouponsProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; +} + +export function Coupons({ coupons, selectedCoupon, applyCoupon, setSelectedCoupon }: CouponsProps) { + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/advanced/ui/header.tsx b/src/advanced/ui/header.tsx new file mode 100644 index 00000000..11d5cff7 --- /dev/null +++ b/src/advanced/ui/header.tsx @@ -0,0 +1,55 @@ +import { CartItem } from '@/types'; +import { CartIcon } from './icons'; +import { SearchInput } from './search-input'; + +interface HeaderProps { + isAdmin: boolean; + searchTerm: string; + setSearchTerm: (term: string) => void; + setIsAdmin: (isAdmin: boolean) => void; + cart: CartItem[]; + totalItemCount: number; +} + +export function Header({ + isAdmin, + searchTerm, + setSearchTerm, + setIsAdmin, + cart, + totalItemCount, +}: HeaderProps) { + return ( +
+
+
+
+

SHOP

+ {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} + {!isAdmin && } +
+ +
+
+
+ ); +} diff --git a/src/advanced/ui/icons/cart.tsx b/src/advanced/ui/icons/cart.tsx new file mode 100644 index 00000000..4660006d --- /dev/null +++ b/src/advanced/ui/icons/cart.tsx @@ -0,0 +1,12 @@ +export function CartIcon() { + return ( + + + + ); +} diff --git a/src/advanced/ui/icons/close.tsx b/src/advanced/ui/icons/close.tsx new file mode 100644 index 00000000..3c9f9d11 --- /dev/null +++ b/src/advanced/ui/icons/close.tsx @@ -0,0 +1,7 @@ +export function CloseIcon() { + return ( + + + + ); +} diff --git a/src/advanced/ui/icons/delete.tsx b/src/advanced/ui/icons/delete.tsx new file mode 100644 index 00000000..24c47340 --- /dev/null +++ b/src/advanced/ui/icons/delete.tsx @@ -0,0 +1,12 @@ +export function DeleteIcon() { + return ( + + + + ); +} diff --git a/src/advanced/ui/icons/empty-bag.tsx b/src/advanced/ui/icons/empty-bag.tsx new file mode 100644 index 00000000..dace96b9 --- /dev/null +++ b/src/advanced/ui/icons/empty-bag.tsx @@ -0,0 +1,17 @@ +export function EmptyBagIcon() { + return ( + + + + ); +} diff --git a/src/advanced/ui/icons/index.ts b/src/advanced/ui/icons/index.ts new file mode 100644 index 00000000..bb17aeba --- /dev/null +++ b/src/advanced/ui/icons/index.ts @@ -0,0 +1,7 @@ +export * from './cart'; +export * from './close'; +export * from './delete'; +export * from './empty-bag'; +export * from './plus'; +export * from './shopping-bag'; +export * from './picture'; diff --git a/src/advanced/ui/icons/picture.tsx b/src/advanced/ui/icons/picture.tsx new file mode 100644 index 00000000..3eb22d76 --- /dev/null +++ b/src/advanced/ui/icons/picture.tsx @@ -0,0 +1,12 @@ +export function PictureIcon() { + return ( + + + + ); +} diff --git a/src/advanced/ui/icons/plus.tsx b/src/advanced/ui/icons/plus.tsx new file mode 100644 index 00000000..dc8e0202 --- /dev/null +++ b/src/advanced/ui/icons/plus.tsx @@ -0,0 +1,7 @@ +export function PlusIcon() { + return ( + + + + ); +} diff --git a/src/advanced/ui/icons/shopping-bag.tsx b/src/advanced/ui/icons/shopping-bag.tsx new file mode 100644 index 00000000..a24291c0 --- /dev/null +++ b/src/advanced/ui/icons/shopping-bag.tsx @@ -0,0 +1,12 @@ +export function ShoppingBagIcon() { + return ( + + + + ); +} diff --git a/src/advanced/ui/index.ts b/src/advanced/ui/index.ts new file mode 100644 index 00000000..32a2e9c9 --- /dev/null +++ b/src/advanced/ui/index.ts @@ -0,0 +1,6 @@ +export * from './notification-item'; +export * from './product-list'; +export * from './cart'; +export * from './coupons'; +export * from './payments'; +export * from './header'; diff --git a/src/advanced/ui/notification-item.tsx b/src/advanced/ui/notification-item.tsx new file mode 100644 index 00000000..2c482f24 --- /dev/null +++ b/src/advanced/ui/notification-item.tsx @@ -0,0 +1,37 @@ +interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +type NotificationItemProps = { + notif: Notification; + onClose: () => void; +}; + +export function NotificationItem({ notif, onClose }: NotificationItemProps) { + return ( +
+ {notif.message} + +
+ ); +} diff --git a/src/advanced/ui/payments.tsx b/src/advanced/ui/payments.tsx new file mode 100644 index 00000000..be4aafb4 --- /dev/null +++ b/src/advanced/ui/payments.tsx @@ -0,0 +1,46 @@ +interface PaymentsProps { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + completeOrder: () => void; +} + +export function Payments({ totals, completeOrder }: PaymentsProps) { + return ( +
+

결제 정보

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

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

+
+
+ ); +} diff --git a/src/advanced/ui/product-list.tsx b/src/advanced/ui/product-list.tsx new file mode 100644 index 00000000..e01ce0df --- /dev/null +++ b/src/advanced/ui/product-list.tsx @@ -0,0 +1,113 @@ +import { ProductWithUI } from '../constants/mocks'; +import { PictureIcon } from './icons'; +import { formatPrice } from '../utils'; + +interface ProductListProps { + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + getRemainingStock: (product: ProductWithUI) => number; + isAdmin: boolean; + addToCart: (product: ProductWithUI) => void; +} + +export function ProductList({ + products, + filteredProducts, + debouncedSearchTerm, + getRemainingStock, + isAdmin, + addToCart, +}: ProductListProps) { + 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}

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

+ {getRemainingStock(product) <= 0 + ? 'SOLD OUT' + : formatPrice(product.price, { isAdmin, showSymbol: !isAdmin })} +

+ {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/ui/search-input.tsx b/src/advanced/ui/search-input.tsx new file mode 100644 index 00000000..1458e6f6 --- /dev/null +++ b/src/advanced/ui/search-input.tsx @@ -0,0 +1,22 @@ +interface SearchInputProps { + searchTerm: string; + onChange: (searchTerm: string) => void; +} + +export function SearchInput({ searchTerm, onChange }: SearchInputProps) { + const handleChange = (e: React.ChangeEvent) => { + onChange(e.target.value); + }; + + return ( +
+ +
+ ); +} diff --git a/src/advanced/utils/calculator.ts b/src/advanced/utils/calculator.ts new file mode 100644 index 00000000..8fa93996 --- /dev/null +++ b/src/advanced/utils/calculator.ts @@ -0,0 +1,59 @@ +import { CartItem, Coupon } from '../../types'; + +const getMaxApplicableDiscount = (item: CartItem, cart: 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; +}; + +export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon?: Coupon | null +): { + 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, cart); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === 'amount') { + totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; diff --git a/src/advanced/utils/filter.ts b/src/advanced/utils/filter.ts new file mode 100644 index 00000000..56f7fd7e --- /dev/null +++ b/src/advanced/utils/filter.ts @@ -0,0 +1,13 @@ +import { ProductWithUI } from '../constants/mocks'; + +export const filterProducts = (products: ProductWithUI[], searchTerm: string): ProductWithUI[] => { + if (!searchTerm) { + return products; + } + + return products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (product.description && product.description.toLowerCase().includes(searchTerm.toLowerCase())) + ); +}; diff --git a/src/advanced/utils/format.ts b/src/advanced/utils/format.ts new file mode 100644 index 00000000..5d6da778 --- /dev/null +++ b/src/advanced/utils/format.ts @@ -0,0 +1,30 @@ +const SOLD_OUT_MESSAGE = 'SOLD OUT'; + +interface PriceFormatOptions { + showSymbol?: boolean; + isSoldOut?: boolean; + isAdmin?: boolean; +} + +export const formatPrice = ( + price: number, + options: PriceFormatOptions = { + showSymbol: true, + isSoldOut: false, + isAdmin: false, + } +): string => { + if (options.isSoldOut) { + return SOLD_OUT_MESSAGE; + } + + if (options.isAdmin) { + return `${price.toLocaleString()}원`; + } + + if (options.showSymbol) { + return `₩${price.toLocaleString()}`; + } + + return price.toLocaleString(); +}; diff --git a/src/advanced/utils/index.ts b/src/advanced/utils/index.ts new file mode 100644 index 00000000..d7a870c9 --- /dev/null +++ b/src/advanced/utils/index.ts @@ -0,0 +1,5 @@ +export * from './format'; +export * from './calculator'; +export * from './inventory'; +export * from './filter'; +export * from './validator'; diff --git a/src/advanced/utils/inventory.ts b/src/advanced/utils/inventory.ts new file mode 100644 index 00000000..56ff7582 --- /dev/null +++ b/src/advanced/utils/inventory.ts @@ -0,0 +1,8 @@ +import { CartItem, Product } from '../../types'; + +export const getRemainingStock = (product: Product, cart: CartItem[]): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +}; diff --git a/src/advanced/utils/validator.ts b/src/advanced/utils/validator.ts new file mode 100644 index 00000000..306c72f7 --- /dev/null +++ b/src/advanced/utils/validator.ts @@ -0,0 +1,30 @@ +import { Coupon } from '../../types'; + +export const validateCouponApplication = ( + coupon: Coupon, + cartTotalAfterDiscount: number +): { isValid: boolean; errorMessage?: string } => { + if (cartTotalAfterDiscount < 10000 && coupon.discountType === 'percentage') { + return { + isValid: false, + errorMessage: 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + }; + } + + return { isValid: true }; +}; + +export const validateCouponCode = ( + newCouponCode: string, + existingCoupons: Coupon[] +): { isValid: boolean; errorMessage?: string } => { + const existingCoupon = existingCoupons.find((c) => c.code === newCouponCode); + if (existingCoupon) { + return { + isValid: false, + errorMessage: '이미 존재하는 쿠폰 코드입니다.', + }; + } + + return { isValid: true }; +}; From a82b67a7d4d6c29f4a957fe5bd453848de1dd958 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 8 Aug 2025 07:35:21 +0900 Subject: [PATCH 24/34] =?UTF-8?q?feat:=20props=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B3=80=EC=88=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/App.tsx | 18 +++--------------- src/advanced/contexts/ProductContext.tsx | 2 +- src/advanced/contexts/index.ts | 10 +++++----- src/advanced/pages/admin-dashboard.tsx | 4 ++-- src/advanced/pages/user-dashboard.tsx | 20 +++++--------------- src/advanced/ui/cart.tsx | 11 +++-------- src/advanced/ui/coupons.tsx | 12 +++--------- src/advanced/ui/header.tsx | 9 ++++----- src/advanced/ui/index.ts | 1 + src/advanced/ui/payments.tsx | 19 +++++++++++-------- 10 files changed, 38 insertions(+), 68 deletions(-) diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index 9a7d5438..8f000e2a 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,26 +1,18 @@ import { useState, useMemo } from 'react'; import { Header, NotificationItem } from './ui'; -import { CartModel, ProductModel } from './models'; -import { useDebounceValue, useTotalItemCount } from './hooks'; +import { ProductModel } from './models'; +import { useDebounceValue } from './hooks'; import { AdminDashboard, UserDashboard } from './pages'; -import { useNotifications, useProducts, useCoupons, useCart, AppProvider } from './contexts'; +import { useNotifications, useProducts, AppProvider } from './contexts'; function AppContent() { const [isAdmin, setIsAdmin] = useState(false); const { notifications, removeNotification } = useNotifications(); const { products } = useProducts(); - const { selectedCoupon } = useCoupons(); - const { cart } = useCart(); - const totalItemCount = useTotalItemCount(cart); const [searchTerm, setSearchTerm] = useState(''); const debouncedSearchTerm = useDebounceValue(searchTerm, 500); - const totals = useMemo(() => { - const cartModel = new CartModel(cart); - return cartModel.calculateTotal(selectedCoupon || undefined); - }, [cart, selectedCoupon]); - const filteredProducts = useMemo(() => { const productModel = new ProductModel(products); return productModel.filter(debouncedSearchTerm); @@ -28,7 +20,6 @@ function AppContent() { return (
- {/* Notification */} {notifications.length > 0 && (
{notifications.map((notif) => ( @@ -45,8 +36,6 @@ function AppContent() { searchTerm={searchTerm} setSearchTerm={setSearchTerm} setIsAdmin={setIsAdmin} - cart={cart} - totalItemCount={totalItemCount} />
{isAdmin ? ( @@ -56,7 +45,6 @@ function AppContent() { filteredProducts={filteredProducts} debouncedSearchTerm={debouncedSearchTerm} isAdmin={isAdmin} - totals={totals} /> )}
diff --git a/src/advanced/contexts/ProductContext.tsx b/src/advanced/contexts/ProductContext.tsx index 7f9c141d..14cfdee4 100644 --- a/src/advanced/contexts/ProductContext.tsx +++ b/src/advanced/contexts/ProductContext.tsx @@ -114,4 +114,4 @@ export function useProducts() { throw new Error('useProducts must be used within a ProductProvider'); } return context; -} \ No newline at end of file +} diff --git a/src/advanced/contexts/index.ts b/src/advanced/contexts/index.ts index 4cddf5e9..fdc0d390 100644 --- a/src/advanced/contexts/index.ts +++ b/src/advanced/contexts/index.ts @@ -1,5 +1,5 @@ -export { NotificationProvider, useNotifications } from './NotificationContext'; -export { ProductProvider, useProducts } from './ProductContext'; -export { CouponProvider, useCoupons } from './CouponContext'; -export { CartProvider, useCart } from './CartContext'; -export { AppProvider } from './AppProvider'; \ No newline at end of file +export { useNotifications } from './NotificationContext'; +export { useProducts } from './ProductContext'; +export { useCoupons } from './CouponContext'; +export { useCart } from './CartContext'; +export { AppProvider } from './AppProvider'; diff --git a/src/advanced/pages/admin-dashboard.tsx b/src/advanced/pages/admin-dashboard.tsx index 0eb62981..f1f073ae 100644 --- a/src/advanced/pages/admin-dashboard.tsx +++ b/src/advanced/pages/admin-dashboard.tsx @@ -18,7 +18,7 @@ export function AdminDashboard({ isAdmin }: AdminDashboardProps) { deleteProduct, handleProductSubmit, } = useProducts(); - + const { coupons, couponForm, @@ -28,7 +28,7 @@ export function AdminDashboard({ isAdmin }: AdminDashboardProps) { deleteCoupon, handleCouponSubmit, } = useCoupons(); - + const { addNotification } = useNotifications(); const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); const [editingProduct, setEditingProduct] = useState(null); diff --git a/src/advanced/pages/user-dashboard.tsx b/src/advanced/pages/user-dashboard.tsx index 078f576f..ccfa4286 100644 --- a/src/advanced/pages/user-dashboard.tsx +++ b/src/advanced/pages/user-dashboard.tsx @@ -1,24 +1,20 @@ -import { Coupon } from '@/types'; import { ProductWithUI } from '../constants/mocks'; import { Cart, Coupons, Payments, ProductList } from '../ui/index'; -import { useProducts, useCoupons, useCart } from '../contexts'; +import { useProducts, useCart } from '../contexts'; interface UserDashboardProps { filteredProducts: ProductWithUI[]; debouncedSearchTerm: string; isAdmin: boolean; - totals: { totalBeforeDiscount: number; totalAfterDiscount: number }; } export function UserDashboard({ filteredProducts, debouncedSearchTerm, isAdmin, - totals, }: UserDashboardProps) { const { products } = useProducts(); - const { coupons, selectedCoupon, setSelectedCoupon, applyCoupon } = useCoupons(); - const { cart, addToCart, removeFromCart, updateQuantity, getStock, completeOrder } = useCart(); + const { cart, addToCart, getStock } = useCart(); return (
@@ -34,18 +30,12 @@ export function UserDashboard({
- + {cart.length > 0 && ( <> - - {/* Payment */} - + + )}
diff --git a/src/advanced/ui/cart.tsx b/src/advanced/ui/cart.tsx index 85253677..218383da 100644 --- a/src/advanced/ui/cart.tsx +++ b/src/advanced/ui/cart.tsx @@ -1,14 +1,9 @@ -import { CartItem } from '../../types'; import { CloseIcon, EmptyBagIcon, ShoppingBagIcon } from './icons'; import { CartModel } from '../models/cart'; +import { useCart } from '../contexts'; -interface CartProps { - cart: CartItem[]; - removeFromCart: (productId: string) => void; - updateQuantity: (productId: string, quantity: number) => void; -} - -export function Cart({ cart, removeFromCart, updateQuantity }: CartProps) { +export function Cart() { + const { cart, removeFromCart, updateQuantity } = useCart(); const cartModel = new CartModel(cart); return ( diff --git a/src/advanced/ui/coupons.tsx b/src/advanced/ui/coupons.tsx index 83a4160d..a76f659a 100644 --- a/src/advanced/ui/coupons.tsx +++ b/src/advanced/ui/coupons.tsx @@ -1,13 +1,7 @@ -import { Coupon } from '@/types'; +import { useCoupons } from '../contexts'; -interface CouponsProps { - coupons: Coupon[]; - selectedCoupon: Coupon | null; - applyCoupon: (coupon: Coupon) => void; - setSelectedCoupon: (coupon: Coupon | null) => void; -} - -export function Coupons({ coupons, selectedCoupon, applyCoupon, setSelectedCoupon }: CouponsProps) { +export function Coupons() { + const { coupons, selectedCoupon, applyCoupon, setSelectedCoupon } = useCoupons(); return (
diff --git a/src/advanced/ui/header.tsx b/src/advanced/ui/header.tsx index 11d5cff7..44eddeb1 100644 --- a/src/advanced/ui/header.tsx +++ b/src/advanced/ui/header.tsx @@ -1,14 +1,13 @@ -import { CartItem } from '@/types'; import { CartIcon } from './icons'; import { SearchInput } from './search-input'; +import { useCart } from '../contexts'; +import { useTotalItemCount } from '../hooks'; interface HeaderProps { isAdmin: boolean; searchTerm: string; setSearchTerm: (term: string) => void; setIsAdmin: (isAdmin: boolean) => void; - cart: CartItem[]; - totalItemCount: number; } export function Header({ @@ -16,9 +15,9 @@ export function Header({ searchTerm, setSearchTerm, setIsAdmin, - cart, - totalItemCount, }: HeaderProps) { + const { cart } = useCart(); + const totalItemCount = useTotalItemCount(cart); return (
diff --git a/src/advanced/ui/index.ts b/src/advanced/ui/index.ts index 32a2e9c9..cc533f61 100644 --- a/src/advanced/ui/index.ts +++ b/src/advanced/ui/index.ts @@ -4,3 +4,4 @@ export * from './cart'; export * from './coupons'; export * from './payments'; export * from './header'; +export * from './icons'; diff --git a/src/advanced/ui/payments.tsx b/src/advanced/ui/payments.tsx index be4aafb4..5bf70440 100644 --- a/src/advanced/ui/payments.tsx +++ b/src/advanced/ui/payments.tsx @@ -1,12 +1,15 @@ -interface PaymentsProps { - totals: { - totalBeforeDiscount: number; - totalAfterDiscount: number; - }; - completeOrder: () => void; -} +import { useMemo } from 'react'; +import { useCart, useCoupons } from '../contexts'; +import { CartModel } from '../models'; + +export function Payments() { + const { cart, completeOrder } = useCart(); + const { selectedCoupon } = useCoupons(); -export function Payments({ totals, completeOrder }: PaymentsProps) { + const totals = useMemo(() => { + const cartModel = new CartModel(cart); + return cartModel.calculateTotal(selectedCoupon || undefined); + }, [cart, selectedCoupon]); return (

결제 정보

From 0aeb5118b031bf1125a65c5aab18ffd1a5e530c0 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 9 Aug 2025 00:53:52 +0900 Subject: [PATCH 25/34] feat: add deploy settings --- package.json | 5 +- pnpm-lock.yaml | 205 ++++++++++++++++++ src/refactoring(hint)/App.tsx | 12 - .../components/AdminPage.tsx | 20 -- src/refactoring(hint)/components/CartPage.tsx | 21 -- .../components/icons/index.tsx | 12 - .../components/ui/UIToast.ts | 0 src/refactoring(hint)/constants/index.ts | 8 - src/refactoring(hint)/hooks/useCart.ts | 29 --- src/refactoring(hint)/hooks/useCoupons.ts | 13 -- src/refactoring(hint)/hooks/useProducts.ts | 18 -- src/refactoring(hint)/main.tsx | 4 - src/refactoring(hint)/models/cart.ts | 18 -- src/refactoring(hint)/models/coupon.ts | 0 src/refactoring(hint)/models/discount.ts | 0 src/refactoring(hint)/models/product.ts | 0 src/refactoring(hint)/utils/formatters.ts | 7 - .../utils/hooks/useDebounce.ts | 11 - .../utils/hooks/useLocalStorage.ts | 15 -- .../utils/hooks/useValidate.ts | 0 src/refactoring(hint)/utils/validators.ts | 8 - vite.config.ts | 8 + 22 files changed, 217 insertions(+), 197 deletions(-) delete mode 100644 src/refactoring(hint)/App.tsx delete mode 100644 src/refactoring(hint)/components/AdminPage.tsx delete mode 100644 src/refactoring(hint)/components/CartPage.tsx delete mode 100644 src/refactoring(hint)/components/icons/index.tsx delete mode 100644 src/refactoring(hint)/components/ui/UIToast.ts delete mode 100644 src/refactoring(hint)/constants/index.ts delete mode 100644 src/refactoring(hint)/hooks/useCart.ts delete mode 100644 src/refactoring(hint)/hooks/useCoupons.ts delete mode 100644 src/refactoring(hint)/hooks/useProducts.ts delete mode 100644 src/refactoring(hint)/main.tsx delete mode 100644 src/refactoring(hint)/models/cart.ts delete mode 100644 src/refactoring(hint)/models/coupon.ts delete mode 100644 src/refactoring(hint)/models/discount.ts delete mode 100644 src/refactoring(hint)/models/product.ts delete mode 100644 src/refactoring(hint)/utils/formatters.ts delete mode 100644 src/refactoring(hint)/utils/hooks/useDebounce.ts delete mode 100644 src/refactoring(hint)/utils/hooks/useLocalStorage.ts delete mode 100644 src/refactoring(hint)/utils/hooks/useValidate.ts delete mode 100644 src/refactoring(hint)/utils/validators.ts diff --git a/package.json b/package.json index e930cdb9..cbd63a5a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", "build": "tsc -b && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "predeploy": "pnpm run build", + "deploy": "gh-pages -d dist" }, "dependencies": { "react": "^19.1.1", @@ -38,6 +40,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", + "gh-pages": "^6.3.0", "jsdom": "^26.1.0", "typescript": "^5.9.2", "vite": "^7.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21d291ce..cf79a7f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.20 version: 0.4.20(eslint@9.32.0) + gh-pages: + specifier: ^6.3.0 + version: 6.3.0 jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -774,6 +777,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'} @@ -809,6 +816,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'} @@ -883,6 +893,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==} @@ -964,6 +981,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'} @@ -981,6 +1002,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'} @@ -1197,10 +1221,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'} @@ -1219,6 +1259,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} @@ -1246,6 +1290,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'} @@ -1266,10 +1315,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==} @@ -1489,6 +1545,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'} @@ -1500,6 +1559,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'} @@ -1533,6 +1596,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'} @@ -1620,14 +1687,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'} @@ -1646,6 +1725,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==} @@ -1668,6 +1751,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'} @@ -1835,6 +1922,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'} @@ -1883,6 +1974,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'} @@ -1947,6 +2042,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'} @@ -1988,6 +2087,10 @@ packages: undici-types@7.10.0: resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + 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 @@ -2739,6 +2842,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 @@ -2798,6 +2903,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 @@ -2880,6 +2987,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: @@ -2952,6 +3063,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 @@ -2968,6 +3083,8 @@ snapshots: electron-to-chromium@1.5.194: {} + email-addresses@5.0.0: {} + entities@4.5.0: {} es-abstract@1.24.0: @@ -3317,10 +3434,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 @@ -3339,6 +3475,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 @@ -3379,6 +3521,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 @@ -3396,8 +3548,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: {} @@ -3632,6 +3795,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 @@ -3648,6 +3817,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 @@ -3674,6 +3847,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: {} @@ -3764,14 +3941,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 @@ -3786,6 +3973,8 @@ snapshots: path-parse@1.0.7: {} + path-type@4.0.0: {} + pathe@2.0.3: {} pathval@2.0.0: {} @@ -3798,6 +3987,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: @@ -4011,6 +4204,8 @@ snapshots: mrmime: 2.0.0 totalist: 3.0.1 + slash@3.0.0: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} @@ -4078,6 +4273,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 @@ -4129,6 +4328,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 @@ -4188,6 +4391,8 @@ snapshots: undici-types@7.10.0: {} + universalify@2.0.1: {} + update-browserslist-db@1.1.3(browserslist@4.25.1): dependencies: browserslist: 4.25.1 diff --git a/src/refactoring(hint)/App.tsx b/src/refactoring(hint)/App.tsx deleted file mode 100644 index d8cc004c..00000000 --- a/src/refactoring(hint)/App.tsx +++ /dev/null @@ -1,12 +0,0 @@ -// TODO: 메인 App 컴포넌트 -// 힌트: -// 1. isAdmin 상태를 관리하여 쇼핑몰/관리자 모드 전환 -// 2. 네비게이션 바에 모드 전환 버튼 포함 -// 3. 조건부 렌더링으로 CartPage 또는 AdminPage 표시 -// 4. 상태 관리는 각 페이지 컴포넌트에서 처리 (App은 라우팅만 담당) - -export function App() { - // TODO: 구현 -} - -export default App; \ No newline at end of file diff --git a/src/refactoring(hint)/components/AdminPage.tsx b/src/refactoring(hint)/components/AdminPage.tsx deleted file mode 100644 index afb5b1ae..00000000 --- a/src/refactoring(hint)/components/AdminPage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// TODO: 관리자 페이지 컴포넌트 -// 힌트: -// 1. 탭 UI로 상품 관리와 쿠폰 관리 분리 -// 2. 상품 추가/수정/삭제 기능 -// 3. 쿠폰 생성 기능 -// 4. 할인 규칙 설정 -// -// 필요한 hooks: -// - useProducts: 상품 CRUD -// - useCoupons: 쿠폰 CRUD -// -// 하위 컴포넌트: -// - ProductForm: 새 상품 추가 폼 -// - ProductAccordion: 상품 정보 표시 및 수정 -// - CouponForm: 새 쿠폰 추가 폼 -// - CouponList: 쿠폰 목록 표시 - -export function AdminPage() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/components/CartPage.tsx b/src/refactoring(hint)/components/CartPage.tsx deleted file mode 100644 index 069edafc..00000000 --- a/src/refactoring(hint)/components/CartPage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// TODO: 장바구니 페이지 컴포넌트 -// 힌트: -// 1. 상품 목록 표시 (검색 기능 포함) -// 2. 장바구니 관리 -// 3. 쿠폰 적용 -// 4. 주문 처리 -// -// 필요한 hooks: -// - useProducts: 상품 목록 관리 -// - useCart: 장바구니 상태 관리 -// - useCoupons: 쿠폰 목록 관리 -// - useDebounce: 검색어 디바운싱 -// -// 하위 컴포넌트: -// - SearchBar: 검색 입력 -// - ProductList: 상품 목록 표시 -// - Cart: 장바구니 표시 및 결제 - -export function CartPage() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/components/icons/index.tsx b/src/refactoring(hint)/components/icons/index.tsx deleted file mode 100644 index 1609d774..00000000 --- a/src/refactoring(hint)/components/icons/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -// TODO: SVG 아이콘 컴포넌트들 -// 구현할 아이콘: -// - CartIcon: 장바구니 아이콘 -// - AdminIcon: 관리자 아이콘 -// - PlusIcon: 플러스 아이콘 -// - MinusIcon: 마이너스 아이콘 -// - TrashIcon: 삭제 아이콘 -// - ChevronDownIcon: 아래 화살표 -// - ChevronUpIcon: 위 화살표 -// - CheckIcon: 체크 아이콘 - -// TODO: 구현 \ No newline at end of file diff --git a/src/refactoring(hint)/components/ui/UIToast.ts b/src/refactoring(hint)/components/ui/UIToast.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/constants/index.ts b/src/refactoring(hint)/constants/index.ts deleted file mode 100644 index bef3834f..00000000 --- a/src/refactoring(hint)/constants/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// TODO: 초기 데이터 상수 -// 정의할 상수들: -// - initialProducts: 초기 상품 목록 (상품1, 상품2, 상품3 + 설명 필드 포함) -// - initialCoupons: 초기 쿠폰 목록 (5000원 할인, 10% 할인) -// -// 참고: origin/App.tsx의 초기 데이터 구조를 참조 - -// TODO: 구현 \ No newline at end of file diff --git a/src/refactoring(hint)/hooks/useCart.ts b/src/refactoring(hint)/hooks/useCart.ts deleted file mode 100644 index 6db309aa..00000000 --- a/src/refactoring(hint)/hooks/useCart.ts +++ /dev/null @@ -1,29 +0,0 @@ -// 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: 장바구니 비우기 함수 - -export function useCart() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/hooks/useCoupons.ts b/src/refactoring(hint)/hooks/useCoupons.ts deleted file mode 100644 index d2ad82ab..00000000 --- a/src/refactoring(hint)/hooks/useCoupons.ts +++ /dev/null @@ -1,13 +0,0 @@ -// TODO: 쿠폰 관리 Hook -// 힌트: -// 1. 쿠폰 목록 상태 관리 (localStorage 연동 고려) -// 2. 쿠폰 추가/삭제 -// -// 반환할 값: -// - coupons: 쿠폰 배열 -// - addCoupon: 새 쿠폰 추가 -// - removeCoupon: 쿠폰 삭제 - -export function useCoupons() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/hooks/useProducts.ts b/src/refactoring(hint)/hooks/useProducts.ts deleted file mode 100644 index f4bef103..00000000 --- a/src/refactoring(hint)/hooks/useProducts.ts +++ /dev/null @@ -1,18 +0,0 @@ -// TODO: 상품 관리 Hook -// 힌트: -// 1. 상품 목록 상태 관리 (localStorage 연동 고려) -// 2. 상품 CRUD 작업 -// 3. 재고 업데이트 -// 4. 할인 규칙 추가/삭제 -// -// 반환할 값: -// - products: 상품 배열 -// - updateProduct: 상품 정보 수정 -// - addProduct: 새 상품 추가 -// - updateProductStock: 재고 수정 -// - addProductDiscount: 할인 규칙 추가 -// - removeProductDiscount: 할인 규칙 삭제 - -export function useProducts() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/main.tsx b/src/refactoring(hint)/main.tsx deleted file mode 100644 index 589b1645..00000000 --- a/src/refactoring(hint)/main.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// TODO: React 앱 엔트리 포인트 -// App 컴포넌트를 root DOM 요소에 렌더링 - -// TODO: 구현 \ No newline at end of file diff --git a/src/refactoring(hint)/models/cart.ts b/src/refactoring(hint)/models/cart.ts deleted file mode 100644 index 5c681048..00000000 --- a/src/refactoring(hint)/models/cart.ts +++ /dev/null @@ -1,18 +0,0 @@ -// TODO: 장바구니 비즈니스 로직 (순수 함수) -// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) -// -// 구현할 함수들: -// 1. calculateItemTotal(item): 개별 아이템의 할인 적용 후 총액 계산 -// 2. getMaxApplicableDiscount(item): 적용 가능한 최대 할인율 계산 -// 3. calculateCartTotal(cart, coupon): 장바구니 총액 계산 (할인 전/후, 할인액) -// 4. updateCartItemQuantity(cart, productId, quantity): 수량 변경 -// 5. addItemToCart(cart, product): 상품 추가 -// 6. removeItemFromCart(cart, productId): 상품 제거 -// 7. getRemainingStock(product, cart): 남은 재고 계산 -// -// 원칙: -// - UI와 관련된 로직 없음 -// - 외부 상태에 의존하지 않음 -// - 모든 필요한 데이터는 파라미터로 전달받음 - -// TODO: 구현 \ No newline at end of file diff --git a/src/refactoring(hint)/models/coupon.ts b/src/refactoring(hint)/models/coupon.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/models/discount.ts b/src/refactoring(hint)/models/discount.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/models/product.ts b/src/refactoring(hint)/models/product.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/utils/formatters.ts b/src/refactoring(hint)/utils/formatters.ts deleted file mode 100644 index ff157f5c..00000000 --- a/src/refactoring(hint)/utils/formatters.ts +++ /dev/null @@ -1,7 +0,0 @@ -// TODO: 포맷팅 유틸리티 함수들 -// 구현할 함수: -// - formatPrice(price: number): string - 가격을 한국 원화 형식으로 포맷 -// - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷 -// - formatPercentage(rate: number): string - 소수를 퍼센트로 변환 (0.1 → 10%) - -// TODO: 구현 \ No newline at end of file diff --git a/src/refactoring(hint)/utils/hooks/useDebounce.ts b/src/refactoring(hint)/utils/hooks/useDebounce.ts deleted file mode 100644 index 53c8a374..00000000 --- a/src/refactoring(hint)/utils/hooks/useDebounce.ts +++ /dev/null @@ -1,11 +0,0 @@ -// TODO: 디바운스 Hook -// 힌트: -// 1. 값이 변경되어도 지정된 시간 동안 대기 -// 2. 대기 시간 동안 값이 다시 변경되면 타이머 리셋 -// 3. 최종적으로 안정된 값만 반환 -// -// 사용 예시: 검색어 입력 디바운싱 - -export function useDebounce(value: T, delay: number): T { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts deleted file mode 100644 index 5dc72c50..00000000 --- a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts +++ /dev/null @@ -1,15 +0,0 @@ -// TODO: LocalStorage Hook -// 힌트: -// 1. localStorage와 React state 동기화 -// 2. 초기값 로드 시 에러 처리 -// 3. 저장 시 JSON 직렬화/역직렬화 -// 4. 빈 배열이나 undefined는 삭제 -// -// 반환값: [저장된 값, 값 설정 함수] - -export function useLocalStorage( - key: string, - initialValue: T -): [T, (value: T | ((val: T) => T)) => void] { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/utils/hooks/useValidate.ts b/src/refactoring(hint)/utils/hooks/useValidate.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/utils/validators.ts b/src/refactoring(hint)/utils/validators.ts deleted file mode 100644 index 7d2dda44..00000000 --- a/src/refactoring(hint)/utils/validators.ts +++ /dev/null @@ -1,8 +0,0 @@ -// TODO: 검증 유틸리티 함수들 -// 구현할 함수: -// - isValidCouponCode(code: string): boolean - 쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자) -// - isValidStock(stock: number): boolean - 재고 수량 검증 (0 이상) -// - isValidPrice(price: number): boolean - 가격 검증 (양수) -// - extractNumbers(value: string): string - 문자열에서 숫자만 추출 - -// TODO: 구현 \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 810b25b6..7e2db9b8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; import path from 'path'; +const base: string = process.env.NODE_ENV === 'production' ? '/front_6th_chapter2-1/' : ''; + export default mergeConfig( defineConfig({ plugins: [react()], @@ -11,6 +13,12 @@ export default mergeConfig( '@': path.resolve(__dirname, './src'), }, }, + base, + build: { + rollupOptions: { + input: 'index.advanced.html', + }, + }, }), defineTestConfig({ test: { From 9bbdd89e9e74809e0fc3671cc3b0e29ac999a66f Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 9 Aug 2025 00:57:01 +0900 Subject: [PATCH 26/34] feat: add 404 html --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cbd63a5a..2d545910 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:basic": "vitest src/basic", "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && cp ./dist/index.advanced.html ./dist/404.html", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "predeploy": "pnpm run build", "deploy": "gh-pages -d dist" From 542e104c2e94c31e3b10dc9cc9333adea6224952 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 9 Aug 2025 01:01:54 +0900 Subject: [PATCH 27/34] fix: add homepage link --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2d545910..96b624af 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { - "name": "assignment-5", + "name": "front-6th-chapter2-2", "private": true, "version": "0.0.0", "type": "module", + "homepage": "https://geonhwiii.github.io/front_6th_chapter2-2", "scripts": { "start:origin": "vite serve --open ./index.origin.html", "start:basic": "vite serve --open ./index.basic.html", From 606f2850b7c428abb5922a29bfcdcff06e13a49b Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 9 Aug 2025 01:02:44 +0900 Subject: [PATCH 28/34] fix: production link --- vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index 7e2db9b8..19d28b5e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; import path from 'path'; -const base: string = process.env.NODE_ENV === 'production' ? '/front_6th_chapter2-1/' : ''; +const base: string = process.env.NODE_ENV === 'production' ? '/front_6th_chapter2-2/' : ''; export default mergeConfig( defineConfig({ From f01d936fa9fa4436d3ed6204a2daf7fda8a28541 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 9 Aug 2025 13:09:12 +0900 Subject: [PATCH 29/34] fix: file name convention --- .../contexts/{AppProvider.tsx => app-provider.tsx} | 14 ++++++-------- .../contexts/{CartContext.tsx => cart-context.tsx} | 10 +++++----- .../{CouponContext.tsx => coupon-context.tsx} | 4 ++-- src/advanced/contexts/index.ts | 10 +++++----- ...icationContext.tsx => notification-context.tsx} | 0 .../{ProductContext.tsx => product-context.tsx} | 2 +- 6 files changed, 19 insertions(+), 21 deletions(-) rename src/advanced/contexts/{AppProvider.tsx => app-provider.tsx} (53%) rename src/advanced/contexts/{CartContext.tsx => cart-context.tsx} (95%) rename src/advanced/contexts/{CouponContext.tsx => coupon-context.tsx} (98%) rename src/advanced/contexts/{NotificationContext.tsx => notification-context.tsx} (100%) rename src/advanced/contexts/{ProductContext.tsx => product-context.tsx} (98%) diff --git a/src/advanced/contexts/AppProvider.tsx b/src/advanced/contexts/app-provider.tsx similarity index 53% rename from src/advanced/contexts/AppProvider.tsx rename to src/advanced/contexts/app-provider.tsx index e6273f38..00bbe72d 100644 --- a/src/advanced/contexts/AppProvider.tsx +++ b/src/advanced/contexts/app-provider.tsx @@ -1,8 +1,8 @@ import { ReactNode } from 'react'; -import { NotificationProvider } from './NotificationContext'; -import { ProductProvider } from './ProductContext'; -import { CouponProvider } from './CouponContext'; -import { CartProvider } from './CartContext'; +import { NotificationProvider } from './notification-context'; +import { ProductProvider } from './product-context'; +import { CouponProvider } from './coupon-context'; +import { CartProvider } from './cart-context'; interface AppProviderProps { children: ReactNode; @@ -13,11 +13,9 @@ export function AppProvider({ children }: AppProviderProps) { - - {children} - + {children} ); -} \ No newline at end of file +} diff --git a/src/advanced/contexts/CartContext.tsx b/src/advanced/contexts/cart-context.tsx similarity index 95% rename from src/advanced/contexts/CartContext.tsx rename to src/advanced/contexts/cart-context.tsx index 05cf9ae4..23e6d596 100644 --- a/src/advanced/contexts/CartContext.tsx +++ b/src/advanced/contexts/cart-context.tsx @@ -3,9 +3,9 @@ import { CartItem, Product } from '@/types'; import { useLocalStorage } from '../hooks/use-local-storage'; import { ProductWithUI } from '../constants/mocks'; import { CartModel } from '../models/cart'; -import { useNotifications } from './NotificationContext'; -import { useProducts } from './ProductContext'; -import { useCoupons } from './CouponContext'; +import { useNotifications } from './notification-context'; +import { useProducts } from './product-context'; +import { useCoupons } from './coupon-context'; interface CartContextType { cart: CartItem[]; @@ -39,7 +39,7 @@ export function CartProvider({ children }: CartProviderProps) { const addToCart = useCallback( (product: ProductWithUI) => { const remainingStock = getStock(product); - + if (remainingStock <= 0) { addNotification('재고가 부족합니다!', 'error'); return; @@ -126,4 +126,4 @@ export function useCart() { throw new Error('useCart must be used within a CartProvider'); } return context; -} \ No newline at end of file +} diff --git a/src/advanced/contexts/CouponContext.tsx b/src/advanced/contexts/coupon-context.tsx similarity index 98% rename from src/advanced/contexts/CouponContext.tsx rename to src/advanced/contexts/coupon-context.tsx index bf6630df..0758d141 100644 --- a/src/advanced/contexts/CouponContext.tsx +++ b/src/advanced/contexts/coupon-context.tsx @@ -4,7 +4,7 @@ import { useLocalStorage } from '../hooks/use-local-storage'; import { Coupon } from '@/types'; import { CouponModel } from '../models'; import { INITIAL_COUPON_FORM } from '../constants/forms'; -import { useNotifications } from './NotificationContext'; +import { useNotifications } from './notification-context'; interface CouponContextType { coupons: Coupon[]; @@ -99,4 +99,4 @@ export function useCoupons() { throw new Error('useCoupons must be used within a CouponProvider'); } return context; -} \ No newline at end of file +} diff --git a/src/advanced/contexts/index.ts b/src/advanced/contexts/index.ts index fdc0d390..fa315b2e 100644 --- a/src/advanced/contexts/index.ts +++ b/src/advanced/contexts/index.ts @@ -1,5 +1,5 @@ -export { useNotifications } from './NotificationContext'; -export { useProducts } from './ProductContext'; -export { useCoupons } from './CouponContext'; -export { useCart } from './CartContext'; -export { AppProvider } from './AppProvider'; +export { useNotifications } from './notification-context'; +export { useProducts } from './product-context'; +export { useCoupons } from './coupon-context'; +export { useCart } from './cart-context'; +export { AppProvider } from './app-provider'; diff --git a/src/advanced/contexts/NotificationContext.tsx b/src/advanced/contexts/notification-context.tsx similarity index 100% rename from src/advanced/contexts/NotificationContext.tsx rename to src/advanced/contexts/notification-context.tsx diff --git a/src/advanced/contexts/ProductContext.tsx b/src/advanced/contexts/product-context.tsx similarity index 98% rename from src/advanced/contexts/ProductContext.tsx rename to src/advanced/contexts/product-context.tsx index 14cfdee4..363c2bf2 100644 --- a/src/advanced/contexts/ProductContext.tsx +++ b/src/advanced/contexts/product-context.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, ReactNode, useCallback, useState } from 'rea import { initialProducts, ProductWithUI } from '../constants/mocks'; import { useLocalStorage } from '../hooks/use-local-storage'; import { INITIAL_PRODUCT_FORM, EDITING_STATES } from '../constants/forms'; -import { useNotifications } from './NotificationContext'; +import { useNotifications } from './notification-context'; interface ProductContextType { products: ProductWithUI[]; From 6781597e568288ed461742a919ee8ee618b6e390 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 9 Aug 2025 13:19:02 +0900 Subject: [PATCH 30/34] =?UTF-8?q?feat:=20notification=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=20=EB=B0=8F=20=ED=95=A8=EC=88=98=EB=AA=85=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/App.tsx | 21 +++++---------------- src/advanced/pages/user-dashboard.tsx | 2 +- src/advanced/ui/header.tsx | 16 +++++----------- src/advanced/ui/index.ts | 1 + src/advanced/ui/notifications.tsx | 18 ++++++++++++++++++ src/advanced/ui/product-list.tsx | 6 +++--- 6 files changed, 33 insertions(+), 31 deletions(-) create mode 100644 src/advanced/ui/notifications.tsx diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index 8f000e2a..5649f7e5 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,13 +1,12 @@ import { useState, useMemo } from 'react'; -import { Header, NotificationItem } from './ui'; +import { Header, Notifications } from './ui'; import { ProductModel } from './models'; import { useDebounceValue } from './hooks'; import { AdminDashboard, UserDashboard } from './pages'; -import { useNotifications, useProducts, AppProvider } from './contexts'; +import { useProducts, AppProvider } from './contexts'; function AppContent() { const [isAdmin, setIsAdmin] = useState(false); - const { notifications, removeNotification } = useNotifications(); const { products } = useProducts(); const [searchTerm, setSearchTerm] = useState(''); @@ -20,22 +19,12 @@ function AppContent() { return (
- {notifications.length > 0 && ( -
- {notifications.map((notif) => ( - removeNotification(notif.id)} - /> - ))} -
- )} +
setIsAdmin((prev) => !prev)} />
{isAdmin ? ( diff --git a/src/advanced/pages/user-dashboard.tsx b/src/advanced/pages/user-dashboard.tsx index ccfa4286..972e42ec 100644 --- a/src/advanced/pages/user-dashboard.tsx +++ b/src/advanced/pages/user-dashboard.tsx @@ -19,8 +19,8 @@ export function UserDashboard({
void; - setIsAdmin: (isAdmin: boolean) => void; + onSearchChange: (term: string) => void; + onToggleAdmin: () => void; } -export function Header({ - isAdmin, - searchTerm, - setSearchTerm, - setIsAdmin, -}: HeaderProps) { +export function Header({ isAdmin, searchTerm, onSearchChange, onToggleAdmin }: HeaderProps) { const { cart } = useCart(); const totalItemCount = useTotalItemCount(cart); return ( @@ -24,12 +19,11 @@ export function Header({

SHOP

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

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.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), + }); + } + }} + 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' + 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.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}개 상품 -
+
+

전체 상품

+
총 {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 +1250,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 +1330,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; From 515615bb7dac92978a9522499ae63170dda2dfb5 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 9 Aug 2025 13:43:59 +0900 Subject: [PATCH 33/34] =?UTF-8?q?fix:=20app=20content=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/App.tsx | 43 +++--------------------------- src/advanced/pages/app-content.tsx | 36 +++++++++++++++++++++++++ src/advanced/pages/index.ts | 3 +-- 3 files changed, 41 insertions(+), 41 deletions(-) create mode 100644 src/advanced/pages/app-content.tsx diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index 4b190696..4b473d9b 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,45 +1,10 @@ -import { useState } from 'react'; -import { Header, Notifications } from './ui'; -import { AdminDashboard, UserDashboard } from './pages'; -import { useProducts, AppProvider } from './contexts'; -import { useFilteredProducts } from '@/shared/hooks/use-filtered-products'; - -function AppContent() { - const [isAdmin, setIsAdmin] = useState(false); - const { products } = useProducts(); - const { searchTerm, setSearchTerm, debouncedSearchTerm, filteredProducts } = - useFilteredProducts(products); +import { AppContent } from './pages'; +import { AppProvider } from './contexts'; - return ( -
- -
setIsAdmin((prev) => !prev)} - /> -
- {isAdmin ? ( - - ) : ( - - )} -
-
- ); -} - -const App = () => { +export default function App() { return ( ); -}; - -export default App; +} diff --git a/src/advanced/pages/app-content.tsx b/src/advanced/pages/app-content.tsx new file mode 100644 index 00000000..0e86cfe5 --- /dev/null +++ b/src/advanced/pages/app-content.tsx @@ -0,0 +1,36 @@ +import { useFilteredProducts } from '@/shared/hooks'; +import { useState } from 'react'; +import { Header, Notifications } from '../ui'; +import { AdminDashboard } from './admin-dashboard'; +import { UserDashboard } from './user-dashboard'; +import { useProducts } from '../contexts'; + +export function AppContent() { + const [isAdmin, setIsAdmin] = useState(false); + const { products } = useProducts(); + const { searchTerm, setSearchTerm, debouncedSearchTerm, filteredProducts } = + useFilteredProducts(products); + + return ( +
+ +
setIsAdmin((prev) => !prev)} + /> +
+ {isAdmin ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/advanced/pages/index.ts b/src/advanced/pages/index.ts index 64add449..5e9f7774 100644 --- a/src/advanced/pages/index.ts +++ b/src/advanced/pages/index.ts @@ -1,2 +1 @@ -export * from './admin-dashboard'; -export * from './user-dashboard'; +export * from './app-content'; From e1c11c4c60b52247504b419039ff279c1ee02932 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 9 Aug 2025 13:51:19 +0900 Subject: [PATCH 34/34] fix: remove unused files --- src/advanced/entities/cart/index.ts | 1 - src/advanced/entities/cart/model/cart.ts | 26 ----------------------- src/advanced/entities/cart/model/index.ts | 1 - 3 files changed, 28 deletions(-) delete mode 100644 src/advanced/entities/cart/index.ts delete mode 100644 src/advanced/entities/cart/model/cart.ts delete mode 100644 src/advanced/entities/cart/model/index.ts diff --git a/src/advanced/entities/cart/index.ts b/src/advanced/entities/cart/index.ts deleted file mode 100644 index 9f8ccadd..00000000 --- a/src/advanced/entities/cart/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './model'; diff --git a/src/advanced/entities/cart/model/cart.ts b/src/advanced/entities/cart/model/cart.ts deleted file mode 100644 index 71acac2f..00000000 --- a/src/advanced/entities/cart/model/cart.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { CartItem } from '@/types'; - -const getMaxApplicableDiscount = (item: CartItem, hasBulkPurchase: boolean): 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); - - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; -}; - -export const calculateItemTotal = (item: CartItem, hasBulkPurchase: boolean): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item, hasBulkPurchase); - - return Math.round(price * quantity * (1 - discount)); -}; diff --git a/src/advanced/entities/cart/model/index.ts b/src/advanced/entities/cart/model/index.ts deleted file mode 100644 index cbcb0736..00000000 --- a/src/advanced/entities/cart/model/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './cart';