diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..0234c3bfe --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,64 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Remove pnpm-workspace.yaml + run: rm -f pnpm-workspace.yaml + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Build basic + run: pnpm run build --config vite.config.basic.ts + + - name: Build advanced + run: pnpm run build --config vite.config.advanced.ts + + - name: Rename index files + run: | + mv dist/basic/index.basic.html dist/basic/index.html + mv dist/advanced/index.advanced.html dist/advanced/index.html + + - name: Copy root index.html + run: cp index.html dist/index.html + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "./dist" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index e38f1e44b..112f9ee32 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,19 @@ -# Chapter3-2. 디자인 패턴과 함수형 프로그래밍 그리고 상태 관리 설계 +# Chapter3-2. 디자인 패턴과 함수형 프로그래밍 그리고 상태 관리 설계 - 4팀 김도현 ## 기본과제: 거대 단일 컴포넌트 리팩토링 이번 과제는 단일책임원칙을 위반한 거대한 컴포넌트를 리팩토링 하는 것입니다. React의 컴포넌트는 단일 책임 원칙(Single Responsibility Principle, SRP)을 따르는 것이 좋습니다. 즉, 각 컴포넌트는 하나의 책임만을 가져야 합니다. 하지만 실제로는 여러 가지 기능을 가진 거대한 컴포넌트를 작성하는 경우가 많습니다. [목표] + ## 1. 취지 + - React의 추구미(!)를 이해해보아요! - 단일 책임 원칙(SRP)을 위반한 거대한 컴포넌트가 얼마나 안 좋은지 경험해보아요! - 단일 책임이라는 개념을 이해하기 상태, 순수함수, 컴포넌트, 훅 등 다양한 계층을 이해해합니다. - 엔티티와 UI를 구분하고 데이터, 상태, 비즈니스 로직 등의 특징이 다르다는 것을 이해해보세요. - 이를 통해 적절한 Custom Hook과 유틸리티 함수를 분리하고, 컴포넌트 계층 구조를 정리하는 능력을 갖춥니다! - ## 2. 목표 모든 소프트웨어에는 적절한 책임과 계층이 존재합니다. 하나의 계층(Component)만으로 소프트웨어를 구성하게 되면 나중에는 정리정돈이 되지 않은 코드를 만나게 됩니다. 예전에는 이러한 BestPractice에 대해서 혼돈의 시대였지만 FE가 진화를 거듭하는 과정에서 적절한 계측에 대한 합의가 이루어지고 있는 상태입니다. @@ -22,7 +23,7 @@ React의 주요 책임 계층은 Component, hook, function 등이 있습니다. - 엔티티를 다루는 상태와 그렇지 않은 상태 - cart, isCartFull vs isShowPopup - 엔티티를 다루는 컴포넌트와 훅 - CartItemView, useCart(), useProduct() - 엔티티를 다루지 않는 컴포넌트와 훅 - Button, useRoute, useEvent 등 -- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) +- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) 이번 과제의 목표는 이러한 계층을 이해하고 분리하여 정리정돈을 하는 기준이나 방법등을 습득하는데 있습니다. @@ -35,34 +36,34 @@ React의 주요 책임 계층은 Component, hook, function 등이 있습니다. #### 1) 장바구니 페이지 요구사항 - 상품 목록 - - 상품명, 가격, 재고 수량 등을 표시 - - 각 상품의 할인 정보 표시 - - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 + - 상품명, 가격, 재고 수량 등을 표시 + - 각 상품의 할인 정보 표시 + - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 - 장바구니 - - 장바구니 내 상품 수량 조절 가능 - - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 - - 적용된 할인율 표시 (예: "10% 할인 적용") - - 장바구니 내 모든 상품의 총액을 계산해야 + - 장바구니 내 상품 수량 조절 가능 + - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 + - 적용된 할인율 표시 (예: "10% 할인 적용") + - 장바구니 내 모든 상품의 총액을 계산해야 - 쿠폰 할인 - - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 + - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 - 주문요약 - - 할인 전 총 금액 - - 총 할인 금액 - - 최종 결제 금액 + - 할인 전 총 금액 + - 총 할인 금액 + - 최종 결제 금액 #### 2) 관리자 페이지 요구사항 - 상품 관리 - - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 - - 새로운 상품 추가 가능 - - 상품 제거 가능 + - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 + - 새로운 상품 추가 가능 + - 상품 제거 가능 - 할인 관리 - - 상품별 할인 정보 추가/수정/삭제 가능 - - 할인 조건 설정 (구매 수량에 따른 할인율) + - 상품별 할인 정보 추가/수정/삭제 가능 + - 할인 조건 설정 (구매 수량에 따른 할인율) - 쿠폰 관리 - - 전체 상품에 적용 가능한 쿠폰 생성 - - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) - - 할인 유형은 금액 또는 비율로 설정 가능 + - 전체 상품에 적용 가능한 쿠폰 생성 + - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) + - 할인 유형은 금액 또는 비율로 설정 가능 ### (2) 코드 개선 요구사항 @@ -88,9 +89,8 @@ React의 주요 책임 계층은 Component, hook, function 등이 있습니다. ### (3) 테스트 코드 통과하기 - - ## 심화과제: Props drilling + > **이번 심화과제는 Props drilling을 없애기 입니다.** # 2. 목표 @@ -98,10 +98,9 @@ React의 주요 책임 계층은 Component, hook, function 등이 있습니다. - basic에서 열심히 컴포넌트를 분리해주었겠죠? - 아마 그 과정에서 container - presenter 패턴으로 만들어졌기에 props drilling이 상당히 불편했을거에요. - 그래서 심화과제에서는 props drilling을 제거하는 작업을 할거에요. - - 전역상태관리가 아직 낯설다. - jotai를 선택해주세요 (참고자료 참고) - - 나는 React만으로 해보고 싶다. - context를 선택해서 상태관리를 해보세요. - - 나는 지금 대세인 Zustand를 할거에요. - zustand를 선택해주세요. - + - 전역상태관리가 아직 낯설다. - jotai를 선택해주세요 (참고자료 참고) + - 나는 React만으로 해보고 싶다. - context를 선택해서 상태관리를 해보세요. + - 나는 지금 대세인 Zustand를 할거에요. - zustand를 선택해주세요. ### (1) 요구사항 @@ -112,8 +111,8 @@ React의 주요 책임 계층은 Component, hook, function 등이 있습니다. ### (2) 힌트 - UI 컴포넌트와 엔티티 컴포넌트는 각각 props를 다르게 받는게 좋습니다. - - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, + - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, - 엔티티 컴포넌트는 가급적 엔티티를 중심으로 전달받는 것이 좋습니다. - 특히 콜백의 경우, - UI 컴포넌트는 이벤트 핸들러를 props로 받아서 처리하도록 해서 재사용성을 높이지만, - - 엔티티 컴포넌트는 props가 아닌 컴포넌트 내부에서 상태를 관리하는 것이 좋습니다. \ No newline at end of file + - 엔티티 컴포넌트는 props가 아닌 컴포넌트 내부에서 상태를 관리하는 것이 좋습니다. diff --git a/index.html b/index.html new file mode 100644 index 000000000..2def921fd --- /dev/null +++ b/index.html @@ -0,0 +1,23 @@ + + + + + + Chapter 3-2 + + + +
+

Chapter 3-2

+

디자인 패턴과 함수형 프로그래밍 그리고 상태 관리 설계

+
+ + Basic Version + + + Advanced Version + +
+
+ + diff --git a/package.json b/package.json index 17b18de25..03811e474 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "jotai": "^2.15.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85f..3f6655757 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + jotai: + specifier: ^2.15.2 + version: 2.15.2(@types/react@19.1.9)(react@19.1.1) react: specifier: ^19.1.1 version: 19.1.1 @@ -1056,6 +1059,24 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jotai@2.15.2: + resolution: {integrity: sha512-El86CCfXNMEOytp20NPfppqGGmcp6H6kIA+tJHdmASEUURJCYW4fh8nTHEnB8rUXEFAY1pm8PdHPwnrcPGwdEg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2413,6 +2434,11 @@ snapshots: isexe@2.0.0: {} + jotai@2.15.2(@types/react@19.1.9)(react@19.1.1): + optionalDependencies: + '@types/react': 19.1.9 + react: 19.1.1 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1d..1b04d4f03 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,457 +1,76 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; - -const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); +import { useState } from 'react'; +import { Provider } from 'jotai'; +import { useCart, useNotification } from './hooks'; +import { CartPage, AdminPage } from './pages'; + +/** + * 내부 앱 컴포넌트 + * Jotai hooks를 사용하므로 Provider 내부에서 렌더링되어야 함 + */ +const AppContent = () => { + // UI 상태만 로컬로 관리 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 { cart } = useCart(); + const { notifications, removeNotification } = useNotification(); - 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 totalItemCount = cart.reduce((sum, item) => sum + item.quantity, 0); return (
+ {/* 알림 영역 */} {notifications.length > 0 && (
{notifications.map(notif => (
{notif.message} -
))}
)} + + {/* 헤더 */}

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} {!isAdmin && (
setSearchTerm(e.target.value)} + onChange={e => 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" /> @@ -462,8 +81,8 @@ const App = () => { {!isAdmin && (
- - + + {cart.length > 0 && ( @@ -486,639 +115,26 @@ const App = () => {
+ {/* 메인 컨텐츠 */}
- {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()}원 -
-
- - - -
-

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

-
-
- - )} -
-
-
- )} + {isAdmin ? : }
); }; -export default App; \ No newline at end of file +/** + * 쇼핑몰 앱 + * + * Jotai를 사용한 전역 상태 관리로 props drilling 제거 + * - Provider로 감싸서 테스트 시 상태 격리 보장 + * - isAdmin, searchTerm만 로컬 UI 상태로 관리 + * - 나머지 모든 도메인 상태는 전역(Jotai)에서 관리 + */ +const App = () => ( + + + +); + +export default App; diff --git a/src/advanced/atoms/cartAtoms.ts b/src/advanced/atoms/cartAtoms.ts new file mode 100644 index 000000000..4480452ea --- /dev/null +++ b/src/advanced/atoms/cartAtoms.ts @@ -0,0 +1,82 @@ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import type { CartItem, Product } from '../../types'; +import { selectedCouponAtom } from './couponAtoms'; +import { + calculateCartTotal, + calculateItemTotal, + updateCartItemQuantity, + getRemainingStock as calcRemainingStock, + addItemToCart, + removeItemFromCart, + type CartTotal +} from '../models'; + +/** + * 장바구니 atom (localStorage 연동) + */ +export const cartAtom = atomWithStorage('cart', []); + +/** + * 장바구니 총액 파생 atom + */ +export const cartTotalAtom = atom(get => { + const cart = get(cartAtom); + const selectedCoupon = get(selectedCouponAtom); + return calculateCartTotal(cart, selectedCoupon); +}); + +/** + * 장바구니 아이템 추가 액션 atom + */ +export const addToCartAtom = atom(null, (get, set, product: Product) => { + const cart = get(cartAtom); + const remainingStock = calcRemainingStock(product, cart); + + if (remainingStock <= 0) { + return false; + } + + set(cartAtom, addItemToCart(cart, product)); + return true; +}); + +/** + * 장바구니 아이템 삭제 액션 atom + */ +export const removeFromCartAtom = atom(null, (get, set, productId: string) => { + const cart = get(cartAtom); + set(cartAtom, removeItemFromCart(cart, productId)); +}); + +/** + * 장바구니 수량 업데이트 액션 atom + */ +export const updateQuantityAtom = atom( + null, + (get, set, { productId, newQuantity }: { productId: string; newQuantity: number }) => { + const cart = get(cartAtom); + set(cartAtom, updateCartItemQuantity(cart, productId, newQuantity)); + } +); + +/** + * 장바구니 비우기 액션 atom + */ +export const clearCartAtom = atom(null, (_get, set) => { + set(cartAtom, []); +}); + +/** + * 남은 재고 계산 함수 (atom이 아닌 유틸리티) + */ +export const getRemainingStock = (product: Product, cart: CartItem[]): number => { + return calcRemainingStock(product, cart); +}; + +/** + * 아이템 총액 계산 함수 (atom이 아닌 유틸리티) + */ +export const getItemTotal = (item: CartItem, cart: CartItem[]): number => { + return calculateItemTotal(item, cart); +}; diff --git a/src/advanced/atoms/couponAtoms.ts b/src/advanced/atoms/couponAtoms.ts new file mode 100644 index 000000000..686a84d0c --- /dev/null +++ b/src/advanced/atoms/couponAtoms.ts @@ -0,0 +1,54 @@ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import type { Coupon } from '../../types'; +import { initialCoupons } from '../constants'; + +/** + * 쿠폰 목록 atom (localStorage 연동) + */ +export const couponsAtom = atomWithStorage('coupons', initialCoupons); + +/** + * 선택된 쿠폰 atom + */ +export const selectedCouponAtom = atom(null); + +/** + * 쿠폰 적용 액션 atom + */ +export const applyCouponAtom = atom(null, (_get, set, coupon: Coupon) => { + set(selectedCouponAtom, coupon); +}); + +/** + * 쿠폰 해제 액션 atom + */ +export const removeCouponAtom = atom(null, (_get, set) => { + set(selectedCouponAtom, null); +}); + +/** + * 쿠폰 추가 액션 atom + */ +export const addCouponAtom = atom(null, (get, set, newCoupon: Coupon) => { + const coupons = get(couponsAtom); + set(couponsAtom, [...coupons, newCoupon]); +}); + +/** + * 쿠폰 삭제 액션 atom + */ +export const deleteCouponAtom = atom(null, (get, set, code: string) => { + const coupons = get(couponsAtom); + const selectedCoupon = get(selectedCouponAtom); + + set( + couponsAtom, + coupons.filter(c => c.code !== code) + ); + + // 삭제된 쿠폰이 선택된 쿠폰이면 선택 해제 + if (selectedCoupon?.code === code) { + set(selectedCouponAtom, null); + } +}); diff --git a/src/advanced/atoms/index.ts b/src/advanced/atoms/index.ts new file mode 100644 index 000000000..d9ee3b2ed --- /dev/null +++ b/src/advanced/atoms/index.ts @@ -0,0 +1,43 @@ +/** + * Jotai Atoms + * + * 전역 상태 관리를 위한 atom 모음 + */ + +// Product atoms +export { + productsAtom, + addProductAtom, + updateProductAtom, + deleteProductAtom +} from './productAtoms'; + +// Coupon atoms +export { + couponsAtom, + selectedCouponAtom, + applyCouponAtom, + removeCouponAtom, + addCouponAtom, + deleteCouponAtom +} from './couponAtoms'; + +// Cart atoms +export { + cartAtom, + cartTotalAtom, + addToCartAtom, + removeFromCartAtom, + updateQuantityAtom, + clearCartAtom, + getRemainingStock, + getItemTotal +} from './cartAtoms'; + +// Notification atoms +export { + notificationsAtom, + addNotificationAtom, + removeNotificationAtom, + type Notification +} from './notificationAtoms'; diff --git a/src/advanced/atoms/notificationAtoms.ts b/src/advanced/atoms/notificationAtoms.ts new file mode 100644 index 000000000..36e9b7021 --- /dev/null +++ b/src/advanced/atoms/notificationAtoms.ts @@ -0,0 +1,50 @@ +import { atom } from 'jotai'; + +/** + * 알림 타입 정의 + */ +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +/** + * 알림 목록 atom + */ +export const notificationsAtom = atom([]); + +/** + * 알림 추가 액션 atom + */ +export const addNotificationAtom = atom( + null, + ( + get, + set, + { message, type = 'success' }: { message: string; type?: 'error' | 'success' | 'warning' } + ) => { + const id = Date.now().toString(); + const notifications = get(notificationsAtom); + + set(notificationsAtom, [...notifications, { id, message, type }]); + + // 3초 후 자동 제거 + setTimeout(() => { + // Jotai set은 업데이터 함수를 지원하지 않으므로 get으로 현재 상태를 가져옴 + const currentNotifications = get(notificationsAtom); + set(notificationsAtom, currentNotifications.filter(n => n.id !== id)); + }, 3000); + } +); + +/** + * 알림 제거 액션 atom + */ +export const removeNotificationAtom = atom(null, (get, set, id: string) => { + const notifications = get(notificationsAtom); + set( + notificationsAtom, + notifications.filter(n => n.id !== id) + ); +}); diff --git a/src/advanced/atoms/productAtoms.ts b/src/advanced/atoms/productAtoms.ts new file mode 100644 index 000000000..4c7907d29 --- /dev/null +++ b/src/advanced/atoms/productAtoms.ts @@ -0,0 +1,53 @@ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import { initialProducts, type ProductWithUI } from '../constants'; + +/** + * 상품 목록 atom (localStorage 연동) + */ +export const productsAtom = atomWithStorage( + 'products', + initialProducts +); + +/** + * 상품 추가 액션 atom + */ +export const addProductAtom = atom( + null, + (get, set, newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + const products = get(productsAtom); + set(productsAtom, [...products, product]); + } +); + +/** + * 상품 수정 액션 atom + */ +export const updateProductAtom = atom( + null, + (get, set, { productId, updates }: { productId: string; updates: Partial }) => { + const products = get(productsAtom); + set( + productsAtom, + products.map(product => + product.id === productId ? { ...product, ...updates } : product + ) + ); + } +); + +/** + * 상품 삭제 액션 atom + */ +export const deleteProductAtom = atom(null, (get, set, productId: string) => { + const products = get(productsAtom); + set( + productsAtom, + products.filter(p => p.id !== productId) + ); +}); diff --git a/src/advanced/components/Button.tsx b/src/advanced/components/Button.tsx new file mode 100644 index 000000000..c3c527d5a --- /dev/null +++ b/src/advanced/components/Button.tsx @@ -0,0 +1,50 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'; +type ButtonSize = 'sm' | 'md' | 'lg'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + children: ReactNode; +} + +const variantStyles: Record = { + primary: 'bg-gray-900 text-white hover:bg-gray-800 disabled:bg-gray-300', + secondary: 'border border-gray-300 text-gray-700 hover:bg-gray-50', + danger: 'text-red-600 hover:text-red-900', + ghost: 'text-gray-600 hover:text-gray-900' +}; + +const sizeStyles: Record = { + sm: 'px-2 py-1 text-xs', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-3 text-base' +}; + +/** + * 범용 버튼 컴포넌트 + * + * Entity에 의존하지 않는 순수 UI 컴포넌트입니다. + * 재사용 가능하며, 스타일 변형을 props로 제어합니다. + */ +export const Button = ({ + variant = 'primary', + size = 'md', + className = '', + disabled, + children, + ...props +}: ButtonProps) => { + const baseStyles = 'rounded-md font-medium transition-colors disabled:cursor-not-allowed'; + + return ( + + ); +}; diff --git a/src/advanced/components/Input.tsx b/src/advanced/components/Input.tsx new file mode 100644 index 000000000..0a4400bd4 --- /dev/null +++ b/src/advanced/components/Input.tsx @@ -0,0 +1,28 @@ +import type { InputHTMLAttributes } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; +} + +/** + * 범용 입력 컴포넌트 + * + * Entity에 의존하지 않는 순수 UI 컴포넌트입니다. + * 레이블과 에러 메시지를 포함할 수 있습니다. + */ +export const Input = ({ label, error, className = '', ...props }: InputProps) => { + const baseStyles = + 'w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border'; + const errorStyles = error ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : ''; + + return ( +
+ {label && ( + + )} + + {error &&

{error}

} +
+ ); +}; diff --git a/src/advanced/components/Select.tsx b/src/advanced/components/Select.tsx new file mode 100644 index 000000000..8d01ad25c --- /dev/null +++ b/src/advanced/components/Select.tsx @@ -0,0 +1,27 @@ +import type { SelectHTMLAttributes, ReactNode } from 'react'; + +interface SelectProps extends SelectHTMLAttributes { + label?: string; + children: ReactNode; +} + +/** + * 범용 셀렉트 컴포넌트 + * + * Entity에 의존하지 않는 순수 UI 컴포넌트입니다. + */ +export const Select = ({ label, className = '', children, ...props }: SelectProps) => { + const baseStyles = + 'w-full text-sm border border-gray-300 rounded px-3 py-2 focus:outline-none focus:border-blue-500'; + + return ( +
+ {label && ( + + )} + +
+ ); +}; diff --git a/src/advanced/components/index.ts b/src/advanced/components/index.ts new file mode 100644 index 000000000..be3673e80 --- /dev/null +++ b/src/advanced/components/index.ts @@ -0,0 +1,9 @@ +/** + * UI 컴포넌트 + * + * Entity에 의존하지 않는 순수 UI 컴포넌트들입니다. + * 재사용 가능하며, 전역 상태를 직접 참조하지 않습니다. + */ +export { Button } from './Button'; +export { Input } from './Input'; +export { Select } from './Select'; diff --git a/src/advanced/constants/index.ts b/src/advanced/constants/index.ts new file mode 100644 index 000000000..423d7a97e --- /dev/null +++ b/src/advanced/constants/index.ts @@ -0,0 +1,64 @@ +import type { Coupon, Product } from '../../types'; + +/** + * UI 확장 Product 타입 (순환 참조 방지를 위해 여기서 정의) + */ +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +/** + * 초기 상품 데이터 + */ +export const initialProducts: ProductWithUI[] = [ + { + id: 'p1', + name: '상품1', + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 } + ], + description: '최고급 품질의 프리미엄 상품입니다.' + }, + { + id: 'p2', + name: '상품2', + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: '다양한 기능을 갖춘 실용적인 상품입니다.', + isRecommended: true + }, + { + id: 'p3', + name: '상품3', + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 } + ], + description: '대용량과 고성능을 자랑하는 상품입니다.' + } +]; + +/** + * 초기 쿠폰 데이터 + */ +export const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000 + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10 + } +]; diff --git a/src/advanced/features/admin/CouponManagement.tsx b/src/advanced/features/admin/CouponManagement.tsx new file mode 100644 index 000000000..9e936deb4 --- /dev/null +++ b/src/advanced/features/admin/CouponManagement.tsx @@ -0,0 +1,247 @@ +import { useState } from 'react'; +import { useCoupon, useNotification } from '../../hooks'; + +/** + * 쿠폰 관리 컴포넌트 + * + * 관리자용 쿠폰 CRUD 기능을 제공합니다. + * - 전역 상태에서 coupons 직접 접근 + * - props 없음 + */ +export const CouponManagement = () => { + const { coupons, addCoupon, deleteCoupon } = useCoupon(); + const { addNotification } = useNotification(); + + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0 + }); + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // 중복 쿠폰 코드 체크 + const existingCoupon = coupons.find(c => c.code === couponForm.code); + if (existingCoupon) { + addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); + return; + } + + addCoupon(couponForm); + addNotification('쿠폰이 추가되었습니다.', 'success'); + setCouponForm({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0 + }); + setShowCouponForm(false); + }; + + const handleDeleteCoupon = (couponCode: string) => { + deleteCoupon(couponCode); + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }; + + return ( +
+
+

쿠폰 관리

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

{coupon.name}

+

+ {coupon.code} +

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

새 쿠폰 생성

+
+
+ + + setCouponForm({ ...couponForm, name: e.target.value }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder="신규 가입 쿠폰" + required + /> +
+
+ + + setCouponForm({ + ...couponForm, + code: e.target.value.toUpperCase() + }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setCouponForm({ + ...couponForm, + discountValue: value === '' ? 0 : parseInt(value) + }); + } + }} + onBlur={e => { + const value = parseInt(e.target.value) || 0; + if (couponForm.discountType === 'percentage') { + if (value > 100) { + addNotification( + '할인율은 100%를 초과할 수 없습니다', + 'error' + ); + setCouponForm({ ...couponForm, discountValue: 100 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } else { + if (value > 100000) { + addNotification( + '할인 금액은 100,000원을 초과할 수 없습니다', + 'error' + ); + setCouponForm({ ...couponForm, discountValue: 100000 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} + required + /> +
+
+
+ + +
+
+
+ )} +
+
+ ); +}; diff --git a/src/advanced/features/admin/ProductManagement.tsx b/src/advanced/features/admin/ProductManagement.tsx new file mode 100644 index 000000000..116455b38 --- /dev/null +++ b/src/advanced/features/admin/ProductManagement.tsx @@ -0,0 +1,375 @@ +import { useState } from 'react'; +import { useProduct, useNotification, type ProductWithUI } from '../../hooks'; +import { formatAdminPrice } from '../../utils'; + +/** + * 상품 관리 컴포넌트 + * + * 관리자용 상품 CRUD 기능을 제공합니다. + * - 전역 상태에서 products 직접 접근 + * - props 없음 + */ +export const ProductManagement = () => { + const { products, addProduct, updateProduct, deleteProduct } = useProduct(); + const { addNotification } = useNotification(); + + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] as Array<{ quantity: number; rate: number }> + }); + + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== 'new') { + updateProduct(editingProduct, productForm); + addNotification('상품이 수정되었습니다.', 'success'); + setEditingProduct(null); + } else { + addProduct({ + ...productForm, + discounts: productForm.discounts + }); + addNotification('상품이 추가되었습니다.', 'success'); + } + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] + }); + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleDeleteProduct = (productId: string) => { + deleteProduct(productId); + addNotification('상품이 삭제되었습니다.', 'success'); + }; + + const startEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [] + }); + setShowProductForm(true); + }; + + return ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map(product => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {formatAdminPrice(product.price)} + + 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="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ )} +
+ ); +}; diff --git a/src/advanced/features/admin/index.ts b/src/advanced/features/admin/index.ts new file mode 100644 index 000000000..3999f0005 --- /dev/null +++ b/src/advanced/features/admin/index.ts @@ -0,0 +1,2 @@ +export { ProductManagement } from './ProductManagement'; +export { CouponManagement } from './CouponManagement'; diff --git a/src/advanced/features/cart/Cart.tsx b/src/advanced/features/cart/Cart.tsx new file mode 100644 index 000000000..aa777e37e --- /dev/null +++ b/src/advanced/features/cart/Cart.tsx @@ -0,0 +1,59 @@ +import { useCart } from '../../hooks'; +import { CartItemComponent } from './CartItem'; + +/** + * 장바구니 컴포넌트 + * + * 장바구니 전체 영역을 렌더링합니다. + * - 전역 상태에서 cart 직접 접근 + * - props 없음 + */ +export const Cart = () => { + const { cart } = useCart(); + + return ( +
+

+ + + + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map(item => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/advanced/features/cart/CartItem.tsx b/src/advanced/features/cart/CartItem.tsx new file mode 100644 index 000000000..eed2d1e4c --- /dev/null +++ b/src/advanced/features/cart/CartItem.tsx @@ -0,0 +1,100 @@ +import type { CartItem as CartItemType } from '../../../types'; +import { useCart, useProduct, useNotification } from '../../hooks'; +import { getAppliedDiscountRate } from '../../models'; + +interface CartItemProps { + item: CartItemType; +} + +/** + * 장바구니 아이템 컴포넌트 + * + * 단일 장바구니 아이템을 렌더링합니다. + * - 전역 상태에서 직접 액션 접근 + * - item만 props로 받음 (어떤 아이템인지 명시) + */ +export const CartItemComponent = ({ item }: CartItemProps) => { + const { cart, updateQuantity, removeFromCart, getItemTotal } = useCart(); + const { products } = useProduct(); + const { addNotification } = useNotification(); + + const itemTotal = getItemTotal(item); + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + // getAppliedDiscountRate는 이미 퍼센트 값을 반환함 (0~100) + const discountRate = getAppliedDiscountRate(item, cart); + + const handleUpdateQuantity = (newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(item.product.id); + return; + } + + const product = products.find(p => p.id === item.product.id); + if (!product) return; + + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + return; + } + + updateQuantity(item.product.id, newQuantity); + }; + + return ( +
+
+

+ {item.product.name} +

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

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

+
+
+
+ ); +}; diff --git a/src/advanced/features/cart/index.ts b/src/advanced/features/cart/index.ts new file mode 100644 index 000000000..5eb84df6f --- /dev/null +++ b/src/advanced/features/cart/index.ts @@ -0,0 +1,2 @@ +export { Cart } from './Cart'; +export { CartItemComponent } from './CartItem'; diff --git a/src/advanced/features/coupon/CouponSelector.tsx b/src/advanced/features/coupon/CouponSelector.tsx new file mode 100644 index 000000000..41384db2c --- /dev/null +++ b/src/advanced/features/coupon/CouponSelector.tsx @@ -0,0 +1,65 @@ +import { useCoupon, useCart, useNotification } from '../../hooks'; + +/** + * 쿠폰 선택기 컴포넌트 + * + * 쿠폰을 선택하여 적용할 수 있는 드롭다운을 렌더링합니다. + * - 전역 상태에서 coupons, selectedCoupon 직접 접근 + * - props 없음 + */ +export const CouponSelector = () => { + const { coupons, selectedCoupon, applyCoupon, removeCoupon } = useCoupon(); + const { getCartTotal } = useCart(); + const { addNotification } = useNotification(); + + const handleChange = (e: React.ChangeEvent) => { + const coupon = coupons.find(c => c.code === e.target.value); + + if (!coupon) { + removeCoupon(); + return; + } + + // percentage 쿠폰은 10,000원 이상 구매 시 사용 가능 + const currentTotal = getCartTotal().totalAfterDiscount; + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + addNotification( + 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + 'error' + ); + return; + } + + applyCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }; + + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +}; diff --git a/src/advanced/features/coupon/index.ts b/src/advanced/features/coupon/index.ts new file mode 100644 index 000000000..b4bb0ed25 --- /dev/null +++ b/src/advanced/features/coupon/index.ts @@ -0,0 +1 @@ +export { CouponSelector } from './CouponSelector'; diff --git a/src/advanced/features/index.ts b/src/advanced/features/index.ts new file mode 100644 index 000000000..ccce8afb2 --- /dev/null +++ b/src/advanced/features/index.ts @@ -0,0 +1,20 @@ +/** + * 도메인 컴포넌트 + * + * 비즈니스 로직을 포함하는 도메인별 컴포넌트들입니다. + */ + +// Cart +export { Cart, CartItemComponent } from './cart'; + +// Product +export { ProductList, ProductCard } from './product'; + +// Coupon +export { CouponSelector } from './coupon'; + +// Order +export { OrderSummary } from './order'; + +// Admin +export { ProductManagement, CouponManagement } from './admin'; diff --git a/src/advanced/features/order/OrderSummary.tsx b/src/advanced/features/order/OrderSummary.tsx new file mode 100644 index 000000000..cb8c265b0 --- /dev/null +++ b/src/advanced/features/order/OrderSummary.tsx @@ -0,0 +1,60 @@ +import { useCart, useCoupon, useNotification } from '../../hooks'; + +/** + * 주문 요약 컴포넌트 + * + * 결제 정보를 요약하여 표시합니다. + * - 전역 상태에서 직접 접근 + * - props 없음 + */ +export const OrderSummary = () => { + const { getCartTotal, clearCart } = useCart(); + const { removeCoupon } = useCoupon(); + const { addNotification } = useNotification(); + + const totals = getCartTotal(); + + const handleCompleteOrder = () => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + clearCart(); + removeCoupon(); + }; + + return ( +
+

결제 정보

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

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

+
+
+ ); +}; diff --git a/src/advanced/features/order/index.ts b/src/advanced/features/order/index.ts new file mode 100644 index 000000000..dd49db3a8 --- /dev/null +++ b/src/advanced/features/order/index.ts @@ -0,0 +1 @@ +export { OrderSummary } from './OrderSummary'; diff --git a/src/advanced/features/product/ProductCard.tsx b/src/advanced/features/product/ProductCard.tsx new file mode 100644 index 000000000..5cea2f79c --- /dev/null +++ b/src/advanced/features/product/ProductCard.tsx @@ -0,0 +1,113 @@ +import type { Product } from '../../../types'; +import { useCart, useNotification } from '../../hooks'; +import { getMaxDiscountPercent } from '../../models'; +import { formatPrice } from '../../utils'; + +interface ProductCardProps { + product: Product & { description?: string; isRecommended?: boolean }; +} + +/** + * 상품 카드 컴포넌트 + * + * 단일 상품의 정보를 카드 형태로 렌더링합니다. + * - 전역 상태에서 직접 cart, notification 접근 + * - product만 props로 받음 (어떤 상품인지 명시) + */ +export const ProductCard = ({ product }: ProductCardProps) => { + const { addToCart, getRemainingStock } = useCart(); + const { addNotification } = useNotification(); + + const remainingStock = getRemainingStock(product); + const maxDiscountPercent = getMaxDiscountPercent(product.discounts); + const displayPrice = + remainingStock <= 0 ? 'SOLD OUT' : formatPrice(product.price); + + const handleAddToCart = () => { + if (remainingStock <= 0) { + addNotification('재고가 부족합니다!', 'error'); + return; + } + addToCart(product); + addNotification('장바구니에 담았습니다', 'success'); + }; + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{maxDiscountPercent}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

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

{displayPrice}

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

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

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

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

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

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/advanced/features/product/ProductList.tsx b/src/advanced/features/product/ProductList.tsx new file mode 100644 index 000000000..dbf843f4d --- /dev/null +++ b/src/advanced/features/product/ProductList.tsx @@ -0,0 +1,59 @@ +import { useState, useEffect } from 'react'; +import { useProduct } from '../../hooks'; +import { ProductCard } from './ProductCard'; + +interface ProductListProps { + searchTerm?: string; +} + +/** + * 상품 목록 컴포넌트 + * + * 상품 목록을 그리드 형태로 렌더링합니다. + * - 전역 상태에서 products 직접 접근 + * - searchTerm만 props로 받음 (UI 상태) + */ +export const ProductList = ({ searchTerm = '' }: ProductListProps) => { + const { products } = useProduct(); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm); + + // 검색어 디바운스 + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + return () => clearTimeout(timer); + }, [searchTerm]); + + const filteredProducts = debouncedSearchTerm + ? products.filter( + product => + product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ) + : products; + + return ( +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map(product => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/advanced/features/product/index.ts b/src/advanced/features/product/index.ts new file mode 100644 index 000000000..0ad977d2f --- /dev/null +++ b/src/advanced/features/product/index.ts @@ -0,0 +1,2 @@ +export { ProductList } from './ProductList'; +export { ProductCard } from './ProductCard'; diff --git a/src/advanced/hooks/index.ts b/src/advanced/hooks/index.ts new file mode 100644 index 000000000..b442976ae --- /dev/null +++ b/src/advanced/hooks/index.ts @@ -0,0 +1,15 @@ +/** + * 커스텀 훅 + * + * 엔티티 훅과 범용 훅을 포함합니다. + * Jotai adapter 패턴으로 구현되어 전역 상태를 사용합니다. + */ + +// 엔티티 훅 (Jotai adapter) +export { useProduct, type ProductWithUI, type UseProductReturn } from './useProduct'; +export { useCoupon, type UseCouponReturn } from './useCoupon'; +export { useCart, type UseCartReturn } from './useCart'; +export { useNotification, type UseNotificationReturn } from './useNotification'; + +// 범용 훅 (더 이상 사용되지 않음, 호환성을 위해 유지) +export { useLocalStorage } from './useLocalStorage'; diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts new file mode 100644 index 000000000..aa00e8f13 --- /dev/null +++ b/src/advanced/hooks/useCart.ts @@ -0,0 +1,104 @@ +import { useCallback } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import type { CartItem, Product } from '../../types'; +import { + cartAtom, + cartTotalAtom, + addToCartAtom, + removeFromCartAtom, + updateQuantityAtom, + clearCartAtom, + getRemainingStock as calcRemainingStock, + getItemTotal as calcItemTotal +} from '../atoms'; +import type { CartTotal } from '../models'; + +/** + * useCart 훅 반환 타입 + */ +export interface UseCartReturn { + cart: CartItem[]; + addToCart: (product: Product) => boolean; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + clearCart: () => void; + getCartTotal: () => CartTotal; + getRemainingStock: (product: Product) => number; + getItemTotal: (item: CartItem) => number; +} + +/** + * 장바구니 관리 훅 (Jotai adapter) + * + * 기존 인터페이스를 유지하면서 내부적으로 Jotai atom 사용 + * 더 이상 selectedCoupon을 파라미터로 받지 않음 (전역 상태에서 직접 참조) + * + * @returns 장바구니 상태와 액션들 + */ +export const useCart = (): UseCartReturn => { + const cart = useAtomValue(cartAtom); + const cartTotal = useAtomValue(cartTotalAtom); + const addToCartAction = useSetAtom(addToCartAtom); + const removeFromCartAction = useSetAtom(removeFromCartAtom); + const updateQuantityAction = useSetAtom(updateQuantityAtom); + const clearCartAction = useSetAtom(clearCartAtom); + + const addToCart = useCallback( + (product: Product): boolean => { + const remainingStock = calcRemainingStock(product, cart); + if (remainingStock <= 0) { + return false; + } + addToCartAction(product); + return true; + }, + [cart, addToCartAction] + ); + + const removeFromCart = useCallback( + (productId: string) => { + removeFromCartAction(productId); + }, + [removeFromCartAction] + ); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + updateQuantityAction({ productId, newQuantity }); + }, + [updateQuantityAction] + ); + + const clearCart = useCallback(() => { + clearCartAction(); + }, [clearCartAction]); + + const getCartTotal = useCallback((): CartTotal => { + return cartTotal; + }, [cartTotal]); + + const getRemainingStock = useCallback( + (product: Product): number => { + return calcRemainingStock(product, cart); + }, + [cart] + ); + + const getItemTotal = useCallback( + (item: CartItem): number => { + return calcItemTotal(item, cart); + }, + [cart] + ); + + return { + cart, + addToCart, + removeFromCart, + updateQuantity, + clearCart, + getCartTotal, + getRemainingStock, + getItemTotal + }; +}; diff --git a/src/advanced/hooks/useCoupon.ts b/src/advanced/hooks/useCoupon.ts new file mode 100644 index 000000000..6417eea58 --- /dev/null +++ b/src/advanced/hooks/useCoupon.ts @@ -0,0 +1,73 @@ +import { useCallback } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import type { Coupon } from '../../types'; +import { + couponsAtom, + selectedCouponAtom, + applyCouponAtom, + removeCouponAtom, + addCouponAtom, + deleteCouponAtom +} from '../atoms'; + +/** + * useCoupon 훅 반환 타입 + */ +export interface UseCouponReturn { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + removeCoupon: () => void; + addCoupon: (coupon: Coupon) => void; + deleteCoupon: (code: string) => void; +} + +/** + * 쿠폰 관리 훅 (Jotai adapter) + * + * 기존 인터페이스를 유지하면서 내부적으로 Jotai atom 사용 + * + * @returns 쿠폰 목록, 선택된 쿠폰, 쿠폰 관리 액션들 + */ +export const useCoupon = (): UseCouponReturn => { + const coupons = useAtomValue(couponsAtom); + const selectedCoupon = useAtomValue(selectedCouponAtom); + const applyCouponAction = useSetAtom(applyCouponAtom); + const removeCouponAction = useSetAtom(removeCouponAtom); + const addCouponAction = useSetAtom(addCouponAtom); + const deleteCouponAction = useSetAtom(deleteCouponAtom); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + applyCouponAction(coupon); + }, + [applyCouponAction] + ); + + const removeCoupon = useCallback(() => { + removeCouponAction(); + }, [removeCouponAction]); + + const addCoupon = useCallback( + (newCoupon: Coupon) => { + addCouponAction(newCoupon); + }, + [addCouponAction] + ); + + const deleteCoupon = useCallback( + (code: string) => { + deleteCouponAction(code); + }, + [deleteCouponAction] + ); + + return { + coupons, + selectedCoupon, + applyCoupon, + removeCoupon, + addCoupon, + deleteCoupon + }; +}; diff --git a/src/advanced/hooks/useLocalStorage.ts b/src/advanced/hooks/useLocalStorage.ts new file mode 100644 index 000000000..203beaed7 --- /dev/null +++ b/src/advanced/hooks/useLocalStorage.ts @@ -0,0 +1,42 @@ +import { useState, useEffect, useCallback } from 'react'; + +/** + * localStorage와 동기화되는 상태를 관리하는 훅 + * + * @param key - localStorage 키 + * @param initialValue - 초기값 + * @returns [상태값, 상태변경함수] 튜플 + */ +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((prev: T) => T)) => void] { + // 초기값 로드 (lazy initialization) + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch { + return initialValue; + } + }); + + // localStorage 동기화 + useEffect(() => { + try { + localStorage.setItem(key, JSON.stringify(storedValue)); + } catch (error) { + console.error(`Error saving to localStorage: ${error}`); + } + }, [key, storedValue]); + + // 함수형 업데이트 지원 + const setValue = useCallback((value: T | ((prev: T) => T)) => { + setStoredValue(prev => { + const newValue = value instanceof Function ? value(prev) : value; + return newValue; + }); + }, []); + + return [storedValue, setValue]; +} diff --git a/src/advanced/hooks/useNotification.ts b/src/advanced/hooks/useNotification.ts new file mode 100644 index 000000000..a3616b3e0 --- /dev/null +++ b/src/advanced/hooks/useNotification.ts @@ -0,0 +1,48 @@ +import { useCallback } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { + notificationsAtom, + addNotificationAtom, + removeNotificationAtom, + type Notification +} from '../atoms'; + +/** + * useNotification 훅 반환 타입 + */ +export interface UseNotificationReturn { + notifications: Notification[]; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; + removeNotification: (id: string) => void; +} + +/** + * 알림 관리 훅 (Jotai adapter) + * + * @returns 알림 목록과 액션들 + */ +export const useNotification = (): UseNotificationReturn => { + const notifications = useAtomValue(notificationsAtom); + const addNotificationAction = useSetAtom(addNotificationAtom); + const removeNotificationAction = useSetAtom(removeNotificationAtom); + + const addNotification = useCallback( + (message: string, type: 'error' | 'success' | 'warning' = 'success') => { + addNotificationAction({ message, type }); + }, + [addNotificationAction] + ); + + const removeNotification = useCallback( + (id: string) => { + removeNotificationAction(id); + }, + [removeNotificationAction] + ); + + return { + notifications, + addNotification, + removeNotification + }; +}; diff --git a/src/advanced/hooks/useProduct.ts b/src/advanced/hooks/useProduct.ts new file mode 100644 index 000000000..83bea2912 --- /dev/null +++ b/src/advanced/hooks/useProduct.ts @@ -0,0 +1,64 @@ +import { useCallback } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { + productsAtom, + addProductAtom, + updateProductAtom, + deleteProductAtom +} from '../atoms'; +import type { ProductWithUI } from '../constants'; + +// Re-export ProductWithUI for external use +export type { ProductWithUI } from '../constants'; + +/** + * useProduct 훅 반환 타입 + */ +export interface UseProductReturn { + products: ProductWithUI[]; + addProduct: (product: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; +} + +/** + * 상품 관리 훅 (Jotai adapter) + * + * 기존 인터페이스를 유지하면서 내부적으로 Jotai atom 사용 + * + * @returns 상품 목록과 CRUD 액션들 + */ +export const useProduct = (): UseProductReturn => { + const products = useAtomValue(productsAtom); + const addProductAction = useSetAtom(addProductAtom); + const updateProductAction = useSetAtom(updateProductAtom); + const deleteProductAction = useSetAtom(deleteProductAtom); + + const addProduct = useCallback( + (newProduct: Omit) => { + addProductAction(newProduct); + }, + [addProductAction] + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + updateProductAction({ productId, updates }); + }, + [updateProductAction] + ); + + const deleteProduct = useCallback( + (productId: string) => { + deleteProductAction(productId); + }, + [deleteProductAction] + ); + + return { + products, + addProduct, + updateProduct, + deleteProduct + }; +}; diff --git a/src/advanced/models/cart.ts b/src/advanced/models/cart.ts new file mode 100644 index 000000000..65f5f4613 --- /dev/null +++ b/src/advanced/models/cart.ts @@ -0,0 +1,118 @@ +import type { CartItem, Coupon, Product } from '../../types'; +import { calculateDiscountedPrice } from './discount'; +import { applyCouponDiscount } from './coupon'; + +/** + * 장바구니 총액 계산 결과 + */ +export interface CartTotal { + totalBeforeDiscount: number; + totalAfterDiscount: number; + totalDiscount: number; +} + +/** + * 장바구니 아이템의 총 금액을 계산합니다. (할인 적용) + * + * @param item - 장바구니 아이템 + * @param cart - 장바구니 (대량 구매 보너스 계산용, 선택사항) + * @returns 할인이 적용된 총 금액 + */ +export const calculateItemTotal = ( + item: CartItem, + cart?: CartItem[] +): number => { + return calculateDiscountedPrice(item, cart); +}; + +/** + * 장바구니 전체 금액을 계산합니다. (쿠폰 할인 포함) + * + * @param cart - 장바구니 아이템 배열 + * @param selectedCoupon - 선택된 쿠폰 (없으면 null) + * @returns 할인 전 총액, 할인 후 총액, 총 할인액 + */ +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): CartTotal => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach(item => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + // 쿠폰 할인 적용 + totalAfterDiscount = applyCouponDiscount(totalAfterDiscount, selectedCoupon); + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + totalDiscount: Math.round(totalBeforeDiscount) - Math.round(totalAfterDiscount) + }; +}; + +/** + * 장바구니 아이템의 수량을 업데이트합니다. (불변성 유지) + * + * @param cart - 장바구니 아이템 배열 + * @param productId - 업데이트할 상품 ID + * @param newQuantity - 새로운 수량 (0 이하면 해당 아이템 제거) + * @returns 새로운 장바구니 배열 + */ +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + newQuantity: number +): CartItem[] => { + if (newQuantity <= 0) { + return cart.filter(item => item.product.id !== productId); + } + + return cart.map(item => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ); +}; + +/** + * 장바구니에 상품을 추가합니다. (불변성 유지) + * + * @param cart - 장바구니 아이템 배열 + * @param product - 추가할 상품 + * @returns 새로운 장바구니 배열 + */ +export const addItemToCart = ( + cart: CartItem[], + product: Product +): CartItem[] => { + const existingItem = cart.find(item => item.product.id === product.id); + + if (existingItem) { + return cart.map(item => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ); + } + + return [...cart, { product, quantity: 1 }]; +}; + +/** + * 장바구니에서 상품을 제거합니다. (불변성 유지) + * + * @param cart - 장바구니 아이템 배열 + * @param productId - 제거할 상품 ID + * @returns 새로운 장바구니 배열 + */ +export const removeItemFromCart = ( + cart: CartItem[], + productId: string +): CartItem[] => { + return cart.filter(item => item.product.id !== productId); +}; diff --git a/src/advanced/models/coupon.ts b/src/advanced/models/coupon.ts new file mode 100644 index 000000000..81f601fb2 --- /dev/null +++ b/src/advanced/models/coupon.ts @@ -0,0 +1,65 @@ +import type { Coupon } from '../../types'; + +/** + * 쿠폰 할인을 적용한 금액을 계산합니다. + * + * @param amount - 원래 금액 + * @param coupon - 적용할 쿠폰 + * @returns 쿠폰 할인이 적용된 금액 + */ +export const applyCouponDiscount = ( + amount: number, + coupon: Coupon | null +): number => { + if (!coupon) return amount; + + if (coupon.discountType === 'amount') { + return Math.max(0, amount - coupon.discountValue); + } + + return Math.round(amount * (1 - coupon.discountValue / 100)); +}; + +/** + * 쿠폰의 할인 금액을 계산합니다. + * + * @param amount - 원래 금액 + * @param coupon - 적용할 쿠폰 + * @returns 쿠폰으로 인한 할인 금액 + */ +export const getCouponDiscountAmount = ( + amount: number, + coupon: Coupon | null +): number => { + if (!coupon) return 0; + + const discountedAmount = applyCouponDiscount(amount, coupon); + return amount - discountedAmount; +}; + +/** + * 쿠폰 코드가 유효한지 검증합니다. + * + * @param code - 쿠폰 코드 + * @param coupons - 전체 쿠폰 목록 + * @returns 유효한 쿠폰이면 해당 쿠폰, 아니면 null + */ +export const findCouponByCode = ( + code: string, + coupons: Coupon[] +): Coupon | null => { + return coupons.find(c => c.code === code) || null; +}; + +/** + * 쿠폰 할인 정보를 포맷팅합니다. + * + * @param coupon - 쿠폰 + * @returns 포맷팅된 할인 정보 문자열 + */ +export const formatCouponDiscount = (coupon: Coupon): string => { + if (coupon.discountType === 'amount') { + return `${coupon.discountValue.toLocaleString()}원 할인`; + } + return `${coupon.discountValue}% 할인`; +}; diff --git a/src/advanced/models/discount.ts b/src/advanced/models/discount.ts new file mode 100644 index 000000000..c885f1522 --- /dev/null +++ b/src/advanced/models/discount.ts @@ -0,0 +1,97 @@ +import type { CartItem, Discount } from '../../types'; + +/** + * 장바구니에 대량 구매 상품이 있는지 확인합니다. + * + * @param cart - 장바구니 아이템 배열 + * @returns 10개 이상 구매한 상품이 있으면 true + */ +export const hasBulkPurchase = (cart: CartItem[]): boolean => { + return cart.some(item => item.quantity >= 10); +}; + +/** + * 할인 목록에서 최대 할인율을 반환합니다. + * + * @param discounts - 할인 목록 + * @returns 최대 할인율 (0 ~ 1) + */ +export const getMaxDiscountRate = (discounts: Discount[]): number => { + if (discounts.length === 0) return 0; + return Math.max(...discounts.map(d => d.rate)); +}; + +/** + * 최대 할인율을 퍼센트로 반환합니다. + * + * @param discounts - 할인 목록 + * @returns 최대 할인율 (퍼센트, 0 ~ 100) + */ +export const getMaxDiscountPercent = (discounts: Discount[]): number => { + return Math.round(getMaxDiscountRate(discounts) * 100); +}; + +/** + * 장바구니 아이템에 적용 가능한 최대 할인율을 계산합니다. + * + * @param item - 장바구니 아이템 + * @param cart - 장바구니 (대량 구매 보너스 계산용, 선택사항) + * @returns 적용 가능한 최대 할인율 (0 ~ 1) + */ +export const getMaxApplicableDiscount = ( + item: CartItem, + cart?: CartItem[] +): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + // 대량 구매 보너스: 장바구니에 10개 이상 구매한 상품이 있으면 +5% (최대 50%) + if (cart && hasBulkPurchase(cart)) { + return Math.min(baseDiscount + 0.05, 0.5); + } + + return baseDiscount; +}; + +/** + * 장바구니 아이템의 할인 적용 후 금액을 계산합니다. + * + * @param item - 장바구니 아이템 + * @param cart - 장바구니 (대량 구매 보너스 계산용, 선택사항) + * @returns 할인이 적용된 금액 + */ +export const calculateDiscountedPrice = ( + item: CartItem, + cart?: CartItem[] +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +/** + * 장바구니 아이템에 적용된 할인율을 퍼센트로 계산합니다. + * + * @param item - 장바구니 아이템 + * @param cart - 장바구니 (대량 구매 보너스 계산용, 선택사항) + * @returns 적용된 할인율 (퍼센트, 0 ~ 100) + */ +export const getAppliedDiscountRate = ( + item: CartItem, + cart?: CartItem[] +): number => { + const originalPrice = item.product.price * item.quantity; + const discountedPrice = calculateDiscountedPrice(item, cart); + + if (originalPrice === 0) return 0; + + return Math.round((1 - discountedPrice / originalPrice) * 100); +}; diff --git a/src/advanced/models/index.ts b/src/advanced/models/index.ts new file mode 100644 index 000000000..7d7152988 --- /dev/null +++ b/src/advanced/models/index.ts @@ -0,0 +1,34 @@ +// Discount - 할인 관련 순수 계산 함수 +export { + hasBulkPurchase, + getMaxDiscountRate, + getMaxDiscountPercent, + getMaxApplicableDiscount, + calculateDiscountedPrice, + getAppliedDiscountRate +} from './discount'; + +// Coupon - 쿠폰 관련 순수 계산 함수 +export { + applyCouponDiscount, + getCouponDiscountAmount, + findCouponByCode, + formatCouponDiscount +} from './coupon'; + +// Cart - 장바구니 관련 순수 계산 함수 +export { + calculateItemTotal, + calculateCartTotal, + updateCartItemQuantity, + addItemToCart, + removeItemFromCart, + type CartTotal +} from './cart'; + +// Product - 상품 관련 순수 계산 함수 +export { + getRemainingStock, + isOutOfStock, + sortByStock +} from './product'; diff --git a/src/advanced/models/product.ts b/src/advanced/models/product.ts new file mode 100644 index 000000000..7db2d24b6 --- /dev/null +++ b/src/advanced/models/product.ts @@ -0,0 +1,40 @@ +import type { CartItem, Product } from '../../types'; + +/** + * 상품의 남은 재고를 계산합니다. + * + * @param product - 상품 + * @param cart - 장바구니 아이템 배열 + * @returns 남은 재고 수량 + */ +export const getRemainingStock = ( + product: Product, + cart: CartItem[] +): number => { + const cartItem = cart.find(item => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); +}; + +/** + * 상품이 품절인지 확인합니다. + * + * @param product - 상품 + * @param cart - 장바구니 아이템 배열 + * @returns 품절이면 true + */ +export const isOutOfStock = ( + product: Product, + cart: CartItem[] +): boolean => { + return getRemainingStock(product, cart) <= 0; +}; + +/** + * 상품을 재고 순으로 정렬합니다. + * + * @param products - 상품 배열 + * @returns 재고가 많은 순으로 정렬된 상품 배열 + */ +export const sortByStock = (products: T[]): T[] => { + return [...products].sort((a, b) => b.stock - a.stock); +}; diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx new file mode 100644 index 000000000..b9653c923 --- /dev/null +++ b/src/advanced/pages/AdminPage.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { ProductManagement } from '../features/admin/ProductManagement'; +import { CouponManagement } from '../features/admin/CouponManagement'; + +/** + * 관리자 페이지 + * + * 상품 관리와 쿠폰 관리 탭을 포함합니다. + * - 전역 상태를 사용하므로 props 없음 + */ +export const AdminPage = () => { + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + + return ( +
+
+

관리자 대시보드

+

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

+
+ +
+ +
+ + {activeTab === 'products' ? : } +
+ ); +}; diff --git a/src/advanced/pages/CartPage.tsx b/src/advanced/pages/CartPage.tsx new file mode 100644 index 000000000..8268c18c7 --- /dev/null +++ b/src/advanced/pages/CartPage.tsx @@ -0,0 +1,43 @@ +import { useCart } from '../hooks'; +import { Cart } from '../features/cart'; +import { ProductList } from '../features/product'; +import { CouponSelector } from '../features/coupon'; +import { OrderSummary } from '../features/order'; + +interface CartPageProps { + searchTerm?: string; +} + +/** + * 장바구니 페이지 + * + * 상품 목록, 장바구니, 쿠폰 선택, 주문 요약을 포함합니다. + * - 전역 상태를 사용하므로 props 최소화 + * - searchTerm만 UI 상태로 받음 + */ +export const CartPage = ({ searchTerm = '' }: CartPageProps) => { + const { cart } = useCart(); + + return ( +
+ {/* 상품 목록 */} +
+ +
+ + {/* 장바구니 사이드바 */} +
+
+ + + {cart.length > 0 && ( + <> + + + + )} +
+
+
+ ); +}; diff --git a/src/advanced/pages/index.ts b/src/advanced/pages/index.ts new file mode 100644 index 000000000..41bed56b5 --- /dev/null +++ b/src/advanced/pages/index.ts @@ -0,0 +1,5 @@ +/** + * 페이지 컴포넌트 + */ +export { CartPage } from './CartPage'; +export { AdminPage } from './AdminPage'; diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts new file mode 100644 index 000000000..3059618aa --- /dev/null +++ b/src/advanced/utils/formatters.ts @@ -0,0 +1,39 @@ +/** + * 가격을 포맷팅합니다. + * + * @param price - 가격 + * @returns 포맷팅된 가격 문자열 (예: "₩10,000") + */ +export const formatPrice = (price: number): string => { + return `₩${price.toLocaleString()}`; +}; + +/** + * 관리자용 가격 포맷팅입니다. + * + * @param price - 가격 + * @returns 포맷팅된 가격 문자열 (예: "10,000원") + */ +export const formatAdminPrice = (price: number): string => { + return `${price.toLocaleString()}원`; +}; + +/** + * 퍼센트를 포맷팅합니다. + * + * @param percent - 퍼센트 값 (0 ~ 100) + * @returns 포맷팅된 퍼센트 문자열 (예: "10%") + */ +export const formatPercent = (percent: number): string => { + return `${percent}%`; +}; + +/** + * 할인율을 포맷팅합니다. + * + * @param rate - 할인율 (0 ~ 1) + * @returns 포맷팅된 할인율 문자열 (예: "10% 할인") + */ +export const formatDiscountRate = (rate: number): string => { + return `${Math.round(rate * 100)}% 할인`; +}; diff --git a/src/advanced/utils/index.ts b/src/advanced/utils/index.ts new file mode 100644 index 000000000..fbe21d6f4 --- /dev/null +++ b/src/advanced/utils/index.ts @@ -0,0 +1,11 @@ +/** + * 유틸리티 + * + * 포매터, 밸리데이터 등 + */ + +// 포매터 +export * from './formatters'; + +// 밸리데이터 +export * from './validators'; diff --git a/src/advanced/utils/validators.ts b/src/advanced/utils/validators.ts new file mode 100644 index 000000000..6d464d705 --- /dev/null +++ b/src/advanced/utils/validators.ts @@ -0,0 +1,60 @@ +/** + * 숫자만 포함된 문자열인지 검증합니다. + * + * @param value - 검증할 문자열 + * @returns 숫자만 포함되어 있으면 true + */ +export const isNumericString = (value: string): boolean => { + return value === '' || /^\d+$/.test(value); +}; + +/** + * 유효한 가격인지 검증합니다. + * + * @param price - 검증할 가격 + * @returns 0 이상의 정수이면 true + */ +export const isValidPrice = (price: number): boolean => { + return Number.isInteger(price) && price >= 0; +}; + +/** + * 유효한 재고인지 검증합니다. + * + * @param stock - 검증할 재고 + * @param max - 최대 재고 (기본값: 9999) + * @returns 0 ~ max 사이의 정수이면 true + */ +export const isValidStock = (stock: number, max = 9999): boolean => { + return Number.isInteger(stock) && stock >= 0 && stock <= max; +}; + +/** + * 유효한 할인율인지 검증합니다. + * + * @param rate - 검증할 할인율 (0 ~ 1) + * @returns 0 ~ 1 사이의 숫자이면 true + */ +export const isValidDiscountRate = (rate: number): boolean => { + return rate >= 0 && rate <= 1; +}; + +/** + * 빈 문자열인지 검증합니다. + * + * @param value - 검증할 문자열 + * @returns 빈 문자열이거나 공백만 있으면 true + */ +export const isEmpty = (value: string): boolean => { + return value.trim() === ''; +}; + +/** + * 쿠폰 코드가 유효한 형식인지 검증합니다. + * + * @param code - 검증할 쿠폰 코드 + * @returns 영문 대문자와 숫자만 포함되어 있으면 true + */ +export const isValidCouponCode = (code: string): boolean => { + return /^[A-Z0-9]+$/.test(code); +}; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..0d9c6be47 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,10 +1,8 @@ import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} +import { useProduct, useCoupon, useCart, type ProductWithUI } from './hooks'; +import { getMaxDiscountPercent, getAppliedDiscountRate } from './models'; +import { formatPrice, formatAdminPrice } from './utils'; +import { CartPage, AdminPage } from './pages'; interface Notification { id: string; @@ -12,231 +10,55 @@ interface Notification { type: 'error' | 'success' | 'warning'; } -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; - const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - 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 { products, addProduct, updateProduct, deleteProduct } = useProduct(); + const { + coupons, + selectedCoupon, + applyCoupon, + removeCoupon, + addCoupon, + deleteCoupon + } = useCoupon(); + const { + cart, + addToCart, + removeFromCart, + updateQuantity, + clearCart, + getCartTotal, + getRemainingStock, + getItemTotal + } = useCart(selectedCoupon); + + // UI 상태 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); - + // 알림 추가 함수 + 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); + }, + [] + ); + + // 총 아이템 수 계산 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); @@ -244,214 +66,215 @@ const App = () => { return () => clearTimeout(timer); }, [searchTerm]); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } + // 장바구니 추가 핸들러 (알림 포함) + const handleAddToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + addNotification('재고가 부족합니다!', 'error'); + return; + } + addToCart(product); + addNotification('장바구니에 담았습니다', 'success'); + }, + [addToCart, getRemainingStock, addNotification] + ); - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); + // 수량 업데이트 핸들러 (알림 포함) + const handleUpdateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; } - - 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 product = products.find(p => p.id === productId); + if (!product) return; - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + return; + } - const product = products.find(p => p.id === productId); - if (!product) return; + updateQuantity(productId, newQuantity); + }, + [products, updateQuantity, removeFromCart, addNotification] + ); - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } + // 쿠폰 적용 핸들러 (알림 포함) + const handleApplyCoupon = useCallback( + (coupon: typeof selectedCoupon) => { + if (!coupon) { + removeCoupon(); + return; + } - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); + const currentTotal = getCartTotal().totalAfterDiscount; - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + addNotification( + 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + 'error' + ); + return; + } - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + applyCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, + [applyCoupon, removeCoupon, getCartTotal, addNotification] + ); + // 주문 완료 핸들러 const completeOrder = useCallback(() => { const orderNumber = `ORD-${Date.now()}`; addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); + clearCart(); + removeCoupon(); + }, [clearCart, removeCoupon, addNotification]); + + // 상품 추가 핸들러 (알림 포함) + const handleAddProduct = useCallback( + (newProduct: Omit) => { + addProduct(newProduct); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [addProduct, addNotification] + ); - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); + // 상품 수정 핸들러 (알림 포함) + const handleUpdateProduct = useCallback( + (productId: string, updates: Partial) => { + updateProduct(productId, updates); + addNotification('상품이 수정되었습니다.', 'success'); + }, + [updateProduct, addNotification] + ); - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); + // 상품 삭제 핸들러 (알림 포함) + const handleDeleteProduct = useCallback( + (productId: string) => { + deleteProduct(productId); + addNotification('상품이 삭제되었습니다.', 'success'); + }, + [deleteProduct, addNotification] + ); - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); + // 쿠폰 추가 핸들러 (알림 포함) + const handleAddCoupon = useCallback( + (newCoupon: { name: string; code: string; discountType: 'amount' | 'percentage'; discountValue: number }) => { + const existingCoupon = coupons.find(c => c.code === newCoupon.code); + if (existingCoupon) { + addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); + return; + } + addCoupon(newCoupon); + addNotification('쿠폰이 추가되었습니다.', 'success'); + }, + [coupons, addCoupon, 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 handleDeleteCoupon = useCallback( + (couponCode: string) => { + deleteCoupon(couponCode); + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }, + [deleteCoupon, 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 displayPrice = useCallback( + (price: number, productId?: string): string => { + if (productId) { + const product = products.find(p => p.id === productId); + if (product && getRemainingStock(product) <= 0) { + return 'SOLD OUT'; + } + } - 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); - }; + if (isAdmin) { + return formatAdminPrice(price); + } - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; + return formatPrice(price); + }, + [products, getRemainingStock, isAdmin] + ); - 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); - }; + // 할인율 계산 (cart 전달) + const getDiscountRate = useCallback( + (item: { product: { id: string; name: string; price: number; stock: number; discounts: Array<{ quantity: number; rate: number }> }; quantity: number }) => { + return getAppliedDiscountRate(item, cart); + }, + [cart] + ); - const totals = calculateCartTotal(); + const totals = getCartTotal(); const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ? products.filter( + product => + product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) ) : products; return (
+ {/* 알림 영역 */} {notifications.length > 0 && (
{notifications.map(notif => (
{notif.message} -
))}
)} + + {/* 헤더 */}

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} {!isAdmin && (
setSearchTerm(e.target.value)} + onChange={e => 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" /> @@ -462,8 +285,8 @@ const App = () => { {!isAdmin && (
- - + + {cart.length > 0 && ( @@ -486,639 +319,44 @@ const App = () => {
+ {/* 메인 컨텐츠 */}
{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()}원 -
-
- - - -
-

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

-
-
- - )} -
-
-
+ )}
); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/components/Button.tsx b/src/basic/components/Button.tsx new file mode 100644 index 000000000..c3c527d5a --- /dev/null +++ b/src/basic/components/Button.tsx @@ -0,0 +1,50 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'; +type ButtonSize = 'sm' | 'md' | 'lg'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + children: ReactNode; +} + +const variantStyles: Record = { + primary: 'bg-gray-900 text-white hover:bg-gray-800 disabled:bg-gray-300', + secondary: 'border border-gray-300 text-gray-700 hover:bg-gray-50', + danger: 'text-red-600 hover:text-red-900', + ghost: 'text-gray-600 hover:text-gray-900' +}; + +const sizeStyles: Record = { + sm: 'px-2 py-1 text-xs', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-3 text-base' +}; + +/** + * 범용 버튼 컴포넌트 + * + * Entity에 의존하지 않는 순수 UI 컴포넌트입니다. + * 재사용 가능하며, 스타일 변형을 props로 제어합니다. + */ +export const Button = ({ + variant = 'primary', + size = 'md', + className = '', + disabled, + children, + ...props +}: ButtonProps) => { + const baseStyles = 'rounded-md font-medium transition-colors disabled:cursor-not-allowed'; + + return ( + + ); +}; diff --git a/src/basic/components/Input.tsx b/src/basic/components/Input.tsx new file mode 100644 index 000000000..0a4400bd4 --- /dev/null +++ b/src/basic/components/Input.tsx @@ -0,0 +1,28 @@ +import type { InputHTMLAttributes } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; +} + +/** + * 범용 입력 컴포넌트 + * + * Entity에 의존하지 않는 순수 UI 컴포넌트입니다. + * 레이블과 에러 메시지를 포함할 수 있습니다. + */ +export const Input = ({ label, error, className = '', ...props }: InputProps) => { + const baseStyles = + 'w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border'; + const errorStyles = error ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : ''; + + return ( +
+ {label && ( + + )} + + {error &&

{error}

} +
+ ); +}; diff --git a/src/basic/components/Select.tsx b/src/basic/components/Select.tsx new file mode 100644 index 000000000..8d01ad25c --- /dev/null +++ b/src/basic/components/Select.tsx @@ -0,0 +1,27 @@ +import type { SelectHTMLAttributes, ReactNode } from 'react'; + +interface SelectProps extends SelectHTMLAttributes { + label?: string; + children: ReactNode; +} + +/** + * 범용 셀렉트 컴포넌트 + * + * Entity에 의존하지 않는 순수 UI 컴포넌트입니다. + */ +export const Select = ({ label, className = '', children, ...props }: SelectProps) => { + const baseStyles = + 'w-full text-sm border border-gray-300 rounded px-3 py-2 focus:outline-none focus:border-blue-500'; + + return ( +
+ {label && ( + + )} + +
+ ); +}; diff --git a/src/basic/components/index.ts b/src/basic/components/index.ts new file mode 100644 index 000000000..be3673e80 --- /dev/null +++ b/src/basic/components/index.ts @@ -0,0 +1,9 @@ +/** + * UI 컴포넌트 + * + * Entity에 의존하지 않는 순수 UI 컴포넌트들입니다. + * 재사용 가능하며, 전역 상태를 직접 참조하지 않습니다. + */ +export { Button } from './Button'; +export { Input } from './Input'; +export { Select } from './Select'; diff --git a/src/basic/constants/index.ts b/src/basic/constants/index.ts new file mode 100644 index 000000000..423d7a97e --- /dev/null +++ b/src/basic/constants/index.ts @@ -0,0 +1,64 @@ +import type { Coupon, Product } from '../../types'; + +/** + * UI 확장 Product 타입 (순환 참조 방지를 위해 여기서 정의) + */ +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +/** + * 초기 상품 데이터 + */ +export const initialProducts: ProductWithUI[] = [ + { + id: 'p1', + name: '상품1', + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 } + ], + description: '최고급 품질의 프리미엄 상품입니다.' + }, + { + id: 'p2', + name: '상품2', + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: '다양한 기능을 갖춘 실용적인 상품입니다.', + isRecommended: true + }, + { + id: 'p3', + name: '상품3', + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 } + ], + description: '대용량과 고성능을 자랑하는 상품입니다.' + } +]; + +/** + * 초기 쿠폰 데이터 + */ +export const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000 + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10 + } +]; diff --git a/src/basic/features/admin/CouponManagement.tsx b/src/basic/features/admin/CouponManagement.tsx new file mode 100644 index 000000000..8cfeb0fcf --- /dev/null +++ b/src/basic/features/admin/CouponManagement.tsx @@ -0,0 +1,240 @@ +import { useState } from 'react'; +import type { Coupon } from '../../../types'; + +interface CouponManagementProps { + coupons: Coupon[]; + onAddCoupon: (coupon: Omit & { code: string }) => void; + onDeleteCoupon: (couponCode: string) => void; + onNotification: (message: string, type: 'error' | 'success' | 'warning') => void; +} + +/** + * 쿠폰 관리 컴포넌트 + * + * 관리자용 쿠폰 CRUD 기능을 제공합니다. + */ +export const CouponManagement = ({ + coupons, + onAddCoupon, + onDeleteCoupon, + onNotification +}: CouponManagementProps) => { + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0 + }); + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onAddCoupon(couponForm); + setCouponForm({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0 + }); + setShowCouponForm(false); + }; + + return ( +
+
+

쿠폰 관리

+
+
+
+ {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) { + onNotification( + '할인율은 100%를 초과할 수 없습니다', + 'error' + ); + setCouponForm({ ...couponForm, discountValue: 100 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } else { + if (value > 100000) { + onNotification( + '할인 금액은 100,000원을 초과할 수 없습니다', + 'error' + ); + setCouponForm({ ...couponForm, discountValue: 100000 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} + required + /> +
+
+
+ + +
+
+
+ )} +
+
+ ); +}; diff --git a/src/basic/features/admin/ProductManagement.tsx b/src/basic/features/admin/ProductManagement.tsx new file mode 100644 index 000000000..06211b7ca --- /dev/null +++ b/src/basic/features/admin/ProductManagement.tsx @@ -0,0 +1,378 @@ +import { useState } from 'react'; +import type { ProductWithUI } from '../../hooks'; + +interface ProductManagementProps { + products: ProductWithUI[]; + onAddProduct: (product: Omit) => void; + onUpdateProduct: (productId: string, updates: Partial) => void; + onDeleteProduct: (productId: string) => void; + displayPrice: (price: number, productId?: string) => string; + onNotification: (message: string, type: 'error' | 'success' | 'warning') => void; +} + +/** + * 상품 관리 컴포넌트 + * + * 관리자용 상품 CRUD 기능을 제공합니다. + */ +export const ProductManagement = ({ + products, + onAddProduct, + onUpdateProduct, + onDeleteProduct, + displayPrice, + onNotification +}: ProductManagementProps) => { + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] as Array<{ quantity: number; rate: number }> + }); + + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== 'new') { + onUpdateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + onAddProduct({ + ...productForm, + discounts: productForm.discounts + }); + } + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] + }); + setEditingProduct(null); + setShowProductForm(false); + }; + + const startEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [] + }); + setShowProductForm(true); + }; + + return ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map(product => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {displayPrice(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) { + onNotification('가격은 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) { + onNotification('재고는 0보다 커야 합니다', 'error'); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + onNotification('재고는 9999개를 초과할 수 없습니다', 'error'); + setProductForm({ ...productForm, stock: 9999 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
+
+
+ +
+ {productForm.discounts.map((discount, index) => ( +
+ { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = parseInt(e.target.value) || 0; + setProductForm({ + ...productForm, + discounts: newDiscounts + }); + }} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; + setProductForm({ + ...productForm, + discounts: newDiscounts + }); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ )} +
+ ); +}; diff --git a/src/basic/features/admin/index.ts b/src/basic/features/admin/index.ts new file mode 100644 index 000000000..3999f0005 --- /dev/null +++ b/src/basic/features/admin/index.ts @@ -0,0 +1,2 @@ +export { ProductManagement } from './ProductManagement'; +export { CouponManagement } from './CouponManagement'; diff --git a/src/basic/features/cart/Cart.tsx b/src/basic/features/cart/Cart.tsx new file mode 100644 index 000000000..affab431b --- /dev/null +++ b/src/basic/features/cart/Cart.tsx @@ -0,0 +1,78 @@ +import type { CartItem } from '../../../types'; +import { CartItemComponent } from './CartItem'; + +interface CartProps { + cart: CartItem[]; + getItemTotal: (item: CartItem) => number; + getDiscountRate: (item: CartItem) => number; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemove: (productId: string) => void; +} + +/** + * 장바구니 컴포넌트 + * + * 장바구니 전체 영역을 렌더링합니다. + * - 비어있을 때 안내 메시지 + * - 아이템 목록 렌더링 + */ +export const Cart = ({ + cart, + getItemTotal, + getDiscountRate, + onUpdateQuantity, + onRemove +}: CartProps) => { + return ( +
+

+ + + + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map(item => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/basic/features/cart/CartItem.tsx b/src/basic/features/cart/CartItem.tsx new file mode 100644 index 000000000..1a77b28a4 --- /dev/null +++ b/src/basic/features/cart/CartItem.tsx @@ -0,0 +1,85 @@ +import type { CartItem as CartItemType } from '../../../types'; + +interface CartItemProps { + item: CartItemType; + discountRate: number; + itemTotal: number; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemove: (productId: string) => void; +} + +/** + * 장바구니 아이템 컴포넌트 + * + * 단일 장바구니 아이템을 렌더링합니다. + * - 상품명, 수량, 가격, 할인율 표시 + * - 수량 증가/감소 버튼 + * - 삭제 버튼 + */ +export const CartItemComponent = ({ + item, + discountRate, + itemTotal, + onUpdateQuantity, + onRemove +}: CartItemProps) => { + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + + return ( +
+
+

+ {item.product.name} +

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

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

+
+
+
+ ); +}; diff --git a/src/basic/features/cart/index.ts b/src/basic/features/cart/index.ts new file mode 100644 index 000000000..5eb84df6f --- /dev/null +++ b/src/basic/features/cart/index.ts @@ -0,0 +1,2 @@ +export { Cart } from './Cart'; +export { CartItemComponent } from './CartItem'; diff --git a/src/basic/features/coupon/CouponSelector.tsx b/src/basic/features/coupon/CouponSelector.tsx new file mode 100644 index 000000000..85e0b9452 --- /dev/null +++ b/src/basic/features/coupon/CouponSelector.tsx @@ -0,0 +1,52 @@ +import type { Coupon } from '../../../types'; + +interface CouponSelectorProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + onApply: (coupon: Coupon | null) => void; +} + +/** + * 쿠폰 선택기 컴포넌트 + * + * 쿠폰을 선택하여 적용할 수 있는 드롭다운을 렌더링합니다. + */ +export const CouponSelector = ({ + coupons, + selectedCoupon, + onApply +}: CouponSelectorProps) => { + const handleChange = (e: React.ChangeEvent) => { + const coupon = coupons.find(c => c.code === e.target.value); + onApply(coupon || null); + }; + + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +}; diff --git a/src/basic/features/coupon/index.ts b/src/basic/features/coupon/index.ts new file mode 100644 index 000000000..b4bb0ed25 --- /dev/null +++ b/src/basic/features/coupon/index.ts @@ -0,0 +1 @@ +export { CouponSelector } from './CouponSelector'; diff --git a/src/basic/features/index.ts b/src/basic/features/index.ts new file mode 100644 index 000000000..ccce8afb2 --- /dev/null +++ b/src/basic/features/index.ts @@ -0,0 +1,20 @@ +/** + * 도메인 컴포넌트 + * + * 비즈니스 로직을 포함하는 도메인별 컴포넌트들입니다. + */ + +// Cart +export { Cart, CartItemComponent } from './cart'; + +// Product +export { ProductList, ProductCard } from './product'; + +// Coupon +export { CouponSelector } from './coupon'; + +// Order +export { OrderSummary } from './order'; + +// Admin +export { ProductManagement, CouponManagement } from './admin'; diff --git a/src/basic/features/order/OrderSummary.tsx b/src/basic/features/order/OrderSummary.tsx new file mode 100644 index 000000000..6d5a49bb6 --- /dev/null +++ b/src/basic/features/order/OrderSummary.tsx @@ -0,0 +1,54 @@ +import type { CartTotal } from '../../models'; + +interface OrderSummaryProps { + totals: CartTotal; + onCompleteOrder: () => void; +} + +/** + * 주문 요약 컴포넌트 + * + * 결제 정보를 요약하여 표시합니다. + * - 상품 금액 + * - 할인 금액 + * - 최종 결제 금액 + * - 결제하기 버튼 + */ +export const OrderSummary = ({ totals, onCompleteOrder }: OrderSummaryProps) => { + return ( +
+

결제 정보

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

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

+
+
+ ); +}; diff --git a/src/basic/features/order/index.ts b/src/basic/features/order/index.ts new file mode 100644 index 000000000..dd49db3a8 --- /dev/null +++ b/src/basic/features/order/index.ts @@ -0,0 +1 @@ +export { OrderSummary } from './OrderSummary'; diff --git a/src/basic/features/product/ProductCard.tsx b/src/basic/features/product/ProductCard.tsx new file mode 100644 index 000000000..e3314693e --- /dev/null +++ b/src/basic/features/product/ProductCard.tsx @@ -0,0 +1,105 @@ +import type { Product } from '../../../types'; + +interface ProductCardProps { + product: Product & { description?: string; isRecommended?: boolean }; + remainingStock: number; + displayPrice: string; + maxDiscountPercent: number; + onAddToCart: () => void; +} + +/** + * 상품 카드 컴포넌트 + * + * 단일 상품의 정보를 카드 형태로 렌더링합니다. + * - 상품 이미지 (placeholder) + * - 상품명, 설명, 가격 + * - 재고 상태, 할인 정보 + * - 장바구니 담기 버튼 + */ +export const ProductCard = ({ + product, + remainingStock, + displayPrice, + maxDiscountPercent, + onAddToCart +}: ProductCardProps) => { + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{maxDiscountPercent}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

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

{displayPrice}

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

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

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

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

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

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/basic/features/product/ProductList.tsx b/src/basic/features/product/ProductList.tsx new file mode 100644 index 000000000..c26cd548c --- /dev/null +++ b/src/basic/features/product/ProductList.tsx @@ -0,0 +1,67 @@ +import type { Product, Discount } from '../../../types'; +import { ProductCard } from './ProductCard'; + +type ProductWithUI = Product & { description?: string; isRecommended?: boolean }; + +interface ProductListProps { + products: ProductWithUI[]; + totalCount: number; + searchTerm: string; + getRemainingStock: (product: Product) => number; + displayPrice: (price: number, productId?: string) => string; + getMaxDiscountPercent: (discounts: Discount[]) => number; + onAddToCart: (product: ProductWithUI) => void; +} + +/** + * 상품 목록 컴포넌트 + * + * 상품 목록을 그리드 형태로 렌더링합니다. + * - 총 상품 수 표시 + * - 검색 결과 없을 때 안내 메시지 + * - 상품 카드 그리드 + */ +export const ProductList = ({ + products, + totalCount, + searchTerm, + getRemainingStock, + displayPrice, + getMaxDiscountPercent, + onAddToCart +}: ProductListProps) => { + return ( +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {products.map(product => { + const remainingStock = getRemainingStock(product); + const maxDiscountPercent = getMaxDiscountPercent(product.discounts); + + return ( + onAddToCart(product)} + /> + ); + })} +
+ )} +
+ ); +}; diff --git a/src/basic/features/product/index.ts b/src/basic/features/product/index.ts new file mode 100644 index 000000000..0ad977d2f --- /dev/null +++ b/src/basic/features/product/index.ts @@ -0,0 +1,2 @@ +export { ProductList } from './ProductList'; +export { ProductCard } from './ProductCard'; diff --git a/src/basic/hooks/index.ts b/src/basic/hooks/index.ts new file mode 100644 index 000000000..c622d6e5d --- /dev/null +++ b/src/basic/hooks/index.ts @@ -0,0 +1,13 @@ +/** + * 커스텀 훅 + * + * 엔티티 훅과 범용 훅을 포함합니다. + */ + +// 엔티티 훅 +export { useProduct, type ProductWithUI, type UseProductReturn } from './useProduct'; +export { useCoupon, type UseCouponReturn } from './useCoupon'; +export { useCart, type UseCartReturn } from './useCart'; + +// 범용 훅 +export { useLocalStorage } from './useLocalStorage'; diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..60aecfe8d --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,96 @@ +import { useCallback } from 'react'; +import type { CartItem, Coupon, Product } from '../../types'; +import { useLocalStorage } from './useLocalStorage'; +import { + calculateCartTotal, + calculateItemTotal, + updateCartItemQuantity, + getRemainingStock as calcRemainingStock, + addItemToCart, + removeItemFromCart, + type CartTotal +} from '../models'; + +/** + * useCart 훅 반환 타입 + */ +export interface UseCartReturn { + cart: CartItem[]; + addToCart: (product: Product) => void; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + clearCart: () => void; + getCartTotal: () => CartTotal; + getRemainingStock: (product: Product) => number; + getItemTotal: (item: CartItem) => number; +} + +/** + * 장바구니 관리 훅 + * + * @param selectedCoupon - 선택된 쿠폰 + * @returns 장바구니 상태와 액션들 + */ +export const useCart = (selectedCoupon: Coupon | null): UseCartReturn => { + const [cart, setCart] = useLocalStorage('cart', []); + + const addToCart = useCallback( + (product: Product) => { + setCart(prevCart => { + const remainingStock = calcRemainingStock(product, prevCart); + if (remainingStock <= 0) { + return prevCart; + } + return addItemToCart(prevCart, product); + }); + }, + [setCart] + ); + + const removeFromCart = useCallback( + (productId: string) => { + setCart(prevCart => removeItemFromCart(prevCart, productId)); + }, + [setCart] + ); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + setCart(prevCart => updateCartItemQuantity(prevCart, productId, newQuantity)); + }, + [setCart] + ); + + const clearCart = useCallback(() => { + setCart([]); + }, [setCart]); + + const getCartTotal = useCallback((): CartTotal => { + return calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + const getRemainingStock = useCallback( + (product: Product): number => { + return calcRemainingStock(product, cart); + }, + [cart] + ); + + const getItemTotal = useCallback( + (item: CartItem): number => { + return calculateItemTotal(item, cart); + }, + [cart] + ); + + return { + cart, + addToCart, + removeFromCart, + updateQuantity, + clearCart, + getCartTotal, + getRemainingStock, + getItemTotal + }; +}; diff --git a/src/basic/hooks/useCoupon.ts b/src/basic/hooks/useCoupon.ts new file mode 100644 index 000000000..e247d2b21 --- /dev/null +++ b/src/basic/hooks/useCoupon.ts @@ -0,0 +1,62 @@ +import { useState, useCallback } from 'react'; +import type { Coupon } from '../../types'; +import { useLocalStorage } from './useLocalStorage'; +import { initialCoupons } from '../constants'; + +/** + * useCoupon 훅 반환 타입 + */ +export interface UseCouponReturn { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + removeCoupon: () => void; + addCoupon: (coupon: Coupon) => void; + deleteCoupon: (code: string) => void; +} + +/** + * 쿠폰 관리 훅 + * + * @returns 쿠폰 목록, 선택된 쿠폰, 쿠폰 관리 액션들 + */ +export const useCoupon = (): UseCouponReturn => { + const [coupons, setCoupons] = useLocalStorage( + 'coupons', + initialCoupons + ); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + const applyCoupon = useCallback((coupon: Coupon) => { + setSelectedCoupon(coupon); + }, []); + + const removeCoupon = useCallback(() => { + setSelectedCoupon(null); + }, []); + + const addCoupon = useCallback( + (newCoupon: Coupon) => { + setCoupons(prev => [...prev, newCoupon]); + }, + [setCoupons] + ); + + const deleteCoupon = useCallback( + (code: string) => { + setCoupons(prev => prev.filter(c => c.code !== code)); + // 삭제된 쿠폰이 선택된 쿠폰이면 선택 해제 + setSelectedCoupon(prev => (prev?.code === code ? null : prev)); + }, + [setCoupons] + ); + + return { + coupons, + selectedCoupon, + applyCoupon, + removeCoupon, + addCoupon, + deleteCoupon + }; +}; diff --git a/src/basic/hooks/useLocalStorage.ts b/src/basic/hooks/useLocalStorage.ts new file mode 100644 index 000000000..203beaed7 --- /dev/null +++ b/src/basic/hooks/useLocalStorage.ts @@ -0,0 +1,42 @@ +import { useState, useEffect, useCallback } from 'react'; + +/** + * localStorage와 동기화되는 상태를 관리하는 훅 + * + * @param key - localStorage 키 + * @param initialValue - 초기값 + * @returns [상태값, 상태변경함수] 튜플 + */ +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((prev: T) => T)) => void] { + // 초기값 로드 (lazy initialization) + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch { + return initialValue; + } + }); + + // localStorage 동기화 + useEffect(() => { + try { + localStorage.setItem(key, JSON.stringify(storedValue)); + } catch (error) { + console.error(`Error saving to localStorage: ${error}`); + } + }, [key, storedValue]); + + // 함수형 업데이트 지원 + const setValue = useCallback((value: T | ((prev: T) => T)) => { + setStoredValue(prev => { + const newValue = value instanceof Function ? value(prev) : value; + return newValue; + }); + }, []); + + return [storedValue, setValue]; +} diff --git a/src/basic/hooks/useProduct.ts b/src/basic/hooks/useProduct.ts new file mode 100644 index 000000000..c6ce26129 --- /dev/null +++ b/src/basic/hooks/useProduct.ts @@ -0,0 +1,64 @@ +import { useCallback } from 'react'; +import { useLocalStorage } from './useLocalStorage'; +import { initialProducts, type ProductWithUI } from '../constants'; + +// Re-export ProductWithUI for external use +export type { ProductWithUI } from '../constants'; + +/** + * useProduct 훅 반환 타입 + */ +export interface UseProductReturn { + products: ProductWithUI[]; + addProduct: (product: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; +} + +/** + * 상품 관리 훅 + * + * @returns 상품 목록과 CRUD 액션들 + */ +export const useProduct = (): UseProductReturn => { + const [products, setProducts] = useLocalStorage( + 'products', + initialProducts + ); + + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + setProducts(prev => [...prev, product]); + }, + [setProducts] + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts(prev => + prev.map(product => + product.id === productId ? { ...product, ...updates } : product + ) + ); + }, + [setProducts] + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts(prev => prev.filter(p => p.id !== productId)); + }, + [setProducts] + ); + + return { + products, + addProduct, + updateProduct, + deleteProduct + }; +}; diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts new file mode 100644 index 000000000..65f5f4613 --- /dev/null +++ b/src/basic/models/cart.ts @@ -0,0 +1,118 @@ +import type { CartItem, Coupon, Product } from '../../types'; +import { calculateDiscountedPrice } from './discount'; +import { applyCouponDiscount } from './coupon'; + +/** + * 장바구니 총액 계산 결과 + */ +export interface CartTotal { + totalBeforeDiscount: number; + totalAfterDiscount: number; + totalDiscount: number; +} + +/** + * 장바구니 아이템의 총 금액을 계산합니다. (할인 적용) + * + * @param item - 장바구니 아이템 + * @param cart - 장바구니 (대량 구매 보너스 계산용, 선택사항) + * @returns 할인이 적용된 총 금액 + */ +export const calculateItemTotal = ( + item: CartItem, + cart?: CartItem[] +): number => { + return calculateDiscountedPrice(item, cart); +}; + +/** + * 장바구니 전체 금액을 계산합니다. (쿠폰 할인 포함) + * + * @param cart - 장바구니 아이템 배열 + * @param selectedCoupon - 선택된 쿠폰 (없으면 null) + * @returns 할인 전 총액, 할인 후 총액, 총 할인액 + */ +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): CartTotal => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach(item => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + // 쿠폰 할인 적용 + totalAfterDiscount = applyCouponDiscount(totalAfterDiscount, selectedCoupon); + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + totalDiscount: Math.round(totalBeforeDiscount) - Math.round(totalAfterDiscount) + }; +}; + +/** + * 장바구니 아이템의 수량을 업데이트합니다. (불변성 유지) + * + * @param cart - 장바구니 아이템 배열 + * @param productId - 업데이트할 상품 ID + * @param newQuantity - 새로운 수량 (0 이하면 해당 아이템 제거) + * @returns 새로운 장바구니 배열 + */ +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + newQuantity: number +): CartItem[] => { + if (newQuantity <= 0) { + return cart.filter(item => item.product.id !== productId); + } + + return cart.map(item => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ); +}; + +/** + * 장바구니에 상품을 추가합니다. (불변성 유지) + * + * @param cart - 장바구니 아이템 배열 + * @param product - 추가할 상품 + * @returns 새로운 장바구니 배열 + */ +export const addItemToCart = ( + cart: CartItem[], + product: Product +): CartItem[] => { + const existingItem = cart.find(item => item.product.id === product.id); + + if (existingItem) { + return cart.map(item => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ); + } + + return [...cart, { product, quantity: 1 }]; +}; + +/** + * 장바구니에서 상품을 제거합니다. (불변성 유지) + * + * @param cart - 장바구니 아이템 배열 + * @param productId - 제거할 상품 ID + * @returns 새로운 장바구니 배열 + */ +export const removeItemFromCart = ( + cart: CartItem[], + productId: string +): CartItem[] => { + return cart.filter(item => item.product.id !== productId); +}; diff --git a/src/basic/models/coupon.ts b/src/basic/models/coupon.ts new file mode 100644 index 000000000..81f601fb2 --- /dev/null +++ b/src/basic/models/coupon.ts @@ -0,0 +1,65 @@ +import type { Coupon } from '../../types'; + +/** + * 쿠폰 할인을 적용한 금액을 계산합니다. + * + * @param amount - 원래 금액 + * @param coupon - 적용할 쿠폰 + * @returns 쿠폰 할인이 적용된 금액 + */ +export const applyCouponDiscount = ( + amount: number, + coupon: Coupon | null +): number => { + if (!coupon) return amount; + + if (coupon.discountType === 'amount') { + return Math.max(0, amount - coupon.discountValue); + } + + return Math.round(amount * (1 - coupon.discountValue / 100)); +}; + +/** + * 쿠폰의 할인 금액을 계산합니다. + * + * @param amount - 원래 금액 + * @param coupon - 적용할 쿠폰 + * @returns 쿠폰으로 인한 할인 금액 + */ +export const getCouponDiscountAmount = ( + amount: number, + coupon: Coupon | null +): number => { + if (!coupon) return 0; + + const discountedAmount = applyCouponDiscount(amount, coupon); + return amount - discountedAmount; +}; + +/** + * 쿠폰 코드가 유효한지 검증합니다. + * + * @param code - 쿠폰 코드 + * @param coupons - 전체 쿠폰 목록 + * @returns 유효한 쿠폰이면 해당 쿠폰, 아니면 null + */ +export const findCouponByCode = ( + code: string, + coupons: Coupon[] +): Coupon | null => { + return coupons.find(c => c.code === code) || null; +}; + +/** + * 쿠폰 할인 정보를 포맷팅합니다. + * + * @param coupon - 쿠폰 + * @returns 포맷팅된 할인 정보 문자열 + */ +export const formatCouponDiscount = (coupon: Coupon): string => { + if (coupon.discountType === 'amount') { + return `${coupon.discountValue.toLocaleString()}원 할인`; + } + return `${coupon.discountValue}% 할인`; +}; diff --git a/src/basic/models/discount.ts b/src/basic/models/discount.ts new file mode 100644 index 000000000..c885f1522 --- /dev/null +++ b/src/basic/models/discount.ts @@ -0,0 +1,97 @@ +import type { CartItem, Discount } from '../../types'; + +/** + * 장바구니에 대량 구매 상품이 있는지 확인합니다. + * + * @param cart - 장바구니 아이템 배열 + * @returns 10개 이상 구매한 상품이 있으면 true + */ +export const hasBulkPurchase = (cart: CartItem[]): boolean => { + return cart.some(item => item.quantity >= 10); +}; + +/** + * 할인 목록에서 최대 할인율을 반환합니다. + * + * @param discounts - 할인 목록 + * @returns 최대 할인율 (0 ~ 1) + */ +export const getMaxDiscountRate = (discounts: Discount[]): number => { + if (discounts.length === 0) return 0; + return Math.max(...discounts.map(d => d.rate)); +}; + +/** + * 최대 할인율을 퍼센트로 반환합니다. + * + * @param discounts - 할인 목록 + * @returns 최대 할인율 (퍼센트, 0 ~ 100) + */ +export const getMaxDiscountPercent = (discounts: Discount[]): number => { + return Math.round(getMaxDiscountRate(discounts) * 100); +}; + +/** + * 장바구니 아이템에 적용 가능한 최대 할인율을 계산합니다. + * + * @param item - 장바구니 아이템 + * @param cart - 장바구니 (대량 구매 보너스 계산용, 선택사항) + * @returns 적용 가능한 최대 할인율 (0 ~ 1) + */ +export const getMaxApplicableDiscount = ( + item: CartItem, + cart?: CartItem[] +): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + // 대량 구매 보너스: 장바구니에 10개 이상 구매한 상품이 있으면 +5% (최대 50%) + if (cart && hasBulkPurchase(cart)) { + return Math.min(baseDiscount + 0.05, 0.5); + } + + return baseDiscount; +}; + +/** + * 장바구니 아이템의 할인 적용 후 금액을 계산합니다. + * + * @param item - 장바구니 아이템 + * @param cart - 장바구니 (대량 구매 보너스 계산용, 선택사항) + * @returns 할인이 적용된 금액 + */ +export const calculateDiscountedPrice = ( + item: CartItem, + cart?: CartItem[] +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +/** + * 장바구니 아이템에 적용된 할인율을 퍼센트로 계산합니다. + * + * @param item - 장바구니 아이템 + * @param cart - 장바구니 (대량 구매 보너스 계산용, 선택사항) + * @returns 적용된 할인율 (퍼센트, 0 ~ 100) + */ +export const getAppliedDiscountRate = ( + item: CartItem, + cart?: CartItem[] +): number => { + const originalPrice = item.product.price * item.quantity; + const discountedPrice = calculateDiscountedPrice(item, cart); + + if (originalPrice === 0) return 0; + + return Math.round((1 - discountedPrice / originalPrice) * 100); +}; diff --git a/src/basic/models/index.ts b/src/basic/models/index.ts new file mode 100644 index 000000000..7d7152988 --- /dev/null +++ b/src/basic/models/index.ts @@ -0,0 +1,34 @@ +// Discount - 할인 관련 순수 계산 함수 +export { + hasBulkPurchase, + getMaxDiscountRate, + getMaxDiscountPercent, + getMaxApplicableDiscount, + calculateDiscountedPrice, + getAppliedDiscountRate +} from './discount'; + +// Coupon - 쿠폰 관련 순수 계산 함수 +export { + applyCouponDiscount, + getCouponDiscountAmount, + findCouponByCode, + formatCouponDiscount +} from './coupon'; + +// Cart - 장바구니 관련 순수 계산 함수 +export { + calculateItemTotal, + calculateCartTotal, + updateCartItemQuantity, + addItemToCart, + removeItemFromCart, + type CartTotal +} from './cart'; + +// Product - 상품 관련 순수 계산 함수 +export { + getRemainingStock, + isOutOfStock, + sortByStock +} from './product'; diff --git a/src/basic/models/product.ts b/src/basic/models/product.ts new file mode 100644 index 000000000..7db2d24b6 --- /dev/null +++ b/src/basic/models/product.ts @@ -0,0 +1,40 @@ +import type { CartItem, Product } from '../../types'; + +/** + * 상품의 남은 재고를 계산합니다. + * + * @param product - 상품 + * @param cart - 장바구니 아이템 배열 + * @returns 남은 재고 수량 + */ +export const getRemainingStock = ( + product: Product, + cart: CartItem[] +): number => { + const cartItem = cart.find(item => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); +}; + +/** + * 상품이 품절인지 확인합니다. + * + * @param product - 상품 + * @param cart - 장바구니 아이템 배열 + * @returns 품절이면 true + */ +export const isOutOfStock = ( + product: Product, + cart: CartItem[] +): boolean => { + return getRemainingStock(product, cart) <= 0; +}; + +/** + * 상품을 재고 순으로 정렬합니다. + * + * @param products - 상품 배열 + * @returns 재고가 많은 순으로 정렬된 상품 배열 + */ +export const sortByStock = (products: T[]): T[] => { + return [...products].sort((a, b) => b.stock - a.stock); +}; diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx new file mode 100644 index 000000000..7f47727f8 --- /dev/null +++ b/src/basic/pages/AdminPage.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import type { Coupon } from '../../types'; +import type { ProductWithUI } from '../hooks'; +import { ProductManagement } from '../features/admin/ProductManagement'; +import { CouponManagement } from '../features/admin/CouponManagement'; + +interface AdminPageProps { + products: ProductWithUI[]; + coupons: Coupon[]; + displayPrice: (price: number, productId?: string) => string; + onAddProduct: (product: Omit) => void; + onUpdateProduct: (productId: string, updates: Partial) => void; + onDeleteProduct: (productId: string) => void; + onAddCoupon: (coupon: Omit) => void; + onDeleteCoupon: (couponCode: string) => void; + onNotification: (message: string, type: 'error' | 'success' | 'warning') => void; +} + +/** + * 관리자 페이지 + * + * 상품 관리와 쿠폰 관리 탭을 포함합니다. + */ +export const AdminPage = ({ + products, + coupons, + displayPrice, + onAddProduct, + onUpdateProduct, + onDeleteProduct, + onAddCoupon, + onDeleteCoupon, + onNotification +}: AdminPageProps) => { + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + + return ( +
+
+

관리자 대시보드

+

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

+
+ +
+ +
+ + {activeTab === 'products' ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/src/basic/pages/CartPage.tsx b/src/basic/pages/CartPage.tsx new file mode 100644 index 000000000..1cc031b42 --- /dev/null +++ b/src/basic/pages/CartPage.tsx @@ -0,0 +1,97 @@ +import type { CartItem, Coupon } from '../../types'; +import type { ProductWithUI } from '../hooks'; +import type { CartTotal } from '../models'; +import { Cart } from '../features/cart'; +import { ProductList } from '../features/product'; +import { CouponSelector } from '../features/coupon'; +import { OrderSummary } from '../features/order'; + +interface CartPageProps { + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + searchTerm: string; + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + totals: CartTotal; + getRemainingStock: (product: ProductWithUI) => number; + displayPrice: (price: number, productId?: string) => string; + getMaxDiscountPercent: (discounts: Array<{ quantity: number; rate: number }>) => number; + getItemTotal: (item: CartItem) => number; + getDiscountRate: (item: CartItem) => number; + onAddToCart: (product: ProductWithUI) => void; + onUpdateQuantity: (productId: string, newQuantity: number) => void; + onRemoveFromCart: (productId: string) => void; + onApplyCoupon: (coupon: Coupon | null) => void; + onCompleteOrder: () => void; +} + +/** + * 장바구니 페이지 + * + * 상품 목록, 장바구니, 쿠폰 선택, 주문 요약을 포함합니다. + */ +export const CartPage = ({ + products, + filteredProducts, + searchTerm, + cart, + coupons, + selectedCoupon, + totals, + getRemainingStock, + displayPrice, + getMaxDiscountPercent, + getItemTotal, + getDiscountRate, + onAddToCart, + onUpdateQuantity, + onRemoveFromCart, + onApplyCoupon, + onCompleteOrder +}: CartPageProps) => { + return ( +
+ {/* 상품 목록 */} +
+ +
+ + {/* 장바구니 사이드바 */} +
+
+ + + {cart.length > 0 && ( + <> + + + + + )} +
+
+
+ ); +}; diff --git a/src/basic/pages/index.ts b/src/basic/pages/index.ts new file mode 100644 index 000000000..41bed56b5 --- /dev/null +++ b/src/basic/pages/index.ts @@ -0,0 +1,5 @@ +/** + * 페이지 컴포넌트 + */ +export { CartPage } from './CartPage'; +export { AdminPage } from './AdminPage'; diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts new file mode 100644 index 000000000..3059618aa --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,39 @@ +/** + * 가격을 포맷팅합니다. + * + * @param price - 가격 + * @returns 포맷팅된 가격 문자열 (예: "₩10,000") + */ +export const formatPrice = (price: number): string => { + return `₩${price.toLocaleString()}`; +}; + +/** + * 관리자용 가격 포맷팅입니다. + * + * @param price - 가격 + * @returns 포맷팅된 가격 문자열 (예: "10,000원") + */ +export const formatAdminPrice = (price: number): string => { + return `${price.toLocaleString()}원`; +}; + +/** + * 퍼센트를 포맷팅합니다. + * + * @param percent - 퍼센트 값 (0 ~ 100) + * @returns 포맷팅된 퍼센트 문자열 (예: "10%") + */ +export const formatPercent = (percent: number): string => { + return `${percent}%`; +}; + +/** + * 할인율을 포맷팅합니다. + * + * @param rate - 할인율 (0 ~ 1) + * @returns 포맷팅된 할인율 문자열 (예: "10% 할인") + */ +export const formatDiscountRate = (rate: number): string => { + return `${Math.round(rate * 100)}% 할인`; +}; diff --git a/src/basic/utils/index.ts b/src/basic/utils/index.ts new file mode 100644 index 000000000..fbe21d6f4 --- /dev/null +++ b/src/basic/utils/index.ts @@ -0,0 +1,11 @@ +/** + * 유틸리티 + * + * 포매터, 밸리데이터 등 + */ + +// 포매터 +export * from './formatters'; + +// 밸리데이터 +export * from './validators'; diff --git a/src/basic/utils/validators.ts b/src/basic/utils/validators.ts new file mode 100644 index 000000000..6d464d705 --- /dev/null +++ b/src/basic/utils/validators.ts @@ -0,0 +1,60 @@ +/** + * 숫자만 포함된 문자열인지 검증합니다. + * + * @param value - 검증할 문자열 + * @returns 숫자만 포함되어 있으면 true + */ +export const isNumericString = (value: string): boolean => { + return value === '' || /^\d+$/.test(value); +}; + +/** + * 유효한 가격인지 검증합니다. + * + * @param price - 검증할 가격 + * @returns 0 이상의 정수이면 true + */ +export const isValidPrice = (price: number): boolean => { + return Number.isInteger(price) && price >= 0; +}; + +/** + * 유효한 재고인지 검증합니다. + * + * @param stock - 검증할 재고 + * @param max - 최대 재고 (기본값: 9999) + * @returns 0 ~ max 사이의 정수이면 true + */ +export const isValidStock = (stock: number, max = 9999): boolean => { + return Number.isInteger(stock) && stock >= 0 && stock <= max; +}; + +/** + * 유효한 할인율인지 검증합니다. + * + * @param rate - 검증할 할인율 (0 ~ 1) + * @returns 0 ~ 1 사이의 숫자이면 true + */ +export const isValidDiscountRate = (rate: number): boolean => { + return rate >= 0 && rate <= 1; +}; + +/** + * 빈 문자열인지 검증합니다. + * + * @param value - 검증할 문자열 + * @returns 빈 문자열이거나 공백만 있으면 true + */ +export const isEmpty = (value: string): boolean => { + return value.trim() === ''; +}; + +/** + * 쿠폰 코드가 유효한 형식인지 검증합니다. + * + * @param code - 검증할 쿠폰 코드 + * @returns 영문 대문자와 숫자만 포함되어 있으면 true + */ +export const isValidCouponCode = (code: string): boolean => { + return /^[A-Z0-9]+$/.test(code); +}; 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.advanced.ts b/vite.config.advanced.ts new file mode 100644 index 000000000..c25738b8b --- /dev/null +++ b/vite.config.advanced.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; + +export default defineConfig(({ mode }) => ({ + plugins: [react()], + base: mode === 'production' ? '/front_7th_chapter3-2/advanced/' : '/', + build: { + outDir: 'dist/advanced', + rollupOptions: { + input: './index.advanced.html', + }, + }, +})); diff --git a/vite.config.basic.ts b/vite.config.basic.ts new file mode 100644 index 000000000..96c0c1b35 --- /dev/null +++ b/vite.config.basic.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; + +export default defineConfig(({ mode }) => ({ + plugins: [react()], + base: mode === 'production' ? '/front_7th_chapter3-2/basic/' : '/', + build: { + outDir: 'dist/basic', + rollupOptions: { + input: './index.basic.html', + }, + }, +}));