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..02f3ad490 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1124 +1,42 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState, useCallback } from "react"; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} +import { useNotification } from "./hooks/useNotification"; +import { UIToast } from "./components/ui/UIToast"; -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import Header from "./components/Header"; +import { AdminPage } from "./components/AdminPage"; +import { CartPage } from "./components/CartPage"; +import { useCart } from "./hooks/useCart"; +import { useCoupons } from "./hooks/useCoupons"; const App = () => { + const { notifications, addNotification, removeNotification } = + useNotification(); + const { clearCart } = useCart(); + const { clearSelectedCoupon } = useCoupons(); - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); const completeOrder = useCallback(() => { const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" ); - 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; + clearCart(); + clearSelectedCoupon(); + }, [addNotification, clearCart, clearSelectedCoupon]); return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && ( -
- setSearchTerm(e.target.value)} - placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" - /> -
- )} -
- -
-
-
+ +
setIsAdmin(!isAdmin)} />
- {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 +export default App; diff --git a/src/advanced/__tests__/origin.test.tsx b/src/advanced/__tests__/origin.test.tsx index 3f5c3d55e..9f526c59b 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -1,11 +1,20 @@ // @ts-nocheck import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; import { vi } from 'vitest'; +import { getDefaultStore } from 'jotai'; import App from '../App'; +import { notificationsAtom } from '../store/atoms/notificationAtom'; +import { cartAtom } from '../store/atoms/cartAtom'; +import { couponsAtom } from '../store/atoms/couponsAtom'; import '../../setupTests'; describe('쇼핑몰 앱 통합 테스트', () => { beforeEach(() => { + // Jotai 전역 상태 초기화 + const store = getDefaultStore(); + store.set(notificationsAtom, []); + store.set(cartAtom, []); + // localStorage 초기화 localStorage.clear(); // console 경고 무시 diff --git a/src/advanced/components/AdminPage/CouponForm.tsx b/src/advanced/components/AdminPage/CouponForm.tsx new file mode 100644 index 000000000..21212c4c2 --- /dev/null +++ b/src/advanced/components/AdminPage/CouponForm.tsx @@ -0,0 +1,146 @@ +import React from "react"; +import { + validateCouponPercentage, + validateCouponAmount, +} from "../../models/coupon"; +import { isNumericInput } from "../../utils/validators"; +import { Button, FormInput, FormSelect } from "../ui"; +import { useNotification } from "../../hooks/useNotification"; + +interface CouponFormData { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +} + +interface CouponFormProps { + couponForm: CouponFormData; + setCouponForm: React.Dispatch>; + setShowCouponForm: React.Dispatch>; + onSubmit: (e: React.FormEvent) => void; +} + +export const CouponForm = ({ + couponForm, + setCouponForm, + setShowCouponForm, + onSubmit, +}: CouponFormProps) => { + const { addNotification } = useNotification(); + const handleNameChange = (e: React.ChangeEvent) => { + setCouponForm({ ...couponForm, name: e.target.value }); + }; + + const handleCodeChange = (e: React.ChangeEvent) => { + setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }); + }; + + const handleDiscountTypeChange = ( + e: React.ChangeEvent + ) => { + setCouponForm({ + ...couponForm, + discountType: e.target.value as "amount" | "percentage", + }); + }; + + const handleDiscountValueChange = ( + e: React.ChangeEvent + ) => { + const value = e.target.value; + if (isNumericInput(value)) { + setCouponForm({ + ...couponForm, + discountValue: value === "" ? 0 : parseInt(value), + }); + } + }; + + const handleDiscountValueBlur = (e: React.FocusEvent) => { + const value = parseInt(e.target.value) || 0; + const result = + couponForm.discountType === "percentage" + ? validateCouponPercentage(value) + : validateCouponAmount(value); + + if (!result.isValid) { + if (result.error) { + addNotification(result.error, "error"); + } + if (result.correctedValue !== undefined) { + setCouponForm({ + ...couponForm, + discountValue: result.correctedValue, + }); + } + } + }; + + const handleCancel = () => { + setShowCouponForm(false); + }; + + const discountValueLabel = + couponForm.discountType === "amount" ? "할인 금액" : "할인율(%)"; + const discountValuePlaceholder = + couponForm.discountType === "amount" ? "5000" : "10"; + + return ( +
+
+

새 쿠폰 생성

+
+ + + + + + + +
+
+ + +
+
+
+ ); +}; + +export default CouponForm; diff --git a/src/advanced/components/AdminPage/CouponList/CouponItem.tsx b/src/advanced/components/AdminPage/CouponList/CouponItem.tsx new file mode 100644 index 000000000..3702cf116 --- /dev/null +++ b/src/advanced/components/AdminPage/CouponList/CouponItem.tsx @@ -0,0 +1,36 @@ +import { Coupon } from "../../../../types"; +import { formatCouponValue } from "../../../models/coupon"; +import { TrashIcon } from "../../icons"; +import { useCoupons } from "../../../hooks/useCoupons"; + +interface CouponItemProps { + coupon: Coupon; +} + +export const CouponItem = ({ coupon }: CouponItemProps) => { + const { deleteCoupon } = useCoupons(); + + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {formatCouponValue(coupon.discountType, coupon.discountValue)} + +
+
+ +
+
+ ); +}; + +export default CouponItem; diff --git a/src/advanced/components/AdminPage/CouponList/index.tsx b/src/advanced/components/AdminPage/CouponList/index.tsx new file mode 100644 index 000000000..6da925a6f --- /dev/null +++ b/src/advanced/components/AdminPage/CouponList/index.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Card } from "../../ui"; +import { PlusIcon } from "../../icons"; +import { CouponItem } from "./CouponItem"; +import { useCoupons } from "../../../hooks/useCoupons"; + +interface CouponListProps { + showCouponForm: boolean; + setShowCouponForm: React.Dispatch>; +} + +export const CouponList = ({ + showCouponForm, + setShowCouponForm, +}: CouponListProps) => { + const { coupons } = useCoupons(); + return ( + +
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( + + ))} + +
+ +
+
+
+
+ ); +}; + +export default CouponList; diff --git a/src/advanced/components/AdminPage/ProductAccordion/ProductRow.tsx b/src/advanced/components/AdminPage/ProductAccordion/ProductRow.tsx new file mode 100644 index 000000000..141c1789d --- /dev/null +++ b/src/advanced/components/AdminPage/ProductAccordion/ProductRow.tsx @@ -0,0 +1,58 @@ +import { Product } from "../../../../types"; +import { ProductWithUI } from "../../../constants"; +import { isSoldOut } from "../../../models/cart"; +import { getStockBadgeClass } from "../../../models/product"; +import { formatPriceKor } from "../../../utils/formatters"; +import { useProducts } from "../../../hooks/useProducts"; +import { useCart } from "../../../hooks/useCart"; + +interface ProductRowProps { + product: Product; + onEdit: (product: ProductWithUI) => void; +} + +export const ProductRow = ({ product, onEdit }: ProductRowProps) => { + const { products, deleteProduct } = useProducts(); + const { cart } = useCart(); + + return ( + + + {product.name} + + + {isSoldOut(products, cart, product.id) + ? "SOLD OUT" + : formatPriceKor(product.price)} + + + + {product.stock}개 + + + + {(product as ProductWithUI).description || "-"} + + + + + + + ); +}; + +export default ProductRow; diff --git a/src/advanced/components/AdminPage/ProductAccordion/index.tsx b/src/advanced/components/AdminPage/ProductAccordion/index.tsx new file mode 100644 index 000000000..317318c85 --- /dev/null +++ b/src/advanced/components/AdminPage/ProductAccordion/index.tsx @@ -0,0 +1,96 @@ +import React from "react"; +import { ProductWithUI, EMPTY_PRODUCT_FORM } from "../../../constants"; +import { Button, Card } from "../../ui"; +import { ProductRow } from "./ProductRow"; +import { useProducts } from "../../../hooks/useProducts"; + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Array<{ quantity: number; rate: number }>; +} + +interface ProductAccordionProps { + setEditingProduct: React.Dispatch>; + setProductForm: React.Dispatch>; + setShowProductForm: React.Dispatch>; +} + +export const ProductAccordion = ({ + setEditingProduct, + setProductForm, + setShowProductForm, +}: ProductAccordionProps) => { + const { products } = useProducts(); + const handleEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [], + }); + setShowProductForm(true); + }; + + const handleAddProduct = () => { + setEditingProduct("new"); + setProductForm(EMPTY_PRODUCT_FORM); + setShowProductForm(true); + }; + + return ( + +
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+
+ ); +}; + +export default ProductAccordion; diff --git a/src/advanced/components/AdminPage/ProductForm.tsx b/src/advanced/components/AdminPage/ProductForm.tsx new file mode 100644 index 000000000..4f3eb4fc5 --- /dev/null +++ b/src/advanced/components/AdminPage/ProductForm.tsx @@ -0,0 +1,234 @@ +import React from "react"; +import { EMPTY_PRODUCT_FORM } from "../../constants"; +import { + validateProductPrice, + validateProductStock, +} from "../../models/product"; +import { isNumericInput } from "../../utils/validators"; +import { Button, FormInput } from "../ui"; +import { CloseIcon } from "../icons"; + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Array<{ quantity: number; rate: number }>; +} + +interface ProductFormProps { + productForm: ProductFormData; + setProductForm: React.Dispatch>; + editingProduct: string | null; + setEditingProduct: React.Dispatch>; + setShowProductForm: React.Dispatch>; + onSubmit: (e: React.FormEvent) => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const ProductForm = ({ + productForm, + setProductForm, + editingProduct, + setEditingProduct, + setShowProductForm, + onSubmit, + addNotification, +}: ProductFormProps) => { + const handleNameChange = (e: React.ChangeEvent) => { + setProductForm({ ...productForm, name: e.target.value }); + }; + + const handleDescriptionChange = (e: React.ChangeEvent) => { + setProductForm({ ...productForm, description: e.target.value }); + }; + + const handlePriceChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (isNumericInput(value)) { + setProductForm({ + ...productForm, + price: value === "" ? 0 : parseInt(value), + }); + } + }; + + const handleStockChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (isNumericInput(value)) { + setProductForm({ + ...productForm, + stock: value === "" ? 0 : parseInt(value), + }); + } + }; + + const handlePriceBlur = (e: React.FocusEvent) => { + const value = parseInt(e.target.value) || 0; + const result = validateProductPrice(value); + + if (!result.isValid) { + if (result.error) { + addNotification(result.error, "error"); + } + if (result.correctedValue !== undefined) { + setProductForm({ ...productForm, price: result.correctedValue }); + } + } + }; + + const handleStockBlur = (e: React.FocusEvent) => { + const value = parseInt(e.target.value) || 0; + const result = validateProductStock(value); + + if (!result.isValid) { + if (result.error) { + addNotification(result.error, "error"); + } + if (result.correctedValue !== undefined) { + setProductForm({ ...productForm, stock: result.correctedValue }); + } + } + }; + + const handleDiscountQuantityChange = ( + index: number, + e: React.ChangeEvent + ) => { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = parseInt(e.target.value) || 0; + setProductForm({ ...productForm, discounts: newDiscounts }); + }; + + const handleDiscountRateChange = ( + index: number, + e: React.ChangeEvent + ) => { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; + setProductForm({ ...productForm, discounts: newDiscounts }); + }; + + const handleRemoveDiscount = (index: number) => { + const newDiscounts = productForm.discounts.filter((_, i) => i !== index); + setProductForm({ ...productForm, discounts: newDiscounts }); + }; + + const handleAddDiscount = () => { + setProductForm({ + ...productForm, + discounts: [...productForm.discounts, { quantity: 10, rate: 0.1 }], + }); + }; + + const handleCancel = () => { + setEditingProduct(null); + setProductForm(EMPTY_PRODUCT_FORM); + setShowProductForm(false); + }; + + const formTitle = editingProduct === "new" ? "새 상품 추가" : "상품 수정"; + const submitButtonText = editingProduct === "new" ? "추가" : "수정"; + + return ( +
+
+

{formTitle}

+
+ + + + +
+
+ +
+ {productForm.discounts.map((discount, index) => ( +
+ handleDiscountQuantityChange(index, e)} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + handleDiscountRateChange(index, e)} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; + +export default ProductForm; diff --git a/src/advanced/components/AdminPage/index.tsx b/src/advanced/components/AdminPage/index.tsx new file mode 100644 index 000000000..d1e72954d --- /dev/null +++ b/src/advanced/components/AdminPage/index.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { EMPTY_PRODUCT_FORM, EMPTY_COUPON_FORM } from "../../constants"; +import { Tabs } from "../ui"; +import { ProductForm } from "./ProductForm"; +import { CouponForm } from "./CouponForm"; +import { CouponList } from "./CouponList"; +import ProductAccordion from "./ProductAccordion"; +import { useProducts } from "../../hooks/useProducts"; +import { useCoupons } from "../../hooks/useCoupons"; + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Array<{ quantity: number; rate: number }>; +} + +interface CouponFormData { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +} + +export const AdminPage = () => { + const { addProduct, updateProduct } = useProducts(); + const { addCoupon } = useCoupons(); + const [activeTab, setActiveTab] = useState<"products" | "coupons">( + "products" + ); + + // Product form states + const [editingProduct, setEditingProduct] = useState(null); + const [showProductForm, setShowProductForm] = useState(false); + const [productForm, setProductForm] = useState(EMPTY_PRODUCT_FORM); + + // Coupon form states + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState(EMPTY_COUPON_FORM); + + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== "new") { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProduct({ + ...productForm, + discounts: productForm.discounts, + }); + } + setProductForm(EMPTY_PRODUCT_FORM); + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm(EMPTY_COUPON_FORM); + setShowCouponForm(false); + }; + + return ( +
+
+

관리자 대시보드

+

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

+
+ + setActiveTab(tabId as "products" | "coupons")} + /> + + {activeTab === "products" ? ( + <> + + {showProductForm && ( + + )} + + ) : ( + <> + + {showCouponForm && ( + + )} + + )} +
+ ); +}; diff --git a/src/advanced/components/CartPage/Cart/CartItemList/CartItem.tsx b/src/advanced/components/CartPage/Cart/CartItemList/CartItem.tsx new file mode 100644 index 000000000..c74263b91 --- /dev/null +++ b/src/advanced/components/CartPage/Cart/CartItemList/CartItem.tsx @@ -0,0 +1,54 @@ +import { CartItem as CartItemType } from "../../../../../types"; +import { QuantityControl } from "../../../ui"; +import { CloseIcon } from "../../../icons"; +import { useCart } from "../../../../hooks/useCart"; + +interface CartItemProps { + item: CartItemType; + itemTotal: number; + discountRate: number; + hasDiscount: boolean; +} + +export const CartItem = ({ + item, + itemTotal, + discountRate, + hasDiscount, +}: CartItemProps) => { + const { removeFromCart, updateQuantity } = useCart(); + return ( +
+
+

+ {item.product.name} +

+ +
+
+ updateQuantity(item.product.id, item.quantity - 1)} + onIncrease={() => updateQuantity(item.product.id, item.quantity + 1)} + /> +
+ {hasDiscount && ( + + -{discountRate}% + + )} +

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

+
+
+
+ ); +}; + +export default CartItem; diff --git a/src/advanced/components/CartPage/Cart/CartItemList/index.tsx b/src/advanced/components/CartPage/Cart/CartItemList/index.tsx new file mode 100644 index 000000000..1446f74e1 --- /dev/null +++ b/src/advanced/components/CartPage/Cart/CartItemList/index.tsx @@ -0,0 +1,51 @@ +import { calculateItemTotal } from "../../../../models/cart"; +import { + calculateDiscountRate, + hasDiscount, +} from "../../../../models/discount"; +import { Card } from "../../../ui"; +import { EmptyCartIcon, EmptyCartIconSmall } from "../../../icons"; +import { CartItem } from "./CartItem"; +import { useCart } from "../../../../hooks/useCart"; + +export const CartItemList = () => { + const { cart } = useCart(); + return ( + +

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = calculateItemTotal(item, cart); + const originalPrice = item.product.price * item.quantity; + const itemHasDiscount = hasDiscount(itemTotal, originalPrice); + const discountRate = calculateDiscountRate( + itemTotal, + originalPrice + ); + + return ( + + ); + })} +
+ )} +
+ ); +}; + +export default CartItemList; diff --git a/src/advanced/components/CartPage/Cart/CouponSelector.tsx b/src/advanced/components/CartPage/Cart/CouponSelector.tsx new file mode 100644 index 000000000..f1adb956d --- /dev/null +++ b/src/advanced/components/CartPage/Cart/CouponSelector.tsx @@ -0,0 +1,46 @@ +import { Card } from "../../ui"; +import { useCoupons } from "../../../hooks/useCoupons"; + +export const CouponSelector = () => { + const { coupons, selectedCoupon, applyCoupon, clearSelectedCoupon } = + useCoupons(); + const handleCouponChange = (e: React.ChangeEvent) => { + const coupon = coupons.find((c) => c.code === e.target.value); + if (coupon) { + applyCoupon(coupon); + } else { + clearSelectedCoupon(); + } + }; + + return ( + +
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +}; + +export default CouponSelector; diff --git a/src/advanced/components/CartPage/Cart/OrderSummary.tsx b/src/advanced/components/CartPage/Cart/OrderSummary.tsx new file mode 100644 index 000000000..c6f886bec --- /dev/null +++ b/src/advanced/components/CartPage/Cart/OrderSummary.tsx @@ -0,0 +1,55 @@ +import { Card } from "../../ui"; + +interface OrderSummaryProps { + totalBeforeDiscount: number; + totalAfterDiscount: number; + completeOrder: () => void; +} + +export const OrderSummary = ({ + totalBeforeDiscount, + totalAfterDiscount, + completeOrder, +}: OrderSummaryProps) => { + const discountAmount = totalBeforeDiscount - totalAfterDiscount; + const hasDiscount = discountAmount > 0; + + return ( + +

결제 정보

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

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

+
+
+ ); +}; + +export default OrderSummary; diff --git a/src/advanced/components/CartPage/Cart/index.tsx b/src/advanced/components/CartPage/Cart/index.tsx new file mode 100644 index 000000000..272fa505e --- /dev/null +++ b/src/advanced/components/CartPage/Cart/index.tsx @@ -0,0 +1,36 @@ +import { calculateCartTotal } from "../../../models/cart"; +import CartItemList from "./CartItemList"; +import { CouponSelector } from "./CouponSelector"; +import { OrderSummary } from "./OrderSummary"; +import { useCart } from "../../../hooks/useCart"; +import { useCoupons } from "../../../hooks/useCoupons"; + +interface CartProps { + completeOrder: () => void; +} + +export const Cart = ({ completeOrder }: CartProps) => { + const { cart } = useCart(); + const { selectedCoupon } = useCoupons(); + const totals = calculateCartTotal(cart, selectedCoupon); + + return ( +
+ + + {cart.length > 0 && ( + <> + + + + + )} +
+ ); +}; + +export default Cart; diff --git a/src/advanced/components/CartPage/ProductList.tsx b/src/advanced/components/CartPage/ProductList.tsx new file mode 100644 index 000000000..353b17fcd --- /dev/null +++ b/src/advanced/components/CartPage/ProductList.tsx @@ -0,0 +1,122 @@ +import { getRemainingStock, isSoldOut } from "../../models/cart"; +import { + getStockStatusMessage, + getAddToCartButtonState, + getMaxDiscountRate, + filterProductsBySearch, +} from "../../models/product"; +import { formatDiscount, formatPriceUnit } from "../../utils/formatters"; +import { ImageIcon } from "../icons"; +import { useProducts } from "../../hooks/useProducts"; +import { useCart } from "../../hooks/useCart"; +import { ProductWithUI } from "../../constants"; + +interface ProductListProps { + debouncedSearchTerm: string; +} + +export const ProductList = ({ debouncedSearchTerm }: ProductListProps) => { + const { products: allProducts } = useProducts(); + const { cart, addToCart } = useCart(); + + const products = filterProductsBySearch( + allProducts, + debouncedSearchTerm + ) as ProductWithUI[]; + + return ( +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {products.map((product) => { + const remainingStock = getRemainingStock(product, cart); + const stockStatus = getStockStatusMessage(remainingStock); + const buttonState = getAddToCartButtonState(remainingStock); + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{formatDiscount(getMaxDiscountRate(product))} + + )} +
+ + {/* 상품 정보 */} +
+

+ {product.name} +

+ {product.description && ( +

+ {product.description} +

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

+ {isSoldOut(products, cart, product.id) + ? "SOLD OUT" + : formatPriceUnit(product.price)} +

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

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

+ )} +
+ + {/* 재고 상태 */} +
+ {stockStatus && ( +

+ {stockStatus.message} +

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); + })} +
+ )} +
+ ); +}; + +export default ProductList; diff --git a/src/advanced/components/CartPage/SearchBar.tsx b/src/advanced/components/CartPage/SearchBar.tsx new file mode 100644 index 000000000..b6ad381a7 --- /dev/null +++ b/src/advanced/components/CartPage/SearchBar.tsx @@ -0,0 +1,25 @@ +interface SearchBarProps { + searchTerm: string; + setSearchTerm: (term: string) => void; + placeholder?: string; +} + +export const SearchBar = ({ + searchTerm, + setSearchTerm, + placeholder = "상품 검색...", +}: SearchBarProps) => { + return ( +
+ setSearchTerm(e.target.value)} + placeholder={placeholder} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ ); +}; + +export default SearchBar; diff --git a/src/advanced/components/CartPage/index.tsx b/src/advanced/components/CartPage/index.tsx new file mode 100644 index 000000000..a35b5c3d6 --- /dev/null +++ b/src/advanced/components/CartPage/index.tsx @@ -0,0 +1,34 @@ +import { useState } from "react"; +import { TIMING } from "../../constants"; +import { useDebounce } from "../../utils/hooks/useDebounce"; +import { SearchBar } from "./SearchBar"; +import { ProductList } from "./ProductList"; +import Cart from "./Cart"; + +interface CartPageProps { + completeOrder: () => void; +} + +export const CartPage = ({ completeOrder }: CartPageProps) => { + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce( + searchTerm, + TIMING.SEARCH_DEBOUNCE_MS + ); + + return ( + <> + + +
+
+ +
+ +
+ +
+
+ + ); +}; diff --git a/src/advanced/components/Header.tsx b/src/advanced/components/Header.tsx new file mode 100644 index 000000000..896490990 --- /dev/null +++ b/src/advanced/components/Header.tsx @@ -0,0 +1,45 @@ +import { CartIcon } from "./icons"; +import { useCart } from "../hooks/useCart"; + +interface HeaderProps { + isAdmin: boolean; + toggleAdmin: () => void; +} + +function Header({ isAdmin, toggleAdmin }: HeaderProps) { + const { cart, totalItemCount } = useCart(); + return ( +
+
+
+
+

SHOP

+
+ +
+
+
+ ); +} +export default Header; diff --git a/src/advanced/components/icons/index.tsx b/src/advanced/components/icons/index.tsx new file mode 100644 index 000000000..91399f5ea --- /dev/null +++ b/src/advanced/components/icons/index.tsx @@ -0,0 +1,126 @@ +// TODO: SVG 아이콘 컴포넌트들 +// 구현할 아이콘: +// - CartIcon: 장바구니 아이콘 +// - AdminIcon: 관리자 아이콘 +// - PlusIcon: 플러스 아이콘 +// - MinusIcon: 마이너스 아이콘 +// - TrashIcon: 삭제 아이콘 +// - ChevronDownIcon: 아래 화살표 +// - ChevronUpIcon: 위 화살표 +// - CheckIcon: 체크 아이콘 + +interface IconProps { + className?: string; +} + +export const CloseIcon = ({ className = "w-4 h-4" }: IconProps) => ( + + + +); + +export const CartIcon = ({ className = "w-6 h-6" }: IconProps) => ( + + + +); + +export const TrashIcon = ({ className = "w-4 h-4" }: IconProps) => ( + + + +); + +export const PlusIcon = ({ className = "w-8 h-8" }: IconProps) => ( + + + +); + +export const ImageIcon = ({ className = "w-24 h-24" }: IconProps) => ( + + + +); + +export const EmptyCartIcon = ({ className = "w-16 h-16" }: IconProps) => ( + + + +); + +export const EmptyCartIconSmall = ({ className = "w-5 h-5" }: IconProps) => ( + + + +); diff --git a/src/advanced/components/ui/Badge.tsx b/src/advanced/components/ui/Badge.tsx new file mode 100644 index 000000000..23048ec4b --- /dev/null +++ b/src/advanced/components/ui/Badge.tsx @@ -0,0 +1,26 @@ +interface BadgeProps { + variant?: "success" | "warning" | "danger" | "info"; + children: React.ReactNode; + className?: string; +} + +export const Badge = ({ + variant = "info", + children, + className = "", +}: BadgeProps) => { + const variantStyles = { + success: "bg-green-100 text-green-800", + warning: "bg-yellow-100 text-yellow-800", + danger: "bg-red-100 text-red-800", + info: "bg-blue-100 text-blue-800", + }; + + return ( + + {children} + + ); +}; diff --git a/src/advanced/components/ui/Button.tsx b/src/advanced/components/ui/Button.tsx new file mode 100644 index 000000000..580ffcfbc --- /dev/null +++ b/src/advanced/components/ui/Button.tsx @@ -0,0 +1,35 @@ +interface ButtonProps { + variant?: "primary" | "secondary"; + children: React.ReactNode; + onClick?: () => void; + type?: "button" | "submit"; + disabled?: boolean; + className?: string; +} + +export const Button = ({ + variant = "primary", + children, + onClick, + type = "button", + disabled = false, + className = "", +}: ButtonProps) => { + const baseStyles = "px-4 py-2 rounded-md text-sm font-medium transition-colors"; + + const variantStyles = { + primary: "bg-indigo-600 text-white hover:bg-indigo-700", + secondary: "border border-gray-300 text-gray-700 hover:bg-gray-50", + }; + + return ( + + ); +}; diff --git a/src/advanced/components/ui/Card.tsx b/src/advanced/components/ui/Card.tsx new file mode 100644 index 000000000..dba6c53fc --- /dev/null +++ b/src/advanced/components/ui/Card.tsx @@ -0,0 +1,12 @@ +interface CardProps { + children: React.ReactNode; + className?: string; +} + +export const Card = ({ children, className = "" }: CardProps) => { + return ( +
+ {children} +
+ ); +}; diff --git a/src/advanced/components/ui/FormInput.tsx b/src/advanced/components/ui/FormInput.tsx new file mode 100644 index 000000000..e19c01809 --- /dev/null +++ b/src/advanced/components/ui/FormInput.tsx @@ -0,0 +1,38 @@ +interface FormInputProps { + label: string; + type?: string; + value: string | number; + onChange: (e: React.ChangeEvent) => void; + onBlur?: (e: React.FocusEvent) => void; + placeholder?: string; + required?: boolean; + className?: string; +} + +export const FormInput = ({ + label, + type = "text", + value, + onChange, + onBlur, + placeholder, + required = false, + className = "", +}: FormInputProps) => { + return ( +
+ + +
+ ); +}; diff --git a/src/advanced/components/ui/FormSelect.tsx b/src/advanced/components/ui/FormSelect.tsx new file mode 100644 index 000000000..2d70ea458 --- /dev/null +++ b/src/advanced/components/ui/FormSelect.tsx @@ -0,0 +1,30 @@ +interface FormSelectProps { + label: string; + value: string; + onChange: (e: React.ChangeEvent) => void; + children: React.ReactNode; + className?: string; +} + +export const FormSelect = ({ + label, + value, + onChange, + children, + className = "", +}: FormSelectProps) => { + return ( +
+ + +
+ ); +}; diff --git a/src/advanced/components/ui/QuantityControl.tsx b/src/advanced/components/ui/QuantityControl.tsx new file mode 100644 index 000000000..7b9414235 --- /dev/null +++ b/src/advanced/components/ui/QuantityControl.tsx @@ -0,0 +1,31 @@ +interface QuantityControlProps { + quantity: number; + onIncrease: () => void; + onDecrease: () => void; +} + +export const QuantityControl = ({ + quantity, + onIncrease, + onDecrease, +}: QuantityControlProps) => { + return ( +
+ + + {quantity} + + +
+ ); +}; diff --git a/src/advanced/components/ui/Table.tsx b/src/advanced/components/ui/Table.tsx new file mode 100644 index 000000000..2de124a86 --- /dev/null +++ b/src/advanced/components/ui/Table.tsx @@ -0,0 +1,51 @@ +interface TableProps { + children: React.ReactNode; + className?: string; +} + +export const Table = ({ children, className = "" }: TableProps) => { + return ( +
+ {children}
+
+ ); +}; + +interface TableHeaderProps { + children: React.ReactNode; + align?: "left" | "right" | "center"; + className?: string; +} + +export const TableHeader = ({ + children, + align = "left", + className = "", +}: TableHeaderProps) => { + const alignStyles = { + left: "text-left", + right: "text-right", + center: "text-center", + }; + + return ( + + {children} + + ); +}; + +interface TableCellProps { + children: React.ReactNode; + className?: string; +} + +export const TableCell = ({ children, className = "" }: TableCellProps) => { + return ( + + {children} + + ); +}; diff --git a/src/advanced/components/ui/Tabs.tsx b/src/advanced/components/ui/Tabs.tsx new file mode 100644 index 000000000..113f8d70e --- /dev/null +++ b/src/advanced/components/ui/Tabs.tsx @@ -0,0 +1,32 @@ +interface Tab { + id: string; + label: string; +} + +interface TabsProps { + tabs: Tab[]; + activeTab: string; + onTabChange: (tabId: string) => void; +} + +export const Tabs = ({ tabs, activeTab, onTabChange }: TabsProps) => { + return ( +
+ +
+ ); +}; diff --git a/src/advanced/components/ui/UIToast.tsx b/src/advanced/components/ui/UIToast.tsx new file mode 100644 index 000000000..dbde3ea82 --- /dev/null +++ b/src/advanced/components/ui/UIToast.tsx @@ -0,0 +1,46 @@ +import { CloseIcon } from "../icons"; + +interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +interface UIToastProps { + notifications: Notification[]; + onClose: (id: string) => void; +} + +const getToastBgColor = (type: Notification["type"]) => { + const colors = { + error: "bg-red-600", + warning: "bg-yellow-600", + success: "bg-green-600", + }; + return colors[type]; +}; + +export const UIToast = ({ notifications, onClose }: UIToastProps) => { + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ); +}; diff --git a/src/advanced/components/ui/index.tsx b/src/advanced/components/ui/index.tsx new file mode 100644 index 000000000..b87e1617d --- /dev/null +++ b/src/advanced/components/ui/index.tsx @@ -0,0 +1,9 @@ +export { Badge } from "./Badge"; +export { Button } from "./Button"; +export { Card } from "./Card"; +export { FormInput } from "./FormInput"; +export { FormSelect } from "./FormSelect"; +export { QuantityControl } from "./QuantityControl"; +export { Table, TableHeader, TableCell } from "./Table"; +export { Tabs } from "./Tabs"; +export { UIToast } from "./UIToast"; diff --git a/src/advanced/constants/index.ts b/src/advanced/constants/index.ts new file mode 100644 index 000000000..d7a22f883 --- /dev/null +++ b/src/advanced/constants/index.ts @@ -0,0 +1,140 @@ +// TODO: 초기 데이터 상수 +// 정의할 상수들: +// - initialProducts: 초기 상품 목록 (상품1, 상품2, 상품3 + 설명 필드 포함) +// - initialCoupons: 초기 쿠폰 목록 (5000원 할인, 10% 할인) +// +// 참고: origin/App.tsx의 초기 데이터 구조를 참조 + +// TODO: 구현 + +import { Product, Coupon } from "../../types"; + +// ============= 초기 데이터 ============= +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export const INITIAL_PRODUCTS: ProductWithUI[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: "최고급 품질의 프리미엄 상품입니다.", + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +export const INITIAL_COUPONS: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; + +// ============= 폼 초기값 ============= +export const EMPTY_PRODUCT_FORM = { + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] as Array<{ quantity: number; rate: number }>, +}; + +export const EMPTY_COUPON_FORM = { + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, +}; + +// ============= 검증 관련 상수 ============= +export const VALIDATION_LIMITS = { + MAX_STOCK: 9999, + MAX_COUPON_AMOUNT: 100000, + MAX_DISCOUNT_PERCENTAGE: 100, + MIN_PRICE: 0, + MIN_STOCK: 0, +}; + +export const STOCK_THRESHOLDS = { + LOW_STOCK: 5, // 품절 임박 기준 + GOOD_STOCK: 10, // 재고 충분 기준 +}; + +// ============= 타이밍 상수 ============= +export const TIMING = { + SEARCH_DEBOUNCE_MS: 500, + NOTIFICATION_AUTO_DISMISS_MS: 3000, +}; + +// ============= 비즈니스 규칙 상수 ============= +export const BUSINESS_RULES = { + MIN_PURCHASE_FOR_PERCENTAGE_COUPON: 10000, // percentage 쿠폰 최소 구매 금액 +}; + +// ============= 메시지 상수 ============= +export const MESSAGES = { + // 검증 에러 메시지 + PRICE_VALIDATION_ERROR: "가격은 0보다 커야 합니다", + STOCK_VALIDATION_ERROR: "재고는 0보다 커야 합니다", + STOCK_MAX_ERROR: "재고는 9999개를 초과할 수 없습니다", + COUPON_PERCENTAGE_MAX_ERROR: "할인율은 100%를 초과할 수 없습니다", + COUPON_AMOUNT_MAX_ERROR: "할인 금액은 100,000원을 초과할 수 없습니다", + + // 성공 메시지 + PRODUCT_ADDED: "상품이 추가되었습니다.", + PRODUCT_UPDATED: "상품이 수정되었습니다.", + PRODUCT_DELETED: "상품이 삭제되었습니다.", + COUPON_ADDED: "쿠폰이 추가되었습니다.", + COUPON_DELETED: "쿠폰이 삭제되었습니다.", + COUPON_APPLIED: "쿠폰이 적용되었습니다.", + CART_ITEM_ADDED: "장바구니에 담았습니다", + + // 에러 메시지 + OUT_OF_STOCK: "재고가 부족합니다!", + STOCK_LIMIT_EXCEEDED: (max: number) => `재고는 ${max}개까지만 있습니다.`, + COUPON_CODE_EXISTS: "이미 존재하는 쿠폰 코드입니다.", + PERCENTAGE_COUPON_MIN_PURCHASE: + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", +}; + +// ============= LocalStorage 키 ============= +export const STORAGE_KEYS = { + PRODUCTS: "products", + CART: "cart", + COUPONS: "coupons", + SELECTED_COUPON: "selectedCoupon", +}; diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts new file mode 100644 index 000000000..bb9b518dc --- /dev/null +++ b/src/advanced/hooks/useCart.ts @@ -0,0 +1,105 @@ +import { useCallback } from "react"; +import { useAtom, useAtomValue } from "jotai"; +import { cartAtom, totalItemCountAtom } from "../store/atoms/cartAtom"; +import { productsAtom } from "../store/atoms/productsAtom"; +import { getRemainingStock } from "../models/cart"; +import { ProductWithUI, MESSAGES } from "../constants"; +import { useNotification } from "./useNotification"; + +export const useCart = () => { + const [cart, setCart] = useAtom(cartAtom); + const products = useAtomValue(productsAtom); + const totalItemCount = useAtomValue(totalItemCountAtom); + const { addNotification } = useNotification(); + + const addToCart = useCallback( + (product: ProductWithUI) => { + let notificationMessage = ""; + let notificationType: "error" | "success" = "success"; + + setCart((prevCart) => { + const remainingStock = getRemainingStock(product, prevCart); + if (remainingStock <= 0) { + notificationMessage = MESSAGES.OUT_OF_STOCK; + notificationType = "error"; + return prevCart; + } + + const existingItem = prevCart.find( + (item) => item.product.id === product.id + ); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + if (newQuantity > product.stock) { + notificationMessage = MESSAGES.STOCK_LIMIT_EXCEEDED(product.stock); + notificationType = "error"; + return prevCart; + } + notificationMessage = MESSAGES.CART_ITEM_ADDED; + return prevCart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ); + } + + notificationMessage = MESSAGES.CART_ITEM_ADDED; + return [...prevCart, { product, quantity: 1 }]; + }); + + if (notificationMessage) { + addNotification(notificationMessage, notificationType); + } + }, + [setCart, addNotification] + ); + + const removeFromCart = useCallback( + (productId: string) => { + setCart((prevCart) => + prevCart.filter((item) => item.product.id !== productId) + ); + }, + [setCart] + ); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } + + const product = products.find((p) => p.id === productId); + if (!product) return; + + if (newQuantity > product.stock) { + addNotification(MESSAGES.STOCK_LIMIT_EXCEEDED(product.stock), "error"); + return; + } + + setCart((prevCart) => + prevCart.map((item) => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ) + ); + }, + [products, removeFromCart, addNotification, setCart] + ); + + const clearCart = useCallback(() => { + setCart([]); + }, [setCart]); + + return { + cart, + totalItemCount, + addToCart, + removeFromCart, + updateQuantity, + clearCart, + }; +}; diff --git a/src/advanced/hooks/useCoupons.ts b/src/advanced/hooks/useCoupons.ts new file mode 100644 index 000000000..f071778b3 --- /dev/null +++ b/src/advanced/hooks/useCoupons.ts @@ -0,0 +1,80 @@ +import { useCallback } from "react"; +import { useAtom, useAtomValue } from "jotai"; +import { couponsAtom, selectedCouponAtom } from "../store/atoms/couponsAtom"; +import { cartAtom } from "../store/atoms/cartAtom"; +import { calculateCartTotal } from "../models/cart"; +import { Coupon } from "../../types"; +import { MESSAGES, BUSINESS_RULES } from "../constants"; +import { useNotification } from "./useNotification"; + +export const useCoupons = () => { + const [coupons, setCoupons] = useAtom(couponsAtom); + const [selectedCoupon, setSelectedCoupon] = useAtom(selectedCouponAtom); + const cart = useAtomValue(cartAtom); + const { addNotification } = useNotification(); + + const addCoupon = useCallback( + (newCoupon: Coupon) => { + let notificationMessage = ""; + let notificationType: "error" | "success" = "success"; + + setCoupons((prev) => { + const existingCoupon = prev.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + notificationMessage = MESSAGES.COUPON_CODE_EXISTS; + notificationType = "error"; + return prev; + } + notificationMessage = MESSAGES.COUPON_ADDED; + return [...prev, newCoupon]; + }); + + if (notificationMessage) { + addNotification(notificationMessage, notificationType); + } + }, + [setCoupons, addNotification] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification(MESSAGES.COUPON_DELETED, "success"); + }, + [selectedCoupon, setCoupons, setSelectedCoupon, addNotification] + ); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal(cart, coupon).totalAfterDiscount; + + if ( + currentTotal < BUSINESS_RULES.MIN_PURCHASE_FOR_PERCENTAGE_COUPON && + coupon.discountType === "percentage" + ) { + addNotification(MESSAGES.PERCENTAGE_COUPON_MIN_PURCHASE, "error"); + return; + } + + setSelectedCoupon(coupon); + addNotification(MESSAGES.COUPON_APPLIED, "success"); + }, + [cart, setSelectedCoupon, addNotification] + ); + + const clearSelectedCoupon = useCallback(() => { + setSelectedCoupon(null); + }, [setSelectedCoupon]); + + return { + coupons, + selectedCoupon, + addCoupon, + deleteCoupon, + applyCoupon, + clearSelectedCoupon, + }; +}; diff --git a/src/advanced/hooks/useNotification.ts b/src/advanced/hooks/useNotification.ts new file mode 100644 index 000000000..14a217f0d --- /dev/null +++ b/src/advanced/hooks/useNotification.ts @@ -0,0 +1,37 @@ +import { useCallback } from "react"; +import { useAtom } from "jotai"; +import { notificationsAtom } from "../store/atoms/notificationAtom"; +import { TIMING } from "../constants"; + +export function useNotification( + duration = TIMING.NOTIFICATION_AUTO_DISMISS_MS +) { + const [notifications, setNotifications] = useAtom(notificationsAtom); + + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = `${Date.now()}-${Math.random()}`; + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, duration); + }, + [duration, setNotifications] + ); + + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, [setNotifications]); + + const clearNotifications = useCallback(() => { + setNotifications([]); + }, [setNotifications]); + + return { + notifications, + addNotification, + removeNotification, + clearNotifications, + }; +} diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts new file mode 100644 index 000000000..4c8c99e7b --- /dev/null +++ b/src/advanced/hooks/useProducts.ts @@ -0,0 +1,50 @@ +import { useCallback } from "react"; +import { useAtom } from "jotai"; +import { productsAtom } from "../store/atoms/productsAtom"; +import { ProductWithUI, MESSAGES } from "../constants"; +import { useNotification } from "./useNotification"; + +export const useProducts = () => { + const [products, setProducts] = useAtom(productsAtom); + const { addNotification } = useNotification(); + + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification(MESSAGES.PRODUCT_ADDED, "success"); + return product; + }, + [setProducts, addNotification] + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => + product.id === productId ? { ...product, ...updates } : product + ) + ); + addNotification(MESSAGES.PRODUCT_UPDATED, "success"); + }, + [setProducts, addNotification] + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification(MESSAGES.PRODUCT_DELETED, "success"); + }, + [setProducts, addNotification] + ); + + 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..768a1bb90 --- /dev/null +++ b/src/advanced/models/cart.ts @@ -0,0 +1,102 @@ +// TODO: 장바구니 비즈니스 로직 (순수 함수) +// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) +// +// 구현할 함수들: +// 1. calculateItemTotal(item): 개별 아이템의 할인 적용 후 총액 계산 +// 2. getMaxApplicableDiscount(item): 적용 가능한 최대 할인율 계산 +// 3. calculateCartTotal(cart, coupon): 장바구니 총액 계산 (할인 전/후, 할인액) +// 4. updateCartItemQuantity(cart, productId, quantity): 수량 변경 +// 5. addItemToCart(cart, product): 상품 추가 +// 6. removeItemFromCart(cart, productId): 상품 제거 +// 7. getRemainingStock(product, cart): 남은 재고 계산 +// +// 원칙: +// - UI와 관련된 로직 없음 +// - 외부 상태에 의존하지 않음 +// - 모든 필요한 데이터는 파라미터로 전달받음 + +import { CartItem, Coupon, Product } from "../../types"; + +// TODO: 구현 +const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; +}; + +export const calculateItemTotal = ( + item: CartItem, + cart: CartItem[] +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +export const calculateCartTotal = ( + cart: CartItem[], + coupon: Coupon | null +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (coupon) { + if (coupon.discountType === "amount") { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - coupon.discountValue + ); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - coupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +export const getRemainingStock = ( + product: Product, + cart: CartItem[] +): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); +}; + +export const isSoldOut = ( + products: Product[], + cart: CartItem[], + productId: string +) => { + const product = products.find((p) => p.id === productId); + if (product && getRemainingStock(product, cart) <= 0) { + return "SOLD OUT"; + } +}; diff --git a/src/advanced/models/coupon.ts b/src/advanced/models/coupon.ts new file mode 100644 index 000000000..bdfbde387 --- /dev/null +++ b/src/advanced/models/coupon.ts @@ -0,0 +1,55 @@ +export interface ValidationResult { + isValid: boolean; + error?: string; + correctedValue?: number; +} + +export const validateCouponPercentage = (value: number): ValidationResult => { + if (value < 0) { + return { + isValid: false, + error: undefined, + correctedValue: 0, + }; + } + if (value > 100) { + return { + isValid: false, + error: "할인율은 100%를 초과할 수 없습니다", + correctedValue: 100, + }; + } + return { isValid: true }; +}; + +export const validateCouponAmount = (value: number): ValidationResult => { + if (value < 0) { + return { + isValid: false, + error: undefined, + correctedValue: 0, + }; + } + if (value > 100000) { + return { + isValid: false, + error: "할인 금액은 100,000원을 초과할 수 없습니다", + correctedValue: 100000, + }; + } + return { isValid: true }; +}; + +export const isValidCouponCode = (code: string): boolean => { + return /^[A-Z0-9]{4,12}$/.test(code); +}; + +export const formatCouponValue = ( + discountType: "amount" | "percentage", + discountValue: number +): string => { + if (discountType === "amount") { + return `${discountValue.toLocaleString()}원 할인`; + } + return `${discountValue}% 할인`; +}; diff --git a/src/advanced/models/discount.ts b/src/advanced/models/discount.ts new file mode 100644 index 000000000..5d6d946a3 --- /dev/null +++ b/src/advanced/models/discount.ts @@ -0,0 +1,14 @@ +export const calculateDiscountRate = ( + itemTotal: number, + originalPrice: number +): number => { + if (itemTotal >= originalPrice) return 0; + return Math.round((1 - itemTotal / originalPrice) * 100); +}; + +export const hasDiscount = ( + itemTotal: number, + originalPrice: number +): boolean => { + return itemTotal < originalPrice; +}; diff --git a/src/advanced/models/product.ts b/src/advanced/models/product.ts new file mode 100644 index 000000000..a40c5ba1f --- /dev/null +++ b/src/advanced/models/product.ts @@ -0,0 +1,128 @@ +import { Product } from "../../types"; +import { ProductWithUI, STOCK_THRESHOLDS } from "../constants"; + +// ============= 타입 정의 ============= + +export interface ValidationResult { + isValid: boolean; + error?: string; + correctedValue?: number; +} + +// ============= 상품 검증 ============= + +/** + * 상품 가격 검증 + */ +export const validateProductPrice = (price: number): ValidationResult => { + if (price < 0) { + return { + isValid: false, + error: "가격은 0보다 커야 합니다", + correctedValue: 0, + }; + } + return { isValid: true }; +}; + +/** + * 상품 재고 검증 + */ +export const validateProductStock = (stock: number): ValidationResult => { + if (stock < 0) { + return { + isValid: false, + error: "재고는 0보다 커야 합니다", + correctedValue: 0, + }; + } + if (stock > 9999) { + return { + isValid: false, + error: "재고는 9999개를 초과할 수 없습니다", + correctedValue: 9999, + }; + } + return { isValid: true }; +}; + +/** + * 가격 유효성 검사 + */ +export const isValidPrice = (price: number): boolean => { + return price > 0; +}; + +/** + * 재고 유효성 검사 + */ +export const isValidStock = (stock: number): boolean => { + return stock >= 0; +}; + +// ============= 할인율 계산 ============= + +export const getMaxDiscountRate = (product: Product): number => { + if (product.discounts.length === 0) return 0; + return Math.max(...product.discounts.map((d) => d.rate)); +}; + +export const getMaxDiscountPercentage = (product: Product): number => { + return getMaxDiscountRate(product) * 100; +}; + +export const filterProductsBySearch = ( + products: ProductWithUI[], + searchTerm: string +): ProductWithUI[] => { + if (!searchTerm) return products; + + const lowerSearch = searchTerm.toLowerCase(); + return products.filter( + (product) => + product.name.toLowerCase().includes(lowerSearch) || + (product.description?.toLowerCase().includes(lowerSearch) ?? false) + ); +}; + +export const getStockStatusMessage = ( + remainingStock: number +): { message: string; className: string } | null => { + if (remainingStock <= 0) { + return null; // 품절인 경우 + } + if (remainingStock <= STOCK_THRESHOLDS.LOW_STOCK) { + return { + message: `품절임박! ${remainingStock}개 남음`, + className: "text-xs text-red-600 font-medium", + }; + } + return { + message: `재고 ${remainingStock}개`, + className: "text-xs text-gray-500", + }; +}; + +/** + * 재고 배지 클래스를 반환 + */ +export const getStockBadgeClass = (stock: number): string => { + if (stock > STOCK_THRESHOLDS.GOOD_STOCK) { + return "bg-green-100 text-green-800"; + } + if (stock > 0) { + return "bg-yellow-100 text-yellow-800"; + } + return "bg-red-100 text-red-800"; +}; + +export const getAddToCartButtonState = (remainingStock: number) => { + const isDisabled = remainingStock <= 0; + return { + disabled: isDisabled, + className: isDisabled + ? "bg-gray-100 text-gray-400 cursor-not-allowed" + : "bg-gray-900 text-white hover:bg-gray-800", + label: isDisabled ? "품절" : "장바구니 담기", + }; +}; diff --git a/src/advanced/store/atoms/cartAtom.ts b/src/advanced/store/atoms/cartAtom.ts new file mode 100644 index 000000000..7f5a8ba58 --- /dev/null +++ b/src/advanced/store/atoms/cartAtom.ts @@ -0,0 +1,19 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; +import { CartItem } from "../../../types"; +import { STORAGE_KEYS } from "../../constants"; + +// 기본 상태 +export const cartAtom = atomWithStorage(STORAGE_KEYS.CART, []); + +// 파생 상태: 총 아이템 개수 +export const totalItemCountAtom = atom((get) => { + const cart = get(cartAtom); + return cart.reduce((sum, item) => sum + item.quantity, 0); +}); + +// 파생 상태: 장바구니가 비어있는지 여부 +export const isCartEmptyAtom = atom((get) => { + const cart = get(cartAtom); + return cart.length === 0; +}); diff --git a/src/advanced/store/atoms/couponsAtom.ts b/src/advanced/store/atoms/couponsAtom.ts new file mode 100644 index 000000000..53023b325 --- /dev/null +++ b/src/advanced/store/atoms/couponsAtom.ts @@ -0,0 +1,22 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; +import { Coupon } from "../../../types"; +import { STORAGE_KEYS, INITIAL_COUPONS } from "../../constants"; + +// 기본 상태: 쿠폰 목록 +export const couponsAtom = atomWithStorage( + STORAGE_KEYS.COUPONS, + INITIAL_COUPONS +); + +// 기본 상태: 선택된 쿠폰 +export const selectedCouponAtom = atomWithStorage( + STORAGE_KEYS.SELECTED_COUPON, + null +); + +// 파생 상태: 쿠폰 개수 +export const couponCountAtom = atom((get) => { + const coupons = get(couponsAtom); + return coupons.length; +}); diff --git a/src/advanced/store/atoms/notificationAtom.ts b/src/advanced/store/atoms/notificationAtom.ts new file mode 100644 index 000000000..3f88b7540 --- /dev/null +++ b/src/advanced/store/atoms/notificationAtom.ts @@ -0,0 +1,16 @@ +import { atom } from "jotai"; + +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +// 기본 상태: 알림 목록 +export const notificationsAtom = atom([]); + +// 파생 상태: 알림이 있는지 여부 +export const hasNotificationsAtom = atom((get) => { + const notifications = get(notificationsAtom); + return notifications.length > 0; +}); diff --git a/src/advanced/store/atoms/productsAtom.ts b/src/advanced/store/atoms/productsAtom.ts new file mode 100644 index 000000000..ac73053e4 --- /dev/null +++ b/src/advanced/store/atoms/productsAtom.ts @@ -0,0 +1,8 @@ +import { atomWithStorage } from "jotai/utils"; +import { ProductWithUI, STORAGE_KEYS, INITIAL_PRODUCTS } from "../../constants"; + +// 기본 상태 +export const productsAtom = atomWithStorage( + STORAGE_KEYS.PRODUCTS, + INITIAL_PRODUCTS +); diff --git a/src/advanced/store/index.ts b/src/advanced/store/index.ts new file mode 100644 index 000000000..44d775761 --- /dev/null +++ b/src/advanced/store/index.ts @@ -0,0 +1,22 @@ +// Cart +export { + cartAtom, + totalItemCountAtom, + isCartEmptyAtom, +} from "./atoms/cartAtom"; + +// Products +export { productsAtom } from "./atoms/productsAtom"; + +// Coupons +export { + couponsAtom, + selectedCouponAtom, + couponCountAtom, +} from "./atoms/couponsAtom"; + +// Notifications +export { + notificationsAtom, + hasNotificationsAtom, +} from "./atoms/notificationAtom"; diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts new file mode 100644 index 000000000..94d1d00fa --- /dev/null +++ b/src/advanced/utils/formatters.ts @@ -0,0 +1,17 @@ +// TODO: 포맷팅 유틸리티 함수들 +// 구현할 함수: +// - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷 + +// TODO: 구현 + +export const formatPriceKor = (price: number): string => { + return `${price.toLocaleString()}원`; +}; + +export const formatPriceUnit = (price: number): string => { + return `₩${price.toLocaleString()}`; +}; + +export const formatDiscount = (rate: number): string => { + return `${Math.round(rate * 100)}%`; +}; diff --git a/src/advanced/utils/hooks/useDebounce.ts b/src/advanced/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..79ef6a48d --- /dev/null +++ b/src/advanced/utils/hooks/useDebounce.ts @@ -0,0 +1,23 @@ +// TODO: 디바운스 Hook +// 힌트: +// 1. 값이 변경되어도 지정된 시간 동안 대기 +// 2. 대기 시간 동안 값이 다시 변경되면 타이머 리셋 +// 3. 최종적으로 안정된 값만 반환 +// +// 사용 예시: 검색어 입력 디바운싱 + +import { useState, useEffect } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} \ No newline at end of file diff --git a/src/advanced/utils/hooks/useLocalStorage.ts b/src/advanced/utils/hooks/useLocalStorage.ts new file mode 100644 index 000000000..1f09bf6d0 --- /dev/null +++ b/src/advanced/utils/hooks/useLocalStorage.ts @@ -0,0 +1,57 @@ +import { useState, useEffect, useCallback } from "react"; + +// TODO: LocalStorage Hook +// 힌트: +// 1. localStorage와 React state 동기화 +// 2. 초기값 로드 시 에러 처리 +// 3. 저장 시 JSON 직렬화/역직렬화 +// 4. 빈 배열이나 undefined는 삭제 +// +// 반환값: [저장된 값, 값 설정 함수] + +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + // 초기값 로드 (lazy initialization) + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + if (item) { + return JSON.parse(item); + } + return initialValue; + } catch { + return initialValue; + } + }); + + // localStorage와 동기화 + useEffect(() => { + try { + // 빈 배열이나 undefined는 삭제 + if ( + storedValue === undefined || + (Array.isArray(storedValue) && storedValue.length === 0) + ) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(storedValue)); + } + } catch (error) { + console.error(`Error saving to localStorage key "${key}":`, error); + } + }, [key, storedValue]); + + // 값 설정 함수 (함수형 업데이트 지원) + const setValue = useCallback((value: T | ((val: T) => T)) => { + setStoredValue((prev) => { + if (value instanceof Function) { + return value(prev); + } + return value; + }); + }, []); + + return [storedValue, setValue]; +} diff --git a/src/advanced/utils/hooks/useValidate.ts b/src/advanced/utils/hooks/useValidate.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/advanced/utils/validators.ts b/src/advanced/utils/validators.ts new file mode 100644 index 000000000..9cbb0595a --- /dev/null +++ b/src/advanced/utils/validators.ts @@ -0,0 +1,14 @@ +// TODO: 검증 유틸리티 함수들 +// 구현할 함수: +// - extractNumbers(value: string): string - 문자열에서 숫자만 추출 + +// TODO: 구현 + +export const isNumericInput = (value: string): boolean => { + if (value === "") return true; + return /^\d+$/.test(value); +}; + +export const extractNumbers = (value: string): string => { + return value.replace(/\D/g, ""); +}; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..1afae2e6d 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,1124 +1,92 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState, useCallback } from "react"; +import { useProducts } from "./hooks/useProducts"; +import { useCoupons } from "./hooks/useCoupons"; +import { useCart } from "./hooks/useCart"; +import { useNotification } from "./hooks/useNotification"; +import { UIToast } from "./components/ui/UIToast"; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import Header from "./components/Header"; +import { AdminPage } from "./components/AdminPage"; +import { CartPage } from "./components/CartPage"; const App = () => { + const { notifications, addNotification, removeNotification } = + useNotification(); - 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 { products, addProduct, updateProduct, deleteProduct } = useProducts({ + addNotification, }); + const { + cart, + totalItemCount, + addToCart, + removeFromCart, + updateQuantity, + clearCart, + } = useCart({ products, addNotification }); + + const { + coupons, + selectedCoupon, + addCoupon, + deleteCoupon, + applyCoupon, + clearSelectedCoupon, + } = useCoupons({ cart, addNotification }); - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); const completeOrder = useCallback(() => { const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" ); - 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; + clearCart(); + clearSelectedCoupon(); + }, [addNotification, clearCart, clearSelectedCoupon]); return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && ( -
- setSearchTerm(e.target.value)} - placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" - /> -
- )} -
- -
-
-
+ +
setIsAdmin(!isAdmin)} + cart={cart} + totalItemCount={totalItemCount} + />
{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/AdminPage/CouponForm.tsx b/src/basic/components/AdminPage/CouponForm.tsx new file mode 100644 index 000000000..7f4fd1012 --- /dev/null +++ b/src/basic/components/AdminPage/CouponForm.tsx @@ -0,0 +1,149 @@ +import React from "react"; +import { + validateCouponPercentage, + validateCouponAmount, +} from "../../models/coupon"; +import { isNumericInput } from "../../utils/validators"; +import { Button, FormInput, FormSelect } from "../ui"; + +interface CouponFormData { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +} + +interface CouponFormProps { + couponForm: CouponFormData; + setCouponForm: React.Dispatch>; + setShowCouponForm: React.Dispatch>; + onSubmit: (e: React.FormEvent) => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const CouponForm = ({ + couponForm, + setCouponForm, + setShowCouponForm, + onSubmit, + addNotification, +}: CouponFormProps) => { + const handleNameChange = (e: React.ChangeEvent) => { + setCouponForm({ ...couponForm, name: e.target.value }); + }; + + const handleCodeChange = (e: React.ChangeEvent) => { + setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }); + }; + + const handleDiscountTypeChange = ( + e: React.ChangeEvent + ) => { + setCouponForm({ + ...couponForm, + discountType: e.target.value as "amount" | "percentage", + }); + }; + + const handleDiscountValueChange = ( + e: React.ChangeEvent + ) => { + const value = e.target.value; + if (isNumericInput(value)) { + setCouponForm({ + ...couponForm, + discountValue: value === "" ? 0 : parseInt(value), + }); + } + }; + + const handleDiscountValueBlur = (e: React.FocusEvent) => { + const value = parseInt(e.target.value) || 0; + const result = + couponForm.discountType === "percentage" + ? validateCouponPercentage(value) + : validateCouponAmount(value); + + if (!result.isValid) { + if (result.error) { + addNotification(result.error, "error"); + } + if (result.correctedValue !== undefined) { + setCouponForm({ + ...couponForm, + discountValue: result.correctedValue, + }); + } + } + }; + + const handleCancel = () => { + setShowCouponForm(false); + }; + + const discountValueLabel = + couponForm.discountType === "amount" ? "할인 금액" : "할인율(%)"; + const discountValuePlaceholder = + couponForm.discountType === "amount" ? "5000" : "10"; + + return ( +
+
+

새 쿠폰 생성

+
+ + + + + + + +
+
+ + +
+
+
+ ); +}; + +export default CouponForm; diff --git a/src/basic/components/AdminPage/CouponList/CouponItem.tsx b/src/basic/components/AdminPage/CouponList/CouponItem.tsx new file mode 100644 index 000000000..68744af0b --- /dev/null +++ b/src/basic/components/AdminPage/CouponList/CouponItem.tsx @@ -0,0 +1,34 @@ +import { Coupon } from "../../../../types"; +import { formatCouponValue } from "../../../models/coupon"; +import { TrashIcon } from "../../icons"; + +interface CouponItemProps { + coupon: Coupon; + deleteCoupon: (code: string) => void; +} + +export const CouponItem = ({ coupon, deleteCoupon }: CouponItemProps) => { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {formatCouponValue(coupon.discountType, coupon.discountValue)} + +
+
+ +
+
+ ); +}; + +export default CouponItem; diff --git a/src/basic/components/AdminPage/CouponList/index.tsx b/src/basic/components/AdminPage/CouponList/index.tsx new file mode 100644 index 000000000..ac3801933 --- /dev/null +++ b/src/basic/components/AdminPage/CouponList/index.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Coupon } from "../../../../types"; +import { Card } from "../../ui"; +import { PlusIcon } from "../../icons"; +import { CouponItem } from "./CouponItem"; + +interface CouponListProps { + coupons: Coupon[]; + deleteCoupon: (code: string) => void; + showCouponForm: boolean; + setShowCouponForm: React.Dispatch>; +} + +export const CouponList = ({ + coupons, + deleteCoupon, + showCouponForm, + setShowCouponForm, +}: CouponListProps) => { + return ( + +
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( + + ))} + +
+ +
+
+
+
+ ); +}; + +export default CouponList; diff --git a/src/basic/components/AdminPage/ProductAccordion/ProductRow.tsx b/src/basic/components/AdminPage/ProductAccordion/ProductRow.tsx new file mode 100644 index 000000000..41e67041c --- /dev/null +++ b/src/basic/components/AdminPage/ProductAccordion/ProductRow.tsx @@ -0,0 +1,62 @@ +import { Product, CartItem } from "../../../../types"; +import { ProductWithUI } from "../../../constants"; +import { isSoldOut } from "../../../models/cart"; +import { getStockBadgeClass } from "../../../models/product"; +import { formatPriceKor } from "../../../utils/formatters"; + +interface ProductRowProps { + product: Product; + products: Product[]; + cart: CartItem[]; + onEdit: (product: ProductWithUI) => void; + onDelete: (id: string) => void; +} + +export const ProductRow = ({ + product, + products, + cart, + onEdit, + onDelete, +}: ProductRowProps) => { + return ( + + + {product.name} + + + {isSoldOut(products, cart, product.id) + ? "SOLD OUT" + : formatPriceKor(product.price)} + + + + {product.stock}개 + + + + {(product as ProductWithUI).description || "-"} + + + + + + + ); +}; + +export default ProductRow; diff --git a/src/basic/components/AdminPage/ProductAccordion/index.tsx b/src/basic/components/AdminPage/ProductAccordion/index.tsx new file mode 100644 index 000000000..bd51c3858 --- /dev/null +++ b/src/basic/components/AdminPage/ProductAccordion/index.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { Product, CartItem } from "../../../../types"; +import { ProductWithUI, EMPTY_PRODUCT_FORM } from "../../../constants"; +import { Button, Card } from "../../ui"; +import { ProductRow } from "./ProductRow"; + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Array<{ quantity: number; rate: number }>; +} + +interface ProductAccordionProps { + products: Product[]; + cart: CartItem[]; + setEditingProduct: React.Dispatch>; + setProductForm: React.Dispatch>; + setShowProductForm: React.Dispatch>; + deleteProduct: (id: string) => void; +} + +export const ProductAccordion = ({ + products, + cart, + setEditingProduct, + setProductForm, + setShowProductForm, + deleteProduct, +}: ProductAccordionProps) => { + const handleEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [], + }); + setShowProductForm(true); + }; + + const handleAddProduct = () => { + setEditingProduct("new"); + setProductForm(EMPTY_PRODUCT_FORM); + setShowProductForm(true); + }; + + return ( + +
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+
+ ); +}; + +export default ProductAccordion; diff --git a/src/basic/components/AdminPage/ProductForm.tsx b/src/basic/components/AdminPage/ProductForm.tsx new file mode 100644 index 000000000..4f3eb4fc5 --- /dev/null +++ b/src/basic/components/AdminPage/ProductForm.tsx @@ -0,0 +1,234 @@ +import React from "react"; +import { EMPTY_PRODUCT_FORM } from "../../constants"; +import { + validateProductPrice, + validateProductStock, +} from "../../models/product"; +import { isNumericInput } from "../../utils/validators"; +import { Button, FormInput } from "../ui"; +import { CloseIcon } from "../icons"; + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Array<{ quantity: number; rate: number }>; +} + +interface ProductFormProps { + productForm: ProductFormData; + setProductForm: React.Dispatch>; + editingProduct: string | null; + setEditingProduct: React.Dispatch>; + setShowProductForm: React.Dispatch>; + onSubmit: (e: React.FormEvent) => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const ProductForm = ({ + productForm, + setProductForm, + editingProduct, + setEditingProduct, + setShowProductForm, + onSubmit, + addNotification, +}: ProductFormProps) => { + const handleNameChange = (e: React.ChangeEvent) => { + setProductForm({ ...productForm, name: e.target.value }); + }; + + const handleDescriptionChange = (e: React.ChangeEvent) => { + setProductForm({ ...productForm, description: e.target.value }); + }; + + const handlePriceChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (isNumericInput(value)) { + setProductForm({ + ...productForm, + price: value === "" ? 0 : parseInt(value), + }); + } + }; + + const handleStockChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (isNumericInput(value)) { + setProductForm({ + ...productForm, + stock: value === "" ? 0 : parseInt(value), + }); + } + }; + + const handlePriceBlur = (e: React.FocusEvent) => { + const value = parseInt(e.target.value) || 0; + const result = validateProductPrice(value); + + if (!result.isValid) { + if (result.error) { + addNotification(result.error, "error"); + } + if (result.correctedValue !== undefined) { + setProductForm({ ...productForm, price: result.correctedValue }); + } + } + }; + + const handleStockBlur = (e: React.FocusEvent) => { + const value = parseInt(e.target.value) || 0; + const result = validateProductStock(value); + + if (!result.isValid) { + if (result.error) { + addNotification(result.error, "error"); + } + if (result.correctedValue !== undefined) { + setProductForm({ ...productForm, stock: result.correctedValue }); + } + } + }; + + const handleDiscountQuantityChange = ( + index: number, + e: React.ChangeEvent + ) => { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = parseInt(e.target.value) || 0; + setProductForm({ ...productForm, discounts: newDiscounts }); + }; + + const handleDiscountRateChange = ( + index: number, + e: React.ChangeEvent + ) => { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; + setProductForm({ ...productForm, discounts: newDiscounts }); + }; + + const handleRemoveDiscount = (index: number) => { + const newDiscounts = productForm.discounts.filter((_, i) => i !== index); + setProductForm({ ...productForm, discounts: newDiscounts }); + }; + + const handleAddDiscount = () => { + setProductForm({ + ...productForm, + discounts: [...productForm.discounts, { quantity: 10, rate: 0.1 }], + }); + }; + + const handleCancel = () => { + setEditingProduct(null); + setProductForm(EMPTY_PRODUCT_FORM); + setShowProductForm(false); + }; + + const formTitle = editingProduct === "new" ? "새 상품 추가" : "상품 수정"; + const submitButtonText = editingProduct === "new" ? "추가" : "수정"; + + return ( +
+
+

{formTitle}

+
+ + + + +
+
+ +
+ {productForm.discounts.map((discount, index) => ( +
+ handleDiscountQuantityChange(index, e)} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + handleDiscountRateChange(index, e)} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; + +export default ProductForm; diff --git a/src/basic/components/AdminPage/index.tsx b/src/basic/components/AdminPage/index.tsx new file mode 100644 index 000000000..441ac683e --- /dev/null +++ b/src/basic/components/AdminPage/index.tsx @@ -0,0 +1,146 @@ +import { useState } from "react"; +import { Product, CartItem, Coupon } from "../../../types"; +import { EMPTY_PRODUCT_FORM, EMPTY_COUPON_FORM } from "../../constants"; +import { Tabs } from "../ui"; +import { ProductForm } from "./ProductForm"; +import { ProductAccordion } from "./ProductAccordion"; +import { CouponForm } from "./CouponForm"; +import { CouponList } from "./CouponList"; + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Array<{ quantity: number; rate: number }>; +} + +interface CouponFormData { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +} + +interface AdminPageProps { + products: Product[]; + coupons: Coupon[]; + addProduct: (product: ProductFormData) => void; + updateProduct: (id: string, product: ProductFormData) => void; + deleteProduct: (id: string) => void; + addCoupon: (coupon: CouponFormData) => void; + deleteCoupon: (code: string) => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; + cart: CartItem[]; +} + +export const AdminPage = ({ + products, + coupons, + addProduct, + updateProduct, + deleteProduct, + addCoupon, + deleteCoupon, + addNotification, + cart, +}: AdminPageProps) => { + const [activeTab, setActiveTab] = useState<"products" | "coupons">( + "products" + ); + + // Product form states + const [editingProduct, setEditingProduct] = useState(null); + const [showProductForm, setShowProductForm] = useState(false); + const [productForm, setProductForm] = useState(EMPTY_PRODUCT_FORM); + + // Coupon form states + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState(EMPTY_COUPON_FORM); + + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== "new") { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProduct({ + ...productForm, + discounts: productForm.discounts, + }); + } + setProductForm(EMPTY_PRODUCT_FORM); + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm(EMPTY_COUPON_FORM); + setShowCouponForm(false); + }; + + return ( +
+
+

관리자 대시보드

+

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

+
+ + setActiveTab(tabId as "products" | "coupons")} + /> + + {activeTab === "products" ? ( + <> + + {showProductForm && ( + + )} + + ) : ( + <> + + {showCouponForm && ( + + )} + + )} +
+ ); +}; diff --git a/src/basic/components/CartPage/Cart/CartItemList/CartItem.tsx b/src/basic/components/CartPage/Cart/CartItemList/CartItem.tsx new file mode 100644 index 000000000..ba83e4cc1 --- /dev/null +++ b/src/basic/components/CartPage/Cart/CartItemList/CartItem.tsx @@ -0,0 +1,56 @@ +import { CartItem as CartItemType } from "../../../../../types"; +import { QuantityControl } from "../../../ui"; +import { CloseIcon } from "../../../icons"; + +interface CartItemProps { + item: CartItemType; + itemTotal: number; + discountRate: number; + hasDiscount: boolean; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +} + +export const CartItem = ({ + item, + itemTotal, + discountRate, + hasDiscount, + removeFromCart, + updateQuantity, +}: CartItemProps) => { + return ( +
+
+

+ {item.product.name} +

+ +
+
+ updateQuantity(item.product.id, item.quantity - 1)} + onIncrease={() => updateQuantity(item.product.id, item.quantity + 1)} + /> +
+ {hasDiscount && ( + + -{discountRate}% + + )} +

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

+
+
+
+ ); +}; + +export default CartItem; diff --git a/src/basic/components/CartPage/Cart/CartItemList/index.tsx b/src/basic/components/CartPage/Cart/CartItemList/index.tsx new file mode 100644 index 000000000..b8e3677b2 --- /dev/null +++ b/src/basic/components/CartPage/Cart/CartItemList/index.tsx @@ -0,0 +1,62 @@ +import { CartItem as CartItemType } from "../../../../../types"; +import { calculateItemTotal } from "../../../../models/cart"; +import { + calculateDiscountRate, + hasDiscount, +} from "../../../../models/discount"; +import { Card } from "../../../ui"; +import { EmptyCartIcon, EmptyCartIconSmall } from "../../../icons"; +import { CartItem } from "./CartItem"; + +interface CartItemListProps { + cart: CartItemType[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +} + +export const CartItemList = ({ + cart, + removeFromCart, + updateQuantity, +}: CartItemListProps) => { + return ( + +

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = calculateItemTotal(item, cart); + const originalPrice = item.product.price * item.quantity; + const itemHasDiscount = hasDiscount(itemTotal, originalPrice); + const discountRate = calculateDiscountRate( + itemTotal, + originalPrice + ); + + return ( + + ); + })} +
+ )} +
+ ); +}; + +export default CartItemList; diff --git a/src/basic/components/CartPage/Cart/CouponSelector.tsx b/src/basic/components/CartPage/Cart/CouponSelector.tsx new file mode 100644 index 000000000..d2b809096 --- /dev/null +++ b/src/basic/components/CartPage/Cart/CouponSelector.tsx @@ -0,0 +1,56 @@ +import { Coupon } from "../../../../types"; +import { Card } from "../../ui"; + +interface CouponSelectorProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + clearSelectedCoupon: () => void; +} + +export const CouponSelector = ({ + coupons, + selectedCoupon, + applyCoupon, + clearSelectedCoupon, +}: CouponSelectorProps) => { + const handleCouponChange = (e: React.ChangeEvent) => { + const coupon = coupons.find((c) => c.code === e.target.value); + if (coupon) { + applyCoupon(coupon); + } else { + clearSelectedCoupon(); + } + }; + + return ( + +
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +}; + +export default CouponSelector; diff --git a/src/basic/components/CartPage/Cart/OrderSummary.tsx b/src/basic/components/CartPage/Cart/OrderSummary.tsx new file mode 100644 index 000000000..c6f886bec --- /dev/null +++ b/src/basic/components/CartPage/Cart/OrderSummary.tsx @@ -0,0 +1,55 @@ +import { Card } from "../../ui"; + +interface OrderSummaryProps { + totalBeforeDiscount: number; + totalAfterDiscount: number; + completeOrder: () => void; +} + +export const OrderSummary = ({ + totalBeforeDiscount, + totalAfterDiscount, + completeOrder, +}: OrderSummaryProps) => { + const discountAmount = totalBeforeDiscount - totalAfterDiscount; + const hasDiscount = discountAmount > 0; + + return ( + +

결제 정보

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

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

+
+
+ ); +}; + +export default OrderSummary; diff --git a/src/basic/components/CartPage/Cart/index.tsx b/src/basic/components/CartPage/Cart/index.tsx new file mode 100644 index 000000000..7cae1f2f4 --- /dev/null +++ b/src/basic/components/CartPage/Cart/index.tsx @@ -0,0 +1,58 @@ +import { CartItem, Coupon } from "../../../../types"; +import { calculateCartTotal } from "../../../models/cart"; +import CartItemList from "./CartItemList"; +import { CouponSelector } from "./CouponSelector"; +import { OrderSummary } from "./OrderSummary"; + +interface CartProps { + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; + applyCoupon: (coupon: Coupon) => void; + clearSelectedCoupon: () => void; + completeOrder: () => void; +} + +export const Cart = ({ + cart, + coupons, + selectedCoupon, + removeFromCart, + updateQuantity, + applyCoupon, + clearSelectedCoupon, + completeOrder, +}: CartProps) => { + const totals = calculateCartTotal(cart, selectedCoupon); + + return ( +
+ + + {cart.length > 0 && ( + <> + + + + + )} +
+ ); +}; + +export default Cart; diff --git a/src/basic/components/CartPage/ProductList.tsx b/src/basic/components/CartPage/ProductList.tsx new file mode 100644 index 000000000..15ca226a7 --- /dev/null +++ b/src/basic/components/CartPage/ProductList.tsx @@ -0,0 +1,120 @@ +import { CartItem } from "../../../types"; +import { ProductWithUI } from "../../constants"; +import { getRemainingStock, isSoldOut } from "../../models/cart"; +import { + getStockStatusMessage, + getAddToCartButtonState, + getMaxDiscountRate, +} from "../../models/product"; +import { formatDiscount, formatPriceUnit } from "../../utils/formatters"; +import { ImageIcon } from "../icons"; + +interface ProductListProps { + products: ProductWithUI[]; + cart: CartItem[]; + debouncedSearchTerm: string; + addToCart: (product: ProductWithUI) => void; +} + +export const ProductList = ({ + products, + cart, + debouncedSearchTerm, + addToCart, +}: ProductListProps) => { + return ( +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {products.map((product) => { + const remainingStock = getRemainingStock(product, cart); + const stockStatus = getStockStatusMessage(remainingStock); + const buttonState = getAddToCartButtonState(remainingStock); + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{formatDiscount(getMaxDiscountRate(product))} + + )} +
+ + {/* 상품 정보 */} +
+

+ {product.name} +

+ {product.description && ( +

+ {product.description} +

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

+ {isSoldOut(products, cart, product.id) + ? "SOLD OUT" + : formatPriceUnit(product.price)} +

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

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

+ )} +
+ + {/* 재고 상태 */} +
+ {stockStatus && ( +

+ {stockStatus.message} +

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); + })} +
+ )} +
+ ); +}; + +export default ProductList; diff --git a/src/basic/components/CartPage/SearchBar.tsx b/src/basic/components/CartPage/SearchBar.tsx new file mode 100644 index 000000000..b6ad381a7 --- /dev/null +++ b/src/basic/components/CartPage/SearchBar.tsx @@ -0,0 +1,25 @@ +interface SearchBarProps { + searchTerm: string; + setSearchTerm: (term: string) => void; + placeholder?: string; +} + +export const SearchBar = ({ + searchTerm, + setSearchTerm, + placeholder = "상품 검색...", +}: SearchBarProps) => { + return ( +
+ setSearchTerm(e.target.value)} + placeholder={placeholder} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ ); +}; + +export default SearchBar; diff --git a/src/basic/components/CartPage/index.tsx b/src/basic/components/CartPage/index.tsx new file mode 100644 index 000000000..9c1f7dbe4 --- /dev/null +++ b/src/basic/components/CartPage/index.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { Product, CartItem, Coupon } from "../../../types"; +import { ProductWithUI, TIMING } from "../../constants"; +import { filterProductsBySearch } from "../../models/product"; +import { useDebounce } from "../../utils/hooks/useDebounce"; +import { SearchBar } from "./SearchBar"; +import { ProductList } from "./ProductList"; +import { Cart } from "./Cart"; + +interface CartPageProps { + products: Product[]; + cart: CartItem[]; + totalItemCount: number; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + addToCart: (product: ProductWithUI) => void; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; + applyCoupon: (coupon: Coupon) => void; + clearSelectedCoupon: () => void; + completeOrder: () => void; +} + +export const CartPage = ({ + products, + cart, + coupons, + selectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + clearSelectedCoupon, + completeOrder, +}: CartPageProps) => { + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce( + searchTerm, + TIMING.SEARCH_DEBOUNCE_MS + ); + + const filteredProducts = filterProductsBySearch( + products, + debouncedSearchTerm + ) as ProductWithUI[]; + + return ( + <> + + +
+
+ +
+ +
+ +
+
+ + ); +}; diff --git a/src/basic/components/Header.tsx b/src/basic/components/Header.tsx new file mode 100644 index 000000000..26793a8eb --- /dev/null +++ b/src/basic/components/Header.tsx @@ -0,0 +1,46 @@ +import { CartItem } from "../../types"; +import { CartIcon } from "./icons"; + +interface HeaderProps { + isAdmin: boolean; + toggleAdmin: () => void; + cart: CartItem[]; + totalItemCount: number; +} + +function Header({ isAdmin, toggleAdmin, cart, totalItemCount }: HeaderProps) { + return ( +
+
+
+
+

SHOP

+
+ +
+
+
+ ); +} +export default Header; diff --git a/src/basic/components/icons/index.tsx b/src/basic/components/icons/index.tsx new file mode 100644 index 000000000..91399f5ea --- /dev/null +++ b/src/basic/components/icons/index.tsx @@ -0,0 +1,126 @@ +// TODO: SVG 아이콘 컴포넌트들 +// 구현할 아이콘: +// - CartIcon: 장바구니 아이콘 +// - AdminIcon: 관리자 아이콘 +// - PlusIcon: 플러스 아이콘 +// - MinusIcon: 마이너스 아이콘 +// - TrashIcon: 삭제 아이콘 +// - ChevronDownIcon: 아래 화살표 +// - ChevronUpIcon: 위 화살표 +// - CheckIcon: 체크 아이콘 + +interface IconProps { + className?: string; +} + +export const CloseIcon = ({ className = "w-4 h-4" }: IconProps) => ( + + + +); + +export const CartIcon = ({ className = "w-6 h-6" }: IconProps) => ( + + + +); + +export const TrashIcon = ({ className = "w-4 h-4" }: IconProps) => ( + + + +); + +export const PlusIcon = ({ className = "w-8 h-8" }: IconProps) => ( + + + +); + +export const ImageIcon = ({ className = "w-24 h-24" }: IconProps) => ( + + + +); + +export const EmptyCartIcon = ({ className = "w-16 h-16" }: IconProps) => ( + + + +); + +export const EmptyCartIconSmall = ({ className = "w-5 h-5" }: IconProps) => ( + + + +); diff --git a/src/basic/components/ui/Badge.tsx b/src/basic/components/ui/Badge.tsx new file mode 100644 index 000000000..23048ec4b --- /dev/null +++ b/src/basic/components/ui/Badge.tsx @@ -0,0 +1,26 @@ +interface BadgeProps { + variant?: "success" | "warning" | "danger" | "info"; + children: React.ReactNode; + className?: string; +} + +export const Badge = ({ + variant = "info", + children, + className = "", +}: BadgeProps) => { + const variantStyles = { + success: "bg-green-100 text-green-800", + warning: "bg-yellow-100 text-yellow-800", + danger: "bg-red-100 text-red-800", + info: "bg-blue-100 text-blue-800", + }; + + return ( + + {children} + + ); +}; diff --git a/src/basic/components/ui/Button.tsx b/src/basic/components/ui/Button.tsx new file mode 100644 index 000000000..580ffcfbc --- /dev/null +++ b/src/basic/components/ui/Button.tsx @@ -0,0 +1,35 @@ +interface ButtonProps { + variant?: "primary" | "secondary"; + children: React.ReactNode; + onClick?: () => void; + type?: "button" | "submit"; + disabled?: boolean; + className?: string; +} + +export const Button = ({ + variant = "primary", + children, + onClick, + type = "button", + disabled = false, + className = "", +}: ButtonProps) => { + const baseStyles = "px-4 py-2 rounded-md text-sm font-medium transition-colors"; + + const variantStyles = { + primary: "bg-indigo-600 text-white hover:bg-indigo-700", + secondary: "border border-gray-300 text-gray-700 hover:bg-gray-50", + }; + + return ( + + ); +}; diff --git a/src/basic/components/ui/Card.tsx b/src/basic/components/ui/Card.tsx new file mode 100644 index 000000000..dba6c53fc --- /dev/null +++ b/src/basic/components/ui/Card.tsx @@ -0,0 +1,12 @@ +interface CardProps { + children: React.ReactNode; + className?: string; +} + +export const Card = ({ children, className = "" }: CardProps) => { + return ( +
+ {children} +
+ ); +}; diff --git a/src/basic/components/ui/FormInput.tsx b/src/basic/components/ui/FormInput.tsx new file mode 100644 index 000000000..e19c01809 --- /dev/null +++ b/src/basic/components/ui/FormInput.tsx @@ -0,0 +1,38 @@ +interface FormInputProps { + label: string; + type?: string; + value: string | number; + onChange: (e: React.ChangeEvent) => void; + onBlur?: (e: React.FocusEvent) => void; + placeholder?: string; + required?: boolean; + className?: string; +} + +export const FormInput = ({ + label, + type = "text", + value, + onChange, + onBlur, + placeholder, + required = false, + className = "", +}: FormInputProps) => { + return ( +
+ + +
+ ); +}; diff --git a/src/basic/components/ui/FormSelect.tsx b/src/basic/components/ui/FormSelect.tsx new file mode 100644 index 000000000..2d70ea458 --- /dev/null +++ b/src/basic/components/ui/FormSelect.tsx @@ -0,0 +1,30 @@ +interface FormSelectProps { + label: string; + value: string; + onChange: (e: React.ChangeEvent) => void; + children: React.ReactNode; + className?: string; +} + +export const FormSelect = ({ + label, + value, + onChange, + children, + className = "", +}: FormSelectProps) => { + return ( +
+ + +
+ ); +}; diff --git a/src/basic/components/ui/QuantityControl.tsx b/src/basic/components/ui/QuantityControl.tsx new file mode 100644 index 000000000..7b9414235 --- /dev/null +++ b/src/basic/components/ui/QuantityControl.tsx @@ -0,0 +1,31 @@ +interface QuantityControlProps { + quantity: number; + onIncrease: () => void; + onDecrease: () => void; +} + +export const QuantityControl = ({ + quantity, + onIncrease, + onDecrease, +}: QuantityControlProps) => { + return ( +
+ + + {quantity} + + +
+ ); +}; diff --git a/src/basic/components/ui/Table.tsx b/src/basic/components/ui/Table.tsx new file mode 100644 index 000000000..2de124a86 --- /dev/null +++ b/src/basic/components/ui/Table.tsx @@ -0,0 +1,51 @@ +interface TableProps { + children: React.ReactNode; + className?: string; +} + +export const Table = ({ children, className = "" }: TableProps) => { + return ( +
+ {children}
+
+ ); +}; + +interface TableHeaderProps { + children: React.ReactNode; + align?: "left" | "right" | "center"; + className?: string; +} + +export const TableHeader = ({ + children, + align = "left", + className = "", +}: TableHeaderProps) => { + const alignStyles = { + left: "text-left", + right: "text-right", + center: "text-center", + }; + + return ( + + {children} + + ); +}; + +interface TableCellProps { + children: React.ReactNode; + className?: string; +} + +export const TableCell = ({ children, className = "" }: TableCellProps) => { + return ( + + {children} + + ); +}; diff --git a/src/basic/components/ui/Tabs.tsx b/src/basic/components/ui/Tabs.tsx new file mode 100644 index 000000000..113f8d70e --- /dev/null +++ b/src/basic/components/ui/Tabs.tsx @@ -0,0 +1,32 @@ +interface Tab { + id: string; + label: string; +} + +interface TabsProps { + tabs: Tab[]; + activeTab: string; + onTabChange: (tabId: string) => void; +} + +export const Tabs = ({ tabs, activeTab, onTabChange }: TabsProps) => { + return ( +
+ +
+ ); +}; diff --git a/src/basic/components/ui/UIToast.tsx b/src/basic/components/ui/UIToast.tsx new file mode 100644 index 000000000..dbde3ea82 --- /dev/null +++ b/src/basic/components/ui/UIToast.tsx @@ -0,0 +1,46 @@ +import { CloseIcon } from "../icons"; + +interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +interface UIToastProps { + notifications: Notification[]; + onClose: (id: string) => void; +} + +const getToastBgColor = (type: Notification["type"]) => { + const colors = { + error: "bg-red-600", + warning: "bg-yellow-600", + success: "bg-green-600", + }; + return colors[type]; +}; + +export const UIToast = ({ notifications, onClose }: UIToastProps) => { + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ); +}; diff --git a/src/basic/components/ui/index.tsx b/src/basic/components/ui/index.tsx new file mode 100644 index 000000000..b87e1617d --- /dev/null +++ b/src/basic/components/ui/index.tsx @@ -0,0 +1,9 @@ +export { Badge } from "./Badge"; +export { Button } from "./Button"; +export { Card } from "./Card"; +export { FormInput } from "./FormInput"; +export { FormSelect } from "./FormSelect"; +export { QuantityControl } from "./QuantityControl"; +export { Table, TableHeader, TableCell } from "./Table"; +export { Tabs } from "./Tabs"; +export { UIToast } from "./UIToast"; diff --git a/src/basic/constants/index.ts b/src/basic/constants/index.ts new file mode 100644 index 000000000..d7a22f883 --- /dev/null +++ b/src/basic/constants/index.ts @@ -0,0 +1,140 @@ +// TODO: 초기 데이터 상수 +// 정의할 상수들: +// - initialProducts: 초기 상품 목록 (상품1, 상품2, 상품3 + 설명 필드 포함) +// - initialCoupons: 초기 쿠폰 목록 (5000원 할인, 10% 할인) +// +// 참고: origin/App.tsx의 초기 데이터 구조를 참조 + +// TODO: 구현 + +import { Product, Coupon } from "../../types"; + +// ============= 초기 데이터 ============= +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export const INITIAL_PRODUCTS: ProductWithUI[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: "최고급 품질의 프리미엄 상품입니다.", + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +export const INITIAL_COUPONS: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; + +// ============= 폼 초기값 ============= +export const EMPTY_PRODUCT_FORM = { + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] as Array<{ quantity: number; rate: number }>, +}; + +export const EMPTY_COUPON_FORM = { + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, +}; + +// ============= 검증 관련 상수 ============= +export const VALIDATION_LIMITS = { + MAX_STOCK: 9999, + MAX_COUPON_AMOUNT: 100000, + MAX_DISCOUNT_PERCENTAGE: 100, + MIN_PRICE: 0, + MIN_STOCK: 0, +}; + +export const STOCK_THRESHOLDS = { + LOW_STOCK: 5, // 품절 임박 기준 + GOOD_STOCK: 10, // 재고 충분 기준 +}; + +// ============= 타이밍 상수 ============= +export const TIMING = { + SEARCH_DEBOUNCE_MS: 500, + NOTIFICATION_AUTO_DISMISS_MS: 3000, +}; + +// ============= 비즈니스 규칙 상수 ============= +export const BUSINESS_RULES = { + MIN_PURCHASE_FOR_PERCENTAGE_COUPON: 10000, // percentage 쿠폰 최소 구매 금액 +}; + +// ============= 메시지 상수 ============= +export const MESSAGES = { + // 검증 에러 메시지 + PRICE_VALIDATION_ERROR: "가격은 0보다 커야 합니다", + STOCK_VALIDATION_ERROR: "재고는 0보다 커야 합니다", + STOCK_MAX_ERROR: "재고는 9999개를 초과할 수 없습니다", + COUPON_PERCENTAGE_MAX_ERROR: "할인율은 100%를 초과할 수 없습니다", + COUPON_AMOUNT_MAX_ERROR: "할인 금액은 100,000원을 초과할 수 없습니다", + + // 성공 메시지 + PRODUCT_ADDED: "상품이 추가되었습니다.", + PRODUCT_UPDATED: "상품이 수정되었습니다.", + PRODUCT_DELETED: "상품이 삭제되었습니다.", + COUPON_ADDED: "쿠폰이 추가되었습니다.", + COUPON_DELETED: "쿠폰이 삭제되었습니다.", + COUPON_APPLIED: "쿠폰이 적용되었습니다.", + CART_ITEM_ADDED: "장바구니에 담았습니다", + + // 에러 메시지 + OUT_OF_STOCK: "재고가 부족합니다!", + STOCK_LIMIT_EXCEEDED: (max: number) => `재고는 ${max}개까지만 있습니다.`, + COUPON_CODE_EXISTS: "이미 존재하는 쿠폰 코드입니다.", + PERCENTAGE_COUPON_MIN_PURCHASE: + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", +}; + +// ============= LocalStorage 키 ============= +export const STORAGE_KEYS = { + PRODUCTS: "products", + CART: "cart", + COUPONS: "coupons", + SELECTED_COUPON: "selectedCoupon", +}; diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..f9f280e9e --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,110 @@ +import { useCallback, useMemo } from "react"; +import { CartItem, Product } from "../../types"; +import { getRemainingStock } from "../models/cart"; +import { ProductWithUI, STORAGE_KEYS, MESSAGES } from "../constants"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +interface UseCartParams { + products: Product[]; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const useCart = ({ products, addNotification }: UseCartParams) => { + const [cart, setCart] = useLocalStorage(STORAGE_KEYS.CART, []); + + const totalItemCount = useMemo(() => { + return cart.reduce((sum, item) => sum + item.quantity, 0); + }, [cart]); + + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product, cart); + if (remainingStock <= 0) { + addNotification(MESSAGES.OUT_OF_STOCK, "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( + MESSAGES.STOCK_LIMIT_EXCEEDED(product.stock), + "error" + ); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + addNotification(MESSAGES.CART_ITEM_ADDED, "success"); + }, + [cart, addNotification, setCart] + ); + + const removeFromCart = useCallback( + (productId: string) => { + setCart((prevCart) => + prevCart.filter((item) => item.product.id !== productId) + ); + }, + [setCart] + ); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } + + const product = products.find((p) => p.id === productId); + if (!product) return; + + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(MESSAGES.STOCK_LIMIT_EXCEEDED(maxStock), "error"); + return; + } + + setCart((prevCart) => + prevCart.map((item) => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ) + ); + }, + [products, removeFromCart, addNotification, setCart] + ); + + const clearCart = useCallback(() => { + setCart([]); + }, [setCart]); + + return { + cart, + totalItemCount, + getRemainingStock, + addToCart, + removeFromCart, + updateQuantity, + clearCart, + }; +}; diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 000000000..30c094b93 --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,85 @@ +import { useCallback } from "react"; +import { CartItem, Coupon } from "../../types"; +import { calculateCartTotal } from "../models/cart"; +import { + INITIAL_COUPONS, + STORAGE_KEYS, + MESSAGES, + BUSINESS_RULES, +} from "../constants"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +interface UseCouponsParams { + cart: CartItem[]; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const useCoupons = ({ cart, addNotification }: UseCouponsParams) => { + const [coupons, setCoupons] = useLocalStorage( + STORAGE_KEYS.COUPONS, + INITIAL_COUPONS + ); + + const [selectedCoupon, setSelectedCoupon] = useLocalStorage( + STORAGE_KEYS.SELECTED_COUPON, + null + ); + + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + addNotification(MESSAGES.COUPON_CODE_EXISTS, "error"); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + addNotification(MESSAGES.COUPON_ADDED, "success"); + }, + [coupons, addNotification, setCoupons] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification(MESSAGES.COUPON_DELETED, "success"); + }, + [selectedCoupon, addNotification, setCoupons, setSelectedCoupon] + ); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal(cart, coupon).totalAfterDiscount; + + if ( + currentTotal < BUSINESS_RULES.MIN_PURCHASE_FOR_PERCENTAGE_COUPON && + coupon.discountType === "percentage" + ) { + addNotification(MESSAGES.PERCENTAGE_COUPON_MIN_PURCHASE, "error"); + return; + } + + setSelectedCoupon(coupon); + addNotification(MESSAGES.COUPON_APPLIED, "success"); + }, + [cart, addNotification, setSelectedCoupon] + ); + + const clearSelectedCoupon = useCallback(() => { + setSelectedCoupon(null); + }, [setSelectedCoupon]); + + return { + coupons, + selectedCoupon, + addCoupon, + deleteCoupon, + applyCoupon, + clearSelectedCoupon, + }; +}; diff --git a/src/basic/hooks/useNotification.ts b/src/basic/hooks/useNotification.ts new file mode 100644 index 000000000..cf1739770 --- /dev/null +++ b/src/basic/hooks/useNotification.ts @@ -0,0 +1,41 @@ +import { useState, useCallback } from "react"; +import { TIMING } from "../constants"; + +interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +export function useNotification( + duration = TIMING.NOTIFICATION_AUTO_DISMISS_MS +) { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, duration); + }, + [duration] + ); + + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + const clearNotifications = useCallback(() => { + setNotifications([]); + }, []); + + return { + notifications, + addNotification, + removeNotification, + clearNotifications, + }; +} diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 000000000..93f73e38d --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,63 @@ +import { useCallback } from "react"; +import { + ProductWithUI, + INITIAL_PRODUCTS, + STORAGE_KEYS, + MESSAGES, +} from "../constants"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +interface UseProductsParams { + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const useProducts = ({ addNotification }: UseProductsParams) => { + const [products, setProducts] = useLocalStorage( + STORAGE_KEYS.PRODUCTS, + INITIAL_PRODUCTS + ); + + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification(MESSAGES.PRODUCT_ADDED, "success"); + + return product; + }, + [addNotification, setProducts] + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => + product.id === productId ? { ...product, ...updates } : product + ) + ); + addNotification(MESSAGES.PRODUCT_UPDATED, "success"); + }, + [addNotification, setProducts] + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification(MESSAGES.PRODUCT_DELETED, "success"); + }, + [addNotification, 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..768a1bb90 --- /dev/null +++ b/src/basic/models/cart.ts @@ -0,0 +1,102 @@ +// TODO: 장바구니 비즈니스 로직 (순수 함수) +// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) +// +// 구현할 함수들: +// 1. calculateItemTotal(item): 개별 아이템의 할인 적용 후 총액 계산 +// 2. getMaxApplicableDiscount(item): 적용 가능한 최대 할인율 계산 +// 3. calculateCartTotal(cart, coupon): 장바구니 총액 계산 (할인 전/후, 할인액) +// 4. updateCartItemQuantity(cart, productId, quantity): 수량 변경 +// 5. addItemToCart(cart, product): 상품 추가 +// 6. removeItemFromCart(cart, productId): 상품 제거 +// 7. getRemainingStock(product, cart): 남은 재고 계산 +// +// 원칙: +// - UI와 관련된 로직 없음 +// - 외부 상태에 의존하지 않음 +// - 모든 필요한 데이터는 파라미터로 전달받음 + +import { CartItem, Coupon, Product } from "../../types"; + +// TODO: 구현 +const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; +}; + +export const calculateItemTotal = ( + item: CartItem, + cart: CartItem[] +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +export const calculateCartTotal = ( + cart: CartItem[], + coupon: Coupon | null +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (coupon) { + if (coupon.discountType === "amount") { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - coupon.discountValue + ); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - coupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +export const getRemainingStock = ( + product: Product, + cart: CartItem[] +): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); +}; + +export const isSoldOut = ( + products: Product[], + cart: CartItem[], + productId: string +) => { + const product = products.find((p) => p.id === productId); + if (product && getRemainingStock(product, cart) <= 0) { + return "SOLD OUT"; + } +}; diff --git a/src/basic/models/coupon.ts b/src/basic/models/coupon.ts new file mode 100644 index 000000000..bdfbde387 --- /dev/null +++ b/src/basic/models/coupon.ts @@ -0,0 +1,55 @@ +export interface ValidationResult { + isValid: boolean; + error?: string; + correctedValue?: number; +} + +export const validateCouponPercentage = (value: number): ValidationResult => { + if (value < 0) { + return { + isValid: false, + error: undefined, + correctedValue: 0, + }; + } + if (value > 100) { + return { + isValid: false, + error: "할인율은 100%를 초과할 수 없습니다", + correctedValue: 100, + }; + } + return { isValid: true }; +}; + +export const validateCouponAmount = (value: number): ValidationResult => { + if (value < 0) { + return { + isValid: false, + error: undefined, + correctedValue: 0, + }; + } + if (value > 100000) { + return { + isValid: false, + error: "할인 금액은 100,000원을 초과할 수 없습니다", + correctedValue: 100000, + }; + } + return { isValid: true }; +}; + +export const isValidCouponCode = (code: string): boolean => { + return /^[A-Z0-9]{4,12}$/.test(code); +}; + +export const formatCouponValue = ( + discountType: "amount" | "percentage", + discountValue: number +): string => { + if (discountType === "amount") { + return `${discountValue.toLocaleString()}원 할인`; + } + return `${discountValue}% 할인`; +}; diff --git a/src/basic/models/discount.ts b/src/basic/models/discount.ts new file mode 100644 index 000000000..5d6d946a3 --- /dev/null +++ b/src/basic/models/discount.ts @@ -0,0 +1,14 @@ +export const calculateDiscountRate = ( + itemTotal: number, + originalPrice: number +): number => { + if (itemTotal >= originalPrice) return 0; + return Math.round((1 - itemTotal / originalPrice) * 100); +}; + +export const hasDiscount = ( + itemTotal: number, + originalPrice: number +): boolean => { + return itemTotal < originalPrice; +}; diff --git a/src/basic/models/product.ts b/src/basic/models/product.ts new file mode 100644 index 000000000..a40c5ba1f --- /dev/null +++ b/src/basic/models/product.ts @@ -0,0 +1,128 @@ +import { Product } from "../../types"; +import { ProductWithUI, STOCK_THRESHOLDS } from "../constants"; + +// ============= 타입 정의 ============= + +export interface ValidationResult { + isValid: boolean; + error?: string; + correctedValue?: number; +} + +// ============= 상품 검증 ============= + +/** + * 상품 가격 검증 + */ +export const validateProductPrice = (price: number): ValidationResult => { + if (price < 0) { + return { + isValid: false, + error: "가격은 0보다 커야 합니다", + correctedValue: 0, + }; + } + return { isValid: true }; +}; + +/** + * 상품 재고 검증 + */ +export const validateProductStock = (stock: number): ValidationResult => { + if (stock < 0) { + return { + isValid: false, + error: "재고는 0보다 커야 합니다", + correctedValue: 0, + }; + } + if (stock > 9999) { + return { + isValid: false, + error: "재고는 9999개를 초과할 수 없습니다", + correctedValue: 9999, + }; + } + return { isValid: true }; +}; + +/** + * 가격 유효성 검사 + */ +export const isValidPrice = (price: number): boolean => { + return price > 0; +}; + +/** + * 재고 유효성 검사 + */ +export const isValidStock = (stock: number): boolean => { + return stock >= 0; +}; + +// ============= 할인율 계산 ============= + +export const getMaxDiscountRate = (product: Product): number => { + if (product.discounts.length === 0) return 0; + return Math.max(...product.discounts.map((d) => d.rate)); +}; + +export const getMaxDiscountPercentage = (product: Product): number => { + return getMaxDiscountRate(product) * 100; +}; + +export const filterProductsBySearch = ( + products: ProductWithUI[], + searchTerm: string +): ProductWithUI[] => { + if (!searchTerm) return products; + + const lowerSearch = searchTerm.toLowerCase(); + return products.filter( + (product) => + product.name.toLowerCase().includes(lowerSearch) || + (product.description?.toLowerCase().includes(lowerSearch) ?? false) + ); +}; + +export const getStockStatusMessage = ( + remainingStock: number +): { message: string; className: string } | null => { + if (remainingStock <= 0) { + return null; // 품절인 경우 + } + if (remainingStock <= STOCK_THRESHOLDS.LOW_STOCK) { + return { + message: `품절임박! ${remainingStock}개 남음`, + className: "text-xs text-red-600 font-medium", + }; + } + return { + message: `재고 ${remainingStock}개`, + className: "text-xs text-gray-500", + }; +}; + +/** + * 재고 배지 클래스를 반환 + */ +export const getStockBadgeClass = (stock: number): string => { + if (stock > STOCK_THRESHOLDS.GOOD_STOCK) { + return "bg-green-100 text-green-800"; + } + if (stock > 0) { + return "bg-yellow-100 text-yellow-800"; + } + return "bg-red-100 text-red-800"; +}; + +export const getAddToCartButtonState = (remainingStock: number) => { + const isDisabled = remainingStock <= 0; + return { + disabled: isDisabled, + className: isDisabled + ? "bg-gray-100 text-gray-400 cursor-not-allowed" + : "bg-gray-900 text-white hover:bg-gray-800", + label: isDisabled ? "품절" : "장바구니 담기", + }; +}; diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts new file mode 100644 index 000000000..94d1d00fa --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,17 @@ +// TODO: 포맷팅 유틸리티 함수들 +// 구현할 함수: +// - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷 + +// TODO: 구현 + +export const formatPriceKor = (price: number): string => { + return `${price.toLocaleString()}원`; +}; + +export const formatPriceUnit = (price: number): string => { + return `₩${price.toLocaleString()}`; +}; + +export const formatDiscount = (rate: number): string => { + return `${Math.round(rate * 100)}%`; +}; diff --git a/src/basic/utils/hooks/useDebounce.ts b/src/basic/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..79ef6a48d --- /dev/null +++ b/src/basic/utils/hooks/useDebounce.ts @@ -0,0 +1,23 @@ +// TODO: 디바운스 Hook +// 힌트: +// 1. 값이 변경되어도 지정된 시간 동안 대기 +// 2. 대기 시간 동안 값이 다시 변경되면 타이머 리셋 +// 3. 최종적으로 안정된 값만 반환 +// +// 사용 예시: 검색어 입력 디바운싱 + +import { useState, useEffect } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} \ No newline at end of file diff --git a/src/basic/utils/hooks/useLocalStorage.ts b/src/basic/utils/hooks/useLocalStorage.ts new file mode 100644 index 000000000..1f09bf6d0 --- /dev/null +++ b/src/basic/utils/hooks/useLocalStorage.ts @@ -0,0 +1,57 @@ +import { useState, useEffect, useCallback } from "react"; + +// TODO: LocalStorage Hook +// 힌트: +// 1. localStorage와 React state 동기화 +// 2. 초기값 로드 시 에러 처리 +// 3. 저장 시 JSON 직렬화/역직렬화 +// 4. 빈 배열이나 undefined는 삭제 +// +// 반환값: [저장된 값, 값 설정 함수] + +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + // 초기값 로드 (lazy initialization) + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + if (item) { + return JSON.parse(item); + } + return initialValue; + } catch { + return initialValue; + } + }); + + // localStorage와 동기화 + useEffect(() => { + try { + // 빈 배열이나 undefined는 삭제 + if ( + storedValue === undefined || + (Array.isArray(storedValue) && storedValue.length === 0) + ) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(storedValue)); + } + } catch (error) { + console.error(`Error saving to localStorage key "${key}":`, error); + } + }, [key, storedValue]); + + // 값 설정 함수 (함수형 업데이트 지원) + const setValue = useCallback((value: T | ((val: T) => T)) => { + setStoredValue((prev) => { + if (value instanceof Function) { + return value(prev); + } + return value; + }); + }, []); + + return [storedValue, setValue]; +} diff --git a/src/basic/utils/hooks/useValidate.ts b/src/basic/utils/hooks/useValidate.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/utils/validators.ts b/src/basic/utils/validators.ts new file mode 100644 index 000000000..9cbb0595a --- /dev/null +++ b/src/basic/utils/validators.ts @@ -0,0 +1,14 @@ +// TODO: 검증 유틸리티 함수들 +// 구현할 함수: +// - extractNumbers(value: string): string - 문자열에서 숫자만 추출 + +// TODO: 구현 + +export const isNumericInput = (value: string): boolean => { + if (value === "") return true; + return /^\d+$/.test(value); +}; + +export const extractNumbers = (value: string): string => { + return value.replace(/\D/g, ""); +}; diff --git a/src/origin/App.tsx b/src/origin/App.tsx index a4369fe1d..4ef0350e8 100644 --- a/src/origin/App.tsx +++ b/src/origin/App.tsx @@ -1,5 +1,5 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState, useCallback, useEffect } from "react"; +import { CartItem, Coupon, Product } from "../types"; interface ProductWithUI extends Product { description?: string; @@ -9,65 +9,62 @@ interface ProductWithUI extends Product { interface Notification { id: string; message: string; - type: 'error' | 'success' | 'warning'; + type: "error" | "success" | "warning"; } // 초기 데이터 const initialProducts: ProductWithUI[] = [ { - id: 'p1', - name: '상품1', + id: "p1", + name: "상품1", price: 10000, stock: 20, discounts: [ { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } + { quantity: 20, rate: 0.2 }, ], - description: '최고급 품질의 프리미엄 상품입니다.' + description: "최고급 품질의 프리미엄 상품입니다.", }, { - id: 'p2', - name: '상품2', + id: "p2", + name: "상품2", price: 20000, stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, }, { - id: 'p3', - name: '상품3', + id: "p3", + name: "상품3", price: 30000, stock: 20, discounts: [ { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } + { quantity: 30, rate: 0.25 }, ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, ]; const initialCoupons: Coupon[] = [ { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, }, { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, ]; const App = () => { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); + const saved = localStorage.getItem("products"); if (saved) { try { return JSON.parse(saved); @@ -79,7 +76,7 @@ const App = () => { }); const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); + const saved = localStorage.getItem("cart"); if (saved) { try { return JSON.parse(saved); @@ -91,7 +88,7 @@ const App = () => { }); const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); + const saved = localStorage.getItem("coupons"); if (saved) { try { return JSON.parse(saved); @@ -106,59 +103,60 @@ const App = () => { const [isAdmin, setIsAdmin] = useState(false); const [notifications, setNotifications] = useState([]); const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [activeTab, setActiveTab] = useState<"products" | "coupons">( + "products" + ); const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); // Admin const [editingProduct, setEditingProduct] = useState(null); const [productForm, setProductForm] = useState({ - name: '', + name: "", price: 0, stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> + description: "", + discounts: [] as Array<{ quantity: number; rate: number }>, }); const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, }); - const formatPrice = (price: number, productId?: string): string => { if (productId) { - const product = products.find(p => p.id === productId); + const product = products.find((p) => p.id === productId); if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; + return "SOLD OUT"; } } if (isAdmin) { return `${price.toLocaleString()}원`; } - + return `₩${price.toLocaleString()}`; }; const getMaxApplicableDiscount = (item: CartItem): number => { const { discounts } = item.product; const { quantity } = item; - + const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate : maxDiscount; }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); if (hasBulkPurchase) { return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 } - + return baseDiscount; }; @@ -166,7 +164,7 @@ const App = () => { const { price } = item.product; const { quantity } = item; const discount = getMaxApplicableDiscount(item); - + return Math.round(price * quantity * (1 - discount)); }; @@ -177,44 +175,51 @@ const App = () => { let totalBeforeDiscount = 0; let totalAfterDiscount = 0; - cart.forEach(item => { + cart.forEach((item) => { const itemPrice = item.product.price * item.quantity; totalBeforeDiscount += itemPrice; totalAfterDiscount += calculateItemTotal(item); }); if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); + if (selectedCoupon.discountType === "amount") { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue + ); } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); } } return { totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) + totalAfterDiscount: Math.round(totalAfterDiscount), }; }; const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); + const cartItem = cart.find((item) => item.product.id === product.id); const remaining = product.stock - (cartItem?.quantity || 0); - + return remaining; }; - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); const [totalItemCount, setTotalItemCount] = useState(0); - useEffect(() => { const count = cart.reduce((sum, item) => sum + item.quantity, 0); @@ -222,18 +227,18 @@ const App = () => { }, [cart]); useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); + localStorage.setItem("products", JSON.stringify(products)); }, [products]); useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); + localStorage.setItem("coupons", JSON.stringify(coupons)); }, [coupons]); useEffect(() => { if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); + localStorage.setItem("cart", JSON.stringify(cart)); } else { - localStorage.removeItem('cart'); + localStorage.removeItem("cart"); } }, [cart]); @@ -244,139 +249,180 @@ const App = () => { return () => clearTimeout(timer); }, [searchTerm]); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + addNotification("재고가 부족합니다!", "error"); + return; + } + + setCart((prevCart) => { + const existingItem = prevCart.find( + (item) => item.product.id === product.id + ); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; + if (newQuantity > product.stock) { + addNotification( + `재고는 ${product.stock}개까지만 있습니다.`, + "error" + ); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ); } - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); + return [...prevCart, { product, quantity: 1 }]; + }); + + addNotification("장바구니에 담았습니다", "success"); + }, + [cart, addNotification, getRemainingStock] + ); const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + setCart((prevCart) => + prevCart.filter((item) => item.product.id !== productId) + ); }, []); - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } - const product = products.find(p => p.id === productId); - if (!product) return; + const product = products.find((p) => p.id === productId); + if (!product) return; - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, "error"); + return; + } - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } + setCart((prevCart) => + prevCart.map((item) => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ) + ); + }, + [products, removeFromCart, addNotification, getRemainingStock] + ); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal().totalAfterDiscount; - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + if (currentTotal < 10000 && coupon.discountType === "percentage") { + addNotification( + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", + "error" + ); + return; + } + + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [addNotification, calculateCartTotal] + ); const completeOrder = useCallback(() => { const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" + ); setCart([]); setSelectedCoupon(null); }, [addNotification]); - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification("상품이 추가되었습니다.", "success"); + }, + [addNotification] + ); - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => + product.id === productId ? { ...product, ...updates } : product + ) + ); + addNotification("상품이 수정되었습니다.", "success"); + }, + [addNotification] + ); - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification("상품이 삭제되었습니다.", "success"); + }, + [addNotification] + ); - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + addNotification("쿠폰이 추가되었습니다.", "success"); + }, + [coupons, addNotification] + ); + + const 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') { + if (editingProduct && editingProduct !== "new") { updateProduct(editingProduct, productForm); setEditingProduct(null); } else { addProduct({ ...productForm, - discounts: productForm.discounts + discounts: productForm.discounts, }); } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); setEditingProduct(null); setShowProductForm(false); }; @@ -385,10 +431,10 @@ const App = () => { e.preventDefault(); addCoupon(couponForm); setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 + name: "", + code: "", + discountType: "amount", + discountValue: 0, }); setShowCouponForm(false); }; @@ -399,8 +445,8 @@ const App = () => { name: product.name, price: product.price, stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] + description: product.description || "", + discounts: product.discounts || [], }); setShowProductForm(true); }; @@ -408,9 +454,15 @@ const App = () => { const totals = calculateCartTotal(); const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) ) : products; @@ -418,22 +470,38 @@ const App = () => {
{notifications.length > 0 && (
- {notifications.map(notif => ( + {notifications.map((notif) => (
{notif.message} -
@@ -462,17 +530,27 @@ const App = () => { {!isAdmin && (
- - + + {cart.length > 0 && ( @@ -490,27 +568,31 @@ const App = () => { {isAdmin ? (
-

관리자 대시보드

-

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

+

+ 관리자 대시보드 +

+

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

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

상품 목록

- +
+
+

상품 목록

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

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

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); + + + {(activeTab === "products" ? products : products).map( + (product) => ( + + + {product.name} + + + {formatPrice(product.price, product.id)} + + + 10 + ? "bg-green-100 text-green-800" + : product.stock > 0 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`} + > + {product.stock}개 + + + + {product.description || "-"} + + + + + + + ) + )} + + +
+ {showProductForm && ( +
+ +

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

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

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

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

쿠폰 관리

+
+
+
+ {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 - /> -
-
-
+
-
-
- )} -
+ + {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 + /> +
+
+
+ + +
+
+
+ )} +
)}
@@ -897,137 +1169,221 @@ const App = () => { {/* 상품 목록 */}
-

전체 상품

+

+ 전체 상품 +

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

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

+

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

+ {product.name} +

+ {product.description && ( +

+ {product.description} +

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

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

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

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

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

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

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

+ 재고 {remainingStock}개 +

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

- - + + 장바구니

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

장바구니가 비어있습니다

+

+ 장바구니가 비어있습니다 +

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

{item.product.name}

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

{Math.round(itemTotal).toLocaleString()}원 @@ -1053,27 +1411,33 @@ const App = () => { <>

-

쿠폰 할인

+

+ 쿠폰 할인 +

{coupons.length > 0 && ( - @@ -1085,27 +1449,40 @@ const App = () => {
상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 + + {totals.totalBeforeDiscount.toLocaleString()}원 +
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( + {totals.totalBeforeDiscount - + totals.totalAfterDiscount > + 0 && (
할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 + + - + {( + totals.totalBeforeDiscount - + totals.totalAfterDiscount + ).toLocaleString()} + 원 +
)}
결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 + + {totals.totalAfterDiscount.toLocaleString()}원 +
- + - +

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

@@ -1121,4 +1498,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App;