diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..e20ce01ae --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,63 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: 'pages' + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build:deploy + + - name: Set advanced as default + run: cp dist/index.advanced.html dist/index.html + + - name: Copy 404.html + run: cp dist/index.html dist/404.html + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..be0504cd0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules +dist +build +*.min.js +pnpm-lock.yaml + diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..3298049b1 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 80, + "arrowParens": "always" +} + diff --git a/README.md b/README.md index e38f1e44b..13844716b 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,31 @@ # Chapter3-2. 디자인 패턴과 함수형 프로그래밍 그리고 상태 관리 설계 -## 기본과제: 거대 단일 컴포넌트 리팩토링 +함수형 프로그래밍에서 말하는 액션, 계산, 데이터의 개념을 정리하고 그 원칙에 따라 리팩토링 했다. +그리고 그냥 내 맘대로, 감으로 익힌 느낌으로 개발을 할 때는 다른 작업자에게 어떤 방향으로 개발해야 하는지 명확한 방법과 이유를 설명하지 못했었는데, 언어를 알게 되어서 도움이 되었다. -이번 과제는 단일책임원칙을 위반한 거대한 컴포넌트를 리팩토링 하는 것입니다. React의 컴포넌트는 단일 책임 원칙(Single Responsibility Principle, SRP)을 따르는 것이 좋습니다. 즉, 각 컴포넌트는 하나의 책임만을 가져야 합니다. 하지만 실제로는 여러 가지 기능을 가진 거대한 컴포넌트를 작성하는 경우가 많습니다. +이 과정에선 약 1000줄에 임박한 단일 컴포넌트인 `App.tsx`를 전면 개편했다. +기존과 동일하게 작동하면서 오류가 생기지 않게 작업하기 위해 우선적으로 페이지 단위의 큰 것부터 컴포넌트를 분리하고 모든 데이터의 전달을 props로 처리했다. +그리고 cart, coupon...등의 엔티티 단위를 나누어 커스텀 훅으로 만들었다. -[목표] -## 1. 취지 -- React의 추구미(!)를 이해해보아요! -- 단일 책임 원칙(SRP)을 위반한 거대한 컴포넌트가 얼마나 안 좋은지 경험해보아요! -- 단일 책임이라는 개념을 이해하기 상태, 순수함수, 컴포넌트, 훅 등 다양한 계층을 이해해합니다. -- 엔티티와 UI를 구분하고 데이터, 상태, 비즈니스 로직 등의 특징이 다르다는 것을 이해해보세요. -- 이를 통해 적절한 Custom Hook과 유틸리티 함수를 분리하고, 컴포넌트 계층 구조를 정리하는 능력을 갖춥니다! +basic은 상태 관리 라이브러리나 Context API를 사용하지 않고 리팩토링 한 것이고, advanced는 Context API를 사용하여 Props drilling을 해소한 버전이다. +basic을 마치고 advanced로 넘어가며, 이 지긋지긋한 props drilling을 감내하였으니 이제 context로 전환하는 작업만 더해서, 최종적으로 코드를 보기 좋게 정리하려 했다. -## 2. 목표 +하나는 상태 관리에 대한 것이었는데 나는 Context API를 그저 props drilling을 없애기 위한 목적으로만 사용할 생각이었다. +그런데 'Context는 Props drilling을 해결하기 위한 것이 아니다'라는 말을 보게 되고, 일단 그 말도 헷갈렸지만 그래서 상태관리는 무엇인가 하는 근원적인 의문에 빠져들었다. +(원래같았더라면 API를 사용하여 조회하는 부분을 클라이언트에서 다루어야 했기에 이런 구조가 되었다. 실제 프로젝트였다면 서버 상태는 Tasntack Query를 사용하는 선으로 정리됐을 것이다.) -모든 소프트웨어에는 적절한 책임과 계층이 존재합니다. 하나의 계층(Component)만으로 소프트웨어를 구성하게 되면 나중에는 정리정돈이 되지 않은 코드를 만나게 됩니다. 예전에는 이러한 BestPractice에 대해서 혼돈의 시대였지만 FE가 진화를 거듭하는 과정에서 적절한 계측에 대한 합의가 이루어지고 있는 상태입니다. +사실 일반적인 웹 프로젝트라면 상태 관리를 꼭 써야 할 일이 많지는 않은 듯 하다. +현실 세계와는 좀 다른 결과물이겠으나, +서버에서 데이터를 받아오는 것 자체가 액션이고, 그걸 화면에 보여주기 위해 가공하는 건 계산이며, 가공된 결과물은 데이터다-이런식으로 분리하는 원칙을 챙겨서 리액트의 추구미를 느껴보았다. -React의 주요 책임 계층은 Component, hook, function 등이 있습니다. 그리고 주요 분류 기준은 **엔티티**가 되어 줍니다. +`useEffect`훅을 거의 생명주기를 대체하여 사용하고 있으나 사실 생명주기와 동일하거나, 그것을 대체하기 위해 만들어진 훅이 아니라는 말은 여기저기서 많이 보았다. +함수형 프로그래밍 관점에서 보면 useEffect는 액션을 다루는 곳이다. API 호출, 구독 설정, 타이머, DOM 직접 조작 같은 부수효과들을 컴포넌트 렌더링 로직(계산)과 분리해서 관리하는 것. +렌더링 자체는 순수해야 하고, 부수효과는 useEffect 안에 격리시키는 것이다. -- 엔티티를 다루는 상태와 그렇지 않은 상태 - cart, isCartFull vs isShowPopup -- 엔티티를 다루는 컴포넌트와 훅 - CartItemView, useCart(), useProduct() -- 엔티티를 다루지 않는 컴포넌트와 훅 - Button, useRoute, useEvent 등 -- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) +나는 이미 함수형 컴포넌트가 탄생되어 보급된 후의 세상에서 개발을 시작했기 때문에 순수한 렌더링만 하던 시절의 함수형 컴포넌트와 그 역사를 체감하지 못했으나 함수형 프로그래밍에 대해 알아보면서 이런 식으로 발전한 이유를 따라갈 수 있었다. -이번 과제의 목표는 이러한 계층을 이해하고 분리하여 정리정돈을 하는 기준이나 방법등을 습득하는데 있습니다. +다른 하나는 폴더 구조에 대한 것인데 사실 폴더 구조라는 것은 다른 많은 개발자들도 많이 고민하는 부분이라 내가 결론을 내리기도 어려운 부분이다. +다른 분들과 대화를 나누거나 발제를 들으며 생각했지만 역시 폴더 구조가 불편해지는 것은 2depth부터 규칙이 없기 떄문인 듯 하다. -제시된 코드는 각각의 컴포넌트에 모든 비즈니스 로직이 작성되어 있습니다. 여기에서 custom hook과 util 함수를 적절하게 분리하고, **테스트 코드를 통과할 수 있도록 해주세요.** - -> basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. - -### (1) 요구사항 - -#### 1) 장바구니 페이지 요구사항 - -- 상품 목록 - - 상품명, 가격, 재고 수량 등을 표시 - - 각 상품의 할인 정보 표시 - - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 -- 장바구니 - - 장바구니 내 상품 수량 조절 가능 - - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 - - 적용된 할인율 표시 (예: "10% 할인 적용") - - 장바구니 내 모든 상품의 총액을 계산해야 -- 쿠폰 할인 - - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 -- 주문요약 - - 할인 전 총 금액 - - 총 할인 금액 - - 최종 결제 금액 - -#### 2) 관리자 페이지 요구사항 - -- 상품 관리 - - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 - - 새로운 상품 추가 가능 - - 상품 제거 가능 -- 할인 관리 - - 상품별 할인 정보 추가/수정/삭제 가능 - - 할인 조건 설정 (구매 수량에 따른 할인율) -- 쿠폰 관리 - - 전체 상품에 적용 가능한 쿠폰 생성 - - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) - - 할인 유형은 금액 또는 비율로 설정 가능 - -### (2) 코드 개선 요구사항 - -#### 1) cart, product에 대한 계산 함수 분리 - -- calculateItemTotal -- getMaxApplicableDiscount -- calculateCartTotal -- updateCartItemQuantity - -#### 2) 상태를 다루는 hook, 유틸리티 hook 분리 - -- useCart -- useCoupon -- useProduct -- useLocalStorage - -#### 3) 엔티티 컴포넌트와 UI 컴포넌트 분리하여 계층구조 만들기 - -- ProductCard -- Cart -- … - -### (3) 테스트 코드 통과하기 - - - -## 심화과제: Props drilling -> **이번 심화과제는 Props drilling을 없애기 입니다.** - -# 2. 목표 - -- basic에서 열심히 컴포넌트를 분리해주었겠죠? -- 아마 그 과정에서 container - presenter 패턴으로 만들어졌기에 props drilling이 상당히 불편했을거에요. -- 그래서 심화과제에서는 props drilling을 제거하는 작업을 할거에요. - - 전역상태관리가 아직 낯설다. - jotai를 선택해주세요 (참고자료 참고) - - 나는 React만으로 해보고 싶다. - context를 선택해서 상태관리를 해보세요. - - 나는 지금 대세인 Zustand를 할거에요. - zustand를 선택해주세요. - - -### (1) 요구사항 - -- 불필요한 props를 제거하고, 필요한 props만을 전달하도록 개선합니다. -- Context나 Jotai 혹은 Zustand를 사용하여 상태를 관리합니다. -- 테스트 코드를 통과합니다. - -### (2) 힌트 - -- UI 컴포넌트와 엔티티 컴포넌트는 각각 props를 다르게 받는게 좋습니다. - - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, - - 엔티티 컴포넌트는 가급적 엔티티를 중심으로 전달받는 것이 좋습니다. -- 특히 콜백의 경우, - - UI 컴포넌트는 이벤트 핸들러를 props로 받아서 처리하도록 해서 재사용성을 높이지만, - - 엔티티 컴포넌트는 props가 아닌 컴포넌트 내부에서 상태를 관리하는 것이 좋습니다. \ No newline at end of file +FSD 아키텍처가 이런 부분을 해결할 수 있는 방법이라고 하는데 일 하면서 써본 적은 없다. 그래서 조금씩 알아보는 중. diff --git a/package.json b/package.json index 17b18de25..006a6e72c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", "build": "tsc -b && vite build", + "build:advanced": "tsc -b && vite build --config vite.config.ts", + "build:deploy": "tsc -b && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { @@ -33,6 +35,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "jsdom": "^26.1.0", + "prettier": "^3.7.2", "typescript": "^5.9.2", "vite": "^7.0.6", "vitest": "^3.2.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85f..48ce4e901 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 + prettier: + specifier: ^3.7.2 + version: 3.7.2 typescript: specifier: ^5.9.2 version: 5.9.2 @@ -1211,6 +1214,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.7.2: + resolution: {integrity: sha512-n3HV2J6QhItCXndGa3oMWvWFAgN1ibnS7R9mt6iokScBOC0Ul9/iZORmU2IWUMcyAQaMPjTlY3uT34TqocUxMA==} + 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} @@ -2559,6 +2567,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.7.2: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1d..85afbae51 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1124 +1,42 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import Admin from './pages/admin/AdminPage'; +import Cart from './pages/CartPage'; +import Notifications from './components/Notifications'; +import { useViewMode } from './hooks/useViewMode'; +import Header from './components/Header'; +import { AppProviders, useNotificationContext } from './contexts'; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} +const AppContent = () => { + const { viewMode, toggleViewMode, isCartView, isAdminView } = + useViewMode('cart'); -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; - -const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [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 { notifications, setNotifications } = useNotificationContext(); return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

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

관리자 대시보드

-

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

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

상품 목록

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

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

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.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()}원 -
-
- - - -
-

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

-
-
- - )} -
-
-
- )} + {viewMode === 'admin' ? : }
); }; -export default App; \ No newline at end of file +const App = () => { + return ( + + + + ); +}; + +export default App; diff --git a/src/advanced/__tests__/origin.test.tsx b/src/advanced/__tests__/origin.test.tsx index 3f5c3d55e..38dfbc803 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -1,5 +1,11 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + within, + waitFor, +} from '@testing-library/react'; import { vi } from 'vitest'; import App from '../App'; import '../../setupTests'; @@ -20,25 +26,30 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('고객 쇼핑 플로우', () => { test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { render(); - + // 검색창에 "프리미엄" 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 디바운스 대기 - await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - }, { timeout: 600 }); - + await waitFor( + () => { + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.') + ).toBeInTheDocument(); + }, + { timeout: 600 } + ); + // 검색된 상품을 장바구니에 추가 (첫 번째 버튼 선택) const addButtons = screen.getAllByText('장바구니 담기'); fireEvent.click(addButtons[0]); - + // 알림 메시지 확인 await waitFor(() => { expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); }); - + // 장바구니에 추가됨 확인 (장바구니 섹션에서) const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -46,64 +57,66 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { render(); - + // 상품1을 장바구니에 추가 const product1 = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(product1); - + // 수량을 10개로 증가 (10% 할인 적용) const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 9; i++) { fireEvent.click(plusButton); } - + // 10% 할인 적용 확인 - 15% (대량 구매 시 추가 5% 포함) expect(screen.getByText('-15%')).toBeInTheDocument(); }); test('쿠폰을 선택하고 적용할 수 있다', () => { render(); - + // 상품 추가 const addButton = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(addButton); - + // 쿠폰 선택 const couponSelect = screen.getByRole('combobox'); fireEvent.change(couponSelect, { target: { value: 'AMOUNT5000' } }); - + // 결제 정보에서 할인 금액 확인 const paymentSection = screen.getByText('결제 정보').closest('section'); - const discountRow = within(paymentSection).getByText('할인 금액').closest('div'); + const discountRow = within(paymentSection) + .getByText('할인 금액') + .closest('div'); expect(within(discountRow).getByText('-5,000원')).toBeInTheDocument(); }); test('품절 임박 상품에 경고가 표시된다', async () => { render(); - + // 관리자 모드로 전환 fireEvent.click(screen.getByText('관리자 페이지로')); - + // 상품 수정 const editButton = screen.getAllByText('수정')[0]; fireEvent.click(editButton); - + // 재고를 5개로 변경 const stockInputs = screen.getAllByPlaceholderText('숫자만 입력'); const stockInput = stockInputs[1]; // 재고 입력 필드는 두 번째 fireEvent.change(stockInput, { target: { value: '5' } }); fireEvent.blur(stockInput); - + // 수정 완료 버튼 클릭 const editButtons = screen.getAllByText('수정'); const completeEditButton = editButtons[editButtons.length - 1]; // 마지막 수정 버튼 (완료 버튼) fireEvent.click(completeEditButton); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 품절임박 메시지 확인 - 재고가 5개 이하면 품절임박 표시 await waitFor(() => { expect(screen.getByText('품절임박! 5개 남음')).toBeInTheDocument(); @@ -112,39 +125,39 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('주문을 완료할 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 결제하기 버튼 클릭 const orderButton = screen.getByText(/원 결제하기/); fireEvent.click(orderButton); - + // 주문 완료 알림 확인 expect(screen.getByText(/주문이 완료되었습니다/)).toBeInTheDocument(); - + // 장바구니가 비어있는지 확인 expect(screen.getByText('장바구니가 비어있습니다')).toBeInTheDocument(); }); test('장바구니에서 상품을 삭제할 수 있다', () => { render(); - + // 상품 2개 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 장바구니 섹션 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); - + // 첫 번째 상품 삭제 (X 버튼) - const deleteButtons = within(cartSection).getAllByRole('button').filter( - button => button.querySelector('svg') - ); + const deleteButtons = within(cartSection) + .getAllByRole('button') + .filter((button) => button.querySelector('svg')); fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되고 상품2만 남음 expect(within(cartSection).queryByText('상품1')).not.toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); @@ -152,54 +165,56 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('재고를 초과하여 구매할 수 없다', async () => { render(); - + // 상품1 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 재고(20개) 이상으로 증가 시도 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + // 19번 클릭하여 총 20개로 만듦 for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 한 번 더 클릭 시도 (21개가 되려고 함) fireEvent.click(plusButton); - + // 수량이 20개에서 멈춰있어야 함 expect(within(cartSection).getByText('20')).toBeInTheDocument(); - + // 재고 부족 메시지 확인 await waitFor(() => { - expect(screen.getByText(/재고는.*개까지만 있습니다/)).toBeInTheDocument(); + expect( + screen.getByText(/재고는.*개까지만 있습니다/) + ).toBeInTheDocument(); }); }); test('장바구니에서 수량을 감소시킬 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); const minusButton = within(cartSection).getByText('−'); // U+2212 마이너스 기호 - + // 수량 3개로 증가 fireEvent.click(plusButton); fireEvent.click(plusButton); expect(within(cartSection).getByText('3')).toBeInTheDocument(); - + // 수량 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('2')).toBeInTheDocument(); - + // 1개로 더 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('1')).toBeInTheDocument(); - + // 1개에서 한 번 더 감소하면 장바구니에서 제거될 수도 있음 fireEvent.click(minusButton); // 장바구니가 비었는지 확인 @@ -214,31 +229,31 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('20개 이상 구매 시 최대 할인이 적용된다', async () => { render(); - + // 관리자 모드로 전환하여 상품1의 재고를 늘림 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getAllByText('수정')[0]); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '30' } }); - + const editButtons = screen.getAllByText('수정'); fireEvent.click(editButtons[editButtons.length - 1]); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 상품1을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 20개로 증가 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 25% 할인 적용 확인 (또는 대량 구매 시 30%) await waitFor(() => { const discount25 = screen.queryByText('-25%'); @@ -258,27 +273,27 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('새 상품을 추가할 수 있다', () => { // 새 상품 추가 버튼 클릭 fireEvent.click(screen.getByText('새 상품 추가')); - + // 폼 입력 - 상품명 입력 const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '테스트 상품' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '25000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '50' } }); - + const descLabels = screen.getAllByText('설명'); - const descLabel = descLabels.find(el => el.tagName === 'LABEL'); + const descLabel = descLabels.find((el) => el.tagName === 'LABEL'); const descInput = descLabel.closest('div').querySelector('input'); fireEvent.change(descInput, { target: { value: '테스트 설명' } }); - + // 저장 fireEvent.click(screen.getByText('추가')); - + // 추가된 상품 확인 expect(screen.getByText('테스트 상품')).toBeInTheDocument(); expect(screen.getByText('25,000원')).toBeInTheDocument(); @@ -287,21 +302,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 버튼 클릭 const addCouponButton = screen.getByText('새 쿠폰 추가'); fireEvent.click(addCouponButton); - + // 쿠폰 정보 입력 - fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { target: { value: '테스트 쿠폰' } }); - fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { target: { value: 'TEST2024' } }); - + fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { + target: { value: '테스트 쿠폰' }, + }); + fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { + target: { value: 'TEST2024' }, + }); + const discountInput = screen.getByPlaceholderText('5000'); fireEvent.change(discountInput, { target: { value: '7000' } }); - + // 쿠폰 생성 fireEvent.click(screen.getByText('쿠폰 생성')); - + // 생성된 쿠폰 확인 expect(screen.getByText('테스트 쿠폰')).toBeInTheDocument(); expect(screen.getByText('TEST2024')).toBeInTheDocument(); @@ -311,25 +330,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('상품의 가격 입력 시 숫자만 허용된다', async () => { // 상품 수정 fireEvent.click(screen.getAllByText('수정')[0]); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - + // 문자와 숫자 혼합 입력 시도 - 숫자만 남음 fireEvent.change(priceInput, { target: { value: 'abc123def' } }); expect(priceInput.value).toBe('10000'); // 유효하지 않은 입력은 무시됨 - + // 숫자만 입력 fireEvent.change(priceInput, { target: { value: '123' } }); expect(priceInput.value).toBe('123'); - + // 음수 입력 시도 - regex가 매치되지 않아 값이 변경되지 않음 fireEvent.change(priceInput, { target: { value: '-100' } }); expect(priceInput.value).toBe('123'); // 이전 값 유지 - + // 유효한 음수 입력하기 위해 먼저 1 입력 후 앞에 - 추가는 불가능 // 대신 blur 이벤트를 통해 음수 검증을 테스트 // parseInt()는 실제로 음수를 파싱할 수 있으므로 다른 방법으로 테스트 - + // 공백 입력 시도 fireEvent.change(priceInput, { target: { value: ' ' } }); expect(priceInput.value).toBe('123'); // 유효하지 않은 입력은 무시됨 @@ -338,23 +357,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 할인율 검증이 작동한다', async () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 fireEvent.click(screen.getByText('새 쿠폰 추가')); - + // 퍼센트 타입으로 변경 - 쿠폰 폼 내의 select 찾기 const couponFormSelects = screen.getAllByRole('combobox'); const typeSelect = couponFormSelects[couponFormSelects.length - 1]; // 마지막 select가 타입 선택 fireEvent.change(typeSelect, { target: { value: 'percentage' } }); - + // 100% 초과 할인율 입력 const discountInput = screen.getByPlaceholderText('10'); fireEvent.change(discountInput, { target: { value: '150' } }); fireEvent.blur(discountInput); - + // 에러 메시지 확인 await waitFor(() => { - expect(screen.getByText('할인율은 100%를 초과할 수 없습니다')).toBeInTheDocument(); + expect( + screen.getByText('할인율은 100%를 초과할 수 없습니다') + ).toBeInTheDocument(); }); }); @@ -362,15 +383,15 @@ describe('쇼핑몰 앱 통합 테스트', () => { // 초기 상품명들 확인 (테이블에서) const productTable = screen.getByRole('table'); expect(within(productTable).getByText('상품1')).toBeInTheDocument(); - + // 삭제 버튼들 찾기 - const deleteButtons = within(productTable).getAllByRole('button').filter( - button => button.textContent === '삭제' - ); - + const deleteButtons = within(productTable) + .getAllByRole('button') + .filter((button) => button.textContent === '삭제'); + // 첫 번째 상품 삭제 fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되었는지 확인 expect(within(productTable).queryByText('상품1')).not.toBeInTheDocument(); expect(within(productTable).getByText('상품2')).toBeInTheDocument(); @@ -379,76 +400,79 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰을 삭제할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 초기 쿠폰들 확인 (h3 제목에서) const couponTitles = screen.getAllByRole('heading', { level: 3 }); - const coupon5000 = couponTitles.find(el => el.textContent === '5000원 할인'); - const coupon10 = couponTitles.find(el => el.textContent === '10% 할인'); + const coupon5000 = couponTitles.find( + (el) => el.textContent === '5000원 할인' + ); + const coupon10 = couponTitles.find((el) => el.textContent === '10% 할인'); expect(coupon5000).toBeInTheDocument(); expect(coupon10).toBeInTheDocument(); - + // 삭제 버튼 찾기 (SVG 아이콘을 포함한 버튼) - const deleteButtons = screen.getAllByRole('button').filter(button => { - return button.querySelector('svg') && - button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path + const deleteButtons = screen.getAllByRole('button').filter((button) => { + return ( + button.querySelector('svg') && + button.querySelector('path[d*="M19 7l"]') + ); // 삭제 아이콘 path }); - + // 첫 번째 쿠폰 삭제 fireEvent.click(deleteButtons[0]); - + // 쿠폰이 삭제되었는지 확인 expect(screen.queryByText('5000원 할인')).not.toBeInTheDocument(); }); - }); describe('로컬스토리지 동기화', () => { test('상품, 장바구니, 쿠폰이 localStorage에 저장된다', () => { render(); - + // 상품을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // localStorage 확인 expect(localStorage.getItem('cart')).toBeTruthy(); expect(JSON.parse(localStorage.getItem('cart'))).toHaveLength(1); - + // 관리자 모드로 전환하여 새 상품 추가 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getByText('새 상품 추가')); - + const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '저장 테스트' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '10000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '10' } }); - + fireEvent.click(screen.getByText('추가')); - + // localStorage에 products가 저장되었는지 확인 expect(localStorage.getItem('products')).toBeTruthy(); const products = JSON.parse(localStorage.getItem('products')); - expect(products.some(p => p.name === '저장 테스트')).toBe(true); + expect(products.some((p) => p.name === '저장 테스트')).toBe(true); }); test('페이지 새로고침 후에도 데이터가 유지된다', () => { const { unmount } = render(); - + // 장바구니에 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 컴포넌트 unmount unmount(); - + // 다시 mount render(); - + // 장바구니 아이템이 유지되는지 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -459,13 +483,13 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('UI 상태 관리', () => { test('할인이 있을 때 할인율이 표시된다', async () => { render(); - + // 상품을 10개 담아서 할인 발생 const addButton = screen.getAllByText('장바구니 담기')[0]; for (let i = 0; i < 10; i++) { fireEvent.click(addButton); } - + // 할인율 표시 확인 - 대량 구매로 15% 할인 await waitFor(() => { expect(screen.getByText('-15%')).toBeInTheDocument(); @@ -474,12 +498,12 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니 아이템 개수가 헤더에 표시된다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 헤더의 장바구니 아이콘 옆 숫자 확인 const cartCount = screen.getByText('3'); expect(cartCount).toBeInTheDocument(); @@ -487,42 +511,57 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('검색을 초기화할 수 있다', async () => { render(); - + // 검색어 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 검색 결과 확인 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.') + ).toBeInTheDocument(); // 다른 상품들은 보이지 않음 - expect(screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.')).not.toBeInTheDocument(); + expect( + screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.') + ).not.toBeInTheDocument(); }); - + // 검색어 초기화 fireEvent.change(searchInput, { target: { value: '' } }); - + // 모든 상품이 다시 표시됨 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('다양한 기능을 갖춘 실용적인 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('대용량과 고성능을 자랑하는 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.') + ).toBeInTheDocument(); + expect( + screen.getByText('다양한 기능을 갖춘 실용적인 상품입니다.') + ).toBeInTheDocument(); + expect( + screen.getByText('대용량과 고성능을 자랑하는 상품입니다.') + ).toBeInTheDocument(); }); }); test('알림 메시지가 자동으로 사라진다', async () => { render(); - + // 상품 추가하여 알림 발생 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 알림 메시지 확인 expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); - + // 3초 후 알림이 사라짐 - await waitFor(() => { - expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); - }, { timeout: 4000 }); + await waitFor( + () => { + expect( + screen.queryByText('장바구니에 담았습니다') + ).not.toBeInTheDocument(); + }, + { timeout: 4000 } + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/advanced/components/CartItem.tsx b/src/advanced/components/CartItem.tsx new file mode 100644 index 000000000..b06d2a8bb --- /dev/null +++ b/src/advanced/components/CartItem.tsx @@ -0,0 +1,86 @@ +import { CartItem as CartItemType } from '../../types'; +import { formatPriceWithWon } from '../utils/formatters'; + +interface CartItemProps { + item: CartItemType; + itemTotal: number; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemove: (productId: string) => void; +} + +const CartItem = ({ + item, + itemTotal, + onUpdateQuantity, + onRemove, +}: CartItemProps) => { + 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}% + + )} +

+ {formatPriceWithWon(Math.round(itemTotal))} +

+
+
+
+ ); +}; + +export default CartItem; + diff --git a/src/advanced/components/CartSection.tsx b/src/advanced/components/CartSection.tsx new file mode 100644 index 000000000..a885c6153 --- /dev/null +++ b/src/advanced/components/CartSection.tsx @@ -0,0 +1,70 @@ +import { CartItem as CartItemType } from '../../types'; +import CartItem from './CartItem'; + +interface CartSectionProps { + cart: CartItemType[]; + getItemTotal: (item: CartItemType) => number; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemove: (productId: string) => void; +} + +const CartSection = ({ + cart, + getItemTotal, + onUpdateQuantity, + onRemove, +}: CartSectionProps) => { + return ( +
+

+ + + + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => ( + + ))} +
+ )} +
+ ); +}; + +export default CartSection; + diff --git a/src/advanced/components/CouponSelector.tsx b/src/advanced/components/CouponSelector.tsx new file mode 100644 index 000000000..eed5e64fa --- /dev/null +++ b/src/advanced/components/CouponSelector.tsx @@ -0,0 +1,45 @@ +import { Coupon } from '../../types'; +import { formatPriceWithWon } from '../utils/formatters'; + +interface CouponSelectorProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + onChangeCoupon: (e: React.ChangeEvent) => void; +} + +const CouponSelector = ({ + coupons, + selectedCoupon, + onChangeCoupon, +}: CouponSelectorProps) => { + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +}; + +export default CouponSelector; diff --git a/src/advanced/components/Empty.tsx b/src/advanced/components/Empty.tsx new file mode 100644 index 000000000..b525909b4 --- /dev/null +++ b/src/advanced/components/Empty.tsx @@ -0,0 +1,15 @@ +interface EmptyProps { + searchTerm: string; +} + +const Empty = ({ searchTerm }: EmptyProps) => { + return ( +
+

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

+
+ ); +}; + +export default Empty; diff --git a/src/advanced/components/Header.tsx b/src/advanced/components/Header.tsx new file mode 100644 index 000000000..1812df568 --- /dev/null +++ b/src/advanced/components/Header.tsx @@ -0,0 +1,79 @@ +import { type ViewMode } from '../hooks/useViewMode'; +import { useCartContext, useSearchContext } from '../contexts'; +import SearchBar from './SesarchBar'; + +interface HeaderProps { + viewMode: ViewMode; + toggleViewMode: () => void; + isAdminView: boolean; + isCartView: boolean; +} + +const Header = ({ + viewMode, + toggleViewMode, + isAdminView, + isCartView, +}: HeaderProps) => { + const { cart } = useCartContext(); + const { searchTerm, setSearchTerm } = useSearchContext(); + + const getTotalItemCount = () => { + return cart.reduce((sum, item) => sum + item.quantity, 0); + }; + return ( +
+
+
+
+

SHOP

+ {viewMode === 'cart' && ( + + )} +
+ +
+
+
+ ); +}; + +export default Header; diff --git a/src/advanced/components/Notifications.tsx b/src/advanced/components/Notifications.tsx new file mode 100644 index 000000000..d01dd10f1 --- /dev/null +++ b/src/advanced/components/Notifications.tsx @@ -0,0 +1,58 @@ +import { Notification } from '../../types'; + +interface NotificationsProps { + notifications: Notification[]; + setNotifications: React.Dispatch>; +} + +const Notifications = ({ + notifications, + setNotifications, +}: NotificationsProps) => { + return ( + <> + {notifications.length > 0 && ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ )} + + ); +}; + +export default Notifications; diff --git a/src/advanced/components/OrderSummary.tsx b/src/advanced/components/OrderSummary.tsx new file mode 100644 index 000000000..b80a56baa --- /dev/null +++ b/src/advanced/components/OrderSummary.tsx @@ -0,0 +1,53 @@ +import { formatPriceWithWon } from '../utils/formatters'; + +interface OrderSummaryProps { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + onCompleteOrder: () => void; +} + +const OrderSummary = ({ totals, onCompleteOrder }: OrderSummaryProps) => { + const discountAmount = totals.totalBeforeDiscount - totals.totalAfterDiscount; + + return ( +
+

결제 정보

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

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

+
+
+ ); +}; + +export default OrderSummary; + diff --git a/src/advanced/components/ProductItem.tsx b/src/advanced/components/ProductItem.tsx new file mode 100644 index 000000000..65d34cbe2 --- /dev/null +++ b/src/advanced/components/ProductItem.tsx @@ -0,0 +1,104 @@ +import { ProductWithUI } from '../../types'; +import { formatPrice } from '../utils/formatters'; +import * as productModel from '../models/product'; + +interface ProductItemProps { + product: ProductWithUI; + remainingStock: number; + onAddToCart: (product: ProductWithUI) => void; +} + +const ProductItem = ({ + product, + remainingStock, + onAddToCart, +}: ProductItemProps) => { + const { name, description, price, isRecommended } = product; + + const maxDiscountRate = productModel.getMaxDiscountRate(product); + const discountQuantity = productModel.getMinDiscountQuantity(product); + const isSoldOut = productModel.isSoldOut(remainingStock); + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {isRecommended && ( + + BEST + + )} + {maxDiscountRate && ( + + ~{Math.round(maxDiscountRate * 100)}% + + )} +
+ + {/* 상품 정보 */} +
+

{name}

+ {description && ( +

+ {description} +

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

+ {isSoldOut ? 'SOLD OUT' : formatPrice(price)} +

+ {maxDiscountRate && discountQuantity && ( +

+ {discountQuantity}개 이상 구매시 최대{' '} + {Math.round(maxDiscountRate * 100)}% 할인 +

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

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

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

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; + +export default ProductItem; diff --git a/src/advanced/components/ProductList.tsx b/src/advanced/components/ProductList.tsx new file mode 100644 index 000000000..39a5baeda --- /dev/null +++ b/src/advanced/components/ProductList.tsx @@ -0,0 +1,29 @@ +import { ProductWithUI } from '../../types'; +import ProductItem from './ProductItem'; + +interface ProductListProps { + products: ProductWithUI[]; + getRemainingStock: (product: ProductWithUI) => number; + onAddToCart: (product: ProductWithUI) => void; +} + +const ProductList = ({ + products, + getRemainingStock, + onAddToCart, +}: ProductListProps) => { + return ( +
+ {products.map((product) => ( + + ))} +
+ ); +}; + +export default ProductList; diff --git a/src/advanced/components/SearchBar.tsx b/src/advanced/components/SearchBar.tsx new file mode 100644 index 000000000..1249c5957 --- /dev/null +++ b/src/advanced/components/SearchBar.tsx @@ -0,0 +1,41 @@ +import useDebounce from '../hooks/useDebounce'; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + className?: string; + placeholder?: string; + debounceMs?: number; +} + +const SearchBar = ({ + value, + onChange, + className, + placeholder, + debounceMs = 0, +}: SearchBarProps) => { + const debouncedOnChange = useDebounce(onChange, debounceMs); + + const handleChange = (e: React.ChangeEvent) => { + if (debounceMs > 0) { + debouncedOnChange(e.target.value); + } else { + onChange(e.target.value); + } + }; + + return ( +
+ +
+ ); +}; + +export default SearchBar; diff --git a/src/advanced/components/SesarchBar.tsx b/src/advanced/components/SesarchBar.tsx new file mode 100644 index 000000000..ef131d7da --- /dev/null +++ b/src/advanced/components/SesarchBar.tsx @@ -0,0 +1,27 @@ +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + className?: string; + placeholder?: string; +} + +const SearchBar = ({ + value, + onChange, + className, + placeholder, +}: SearchBarProps) => { + return ( +
+ onChange(e.target.value)} + placeholder={placeholder || '상품 검색...'} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ ); +}; + +export default SearchBar; diff --git a/src/advanced/components/admin/CouponCard.tsx b/src/advanced/components/admin/CouponCard.tsx new file mode 100644 index 000000000..1ccc97f51 --- /dev/null +++ b/src/advanced/components/admin/CouponCard.tsx @@ -0,0 +1,48 @@ +import { Coupon } from '../../../types'; +import { formatPriceWithWon } from '../../utils/formatters'; + +interface CouponCardProps { + coupon: Coupon; + onRemove: (couponCode: string) => void; +} + +const CouponCard = ({ coupon, onRemove }: CouponCardProps) => { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${formatPriceWithWon(coupon.discountValue)} 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +}; + +export default CouponCard; + diff --git a/src/advanced/components/admin/CouponForm.tsx b/src/advanced/components/admin/CouponForm.tsx new file mode 100644 index 000000000..749838e8b --- /dev/null +++ b/src/advanced/components/admin/CouponForm.tsx @@ -0,0 +1,153 @@ +import { useState } from 'react'; +import { Coupon } from '../../../types'; +import { isValidCouponCode } from '../../utils/validators'; +import { useNotificationContext } from '../../contexts'; + +interface CouponFormProps { + onSubmit: (coupon: Coupon) => void; + onCancel: () => void; +} + +const CouponForm = ({ onSubmit, onCancel }: CouponFormProps) => { + const { addNotification } = useNotificationContext(); + const [form, setForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!isValidCouponCode(form.code)) { + addNotification('쿠폰 코드는 4-12자의 영문 대문자와 숫자만 가능합니다', 'error'); + return; + } + + onSubmit(form); + setForm({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + }; + + const handleDiscountValueChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setForm({ + ...form, + discountValue: value === '' ? 0 : parseInt(value), + }); + } + }; + + const handleDiscountValueBlur = (e: React.FocusEvent) => { + const value = parseInt(e.target.value) || 0; + if (form.discountType === 'percentage') { + if (value > 100) { + addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); + setForm({ ...form, discountValue: 100 }); + } else if (value < 0) { + setForm({ ...form, discountValue: 0 }); + } + } else { + if (value > 100000) { + addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); + setForm({ ...form, discountValue: 100000 }); + } else if (value < 0) { + setForm({ ...form, discountValue: 0 }); + } + } + }; + + return ( +
+
+

새 쿠폰 생성

+
+
+ + setForm({ ...form, 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 + /> +
+
+ + + setForm({ ...form, 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 + /> +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ ); +}; + +export default CouponForm; + diff --git a/src/advanced/components/admin/ProductForm.tsx b/src/advanced/components/admin/ProductForm.tsx new file mode 100644 index 000000000..b8ea931e1 --- /dev/null +++ b/src/advanced/components/admin/ProductForm.tsx @@ -0,0 +1,262 @@ +import { useState, useEffect } from 'react'; +import { ProductWithUI, Discount } from '../../../types'; +import { isValidPrice, isValidStock } from '../../utils/validators'; +import { useNotificationContext } from '../../contexts'; + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} + +interface ProductFormProps { + editingProduct: ProductWithUI | null; + onSubmit: (data: ProductFormData) => void; + onCancel: () => void; +} + +const initialForm: ProductFormData = { + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], +}; + +const ProductForm = ({ + editingProduct, + onSubmit, + onCancel, +}: ProductFormProps) => { + const { addNotification } = useNotificationContext(); + const [form, setForm] = useState(initialForm); + const isEditing = editingProduct !== null; + + useEffect(() => { + if (editingProduct) { + setForm({ + name: editingProduct.name, + price: editingProduct.price, + stock: editingProduct.stock, + description: editingProduct.description || '', + discounts: editingProduct.discounts || [], + }); + } else { + setForm(initialForm); + } + }, [editingProduct]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(form); + }; + + const handlePriceChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setForm({ ...form, price: value === '' ? 0 : parseInt(value) }); + } + }; + + const handlePriceBlur = (e: React.FocusEvent) => { + const value = e.target.value; + const price = parseInt(value) || 0; + + if (!isValidPrice(price)) { + addNotification('가격은 0보다 커야 합니다', 'error'); + setForm({ ...form, price: 0 }); + } + }; + + const handleStockChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setForm({ ...form, stock: value === '' ? 0 : parseInt(value) }); + } + }; + + const handleStockBlur = (e: React.FocusEvent) => { + const value = e.target.value; + const stock = parseInt(value) || 0; + + if (!isValidStock(stock)) { + addNotification('재고는 0보다 커야 합니다', 'error'); + setForm({ ...form, stock: 0 }); + } else if (stock > 9999) { + addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); + setForm({ ...form, stock: 9999 }); + } + }; + + const handleDiscountQuantityChange = (index: number, value: string) => { + const newDiscounts = [...form.discounts]; + newDiscounts[index].quantity = parseInt(value) || 0; + setForm({ ...form, discounts: newDiscounts }); + }; + + const handleDiscountRateChange = (index: number, value: string) => { + const newDiscounts = [...form.discounts]; + newDiscounts[index].rate = (parseInt(value) || 0) / 100; + setForm({ ...form, discounts: newDiscounts }); + }; + + const handleRemoveDiscount = (index: number) => { + const newDiscounts = form.discounts.filter((_, i) => i !== index); + setForm({ ...form, discounts: newDiscounts }); + }; + + const handleAddDiscount = () => { + setForm({ + ...form, + discounts: [...form.discounts, { quantity: 10, rate: 0.1 }], + }); + }; + + return ( +
+
+

+ {isEditing ? '상품 수정' : '새 상품 추가'} +

+
+
+ + setForm({ ...form, 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 + /> +
+
+ + setForm({ ...form, 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" + /> +
+
+ + +
+
+ + +
+
+ +
+ +
+ {form.discounts.map((discount, index) => ( +
+ + handleDiscountQuantityChange(index, e.target.value) + } + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + + handleDiscountRateChange(index, e.target.value) + } + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; + +export default ProductForm; + diff --git a/src/advanced/components/admin/ProductTableRow.tsx b/src/advanced/components/admin/ProductTableRow.tsx new file mode 100644 index 000000000..6a21144db --- /dev/null +++ b/src/advanced/components/admin/ProductTableRow.tsx @@ -0,0 +1,62 @@ +import { ProductWithUI } from '../../../types'; +import { formatPriceWithWon } from '../../utils/formatters'; + +interface ProductTableRowProps { + product: ProductWithUI; + remainingStock: number; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +} + +const ProductTableRow = ({ + product, + remainingStock, + onEdit, + onDelete, +}: ProductTableRowProps) => { + const displayPrice = remainingStock <= 0 ? 'SOLD OUT' : formatPriceWithWon(product.price); + + const getStockBadgeClass = () => { + if (product.stock > 10) return 'bg-green-100 text-green-800'; + if (product.stock > 0) return 'bg-yellow-100 text-yellow-800'; + return 'bg-red-100 text-red-800'; + }; + + return ( + + + {product.name} + + + {displayPrice} + + + + {product.stock}개 + + + + {product.description || '-'} + + + + + + + ); +}; + +export default ProductTableRow; + diff --git a/src/advanced/constants/index.ts b/src/advanced/constants/index.ts new file mode 100644 index 000000000..2e67006ca --- /dev/null +++ b/src/advanced/constants/index.ts @@ -0,0 +1,51 @@ +import { Coupon, ProductWithUI } from '../../types'; + +// 초기 데이터 +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/CartContext.tsx b/src/advanced/contexts/CartContext.tsx new file mode 100644 index 000000000..3083fef86 --- /dev/null +++ b/src/advanced/contexts/CartContext.tsx @@ -0,0 +1,25 @@ +import { createContext, useContext, ReactNode } from 'react'; +import { useCart } from '../hooks/useCart'; +import { useNotificationContext } from './NotificationContext'; + +type CartContextType = ReturnType; + +const CartContext = createContext(undefined); + +export const CartProvider = ({ children }: { children: ReactNode }) => { + const { addNotification } = useNotificationContext(); + const cartState = useCart(addNotification); + + return ( + {children} + ); +}; + +export const useCartContext = () => { + const context = useContext(CartContext); + if (!context) { + throw new Error('useCartContext must be used within CartProvider'); + } + return context; +}; + diff --git a/src/advanced/contexts/CouponContext.tsx b/src/advanced/contexts/CouponContext.tsx new file mode 100644 index 000000000..dd7e3e736 --- /dev/null +++ b/src/advanced/contexts/CouponContext.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext, ReactNode } from 'react'; +import { useCoupons } from '../hooks/useCoupons'; +import { useNotificationContext } from './NotificationContext'; + +type CouponContextType = ReturnType; + +const CouponContext = createContext(undefined); + +export const CouponProvider = ({ children }: { children: ReactNode }) => { + const { addNotification } = useNotificationContext(); + const couponState = useCoupons(addNotification); + + return ( + + {children} + + ); +}; + +export const useCouponContext = () => { + const context = useContext(CouponContext); + if (!context) { + throw new Error('useCouponContext must be used within CouponProvider'); + } + return context; +}; + diff --git a/src/advanced/contexts/NotificationContext.tsx b/src/advanced/contexts/NotificationContext.tsx new file mode 100644 index 000000000..1677ff047 --- /dev/null +++ b/src/advanced/contexts/NotificationContext.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, ReactNode } from 'react'; +import { useNotifications } from '../hooks/useNotifications'; +import { Notification } from '../../types'; + +interface NotificationContextType { + notifications: Notification[]; + addNotification: ( + message: string, + type: 'error' | 'success' | 'warning' + ) => void; + setNotifications: React.Dispatch>; +} + +const NotificationContext = createContext( + undefined +); + +export const NotificationProvider = ({ children }: { children: ReactNode }) => { + const notificationState = useNotifications(); + + return ( + + {children} + + ); +}; + +export const useNotificationContext = () => { + const context = useContext(NotificationContext); + if (!context) { + throw new Error( + 'useNotificationContext must be used within NotificationProvider' + ); + } + return context; +}; + diff --git a/src/advanced/contexts/ProductContext.tsx b/src/advanced/contexts/ProductContext.tsx new file mode 100644 index 000000000..279acd5f6 --- /dev/null +++ b/src/advanced/contexts/ProductContext.tsx @@ -0,0 +1,25 @@ +import { createContext, useContext, ReactNode } from 'react'; +import { useProducts } from '../hooks/useProducts'; + +type ProductContextType = ReturnType; + +const ProductContext = createContext(undefined); + +export const ProductProvider = ({ children }: { children: ReactNode }) => { + const productState = useProducts(); + + return ( + + {children} + + ); +}; + +export const useProductContext = () => { + const context = useContext(ProductContext); + if (!context) { + throw new Error('useProductContext must be used within ProductProvider'); + } + return context; +}; + diff --git a/src/advanced/contexts/SearchContext.tsx b/src/advanced/contexts/SearchContext.tsx new file mode 100644 index 000000000..8013500be --- /dev/null +++ b/src/advanced/contexts/SearchContext.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext, ReactNode, useState } from 'react'; + +interface SearchContextType { + searchTerm: string; + setSearchTerm: (term: string) => void; +} + +const SearchContext = createContext(undefined); + +export const SearchProvider = ({ children }: { children: ReactNode }) => { + const [searchTerm, setSearchTerm] = useState(''); + + return ( + + {children} + + ); +}; + +export const useSearchContext = () => { + const context = useContext(SearchContext); + if (!context) { + throw new Error('useSearchContext must be used within SearchProvider'); + } + return context; +}; + diff --git a/src/advanced/contexts/index.tsx b/src/advanced/contexts/index.tsx new file mode 100644 index 000000000..2895a5cc8 --- /dev/null +++ b/src/advanced/contexts/index.tsx @@ -0,0 +1,41 @@ +import { ReactNode } from 'react'; +import { NotificationProvider } from './NotificationContext'; +import { CartProvider } from './CartContext'; +import { ProductProvider } from './ProductContext'; +import { CouponProvider } from './CouponContext'; +import { SearchProvider } from './SearchContext'; + +/** + * Provider를 조합하는 헬퍼 함수 + * 여러 Provider를 배열로 받아서 자동으로 중첩 구조를 만듦 + */ +const composeProviders = (providers: React.FC<{ children: ReactNode }>[]) => { + return ({ children }: { children: ReactNode }) => { + return providers.reduceRight((acc, Provider) => { + return {acc}; + }, children); + }; +}; + +/** + * 모든 Context Provider를 하나로 합친 최상위 Provider + * 의존성 순서를 고려하여 배치: + * 1. NotificationProvider (다른 Provider들이 사용) + * 2. SearchProvider (독립적) + * 3. CartProvider, ProductProvider, CouponProvider (NotificationProvider 의존) + */ +export const AppProviders = composeProviders([ + NotificationProvider, + SearchProvider, + CartProvider, + ProductProvider, + CouponProvider, +]); + +// 각 Context hook들을 re-export +export { useNotificationContext } from './NotificationContext'; +export { useCartContext } from './CartContext'; +export { useProductContext } from './ProductContext'; +export { useCouponContext } from './CouponContext'; +export { useSearchContext } from './SearchContext'; + diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts new file mode 100644 index 000000000..c5eb0c896 --- /dev/null +++ b/src/advanced/hooks/useCart.ts @@ -0,0 +1,116 @@ +import { useState } from 'react'; +import { CartItem, Coupon, Product } from '../../types'; +import { useLocalStorage } from './useLocalStorage'; +import * as cartModel from '../models/cart'; + +type NotificationFn = ( + message: string, + type: 'error' | 'success' | 'warning' +) => void; + +export function useCart(addNotification?: NotificationFn) { + const [cart, setCart] = useLocalStorage('cart', []); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + // 상품 추가 + const addToCart = (product: Product) => { + const remainingStock = cartModel.getRemainingStock(product, cart); + if (remainingStock <= 0) { + addNotification?.('재고가 부족합니다!', 'error'); + return; + } + + const existingItem = cart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + if (newQuantity > product.stock) { + addNotification?.(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + return; + } + } + + setCart((prevCart) => { + if (existingItem) { + return cartModel.updateCartItemQuantity( + prevCart, + product.id, + existingItem.quantity + 1 + ); + } + return cartModel.addItemToCart(prevCart, product); + }); + + addNotification?.('장바구니에 담았습니다', 'success'); + }; + + const removeFromCart = (product: Product) => { + setCart((prevCart) => cartModel.removeItemFromCart(prevCart, product.id)); + }; + + const updateQuantity = (product: Product, quantity: number) => { + if (quantity <= 0) { + removeFromCart(product); + return; + } + + // 수량 업데이트 시에는 총 재고와 비교해야 함 + if (quantity > product.stock) { + addNotification?.(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + return; + } + + setCart((prevCart) => + cartModel.updateCartItemQuantity(prevCart, product.id, quantity) + ); + }; + + const getRemainingStock = (product: Product) => { + return cartModel.getRemainingStock(product, cart); + }; + + const calculateTotal = () => { + return cartModel.calculateCartTotal(cart, selectedCoupon ?? undefined); + }; + + const applyCoupon = (coupon: Coupon) => { + const { totalAfterDiscount } = cartModel.calculateCartTotal(cart, coupon); + + if (totalAfterDiscount < 10000 && coupon.discountType === 'percentage') { + addNotification?.( + 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + 'error' + ); + return; + } + setSelectedCoupon(coupon); + addNotification?.('쿠폰이 적용되었습니다.', 'success'); + }; + + const clearCart = () => { + setCart([]); + setSelectedCoupon(null); + }; + + const clearCoupon = () => { + setSelectedCoupon(null); + }; + + const getItemTotal = (item: CartItem) => { + return cartModel.calculateItemTotal(item, cart); + }; + + return { + cart, + addToCart, + calculateTotal, + applyCoupon, + clearCart, + clearCoupon, + getRemainingStock, + getItemTotal, + removeFromCart, + updateQuantity, + selectedCoupon, + }; +} diff --git a/src/advanced/hooks/useCoupons.ts b/src/advanced/hooks/useCoupons.ts new file mode 100644 index 000000000..70b4e9f78 --- /dev/null +++ b/src/advanced/hooks/useCoupons.ts @@ -0,0 +1,39 @@ +import { Coupon } from '../../types'; +import { initialCoupons } from '../constants'; +import { useLocalStorage } from './useLocalStorage'; +import * as couponModel from '../models/coupon'; + +type NotificationFn = ( + message: string, + type: 'error' | 'success' | 'warning' +) => void; + +export const useCoupons = (addNotification?: NotificationFn) => { + const [coupons, setCoupons] = useLocalStorage( + 'coupons', + initialCoupons + ); + + const addCoupon = (newCoupon: Coupon) => { + // validate + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + addNotification?.('이미 존재하는 쿠폰 코드입니다.', 'error'); + return; + } + setCoupons((prev) => couponModel.addCoupon(newCoupon, prev)); + addNotification?.('쿠폰이 추가되었습니다.', 'success'); + }; + + const removeCoupon = (couponCode: string) => { + const existingCoupon = coupons.find((c) => c.code === couponCode); + if (!existingCoupon) { + addNotification?.('존재하지 않는 쿠폰 코드입니다.', 'error'); + return; + } + + setCoupons((prev) => couponModel.removeCoupon(couponCode, prev)); + }; + + return { coupons, setCoupons, addCoupon, removeCoupon }; +}; diff --git a/src/advanced/hooks/useDebounce.ts b/src/advanced/hooks/useDebounce.ts new file mode 100644 index 000000000..0c6bb7a60 --- /dev/null +++ b/src/advanced/hooks/useDebounce.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react'; + +const useDebounce = (value: T, delayMs = 500) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debouncedValue; +}; + +export default useDebounce; diff --git a/src/advanced/hooks/useLocalStorage.ts b/src/advanced/hooks/useLocalStorage.ts new file mode 100644 index 000000000..08eb7fe1b --- /dev/null +++ b/src/advanced/hooks/useLocalStorage.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + const [value, setValue] = useState(() => { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + }); + + useEffect(() => { + localStorage.setItem(key, JSON.stringify(value)); + }, [key, value]); + + return [value, setValue]; +} diff --git a/src/advanced/hooks/useNotifications.ts b/src/advanced/hooks/useNotifications.ts new file mode 100644 index 000000000..c9b01b3bf --- /dev/null +++ b/src/advanced/hooks/useNotifications.ts @@ -0,0 +1,19 @@ +import { useState } from 'react'; +import type { Notification } from '../../types'; + +export const useNotifications = () => { + const [notifications, setNotifications] = useState([]); + + const addNotification = ( + message: string, + type: 'error' | 'success' | 'warning' = 'success' + ) => { + const id = `${Date.now()}-${Math.random()}`; + setNotifications((prev) => [...prev, { id, message, type }]); + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }; + + return { notifications, addNotification, setNotifications }; +}; diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts new file mode 100644 index 000000000..c936cc400 --- /dev/null +++ b/src/advanced/hooks/useProducts.ts @@ -0,0 +1,87 @@ +import { Discount, Product, ProductWithUI } from '../../types'; +import { initialProducts } from '../constants'; +import { useLocalStorage } from './useLocalStorage'; +import * as productModel from '../models/product'; + +type NotificationFn = ( + message: string, + type: 'error' | 'success' | 'warning' +) => void; + +export const useProducts = (addNotification?: NotificationFn) => { + // - updateProductStock: 재고 수정 + // - addProductDiscount: 할인 규칙 추가 + // - removeProductDiscount: 할인 규칙 삭제 + + const [products, setProducts] = useLocalStorage( + 'products', + initialProducts + ); + + const addProduct = (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}-${Math.random()}`, + }; + + setProducts((prev) => productModel.addProduct(product, prev)); + + addNotification?.('상품이 추가되었습니다.', 'success'); + return newProduct; + }; + + const updateProduct = ( + productId: string, + updates: Partial + ) => { + const existingProduct = products.find((p) => p.id === productId); + if (!existingProduct) { + return; + } + + setProducts((prev) => + productModel.updateProduct( + { + id: productId, + ...updates, + }, + prev + ) + ); + }; + + const removeProduct = (productId: string) => { + setProducts((prev) => productModel.removeProduct(productId, prev)); + }; + + const updateProductStock = (product: ProductWithUI) => { + const existingProduct = products.find((p) => p.id === product.id); + if (!existingProduct) { + return; + } + setProducts((prev) => productModel.updateProductStock(product, prev)); + }; + + const addProductDiscount = (discount: Discount, product: Product) => { + const existingProduct = products.find((p) => p.id === product.id); + if (!existingProduct) { + return; + } + setProducts((prev) => + prev.map((p) => + p.id === product.id + ? productModel.updateDiscountForProduct(discount, p) + : p + ) + ); + }; + + return { + products, + addProduct, + updateProduct, + updateProductStock, + addProductDiscount, + removeProduct, + }; +}; diff --git a/src/advanced/hooks/useViewMode.ts b/src/advanced/hooks/useViewMode.ts new file mode 100644 index 000000000..c3bc7a876 --- /dev/null +++ b/src/advanced/hooks/useViewMode.ts @@ -0,0 +1,22 @@ +import { useState } from 'react'; + +export type ViewMode = 'cart' | 'admin'; + +export function useViewMode(initialMode: ViewMode = 'cart') { + const [viewMode, setViewMode] = useState(initialMode); + + const toggleViewMode = () => { + setViewMode((prev) => (prev === 'cart' ? 'admin' : 'cart')); + }; + + const isCartView = viewMode === 'cart'; + const isAdminView = viewMode === 'admin'; + + return { + viewMode, + setViewMode, + toggleViewMode, + isCartView, + isAdminView, + }; +} diff --git a/src/advanced/main.tsx b/src/advanced/main.tsx index e63eef4a8..5a0654ace 100644 --- a/src/advanced/main.tsx +++ b/src/advanced/main.tsx @@ -1,9 +1,9 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( - , -) + +); diff --git a/src/advanced/models/cart.ts b/src/advanced/models/cart.ts new file mode 100644 index 000000000..31a863174 --- /dev/null +++ b/src/advanced/models/cart.ts @@ -0,0 +1,94 @@ +import type { CartItem, Coupon, Product } from '../../types'; + +const getProductQuantityDiscount = (item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + return discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); +}; + +const hasBulkDiscount = (cart: CartItem[]): boolean => { + return cart.some((cartItem) => cartItem.quantity >= 10); +}; + +const applyBulkDiscount = (baseDiscount: number): number => { + return Math.min(baseDiscount + 0.05, 0.5); // 추가 5% 할인, 최대 50% +}; + +const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + const baseDiscount = getProductQuantityDiscount(item); + + if (hasBulkDiscount(cart)) { + return applyBulkDiscount(baseDiscount); + } + + 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[], coupon?: Coupon) => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (coupon) { + if (coupon.discountType === 'amount') { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - coupon.discountValue + ); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - coupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + discountAmount: Math.round(totalBeforeDiscount - totalAfterDiscount), + }; +}; + +export const removeItemFromCart = (cart: CartItem[], productId: string) => { + return cart.filter((item) => item.product.id !== productId); +}; + +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + quantity: number +) => { + return cart.map((item) => + item.product.id === productId ? { ...item, quantity } : item + ); +}; + +export const addItemToCart = (cart: CartItem[], product: Product) => { + return [...cart, { product, quantity: 1 }]; +}; + +export const getRemainingStock = (product: Product, cart: CartItem[]) => { + const cartItem = cart.find((item) => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); +}; diff --git a/src/advanced/models/coupon.ts b/src/advanced/models/coupon.ts new file mode 100644 index 000000000..2428ffb5b --- /dev/null +++ b/src/advanced/models/coupon.ts @@ -0,0 +1,9 @@ +import { Coupon } from '../../types'; + +export const addCoupon = (newCoupon: Coupon, coupons: Coupon[]) => { + return [...coupons, newCoupon]; +}; + +export const removeCoupon = (couponCode: string, coupons: Coupon[]) => { + return coupons.filter((c) => c.code !== couponCode); +}; diff --git a/src/advanced/models/product.ts b/src/advanced/models/product.ts new file mode 100644 index 000000000..0cde3ce4a --- /dev/null +++ b/src/advanced/models/product.ts @@ -0,0 +1,66 @@ +import { Discount, Product, ProductWithUI } from '../../types'; + +export const getMaxDiscountRate = (product: Product): number | undefined => { + if (product.discounts.length === 0) return undefined; + return Math.max(...product.discounts.map((d) => d.rate)); +}; + +export const getMinDiscountQuantity = ( + product: Product +): number | undefined => { + if (product.discounts.length === 0) return undefined; + return Math.min(...product.discounts.map((d) => d.quantity)); +}; + +export const isSoldOut = (remainingStock: number): boolean => { + return remainingStock <= 0; +}; + +export const filterProductsBySearchTerm = ( + products: ProductWithUI[], + searchTerm: string +): ProductWithUI[] => { + if (!searchTerm) return products; + + const term = searchTerm.toLowerCase(); + return products.filter((product) => { + return ( + product.name.toLowerCase().includes(term) || + product.description?.toLowerCase().includes(term) + ); + }); +}; + +export const addProduct = ( + product: ProductWithUI, + products: ProductWithUI[] +) => { + return [...products, product]; +}; + +export const updateProduct = ( + updates: Partial, + products: ProductWithUI[] +) => { + return products.map((p) => (p.id === updates.id ? { ...p, ...updates } : p)); +}; + +export const updateProductStock = ( + product: ProductWithUI, + products: ProductWithUI[] +) => { + return products.map((p) => + p.id === product.id ? { ...p, stock: product.stock } : p + ); +}; + +export const removeProduct = (productId: string, products: ProductWithUI[]) => { + return products.filter((p) => p.id !== productId); +}; + +export const updateDiscountForProduct = ( + discount: Discount, + product: ProductWithUI +) => { + return { ...product, discounts: [...product.discounts, discount] }; +}; diff --git a/src/advanced/pages/CartPage.tsx b/src/advanced/pages/CartPage.tsx new file mode 100644 index 000000000..610ccaf42 --- /dev/null +++ b/src/advanced/pages/CartPage.tsx @@ -0,0 +1,132 @@ +import { ProductWithUI } from '../../types'; +import CartSection from '../components/CartSection'; +import CouponSelector from '../components/CouponSelector'; +import Empty from '../components/Empty'; +import OrderSummary from '../components/OrderSummary'; +import ProductList from '../components/ProductList'; +import useDebounce from '../hooks/useDebounce'; +import * as productModel from '../models/product'; +import { + useNotificationContext, + useCartContext, + useProductContext, + useCouponContext, + useSearchContext, +} from '../contexts'; + +const Cart = () => { + const { addNotification } = useNotificationContext(); + const { + cart, + addToCart, + updateQuantity, + removeFromCart, + applyCoupon, + clearCoupon, + selectedCoupon, + calculateTotal, + getRemainingStock, + getItemTotal, + clearCart, + } = useCartContext(); + const { products } = useProductContext(); + const { coupons } = useCouponContext(); + const { searchTerm } = useSearchContext(); + + const debouncedSearchTerm = useDebounce(searchTerm, 500); + + const handleClickAddToCart = (product: ProductWithUI) => { + addToCart(product); + }; + + const handleClickRemoveFromCart = (productId: string) => { + const product = products.find((p) => p.id === productId); + if (product) { + removeFromCart(product); + } + }; + + const handleClickUpdateQuantity = ( + productId: string, + newQuantity: number + ) => { + const product = products.find((p) => p.id === productId); + if (product) { + updateQuantity(product, newQuantity); + } + }; + + const handleChangeCoupon = (e: React.ChangeEvent) => { + const coupon = coupons.find((c) => c.code === e.target.value); + if (coupon) applyCoupon(coupon); + else clearCoupon(); + }; + + const handleClickCompleteOrder = () => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + 'success' + ); + clearCart(); + }; + + const totals = calculateTotal(); + + const filteredProducts = productModel.filterProductsBySearchTerm( + products, + debouncedSearchTerm + ); + + return ( +
+
+ {/* 상품 목록 */} +
+
+

전체 상품

+
+ 총 {products.length}개 상품 +
+
+ {filteredProducts.length === 0 ? ( + + ) : ( + + )} +
+
+ +
+
+ + + {cart.length > 0 && ( + <> + + + + )} +
+
+
+ ); +}; + +export default Cart; diff --git a/src/advanced/pages/admin/AdminPage.tsx b/src/advanced/pages/admin/AdminPage.tsx new file mode 100644 index 000000000..c19fed654 --- /dev/null +++ b/src/advanced/pages/admin/AdminPage.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import ProductSection from './ProductSection'; +import CouponSection from './CouponSection'; + +const Admin = () => { + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>( + 'products' + ); + + return ( +
+
+

관리자 대시보드

+

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

+
+
+ +
+ + {activeTab === 'products' ? : } +
+ ); +}; + +export default Admin; diff --git a/src/advanced/pages/admin/CouponSection.tsx b/src/advanced/pages/admin/CouponSection.tsx new file mode 100644 index 000000000..12a93d716 --- /dev/null +++ b/src/advanced/pages/admin/CouponSection.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import { Coupon } from '../../../types'; +import CouponCard from '../../components/admin/CouponCard'; +import CouponForm from '../../components/admin/CouponForm'; +import { useCouponContext } from '../../contexts'; + +const CouponSection = () => { + const couponActions = useCouponContext(); + const { coupons } = couponActions; + const [showCouponForm, setShowCouponForm] = useState(false); + + const handleAddCoupon = (newCoupon: Coupon) => { + couponActions.addCoupon(newCoupon); + setShowCouponForm(false); + }; + + const handleRemoveCoupon = (couponCode: string) => { + couponActions.removeCoupon(couponCode); + }; + + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( + + ))} + +
+ +
+
+ + {showCouponForm && ( + setShowCouponForm(false)} + /> + )} +
+
+ ); +}; + +export default CouponSection; diff --git a/src/advanced/pages/admin/ProductSection.tsx b/src/advanced/pages/admin/ProductSection.tsx new file mode 100644 index 000000000..a31fc44cc --- /dev/null +++ b/src/advanced/pages/admin/ProductSection.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; +import { ProductWithUI, Discount } from '../../../types'; +import ProductTableRow from '../../components/admin/ProductTableRow'; +import ProductForm from '../../components/admin/ProductForm'; +import * as cartModel from '../../models/cart'; +import { useCartContext, useProductContext } from '../../contexts'; + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} + +const ProductSection = () => { + const { cart } = useCartContext(); + const productActions = useProductContext(); + const { products } = productActions; + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState( + null + ); + + const handleProductSubmit = (formData: ProductFormData) => { + if (editingProduct) { + productActions.updateProduct(editingProduct.id, formData); + } else { + productActions.addProduct(formData); + } + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleEditProduct = (product: ProductWithUI) => { + setEditingProduct(product); + setShowProductForm(true); + }; + + const handleDeleteProduct = (productId: string) => { + productActions.removeProduct(productId); + }; + + const handleCancel = () => { + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleAddNew = () => { + setEditingProduct(null); + setShowProductForm(true); + }; + + return ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+ + {showProductForm && ( + + )} +
+ ); +}; + +export default ProductSection; diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts new file mode 100644 index 000000000..afc3e73eb --- /dev/null +++ b/src/advanced/utils/formatters.ts @@ -0,0 +1,18 @@ +// 가격을 한국 원화 형식으로 포맷 +export const formatPrice = (price: number): string => { + return `₩${price.toLocaleString('ko-KR')}`; +}; + +export const formatPriceWithWon = (price: number): string => { + return `${price.toLocaleString('ko-KR')}원`; +}; + +// 날짜를 YYYY-MM-DD 형식으로 포맷 +export const formatDate = (date: Date): string => { + return date.toISOString().split('T')[0]; +}; + +// 소수를 퍼센트로 변환 (0.1 → 10%) +export const formatPercentage = (rate: number): string => { + return `${Math.round(rate * 100)}%`; +}; diff --git a/src/advanced/utils/validators.ts b/src/advanced/utils/validators.ts new file mode 100644 index 000000000..c013a5c28 --- /dev/null +++ b/src/advanced/utils/validators.ts @@ -0,0 +1,11 @@ +export const isValidCouponCode = (code: string): boolean => { + return /^[A-Z0-9]{4,12}$/.test(code); +}; + +export const isValidStock = (stock: number): boolean => { + return stock >= 0; +}; + +export const isValidPrice = (price: number): boolean => { + return price > 0; +}; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..e5552ec54 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,1124 +1,68 @@ -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 - } -]; +import { useState } from 'react'; +import Admin from './pages/admin/AdminPage'; +import Cart from './pages/CartPage'; +import Notifications from './components/Notifications'; +import { useViewMode } from './hooks/useViewMode'; +import { useCart } from './hooks/useCart'; +import { useCoupons } from './hooks/useCoupons'; +import { useProducts } from './hooks/useProducts'; +import { useNotifications } from './hooks/useNotifications'; +import Header from './components/Header'; const App = () => { + const { viewMode, toggleViewMode, isCartView, isAdminView } = + useViewMode('cart'); - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - 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]); + const { notifications, addNotification, setNotifications } = + useNotifications(); - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); + const cartActions = useCart(addNotification); + const { cart } = cartActions; - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } + const couponActions = useCoupons(addNotification); + const { coupons } = couponActions; - 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 productActions = useProducts(); + const { products } = productActions; - 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 [searchTerm, setSearchTerm] = useState(''); return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

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

관리자 대시보드

-

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

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

상품 목록

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

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

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.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 - /> -
-
-
- - -
-
-
- )} -
-
- )} -
+ {viewMode === 'admin' ? ( + ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

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

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

-
-
- - )} -
-
-
+ )}
); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/components/CartItem.tsx b/src/basic/components/CartItem.tsx new file mode 100644 index 000000000..b06d2a8bb --- /dev/null +++ b/src/basic/components/CartItem.tsx @@ -0,0 +1,86 @@ +import { CartItem as CartItemType } from '../../types'; +import { formatPriceWithWon } from '../utils/formatters'; + +interface CartItemProps { + item: CartItemType; + itemTotal: number; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemove: (productId: string) => void; +} + +const CartItem = ({ + item, + itemTotal, + onUpdateQuantity, + onRemove, +}: CartItemProps) => { + 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}% + + )} +

+ {formatPriceWithWon(Math.round(itemTotal))} +

+
+
+
+ ); +}; + +export default CartItem; + diff --git a/src/basic/components/CartSection.tsx b/src/basic/components/CartSection.tsx new file mode 100644 index 000000000..a885c6153 --- /dev/null +++ b/src/basic/components/CartSection.tsx @@ -0,0 +1,70 @@ +import { CartItem as CartItemType } from '../../types'; +import CartItem from './CartItem'; + +interface CartSectionProps { + cart: CartItemType[]; + getItemTotal: (item: CartItemType) => number; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemove: (productId: string) => void; +} + +const CartSection = ({ + cart, + getItemTotal, + onUpdateQuantity, + onRemove, +}: CartSectionProps) => { + return ( +
+

+ + + + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => ( + + ))} +
+ )} +
+ ); +}; + +export default CartSection; + diff --git a/src/basic/components/CouponSelector.tsx b/src/basic/components/CouponSelector.tsx new file mode 100644 index 000000000..eed5e64fa --- /dev/null +++ b/src/basic/components/CouponSelector.tsx @@ -0,0 +1,45 @@ +import { Coupon } from '../../types'; +import { formatPriceWithWon } from '../utils/formatters'; + +interface CouponSelectorProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + onChangeCoupon: (e: React.ChangeEvent) => void; +} + +const CouponSelector = ({ + coupons, + selectedCoupon, + onChangeCoupon, +}: CouponSelectorProps) => { + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +}; + +export default CouponSelector; diff --git a/src/basic/components/Empty.tsx b/src/basic/components/Empty.tsx new file mode 100644 index 000000000..b525909b4 --- /dev/null +++ b/src/basic/components/Empty.tsx @@ -0,0 +1,15 @@ +interface EmptyProps { + searchTerm: string; +} + +const Empty = ({ searchTerm }: EmptyProps) => { + return ( +
+

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

+
+ ); +}; + +export default Empty; diff --git a/src/basic/components/Header.tsx b/src/basic/components/Header.tsx new file mode 100644 index 000000000..1009de3a5 --- /dev/null +++ b/src/basic/components/Header.tsx @@ -0,0 +1,83 @@ +import { CartItem } from '../../types'; +import { type ViewMode } from '../hooks/useViewMode'; + +import SearchBar from './SesarchBar'; + +interface HeaderProps { + viewMode: ViewMode; + toggleViewMode: () => void; + isAdminView: boolean; + isCartView: boolean; + cart: CartItem[]; + searchTerm: string; + setSearchTerm: (value: string) => void; +} + +const Header = ({ + viewMode, + toggleViewMode, + isAdminView, + isCartView, + cart, + searchTerm, + setSearchTerm, +}: HeaderProps) => { + const getTotalItemCount = () => { + return cart.reduce((sum, item) => sum + item.quantity, 0); + }; + return ( +
+
+
+
+

SHOP

+ {viewMode === 'cart' && ( + + )} +
+ +
+
+
+ ); +}; + +export default Header; diff --git a/src/basic/components/Notifications.tsx b/src/basic/components/Notifications.tsx new file mode 100644 index 000000000..d01dd10f1 --- /dev/null +++ b/src/basic/components/Notifications.tsx @@ -0,0 +1,58 @@ +import { Notification } from '../../types'; + +interface NotificationsProps { + notifications: Notification[]; + setNotifications: React.Dispatch>; +} + +const Notifications = ({ + notifications, + setNotifications, +}: NotificationsProps) => { + return ( + <> + {notifications.length > 0 && ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ )} + + ); +}; + +export default Notifications; diff --git a/src/basic/components/OrderSummary.tsx b/src/basic/components/OrderSummary.tsx new file mode 100644 index 000000000..b80a56baa --- /dev/null +++ b/src/basic/components/OrderSummary.tsx @@ -0,0 +1,53 @@ +import { formatPriceWithWon } from '../utils/formatters'; + +interface OrderSummaryProps { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + onCompleteOrder: () => void; +} + +const OrderSummary = ({ totals, onCompleteOrder }: OrderSummaryProps) => { + const discountAmount = totals.totalBeforeDiscount - totals.totalAfterDiscount; + + return ( +
+

결제 정보

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

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

+
+
+ ); +}; + +export default OrderSummary; + diff --git a/src/basic/components/ProductItem.tsx b/src/basic/components/ProductItem.tsx new file mode 100644 index 000000000..65d34cbe2 --- /dev/null +++ b/src/basic/components/ProductItem.tsx @@ -0,0 +1,104 @@ +import { ProductWithUI } from '../../types'; +import { formatPrice } from '../utils/formatters'; +import * as productModel from '../models/product'; + +interface ProductItemProps { + product: ProductWithUI; + remainingStock: number; + onAddToCart: (product: ProductWithUI) => void; +} + +const ProductItem = ({ + product, + remainingStock, + onAddToCart, +}: ProductItemProps) => { + const { name, description, price, isRecommended } = product; + + const maxDiscountRate = productModel.getMaxDiscountRate(product); + const discountQuantity = productModel.getMinDiscountQuantity(product); + const isSoldOut = productModel.isSoldOut(remainingStock); + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {isRecommended && ( + + BEST + + )} + {maxDiscountRate && ( + + ~{Math.round(maxDiscountRate * 100)}% + + )} +
+ + {/* 상품 정보 */} +
+

{name}

+ {description && ( +

+ {description} +

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

+ {isSoldOut ? 'SOLD OUT' : formatPrice(price)} +

+ {maxDiscountRate && discountQuantity && ( +

+ {discountQuantity}개 이상 구매시 최대{' '} + {Math.round(maxDiscountRate * 100)}% 할인 +

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

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

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

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; + +export default ProductItem; diff --git a/src/basic/components/ProductList.tsx b/src/basic/components/ProductList.tsx new file mode 100644 index 000000000..39a5baeda --- /dev/null +++ b/src/basic/components/ProductList.tsx @@ -0,0 +1,29 @@ +import { ProductWithUI } from '../../types'; +import ProductItem from './ProductItem'; + +interface ProductListProps { + products: ProductWithUI[]; + getRemainingStock: (product: ProductWithUI) => number; + onAddToCart: (product: ProductWithUI) => void; +} + +const ProductList = ({ + products, + getRemainingStock, + onAddToCart, +}: ProductListProps) => { + return ( +
+ {products.map((product) => ( + + ))} +
+ ); +}; + +export default ProductList; diff --git a/src/basic/components/SearchBar.tsx b/src/basic/components/SearchBar.tsx new file mode 100644 index 000000000..1249c5957 --- /dev/null +++ b/src/basic/components/SearchBar.tsx @@ -0,0 +1,41 @@ +import useDebounce from '../hooks/useDebounce'; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + className?: string; + placeholder?: string; + debounceMs?: number; +} + +const SearchBar = ({ + value, + onChange, + className, + placeholder, + debounceMs = 0, +}: SearchBarProps) => { + const debouncedOnChange = useDebounce(onChange, debounceMs); + + const handleChange = (e: React.ChangeEvent) => { + if (debounceMs > 0) { + debouncedOnChange(e.target.value); + } else { + onChange(e.target.value); + } + }; + + return ( +
+ +
+ ); +}; + +export default SearchBar; diff --git a/src/basic/components/SesarchBar.tsx b/src/basic/components/SesarchBar.tsx new file mode 100644 index 000000000..ef131d7da --- /dev/null +++ b/src/basic/components/SesarchBar.tsx @@ -0,0 +1,27 @@ +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + className?: string; + placeholder?: string; +} + +const SearchBar = ({ + value, + onChange, + className, + placeholder, +}: SearchBarProps) => { + return ( +
+ onChange(e.target.value)} + placeholder={placeholder || '상품 검색...'} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ ); +}; + +export default SearchBar; diff --git a/src/basic/components/admin/CouponCard.tsx b/src/basic/components/admin/CouponCard.tsx new file mode 100644 index 000000000..1ccc97f51 --- /dev/null +++ b/src/basic/components/admin/CouponCard.tsx @@ -0,0 +1,48 @@ +import { Coupon } from '../../../types'; +import { formatPriceWithWon } from '../../utils/formatters'; + +interface CouponCardProps { + coupon: Coupon; + onRemove: (couponCode: string) => void; +} + +const CouponCard = ({ coupon, onRemove }: CouponCardProps) => { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${formatPriceWithWon(coupon.discountValue)} 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +}; + +export default CouponCard; + diff --git a/src/basic/components/admin/CouponForm.tsx b/src/basic/components/admin/CouponForm.tsx new file mode 100644 index 000000000..4aa9bb0e7 --- /dev/null +++ b/src/basic/components/admin/CouponForm.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react'; +import { Coupon } from '../../../types'; +import { isValidCouponCode } from '../../utils/validators'; + +interface CouponFormProps { + onSubmit: (coupon: Coupon) => void; + onCancel: () => void; + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; +} + +const CouponForm = ({ onSubmit, onCancel, addNotification }: CouponFormProps) => { + const [form, setForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!isValidCouponCode(form.code)) { + addNotification('쿠폰 코드는 4-12자의 영문 대문자와 숫자만 가능합니다', 'error'); + return; + } + + onSubmit(form); + setForm({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + }; + + const handleDiscountValueChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setForm({ + ...form, + discountValue: value === '' ? 0 : parseInt(value), + }); + } + }; + + const handleDiscountValueBlur = (e: React.FocusEvent) => { + const value = parseInt(e.target.value) || 0; + if (form.discountType === 'percentage') { + if (value > 100) { + addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); + setForm({ ...form, discountValue: 100 }); + } else if (value < 0) { + setForm({ ...form, discountValue: 0 }); + } + } else { + if (value > 100000) { + addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); + setForm({ ...form, discountValue: 100000 }); + } else if (value < 0) { + setForm({ ...form, discountValue: 0 }); + } + } + }; + + return ( +
+
+

새 쿠폰 생성

+
+
+ + setForm({ ...form, 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 + /> +
+
+ + + setForm({ ...form, 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 + /> +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ ); +}; + +export default CouponForm; + diff --git a/src/basic/components/admin/ProductForm.tsx b/src/basic/components/admin/ProductForm.tsx new file mode 100644 index 000000000..50223ad1f --- /dev/null +++ b/src/basic/components/admin/ProductForm.tsx @@ -0,0 +1,262 @@ +import { useState, useEffect } from 'react'; +import { ProductWithUI, Discount } from '../../../types'; +import { isValidPrice, isValidStock } from '../../utils/validators'; + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} + +interface ProductFormProps { + editingProduct: ProductWithUI | null; + onSubmit: (data: ProductFormData) => void; + onCancel: () => void; + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; +} + +const initialForm: ProductFormData = { + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], +}; + +const ProductForm = ({ + editingProduct, + onSubmit, + onCancel, + addNotification, +}: ProductFormProps) => { + const [form, setForm] = useState(initialForm); + const isEditing = editingProduct !== null; + + useEffect(() => { + if (editingProduct) { + setForm({ + name: editingProduct.name, + price: editingProduct.price, + stock: editingProduct.stock, + description: editingProduct.description || '', + discounts: editingProduct.discounts || [], + }); + } else { + setForm(initialForm); + } + }, [editingProduct]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(form); + }; + + const handlePriceChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setForm({ ...form, price: value === '' ? 0 : parseInt(value) }); + } + }; + + const handlePriceBlur = (e: React.FocusEvent) => { + const value = e.target.value; + const price = parseInt(value) || 0; + + if (!isValidPrice(price)) { + addNotification('가격은 0보다 커야 합니다', 'error'); + setForm({ ...form, price: 0 }); + } + }; + + const handleStockChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setForm({ ...form, stock: value === '' ? 0 : parseInt(value) }); + } + }; + + const handleStockBlur = (e: React.FocusEvent) => { + const value = e.target.value; + const stock = parseInt(value) || 0; + + if (!isValidStock(stock)) { + addNotification('재고는 0보다 커야 합니다', 'error'); + setForm({ ...form, stock: 0 }); + } else if (stock > 9999) { + addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); + setForm({ ...form, stock: 9999 }); + } + }; + + const handleDiscountQuantityChange = (index: number, value: string) => { + const newDiscounts = [...form.discounts]; + newDiscounts[index].quantity = parseInt(value) || 0; + setForm({ ...form, discounts: newDiscounts }); + }; + + const handleDiscountRateChange = (index: number, value: string) => { + const newDiscounts = [...form.discounts]; + newDiscounts[index].rate = (parseInt(value) || 0) / 100; + setForm({ ...form, discounts: newDiscounts }); + }; + + const handleRemoveDiscount = (index: number) => { + const newDiscounts = form.discounts.filter((_, i) => i !== index); + setForm({ ...form, discounts: newDiscounts }); + }; + + const handleAddDiscount = () => { + setForm({ + ...form, + discounts: [...form.discounts, { quantity: 10, rate: 0.1 }], + }); + }; + + return ( +
+
+

+ {isEditing ? '상품 수정' : '새 상품 추가'} +

+
+
+ + setForm({ ...form, 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 + /> +
+
+ + setForm({ ...form, 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" + /> +
+
+ + +
+
+ + +
+
+ +
+ +
+ {form.discounts.map((discount, index) => ( +
+ + handleDiscountQuantityChange(index, e.target.value) + } + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + + handleDiscountRateChange(index, e.target.value) + } + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; + +export default ProductForm; + diff --git a/src/basic/components/admin/ProductTableRow.tsx b/src/basic/components/admin/ProductTableRow.tsx new file mode 100644 index 000000000..6a21144db --- /dev/null +++ b/src/basic/components/admin/ProductTableRow.tsx @@ -0,0 +1,62 @@ +import { ProductWithUI } from '../../../types'; +import { formatPriceWithWon } from '../../utils/formatters'; + +interface ProductTableRowProps { + product: ProductWithUI; + remainingStock: number; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +} + +const ProductTableRow = ({ + product, + remainingStock, + onEdit, + onDelete, +}: ProductTableRowProps) => { + const displayPrice = remainingStock <= 0 ? 'SOLD OUT' : formatPriceWithWon(product.price); + + const getStockBadgeClass = () => { + if (product.stock > 10) return 'bg-green-100 text-green-800'; + if (product.stock > 0) return 'bg-yellow-100 text-yellow-800'; + return 'bg-red-100 text-red-800'; + }; + + return ( + + + {product.name} + + + {displayPrice} + + + + {product.stock}개 + + + + {product.description || '-'} + + + + + + + ); +}; + +export default ProductTableRow; + diff --git a/src/basic/constants/index.ts b/src/basic/constants/index.ts new file mode 100644 index 000000000..2e67006ca --- /dev/null +++ b/src/basic/constants/index.ts @@ -0,0 +1,51 @@ +import { Coupon, ProductWithUI } from '../../types'; + +// 초기 데이터 +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/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..c5eb0c896 --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,116 @@ +import { useState } from 'react'; +import { CartItem, Coupon, Product } from '../../types'; +import { useLocalStorage } from './useLocalStorage'; +import * as cartModel from '../models/cart'; + +type NotificationFn = ( + message: string, + type: 'error' | 'success' | 'warning' +) => void; + +export function useCart(addNotification?: NotificationFn) { + const [cart, setCart] = useLocalStorage('cart', []); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + // 상품 추가 + const addToCart = (product: Product) => { + const remainingStock = cartModel.getRemainingStock(product, cart); + if (remainingStock <= 0) { + addNotification?.('재고가 부족합니다!', 'error'); + return; + } + + const existingItem = cart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + if (newQuantity > product.stock) { + addNotification?.(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + return; + } + } + + setCart((prevCart) => { + if (existingItem) { + return cartModel.updateCartItemQuantity( + prevCart, + product.id, + existingItem.quantity + 1 + ); + } + return cartModel.addItemToCart(prevCart, product); + }); + + addNotification?.('장바구니에 담았습니다', 'success'); + }; + + const removeFromCart = (product: Product) => { + setCart((prevCart) => cartModel.removeItemFromCart(prevCart, product.id)); + }; + + const updateQuantity = (product: Product, quantity: number) => { + if (quantity <= 0) { + removeFromCart(product); + return; + } + + // 수량 업데이트 시에는 총 재고와 비교해야 함 + if (quantity > product.stock) { + addNotification?.(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + return; + } + + setCart((prevCart) => + cartModel.updateCartItemQuantity(prevCart, product.id, quantity) + ); + }; + + const getRemainingStock = (product: Product) => { + return cartModel.getRemainingStock(product, cart); + }; + + const calculateTotal = () => { + return cartModel.calculateCartTotal(cart, selectedCoupon ?? undefined); + }; + + const applyCoupon = (coupon: Coupon) => { + const { totalAfterDiscount } = cartModel.calculateCartTotal(cart, coupon); + + if (totalAfterDiscount < 10000 && coupon.discountType === 'percentage') { + addNotification?.( + 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + 'error' + ); + return; + } + setSelectedCoupon(coupon); + addNotification?.('쿠폰이 적용되었습니다.', 'success'); + }; + + const clearCart = () => { + setCart([]); + setSelectedCoupon(null); + }; + + const clearCoupon = () => { + setSelectedCoupon(null); + }; + + const getItemTotal = (item: CartItem) => { + return cartModel.calculateItemTotal(item, cart); + }; + + return { + cart, + addToCart, + calculateTotal, + applyCoupon, + clearCart, + clearCoupon, + getRemainingStock, + getItemTotal, + removeFromCart, + updateQuantity, + selectedCoupon, + }; +} diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 000000000..70b4e9f78 --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,39 @@ +import { Coupon } from '../../types'; +import { initialCoupons } from '../constants'; +import { useLocalStorage } from './useLocalStorage'; +import * as couponModel from '../models/coupon'; + +type NotificationFn = ( + message: string, + type: 'error' | 'success' | 'warning' +) => void; + +export const useCoupons = (addNotification?: NotificationFn) => { + const [coupons, setCoupons] = useLocalStorage( + 'coupons', + initialCoupons + ); + + const addCoupon = (newCoupon: Coupon) => { + // validate + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + addNotification?.('이미 존재하는 쿠폰 코드입니다.', 'error'); + return; + } + setCoupons((prev) => couponModel.addCoupon(newCoupon, prev)); + addNotification?.('쿠폰이 추가되었습니다.', 'success'); + }; + + const removeCoupon = (couponCode: string) => { + const existingCoupon = coupons.find((c) => c.code === couponCode); + if (!existingCoupon) { + addNotification?.('존재하지 않는 쿠폰 코드입니다.', 'error'); + return; + } + + setCoupons((prev) => couponModel.removeCoupon(couponCode, prev)); + }; + + return { coupons, setCoupons, addCoupon, removeCoupon }; +}; diff --git a/src/basic/hooks/useDebounce.ts b/src/basic/hooks/useDebounce.ts new file mode 100644 index 000000000..0c6bb7a60 --- /dev/null +++ b/src/basic/hooks/useDebounce.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react'; + +const useDebounce = (value: T, delayMs = 500) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debouncedValue; +}; + +export default useDebounce; diff --git a/src/basic/hooks/useLocalStorage.ts b/src/basic/hooks/useLocalStorage.ts new file mode 100644 index 000000000..08eb7fe1b --- /dev/null +++ b/src/basic/hooks/useLocalStorage.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + const [value, setValue] = useState(() => { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + }); + + useEffect(() => { + localStorage.setItem(key, JSON.stringify(value)); + }, [key, value]); + + return [value, setValue]; +} diff --git a/src/basic/hooks/useNotifications.ts b/src/basic/hooks/useNotifications.ts new file mode 100644 index 000000000..c9b01b3bf --- /dev/null +++ b/src/basic/hooks/useNotifications.ts @@ -0,0 +1,19 @@ +import { useState } from 'react'; +import type { Notification } from '../../types'; + +export const useNotifications = () => { + const [notifications, setNotifications] = useState([]); + + const addNotification = ( + message: string, + type: 'error' | 'success' | 'warning' = 'success' + ) => { + const id = `${Date.now()}-${Math.random()}`; + setNotifications((prev) => [...prev, { id, message, type }]); + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }; + + return { notifications, addNotification, setNotifications }; +}; diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 000000000..c936cc400 --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,87 @@ +import { Discount, Product, ProductWithUI } from '../../types'; +import { initialProducts } from '../constants'; +import { useLocalStorage } from './useLocalStorage'; +import * as productModel from '../models/product'; + +type NotificationFn = ( + message: string, + type: 'error' | 'success' | 'warning' +) => void; + +export const useProducts = (addNotification?: NotificationFn) => { + // - updateProductStock: 재고 수정 + // - addProductDiscount: 할인 규칙 추가 + // - removeProductDiscount: 할인 규칙 삭제 + + const [products, setProducts] = useLocalStorage( + 'products', + initialProducts + ); + + const addProduct = (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}-${Math.random()}`, + }; + + setProducts((prev) => productModel.addProduct(product, prev)); + + addNotification?.('상품이 추가되었습니다.', 'success'); + return newProduct; + }; + + const updateProduct = ( + productId: string, + updates: Partial + ) => { + const existingProduct = products.find((p) => p.id === productId); + if (!existingProduct) { + return; + } + + setProducts((prev) => + productModel.updateProduct( + { + id: productId, + ...updates, + }, + prev + ) + ); + }; + + const removeProduct = (productId: string) => { + setProducts((prev) => productModel.removeProduct(productId, prev)); + }; + + const updateProductStock = (product: ProductWithUI) => { + const existingProduct = products.find((p) => p.id === product.id); + if (!existingProduct) { + return; + } + setProducts((prev) => productModel.updateProductStock(product, prev)); + }; + + const addProductDiscount = (discount: Discount, product: Product) => { + const existingProduct = products.find((p) => p.id === product.id); + if (!existingProduct) { + return; + } + setProducts((prev) => + prev.map((p) => + p.id === product.id + ? productModel.updateDiscountForProduct(discount, p) + : p + ) + ); + }; + + return { + products, + addProduct, + updateProduct, + updateProductStock, + addProductDiscount, + removeProduct, + }; +}; diff --git a/src/basic/hooks/useViewMode.ts b/src/basic/hooks/useViewMode.ts new file mode 100644 index 000000000..c3bc7a876 --- /dev/null +++ b/src/basic/hooks/useViewMode.ts @@ -0,0 +1,22 @@ +import { useState } from 'react'; + +export type ViewMode = 'cart' | 'admin'; + +export function useViewMode(initialMode: ViewMode = 'cart') { + const [viewMode, setViewMode] = useState(initialMode); + + const toggleViewMode = () => { + setViewMode((prev) => (prev === 'cart' ? 'admin' : 'cart')); + }; + + const isCartView = viewMode === 'cart'; + const isAdminView = viewMode === 'admin'; + + return { + viewMode, + setViewMode, + toggleViewMode, + isCartView, + isAdminView, + }; +} diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts new file mode 100644 index 000000000..31a863174 --- /dev/null +++ b/src/basic/models/cart.ts @@ -0,0 +1,94 @@ +import type { CartItem, Coupon, Product } from '../../types'; + +const getProductQuantityDiscount = (item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + return discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); +}; + +const hasBulkDiscount = (cart: CartItem[]): boolean => { + return cart.some((cartItem) => cartItem.quantity >= 10); +}; + +const applyBulkDiscount = (baseDiscount: number): number => { + return Math.min(baseDiscount + 0.05, 0.5); // 추가 5% 할인, 최대 50% +}; + +const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + const baseDiscount = getProductQuantityDiscount(item); + + if (hasBulkDiscount(cart)) { + return applyBulkDiscount(baseDiscount); + } + + 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[], coupon?: Coupon) => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (coupon) { + if (coupon.discountType === 'amount') { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - coupon.discountValue + ); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - coupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + discountAmount: Math.round(totalBeforeDiscount - totalAfterDiscount), + }; +}; + +export const removeItemFromCart = (cart: CartItem[], productId: string) => { + return cart.filter((item) => item.product.id !== productId); +}; + +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + quantity: number +) => { + return cart.map((item) => + item.product.id === productId ? { ...item, quantity } : item + ); +}; + +export const addItemToCart = (cart: CartItem[], product: Product) => { + return [...cart, { product, quantity: 1 }]; +}; + +export const getRemainingStock = (product: Product, cart: CartItem[]) => { + const cartItem = cart.find((item) => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); +}; diff --git a/src/basic/models/coupon.ts b/src/basic/models/coupon.ts new file mode 100644 index 000000000..2428ffb5b --- /dev/null +++ b/src/basic/models/coupon.ts @@ -0,0 +1,9 @@ +import { Coupon } from '../../types'; + +export const addCoupon = (newCoupon: Coupon, coupons: Coupon[]) => { + return [...coupons, newCoupon]; +}; + +export const removeCoupon = (couponCode: string, coupons: Coupon[]) => { + return coupons.filter((c) => c.code !== couponCode); +}; diff --git a/src/basic/models/product.ts b/src/basic/models/product.ts new file mode 100644 index 000000000..0cde3ce4a --- /dev/null +++ b/src/basic/models/product.ts @@ -0,0 +1,66 @@ +import { Discount, Product, ProductWithUI } from '../../types'; + +export const getMaxDiscountRate = (product: Product): number | undefined => { + if (product.discounts.length === 0) return undefined; + return Math.max(...product.discounts.map((d) => d.rate)); +}; + +export const getMinDiscountQuantity = ( + product: Product +): number | undefined => { + if (product.discounts.length === 0) return undefined; + return Math.min(...product.discounts.map((d) => d.quantity)); +}; + +export const isSoldOut = (remainingStock: number): boolean => { + return remainingStock <= 0; +}; + +export const filterProductsBySearchTerm = ( + products: ProductWithUI[], + searchTerm: string +): ProductWithUI[] => { + if (!searchTerm) return products; + + const term = searchTerm.toLowerCase(); + return products.filter((product) => { + return ( + product.name.toLowerCase().includes(term) || + product.description?.toLowerCase().includes(term) + ); + }); +}; + +export const addProduct = ( + product: ProductWithUI, + products: ProductWithUI[] +) => { + return [...products, product]; +}; + +export const updateProduct = ( + updates: Partial, + products: ProductWithUI[] +) => { + return products.map((p) => (p.id === updates.id ? { ...p, ...updates } : p)); +}; + +export const updateProductStock = ( + product: ProductWithUI, + products: ProductWithUI[] +) => { + return products.map((p) => + p.id === product.id ? { ...p, stock: product.stock } : p + ); +}; + +export const removeProduct = (productId: string, products: ProductWithUI[]) => { + return products.filter((p) => p.id !== productId); +}; + +export const updateDiscountForProduct = ( + discount: Discount, + product: ProductWithUI +) => { + return { ...product, discounts: [...product.discounts, discount] }; +}; diff --git a/src/basic/pages/CartPage.tsx b/src/basic/pages/CartPage.tsx new file mode 100644 index 000000000..666e9f693 --- /dev/null +++ b/src/basic/pages/CartPage.tsx @@ -0,0 +1,138 @@ +import { Coupon, ProductWithUI } from '../../types'; +import CartSection from '../components/CartSection'; +import CouponSelector from '../components/CouponSelector'; +import Empty from '../components/Empty'; +import OrderSummary from '../components/OrderSummary'; +import ProductList from '../components/ProductList'; +import { useCart } from '../hooks/useCart'; +import useDebounce from '../hooks/useDebounce'; +import * as productModel from '../models/product'; + +interface CartProps { + addNotification: ( + message: string, + type: 'error' | 'success' | 'warning' + ) => void; + cartActions: ReturnType; + products: ProductWithUI[]; + coupons: Coupon[]; + searchTerm: string; +} +const Cart = ({ + addNotification, + cartActions, + products, + coupons, + searchTerm, +}: CartProps) => { + const { + cart, + addToCart, + updateQuantity, + removeFromCart, + applyCoupon, + clearCoupon, + selectedCoupon, + calculateTotal, + getRemainingStock, + getItemTotal, + clearCart, + } = cartActions; + + const debouncedSearchTerm = useDebounce(searchTerm, 500); + + const handleClickAddToCart = (product: ProductWithUI) => { + addToCart(product); + }; + + const handleClickRemoveFromCart = (productId: string) => { + const product = products.find((p) => p.id === productId); + if (product) { + removeFromCart(product); + } + }; + + const handleClickUpdateQuantity = ( + productId: string, + newQuantity: number + ) => { + const product = products.find((p) => p.id === productId); + if (product) { + updateQuantity(product, newQuantity); + } + }; + + const handleChangeCoupon = (e: React.ChangeEvent) => { + const coupon = coupons.find((c) => c.code === e.target.value); + if (coupon) applyCoupon(coupon); + else clearCoupon(); + }; + + const handleClickCompleteOrder = () => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + 'success' + ); + clearCart(); + }; + + const totals = calculateTotal(); + + const filteredProducts = productModel.filterProductsBySearchTerm( + products, + debouncedSearchTerm + ); + + return ( +
+
+ {/* 상품 목록 */} +
+
+

전체 상품

+
+ 총 {products.length}개 상품 +
+
+ {filteredProducts.length === 0 ? ( + + ) : ( + + )} +
+
+ +
+
+ + + {cart.length > 0 && ( + <> + + + + )} +
+
+
+ ); +}; + +export default Cart; diff --git a/src/basic/pages/admin/AdminPage.tsx b/src/basic/pages/admin/AdminPage.tsx new file mode 100644 index 000000000..80d60db6f --- /dev/null +++ b/src/basic/pages/admin/AdminPage.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import { CartItem } from '../../../types'; +import { useProducts } from '../../hooks/useProducts'; +import { useCoupons } from '../../hooks/useCoupons'; +import ProductSection from './ProductSection'; +import CouponSection from './CouponSection'; + +interface AdminProps { + addNotification: ( + message: string, + type: 'error' | 'success' | 'warning' + ) => void; + productActions: ReturnType; + couponActions: ReturnType; + cart: CartItem[]; +} + +const Admin = ({ + addNotification, + productActions, + couponActions, + cart, +}: AdminProps) => { + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>( + 'products' + ); + + return ( +
+
+

관리자 대시보드

+

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

+
+
+ +
+ + {activeTab === 'products' ? ( + + ) : ( + + )} +
+ ); +}; + +export default Admin; diff --git a/src/basic/pages/admin/CouponSection.tsx b/src/basic/pages/admin/CouponSection.tsx new file mode 100644 index 000000000..e54aea362 --- /dev/null +++ b/src/basic/pages/admin/CouponSection.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { Coupon } from '../../../types'; +import { useCoupons } from '../../hooks/useCoupons'; +import CouponCard from '../../components/admin/CouponCard'; +import CouponForm from '../../components/admin/CouponForm'; + +interface CouponSectionProps { + addNotification: ( + message: string, + type: 'error' | 'success' | 'warning' + ) => void; + couponActions: ReturnType; +} + +const CouponSection = ({ + addNotification, + couponActions, +}: CouponSectionProps) => { + const { coupons } = couponActions; + const [showCouponForm, setShowCouponForm] = useState(false); + + const handleAddCoupon = (newCoupon: Coupon) => { + couponActions.addCoupon(newCoupon); + setShowCouponForm(false); + }; + + const handleRemoveCoupon = (couponCode: string) => { + couponActions.removeCoupon(couponCode); + }; + + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( + + ))} + +
+ +
+
+ + {showCouponForm && ( + setShowCouponForm(false)} + addNotification={addNotification} + /> + )} +
+
+ ); +}; + +export default CouponSection; diff --git a/src/basic/pages/admin/ProductSection.tsx b/src/basic/pages/admin/ProductSection.tsx new file mode 100644 index 000000000..9bb06d0a2 --- /dev/null +++ b/src/basic/pages/admin/ProductSection.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { CartItem, ProductWithUI, Discount } from '../../../types'; +import { useProducts } from '../../hooks/useProducts'; +import ProductTableRow from '../../components/admin/ProductTableRow'; +import ProductForm from '../../components/admin/ProductForm'; +import * as cartModel from '../../models/cart'; + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} + +interface ProductProps { + addNotification: ( + message: string, + type: 'error' | 'success' | 'warning' + ) => void; + productActions: ReturnType; + cart: CartItem[]; +} + +const ProductSection = ({ + addNotification, + productActions, + cart, +}: ProductProps) => { + const { products } = productActions; + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState( + null + ); + + const handleProductSubmit = (formData: ProductFormData) => { + if (editingProduct) { + productActions.updateProduct(editingProduct.id, formData); + } else { + productActions.addProduct(formData); + } + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleEditProduct = (product: ProductWithUI) => { + setEditingProduct(product); + setShowProductForm(true); + }; + + const handleDeleteProduct = (productId: string) => { + productActions.removeProduct(productId); + }; + + const handleCancel = () => { + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleAddNew = () => { + setEditingProduct(null); + setShowProductForm(true); + }; + + return ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+ + {showProductForm && ( + + )} +
+ ); +}; + +export default ProductSection; diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts new file mode 100644 index 000000000..afc3e73eb --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,18 @@ +// 가격을 한국 원화 형식으로 포맷 +export const formatPrice = (price: number): string => { + return `₩${price.toLocaleString('ko-KR')}`; +}; + +export const formatPriceWithWon = (price: number): string => { + return `${price.toLocaleString('ko-KR')}원`; +}; + +// 날짜를 YYYY-MM-DD 형식으로 포맷 +export const formatDate = (date: Date): string => { + return date.toISOString().split('T')[0]; +}; + +// 소수를 퍼센트로 변환 (0.1 → 10%) +export const formatPercentage = (rate: number): string => { + return `${Math.round(rate * 100)}%`; +}; diff --git a/src/basic/utils/validators.ts b/src/basic/utils/validators.ts new file mode 100644 index 000000000..c013a5c28 --- /dev/null +++ b/src/basic/utils/validators.ts @@ -0,0 +1,11 @@ +export const isValidCouponCode = (code: string): boolean => { + return /^[A-Z0-9]{4,12}$/.test(code); +}; + +export const isValidStock = (stock: number): boolean => { + return stock >= 0; +}; + +export const isValidPrice = (price: number): boolean => { + return price > 0; +}; diff --git a/src/types.ts b/src/types.ts index 5489e296e..3a616748d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,3 +22,14 @@ export interface Coupon { discountType: 'amount' | 'percentage'; discountValue: number; } + +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} diff --git a/tsconfig.app.json b/tsconfig.app.json index d739292ae..d82c950cf 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,5 +23,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/refactoring(hint)"] } diff --git a/vite.config.ts b/vite.config.ts index e6c4016bc..9de833131 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,12 +5,19 @@ import react from '@vitejs/plugin-react-swc'; export default mergeConfig( defineConfig({ plugins: [react()], + base: '/front_7th_chapter3-2/', + build: { + rollupOptions: { + input: ['./index.advanced.html', './index.basic.html'], + }, + outDir: 'dist', + }, }), defineTestConfig({ test: { globals: true, environment: 'jsdom', - setupFiles: './src/setupTests.ts' + setupFiles: './src/setupTests.ts', }, }) -) +);