diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..9652b300e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,58 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: 'pages' + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './dist' + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/package.json b/package.json index 17b18de25..93138677d 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { + "dev": "vite", "dev:origin": "vite --open ./index.origin.html", "dev:basic": "vite --open ./index.basic.html", "dev:advanced": "vite --open ./index.advanced.html", @@ -13,9 +14,11 @@ "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", "build": "tsc -b && vite build", + "preview": "vite preview", "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" }, @@ -23,6 +26,7 @@ "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", "@typescript-eslint/eslint-plugin": "^8.38.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85f..ba2de1cf1 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 @@ -24,6 +27,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 '@types/react': specifier: ^19.1.9 version: 19.1.9 @@ -38,7 +44,7 @@ importers: version: 8.38.0(eslint@9.32.0)(typescript@5.9.2) '@vitejs/plugin-react-swc': specifier: ^3.11.0 - version: 3.11.0(vite@7.0.6) + version: 3.11.0(vite@7.0.6(@types/node@24.10.1)) '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) @@ -59,10 +65,10 @@ importers: version: 5.9.2 vite: specifier: ^7.0.6 - version: 7.0.6 + version: 7.0.6(@types/node@24.10.1) vitest: specifier: ^3.2.4 - version: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) + version: 3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0) packages: @@ -583,6 +589,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/react-dom@19.1.7': resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} peerDependencies: @@ -1056,6 +1065,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==} @@ -1382,6 +1409,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1886,6 +1916,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + '@types/react-dom@19.1.7(@types/react@19.1.9)': dependencies: '@types/react': 19.1.9 @@ -1987,11 +2021,11 @@ snapshots: '@typescript-eslint/types': 8.38.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6)': + '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6(@types/node@24.10.1))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 '@swc/core': 1.13.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) transitivePeerDependencies: - '@swc/helpers' @@ -2003,13 +2037,13 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.0.6)': + '@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.10.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -2040,7 +2074,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) + vitest: 3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0) '@vitest/utils@3.2.4': dependencies: @@ -2413,6 +2447,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: {} @@ -2716,17 +2755,19 @@ snapshots: typescript@5.9.2: {} + undici-types@7.16.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite-node@3.2.4: + vite-node@3.2.4(@types/node@24.10.1): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) transitivePeerDependencies: - '@types/node' - jiti @@ -2741,7 +2782,7 @@ snapshots: - tsx - yaml - vite@7.0.6: + vite@7.0.6(@types/node@24.10.1): dependencies: esbuild: 0.25.8 fdir: 6.4.6(picomatch@4.0.3) @@ -2750,13 +2791,14 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.14 optionalDependencies: + '@types/node': 24.10.1 fsevents: 2.3.3 - vitest@3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0): + vitest@3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.0.6) + '@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.10.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -2774,10 +2816,11 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.6 - vite-node: 3.2.4 + vite: 7.0.6(@types/node@24.10.1) + vite-node: 3.2.4(@types/node@24.10.1) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 24.10.1 '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 26.1.0 transitivePeerDependencies: diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1d..74ae110b5 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1124 +1,18 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import { Notification } from './features/notification/index'; +import { AdminPage } from './pages/admin/AdminPage'; +import { ShopPage } from './pages/shop/ShopPage'; +import { isAdminAtom } from './shared/atoms'; +import { useAtomValue } from 'jotai'; const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const isAdmin = useAtomValue(isAdminAtom); return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

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

관리자 대시보드

-

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

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

상품 목록

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

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

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

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

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

새 쿠폰 생성

-
-
- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === 'percentage') { - if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} - required - /> -
-
-
- - -
-
-
- )} -
-
- )} -
- ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

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

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

-
-
- - )} -
-
-
- )} -
+ + {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..681c98fcc 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -1,44 +1,84 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + within, + waitFor, +} from '@testing-library/react'; import { vi } from 'vitest'; +import { useSetAtom } from 'jotai'; import App from '../App'; import '../../setupTests'; +import { notificationAtom } from '../features/notification/atoms'; +import { cartAtom } from '../features/cart/atoms/cart.atom'; +import { isAdminAtom } from '../shared/atoms'; +import { + couponsWithStorageAtom, + selectedCouponAtom, +} from '../features/coupon/atoms/coupon.atom'; +import { initialCoupons } from '../features/coupon/hooks/useManageCoupon'; + +// Jotai atom 초기화 헬퍼 컴포넌트 +const AtomReset = () => { + const setNotifications = useSetAtom(notificationAtom); + const setCart = useSetAtom(cartAtom); + const setIsAdmin = useSetAtom(isAdminAtom); + const setSelectedCoupon = useSetAtom(selectedCouponAtom); + const setCoupons = useSetAtom(couponsWithStorageAtom); + + setNotifications([]); + setCart([]); + setIsAdmin(false); + setSelectedCoupon(null); + setCoupons(initialCoupons); + return null; +}; describe('쇼핑몰 앱 통합 테스트', () => { beforeEach(() => { // localStorage 초기화 localStorage.clear(); + // Jotai atom 초기화 + render(); // console 경고 무시 vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'log').mockImplementation(() => {}); }); afterEach(() => { + // Jotai atom 초기화 (테스트 간 상태 격리) + render(); vi.restoreAllMocks(); }); describe('고객 쇼핑 플로우', () => { test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { render(); - + // 검색창에 "프리미엄" 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 디바운스 대기 - await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - }, { timeout: 600 }); - + await waitFor( + () => { + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.'), + ).toBeInTheDocument(); + }, + { timeout: 600 }, + ); + // 검색된 상품을 장바구니에 추가 (첫 번째 버튼 선택) const addButtons = screen.getAllByText('장바구니 담기'); fireEvent.click(addButtons[0]); - + // 알림 메시지 확인 await waitFor(() => { expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); }); - + // 장바구니에 추가됨 확인 (장바구니 섹션에서) const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -46,64 +86,66 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { render(); - + // 상품1을 장바구니에 추가 const product1 = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(product1); - + // 수량을 10개로 증가 (10% 할인 적용) const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 9; i++) { fireEvent.click(plusButton); } - + // 10% 할인 적용 확인 - 15% (대량 구매 시 추가 5% 포함) expect(screen.getByText('-15%')).toBeInTheDocument(); }); test('쿠폰을 선택하고 적용할 수 있다', () => { render(); - + // 상품 추가 const addButton = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(addButton); - + // 쿠폰 선택 const couponSelect = screen.getByRole('combobox'); fireEvent.change(couponSelect, { target: { value: 'AMOUNT5000' } }); - + // 결제 정보에서 할인 금액 확인 const paymentSection = screen.getByText('결제 정보').closest('section'); - const discountRow = within(paymentSection).getByText('할인 금액').closest('div'); + const discountRow = within(paymentSection) + .getByText('할인 금액') + .closest('div'); expect(within(discountRow).getByText('-5,000원')).toBeInTheDocument(); }); test('품절 임박 상품에 경고가 표시된다', async () => { render(); - + // 관리자 모드로 전환 fireEvent.click(screen.getByText('관리자 페이지로')); - + // 상품 수정 const editButton = screen.getAllByText('수정')[0]; fireEvent.click(editButton); - + // 재고를 5개로 변경 const stockInputs = screen.getAllByPlaceholderText('숫자만 입력'); const stockInput = stockInputs[1]; // 재고 입력 필드는 두 번째 fireEvent.change(stockInput, { target: { value: '5' } }); fireEvent.blur(stockInput); - + // 수정 완료 버튼 클릭 const editButtons = screen.getAllByText('수정'); const completeEditButton = editButtons[editButtons.length - 1]; // 마지막 수정 버튼 (완료 버튼) fireEvent.click(completeEditButton); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 품절임박 메시지 확인 - 재고가 5개 이하면 품절임박 표시 await waitFor(() => { expect(screen.getByText('품절임박! 5개 남음')).toBeInTheDocument(); @@ -112,39 +154,39 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('주문을 완료할 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 결제하기 버튼 클릭 const orderButton = screen.getByText(/원 결제하기/); fireEvent.click(orderButton); - + // 주문 완료 알림 확인 expect(screen.getByText(/주문이 완료되었습니다/)).toBeInTheDocument(); - + // 장바구니가 비어있는지 확인 expect(screen.getByText('장바구니가 비어있습니다')).toBeInTheDocument(); }); test('장바구니에서 상품을 삭제할 수 있다', () => { render(); - + // 상품 2개 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 장바구니 섹션 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); - + // 첫 번째 상품 삭제 (X 버튼) - const deleteButtons = within(cartSection).getAllByRole('button').filter( - button => button.querySelector('svg') - ); + const deleteButtons = within(cartSection) + .getAllByRole('button') + .filter((button) => button.querySelector('svg')); fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되고 상품2만 남음 expect(within(cartSection).queryByText('상품1')).not.toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); @@ -152,54 +194,56 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('재고를 초과하여 구매할 수 없다', async () => { render(); - + // 상품1 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 재고(20개) 이상으로 증가 시도 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + // 19번 클릭하여 총 20개로 만듦 for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 한 번 더 클릭 시도 (21개가 되려고 함) fireEvent.click(plusButton); - + // 수량이 20개에서 멈춰있어야 함 expect(within(cartSection).getByText('20')).toBeInTheDocument(); - + // 재고 부족 메시지 확인 await waitFor(() => { - expect(screen.getByText(/재고는.*개까지만 있습니다/)).toBeInTheDocument(); + expect( + screen.getByText(/재고는.*개까지만 있습니다/), + ).toBeInTheDocument(); }); }); test('장바구니에서 수량을 감소시킬 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); const minusButton = within(cartSection).getByText('−'); // U+2212 마이너스 기호 - + // 수량 3개로 증가 fireEvent.click(plusButton); fireEvent.click(plusButton); expect(within(cartSection).getByText('3')).toBeInTheDocument(); - + // 수량 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('2')).toBeInTheDocument(); - + // 1개로 더 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('1')).toBeInTheDocument(); - + // 1개에서 한 번 더 감소하면 장바구니에서 제거될 수도 있음 fireEvent.click(minusButton); // 장바구니가 비었는지 확인 @@ -214,31 +258,31 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('20개 이상 구매 시 최대 할인이 적용된다', async () => { render(); - + // 관리자 모드로 전환하여 상품1의 재고를 늘림 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getAllByText('수정')[0]); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '30' } }); - + const editButtons = screen.getAllByText('수정'); fireEvent.click(editButtons[editButtons.length - 1]); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 상품1을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 20개로 증가 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 25% 할인 적용 확인 (또는 대량 구매 시 30%) await waitFor(() => { const discount25 = screen.queryByText('-25%'); @@ -258,27 +302,27 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('새 상품을 추가할 수 있다', () => { // 새 상품 추가 버튼 클릭 fireEvent.click(screen.getByText('새 상품 추가')); - + // 폼 입력 - 상품명 입력 const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '테스트 상품' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '25000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '50' } }); - + const descLabels = screen.getAllByText('설명'); - const descLabel = descLabels.find(el => el.tagName === 'LABEL'); + const descLabel = descLabels.find((el) => el.tagName === 'LABEL'); const descInput = descLabel.closest('div').querySelector('input'); fireEvent.change(descInput, { target: { value: '테스트 설명' } }); - + // 저장 fireEvent.click(screen.getByText('추가')); - + // 추가된 상품 확인 expect(screen.getByText('테스트 상품')).toBeInTheDocument(); expect(screen.getByText('25,000원')).toBeInTheDocument(); @@ -287,21 +331,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 버튼 클릭 const addCouponButton = screen.getByText('새 쿠폰 추가'); fireEvent.click(addCouponButton); - + // 쿠폰 정보 입력 - fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { target: { value: '테스트 쿠폰' } }); - fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { target: { value: 'TEST2024' } }); - + fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { + target: { value: '테스트 쿠폰' }, + }); + fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { + target: { value: 'TEST2024' }, + }); + const discountInput = screen.getByPlaceholderText('5000'); fireEvent.change(discountInput, { target: { value: '7000' } }); - + // 쿠폰 생성 fireEvent.click(screen.getByText('쿠폰 생성')); - + // 생성된 쿠폰 확인 expect(screen.getByText('테스트 쿠폰')).toBeInTheDocument(); expect(screen.getByText('TEST2024')).toBeInTheDocument(); @@ -311,25 +359,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('상품의 가격 입력 시 숫자만 허용된다', async () => { // 상품 수정 fireEvent.click(screen.getAllByText('수정')[0]); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - + // 문자와 숫자 혼합 입력 시도 - 숫자만 남음 fireEvent.change(priceInput, { target: { value: 'abc123def' } }); expect(priceInput.value).toBe('10000'); // 유효하지 않은 입력은 무시됨 - + // 숫자만 입력 fireEvent.change(priceInput, { target: { value: '123' } }); expect(priceInput.value).toBe('123'); - + // 음수 입력 시도 - regex가 매치되지 않아 값이 변경되지 않음 fireEvent.change(priceInput, { target: { value: '-100' } }); expect(priceInput.value).toBe('123'); // 이전 값 유지 - + // 유효한 음수 입력하기 위해 먼저 1 입력 후 앞에 - 추가는 불가능 // 대신 blur 이벤트를 통해 음수 검증을 테스트 // parseInt()는 실제로 음수를 파싱할 수 있으므로 다른 방법으로 테스트 - + // 공백 입력 시도 fireEvent.change(priceInput, { target: { value: ' ' } }); expect(priceInput.value).toBe('123'); // 유효하지 않은 입력은 무시됨 @@ -338,23 +386,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 할인율 검증이 작동한다', async () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 fireEvent.click(screen.getByText('새 쿠폰 추가')); - + // 퍼센트 타입으로 변경 - 쿠폰 폼 내의 select 찾기 const couponFormSelects = screen.getAllByRole('combobox'); const typeSelect = couponFormSelects[couponFormSelects.length - 1]; // 마지막 select가 타입 선택 fireEvent.change(typeSelect, { target: { value: 'percentage' } }); - + // 100% 초과 할인율 입력 const discountInput = screen.getByPlaceholderText('10'); fireEvent.change(discountInput, { target: { value: '150' } }); fireEvent.blur(discountInput); - + // 에러 메시지 확인 await waitFor(() => { - expect(screen.getByText('할인율은 100%를 초과할 수 없습니다')).toBeInTheDocument(); + expect( + screen.getByText('할인율은 100%를 초과할 수 없습니다'), + ).toBeInTheDocument(); }); }); @@ -362,15 +412,15 @@ describe('쇼핑몰 앱 통합 테스트', () => { // 초기 상품명들 확인 (테이블에서) const productTable = screen.getByRole('table'); expect(within(productTable).getByText('상품1')).toBeInTheDocument(); - + // 삭제 버튼들 찾기 - const deleteButtons = within(productTable).getAllByRole('button').filter( - button => button.textContent === '삭제' - ); - + const deleteButtons = within(productTable) + .getAllByRole('button') + .filter((button) => button.textContent === '삭제'); + // 첫 번째 상품 삭제 fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되었는지 확인 expect(within(productTable).queryByText('상품1')).not.toBeInTheDocument(); expect(within(productTable).getByText('상품2')).toBeInTheDocument(); @@ -379,76 +429,79 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰을 삭제할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 초기 쿠폰들 확인 (h3 제목에서) const couponTitles = screen.getAllByRole('heading', { level: 3 }); - const coupon5000 = couponTitles.find(el => el.textContent === '5000원 할인'); - const coupon10 = couponTitles.find(el => el.textContent === '10% 할인'); + const coupon5000 = couponTitles.find( + (el) => el.textContent === '5000원 할인', + ); + const coupon10 = couponTitles.find((el) => el.textContent === '10% 할인'); expect(coupon5000).toBeInTheDocument(); expect(coupon10).toBeInTheDocument(); - + // 삭제 버튼 찾기 (SVG 아이콘을 포함한 버튼) - const deleteButtons = screen.getAllByRole('button').filter(button => { - return button.querySelector('svg') && - button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path + const deleteButtons = screen.getAllByRole('button').filter((button) => { + return ( + button.querySelector('svg') && + button.querySelector('path[d*="M19 7l"]') + ); // 삭제 아이콘 path }); - + // 첫 번째 쿠폰 삭제 fireEvent.click(deleteButtons[0]); - + // 쿠폰이 삭제되었는지 확인 expect(screen.queryByText('5000원 할인')).not.toBeInTheDocument(); }); - }); describe('로컬스토리지 동기화', () => { test('상품, 장바구니, 쿠폰이 localStorage에 저장된다', () => { render(); - + // 상품을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // localStorage 확인 expect(localStorage.getItem('cart')).toBeTruthy(); expect(JSON.parse(localStorage.getItem('cart'))).toHaveLength(1); - + // 관리자 모드로 전환하여 새 상품 추가 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getByText('새 상품 추가')); - + const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '저장 테스트' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '10000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '10' } }); - + fireEvent.click(screen.getByText('추가')); - + // localStorage에 products가 저장되었는지 확인 expect(localStorage.getItem('products')).toBeTruthy(); const products = JSON.parse(localStorage.getItem('products')); - expect(products.some(p => p.name === '저장 테스트')).toBe(true); + expect(products.some((p) => p.name === '저장 테스트')).toBe(true); }); test('페이지 새로고침 후에도 데이터가 유지된다', () => { const { unmount } = render(); - + // 장바구니에 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 컴포넌트 unmount unmount(); - + // 다시 mount render(); - + // 장바구니 아이템이 유지되는지 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -459,13 +512,13 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('UI 상태 관리', () => { test('할인이 있을 때 할인율이 표시된다', async () => { render(); - + // 상품을 10개 담아서 할인 발생 const addButton = screen.getAllByText('장바구니 담기')[0]; for (let i = 0; i < 10; i++) { fireEvent.click(addButton); } - + // 할인율 표시 확인 - 대량 구매로 15% 할인 await waitFor(() => { expect(screen.getByText('-15%')).toBeInTheDocument(); @@ -474,12 +527,12 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니 아이템 개수가 헤더에 표시된다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 헤더의 장바구니 아이콘 옆 숫자 확인 const cartCount = screen.getByText('3'); expect(cartCount).toBeInTheDocument(); @@ -487,42 +540,57 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('검색을 초기화할 수 있다', async () => { render(); - + // 검색어 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 검색 결과 확인 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.'), + ).toBeInTheDocument(); // 다른 상품들은 보이지 않음 - expect(screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.')).not.toBeInTheDocument(); + expect( + screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.'), + ).not.toBeInTheDocument(); }); - + // 검색어 초기화 fireEvent.change(searchInput, { target: { value: '' } }); - + // 모든 상품이 다시 표시됨 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('다양한 기능을 갖춘 실용적인 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('대용량과 고성능을 자랑하는 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.'), + ).toBeInTheDocument(); + expect( + screen.getByText('다양한 기능을 갖춘 실용적인 상품입니다.'), + ).toBeInTheDocument(); + expect( + screen.getByText('대용량과 고성능을 자랑하는 상품입니다.'), + ).toBeInTheDocument(); }); }); test('알림 메시지가 자동으로 사라진다', async () => { render(); - + // 상품 추가하여 알림 발생 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 알림 메시지 확인 expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); - + // 3초 후 알림이 사라짐 - await waitFor(() => { - expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); - }, { timeout: 4000 }); + await waitFor( + () => { + expect( + screen.queryByText('장바구니에 담았습니다'), + ).not.toBeInTheDocument(); + }, + { timeout: 4000 }, + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/advanced/features/cart/Cart.tsx b/src/advanced/features/cart/Cart.tsx new file mode 100644 index 000000000..fa229e928 --- /dev/null +++ b/src/advanced/features/cart/Cart.tsx @@ -0,0 +1,42 @@ +import { CouponSection } from '../coupon/components/CouponSection'; +import { PaymentSection } from './components/payment/PaymentSection'; +import { CartSection } from './components/cart/CartSection'; +import { CartItem } from '../../../types'; + +export interface CartProps { + cart: { + items: CartItem[]; + totalPrice: { totalBeforeDiscount: number; totalAfterDiscount: number }; + totalItemCount: number; + }; + cartActions: { + updateQuantity: (productId: string, quantity: number) => void; + removeFromCart: (productId: string) => void; + completeOrder: () => void; + }; +} + +export const Cart = ({ cart, cartActions }: CartProps) => { + return ( +
+
+ + + {cart.items.length > 0 && ( + <> + + + + + )} +
+
+ ); +}; diff --git a/src/advanced/features/cart/atoms/cart.atom.ts b/src/advanced/features/cart/atoms/cart.atom.ts new file mode 100644 index 000000000..0d095e0e8 --- /dev/null +++ b/src/advanced/features/cart/atoms/cart.atom.ts @@ -0,0 +1,47 @@ +import { atom } from 'jotai'; +import { CartItem } from '../../../../types'; + +// localStorage에서 초기값 로드 +const loadCartFromStorage = (): CartItem[] => { + if (typeof window === 'undefined') return []; + + const saved = localStorage.getItem('cart'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return []; + } + } + return []; +}; + +// 장바구니 atom +export const cartAtom = atom(loadCartFromStorage()); + +// localStorage 동기화를 위한 atom +export const cartWithStorageAtom = atom( + (get) => get(cartAtom), + (get, set, newCart: CartItem[] | ((prev: CartItem[]) => CartItem[])) => { + const currentCart = get(cartAtom); + const updatedCart = + typeof newCart === 'function' ? newCart(currentCart) : newCart; + + set(cartAtom, updatedCart); + + // localStorage에 저장 + if (typeof window !== 'undefined') { + if (updatedCart.length > 0) { + localStorage.setItem('cart', JSON.stringify(updatedCart)); + } else { + localStorage.removeItem('cart'); + } + } + }, +); + +// 장바구니 총 아이템 개수 (derived atom) +export const totalCartItemCountAtom = atom((get) => { + const cart = get(cartAtom); + return cart.reduce((sum, item) => sum + item.quantity, 0); +}); diff --git a/src/advanced/features/cart/atoms/index.ts b/src/advanced/features/cart/atoms/index.ts new file mode 100644 index 000000000..c2fe1ec38 --- /dev/null +++ b/src/advanced/features/cart/atoms/index.ts @@ -0,0 +1,2 @@ +export * from './cart.atom'; + diff --git a/src/advanced/features/cart/components/cart/CartEmptyList.tsx b/src/advanced/features/cart/components/cart/CartEmptyList.tsx new file mode 100644 index 000000000..633bd11e6 --- /dev/null +++ b/src/advanced/features/cart/components/cart/CartEmptyList.tsx @@ -0,0 +1,20 @@ +export const CartEmptyList = () => { + return ( +
+ + + +

장바구니가 비어있습니다

+
+ ); +}; diff --git a/src/advanced/features/cart/components/cart/CartItem.tsx b/src/advanced/features/cart/components/cart/CartItem.tsx new file mode 100644 index 000000000..158e4d4d0 --- /dev/null +++ b/src/advanced/features/cart/components/cart/CartItem.tsx @@ -0,0 +1,123 @@ +import { CartItem } from '../../../../../types'; + +const DeleteButton = ({ onClick }: { onClick: () => void }) => { + return ( + + ); +}; + +const Button = ({ + onClick, + children, +}: { + onClick: () => void; + children: React.ReactNode; +}) => { + return ( + + ); +}; + +const CartQuantityHandler = ({ + item, + updateQuantity, +}: { + item: CartItem; + updateQuantity: (productId: string, quantity: number) => void; +}) => { + return ( +
+
+ ); +}; + +const CartItemPrice = ({ + hasDiscount, + discountRate, + itemTotal, +}: { + hasDiscount: boolean; + discountRate: number; + itemTotal: number; +}) => { + return ( +
+ {hasDiscount && ( + + -{discountRate}% + + )} +

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

+
+ ); +}; + +export const CartItemComponent = ({ + item, + itemTotal, + removeFromCart, + updateQuantity, + originalPrice, +}: { + item: CartItem; + itemTotal: number; + originalPrice: number; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +}) => { + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + + return ( +
+
+

+ {item.product.name} +

+ removeFromCart(item.product.id)} /> +
+
+ + +
+
+ ); +}; diff --git a/src/advanced/features/cart/components/cart/CartSection.tsx b/src/advanced/features/cart/components/cart/CartSection.tsx new file mode 100644 index 000000000..59c903604 --- /dev/null +++ b/src/advanced/features/cart/components/cart/CartSection.tsx @@ -0,0 +1,43 @@ +import { calculateItemTotal } from '../../service/cart.service'; +import { CartItem } from '../../../../../types'; +import { CartTitle } from './CartTitle'; +import { CartEmptyList } from './CartEmptyList'; +import { CartItemComponent } from './CartItem'; + +export const CartSection = ({ + cart, + removeFromCart, + updateQuantity, +}: { + cart: CartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +}) => { + return ( +
+ + + {cart.length === 0 ? ( + + ) : ( +
+ {cart.map((item) => { + const itemTotal = calculateItemTotal(item, cart); + const originalPrice = item.product.price * item.quantity; + + return ( + + ); + })} +
+ )} +
+ ); +}; diff --git a/src/advanced/features/cart/components/cart/CartTitle.tsx b/src/advanced/features/cart/components/cart/CartTitle.tsx new file mode 100644 index 000000000..438472403 --- /dev/null +++ b/src/advanced/features/cart/components/cart/CartTitle.tsx @@ -0,0 +1,20 @@ +export const CartTitle = () => { + return ( +

+ + + + 장바구니 +

+ ); +}; diff --git a/src/advanced/features/cart/components/payment/PaymentCompleteButton.tsx b/src/advanced/features/cart/components/payment/PaymentCompleteButton.tsx new file mode 100644 index 000000000..b85b3c2ca --- /dev/null +++ b/src/advanced/features/cart/components/payment/PaymentCompleteButton.tsx @@ -0,0 +1,18 @@ +import { formatPrice } from '../../../../shared/utils/priceUtils'; + +export const PaymentCompleteButton = ({ + paymentAmount, + completeOrder, +}: { + paymentAmount: number; + completeOrder: () => void; +}) => { + return ( + + ); +}; diff --git a/src/advanced/features/cart/components/payment/PaymentSection.tsx b/src/advanced/features/cart/components/payment/PaymentSection.tsx new file mode 100644 index 000000000..1362dd679 --- /dev/null +++ b/src/advanced/features/cart/components/payment/PaymentSection.tsx @@ -0,0 +1,70 @@ +import { formatPrice } from '../../../../shared/utils/priceUtils'; +import { PaymentCompleteButton } from './PaymentCompleteButton'; + +const PaymentTotalPrice = ({ + title, + value, +}: { + title: string; + value: number; +}) => { + return ( +
+ {title} + {formatPrice(value)}원 +
+ ); +}; + +const PaymentDiscountedAmount = ({ value }: { value: number }) => { + return ( +
+ 할인 금액 + -{formatPrice(value)}원 +
+ ); +}; + +const PaymentFinalAmount = ({ value }: { value: number }) => { + return ( +
+ 결제 예정 금액 + + {formatPrice(value)}원 + +
+ ); +}; +export const PaymentSection = ({ + cartTotalPrice, + completeOrder, +}: { + cartTotalPrice: { totalBeforeDiscount: number; totalAfterDiscount: number }; + completeOrder: () => void; +}) => { + const { totalBeforeDiscount, totalAfterDiscount } = cartTotalPrice; + + const discountedAmount = totalBeforeDiscount - totalAfterDiscount; + const paymentAmount = totalAfterDiscount; + + return ( +
+

결제 정보

+
+ + {discountedAmount > 0 && ( + + )} + +
+ + +
+

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

+
+
+ ); +}; diff --git a/src/advanced/features/cart/hooks/useCart.ts b/src/advanced/features/cart/hooks/useCart.ts new file mode 100644 index 000000000..200c4d0e3 --- /dev/null +++ b/src/advanced/features/cart/hooks/useCart.ts @@ -0,0 +1,143 @@ +import { useCallback } from 'react'; +import { CartItem, Product } from '../../../../types'; +import { calculateCartTotalPrice } from '../service/cart.service'; +import { applyCouponDiscount } from '../../coupon/services/coupon.service'; +import { ProductWithUI } from '../../product/hooks/useProduct'; +import { useNotification } from '../../notification/hooks/useNotification'; +import { + cartWithStorageAtom, + totalCartItemCountAtom, +} from '../atoms/cart.atom'; +import { useAtom, useAtomValue } from 'jotai'; +import { selectedCouponAtom } from '../../coupon/atoms/coupon.atom'; + +export const useCart = ({ products }: { products: ProductWithUI[] }) => { + const { addNotification } = useNotification(); + const [cart, setCart] = useAtom(cartWithStorageAtom); + const totalItemCount = useAtomValue(totalCartItemCountAtom); + const [selectedCoupon, setSelectedCoupon] = useAtom(selectedCouponAtom); + 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, + ), + ); + }, + [cart, setCart, addNotification], + ); + + const removeFromCart = useCallback((productId: string) => { + setCart((prevCart) => + prevCart.filter((item) => item.product.id !== productId), + ); + }, []); + + const calculateCartTotal = ( + cart: CartItem[], + ): { + totalBeforeDiscount: number; + totalAfterDiscount: number; + } => { + const { totalBeforeDiscount, totalAfterDiscount } = + calculateCartTotalPrice(cart); + + if (selectedCoupon) { + return applyCouponDiscount(selectedCoupon, { + totalBeforeDiscount, + totalAfterDiscount, + }); + } + + return { totalBeforeDiscount, totalAfterDiscount }; + }; + + const cartTotalPrice = calculateCartTotal(cart); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + 'success', + ); + setCart([]); + setSelectedCoupon(null); + }, [addNotification]); + + const getRemainingStock = (cart: CartItem[], product: Product): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; + }; + + const addToCart = useCallback( + ( + product: ProductWithUI, + onSuccess?: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void, + ) => { + const remainingStock = getRemainingStock(cart, product); + if (remainingStock <= 0) { + onSuccess?.('재고가 부족합니다!', '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) { + onSuccess?.(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item, + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + onSuccess?.('장바구니에 담았습니다', 'success'); + }, + [cart, getRemainingStock], + ); + + return { + updateQuantity, + removeFromCart, + cartTotalPrice, + completeOrder, + cart, + setCart, + totalItemCount, + getRemainingStock, + addToCart, + }; +}; diff --git a/src/advanced/features/cart/service/cart.service.ts b/src/advanced/features/cart/service/cart.service.ts new file mode 100644 index 000000000..6d5444bb6 --- /dev/null +++ b/src/advanced/features/cart/service/cart.service.ts @@ -0,0 +1,62 @@ +import { CartItem, Coupon } from '../../../../types'; + +export const checkCouponAvailability = ( + coupon: Coupon, + currentCartTotal: number, +) => { + if (currentCartTotal < 10000 && coupon.discountType === 'percentage') { + return false; + } + return true; +}; + +const checkHasBulkPurchase = (cart: CartItem[]): boolean => { + return cart.some((cartItem) => cartItem.quantity >= 10); +}; + +// 장바구니 자체 discount 적용 +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 = checkHasBulkPurchase(cart); + 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 calculateCartTotalPrice = ( + cart: CartItem[], +): { + 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); + }); + return { totalBeforeDiscount, totalAfterDiscount }; +}; diff --git a/src/advanced/features/coupon/admin/AddNewCouponCard.tsx b/src/advanced/features/coupon/admin/AddNewCouponCard.tsx new file mode 100644 index 000000000..84d14beb0 --- /dev/null +++ b/src/advanced/features/coupon/admin/AddNewCouponCard.tsx @@ -0,0 +1,29 @@ +export const AddNewCouponCard = ({ + toggleShowCouponForm, +}: { + toggleShowCouponForm: () => void; +}) => { + return ( +
+ +
+ ); +}; diff --git a/src/advanced/features/coupon/admin/AdminCouponList.tsx b/src/advanced/features/coupon/admin/AdminCouponList.tsx new file mode 100644 index 000000000..1630f1be7 --- /dev/null +++ b/src/advanced/features/coupon/admin/AdminCouponList.tsx @@ -0,0 +1,58 @@ +import { useManageCoupon } from '../hooks/useManageCoupon'; +import { Coupon } from '../../../../types'; +import { AddNewCouponCard } from './AddNewCouponCard'; +import { CouponAddForm } from './CouponAddForm'; +import { CouponCard } from './CouponCard'; +import { useNotification } from '../../notification/hooks/useNotification'; + +export interface CouponForm { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} +export const AdminCouponList = () => { + const { addNotification } = useNotification(); + const { + coupons, + addCoupon, + toggleShowCouponForm, + handleDeleteCoupon, + showCouponForm, + } = useManageCoupon(); + + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon: Coupon) => ( + + handleDeleteCoupon(couponCode, { onSuccess: addNotification }) + } + /> + ))} + +
+ + {showCouponForm && ( + { + addCoupon(form as Coupon); + toggleShowCouponForm(); + }} + onCancel={toggleShowCouponForm} + onValidationError={(message: string) => + addNotification(message, 'error') + } + /> + )} +
+
+ ); +}; diff --git a/src/advanced/features/coupon/admin/CouponAddForm.tsx b/src/advanced/features/coupon/admin/CouponAddForm.tsx new file mode 100644 index 000000000..8582b1b5b --- /dev/null +++ b/src/advanced/features/coupon/admin/CouponAddForm.tsx @@ -0,0 +1,107 @@ +import { Label, Input, Select } from '../../../shared/component/ui'; +import { useCouponForm } from '../hooks/useCouponForm'; +import { CouponForm } from './AdminCouponList'; + +interface CouponAddFormProps { + onSubmit: (form: CouponForm) => void; + onCancel: () => void; + onValidationError?: (message: string) => void; +} + +export const CouponAddForm = ({ + onSubmit, + onCancel, + onValidationError, +}: CouponAddFormProps) => { + const { form, updateField, handleSubmit, handleDiscountValueBlur } = + useCouponForm({ + onSubmit, + onValidationError, + }); + + return ( +
+
+

새 쿠폰 생성

+
+
+ + updateField('name', e.target.value)} + placeholder="신규 가입 쿠폰" + required + /> +
+
+ + + updateField('code', e.target.value.toUpperCase()) + } + className="font-mono" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + updateField( + 'discountValue', + value === '' ? 0 : parseInt(value), + ); + } + }} + onBlur={() => { + handleDiscountValueBlur(form.discountValue); + }} + placeholder={form.discountType === 'amount' ? '5000' : '10'} + required + /> +
+
+
+ + +
+
+
+ ); +}; diff --git a/src/advanced/features/coupon/admin/CouponCard.tsx b/src/advanced/features/coupon/admin/CouponCard.tsx new file mode 100644 index 000000000..5b4939d95 --- /dev/null +++ b/src/advanced/features/coupon/admin/CouponCard.tsx @@ -0,0 +1,48 @@ +import { Coupon } from '../../../../types'; + +export const CouponCard = ({ + coupon, + handleDeleteCoupon, +}: { + coupon: Coupon; + handleDeleteCoupon: (couponCode: string) => void; +}) => { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +}; diff --git a/src/advanced/features/coupon/atoms/coupon.atom.ts b/src/advanced/features/coupon/atoms/coupon.atom.ts new file mode 100644 index 000000000..45704978d --- /dev/null +++ b/src/advanced/features/coupon/atoms/coupon.atom.ts @@ -0,0 +1,57 @@ +import { atom } from 'jotai'; +import { Coupon } from '../../../../types'; + +const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; + +// localStorage에서 초기값 로드 +const loadCouponsFromStorage = (): Coupon[] => { + if (typeof window === 'undefined') return initialCoupons; + + const saved = localStorage.getItem('coupons'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialCoupons; + } + } + return initialCoupons; +}; + +// 쿠폰 목록 atom +export const couponsAtom = atom(loadCouponsFromStorage()); + +// localStorage 동기화를 위한 atom +export const couponsWithStorageAtom = atom( + (get) => get(couponsAtom), + (get, set, newCoupons: Coupon[] | ((prev: Coupon[]) => Coupon[])) => { + const currentCoupons = get(couponsAtom); + const updatedCoupons = + typeof newCoupons === 'function' + ? newCoupons(currentCoupons) + : newCoupons; + + set(couponsAtom, updatedCoupons); + + // localStorage에 저장 + if (typeof window !== 'undefined') { + localStorage.setItem('coupons', JSON.stringify(updatedCoupons)); + } + }, +); + +// 선택된 쿠폰 atom +export const selectedCouponAtom = atom(null); diff --git a/src/advanced/features/coupon/atoms/index.ts b/src/advanced/features/coupon/atoms/index.ts new file mode 100644 index 000000000..8c18b9460 --- /dev/null +++ b/src/advanced/features/coupon/atoms/index.ts @@ -0,0 +1,2 @@ +export * from './coupon.atom'; + diff --git a/src/advanced/features/coupon/components/CouponSection.tsx b/src/advanced/features/coupon/components/CouponSection.tsx new file mode 100644 index 000000000..92edb6ac4 --- /dev/null +++ b/src/advanced/features/coupon/components/CouponSection.tsx @@ -0,0 +1,37 @@ +import { useAtom, useAtomValue } from 'jotai'; +import { + couponsWithStorageAtom, + selectedCouponAtom, +} from '../atoms/coupon.atom'; +import { CouponSelector } from './CouponSelector'; +import { useManageCoupon } from '../hooks/useManageCoupon'; + +export const CouponSection = ({ + cartTotalPrice, +}: { + cartTotalPrice: { totalBeforeDiscount: number; totalAfterDiscount: number }; +}) => { + const { applyCoupon } = useManageCoupon(); + + const [selectedCoupon, setSelectedCoupon] = useAtom(selectedCouponAtom); + const coupons = useAtomValue(couponsWithStorageAtom); + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +}; diff --git a/src/advanced/features/coupon/components/CouponSelector.tsx b/src/advanced/features/coupon/components/CouponSelector.tsx new file mode 100644 index 000000000..1b37cd866 --- /dev/null +++ b/src/advanced/features/coupon/components/CouponSelector.tsx @@ -0,0 +1,79 @@ +import { useCallback } from 'react'; +import { Coupon } from '../../../../types'; +import { checkCouponAvailability } from '../services/coupon.service'; +import { formatPrice } from '../../../shared/utils/priceUtils'; +import { useNotification } from '../../notification/hooks/useNotification'; +import { Select } from '../../../shared/component/ui'; + +export const CouponSelector = ({ + selectedCoupon, + setSelectedCoupon, + coupons, + cartTotalPrice, + applyCoupon, +}: { + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + coupons: Coupon[]; + cartTotalPrice: { totalBeforeDiscount: number; totalAfterDiscount: number }; + applyCoupon: ( + coupon: Coupon, + { onSuccess }: { onSuccess?: () => void }, + ) => void; +}) => { + const { addNotification } = useNotification(); + + const onCouponChange = useCallback( + (e: React.ChangeEvent) => { + const coupon = coupons.find((c) => c.code === e.target.value); + + if (!coupon) return; + + const isCouponAvailable = checkCouponAvailability( + coupon, + cartTotalPrice.totalAfterDiscount, + ); + + if (!isCouponAvailable) { + addNotification( + 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + 'error', + ); + setSelectedCoupon(null); + return; + } + + applyCoupon(coupon, { + onSuccess: () => { + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, + }); + }, + [ + coupons, + cartTotalPrice.totalAfterDiscount, + addNotification, + setSelectedCoupon, + applyCoupon, + ], + ); + + return ( + + ); +}; diff --git a/src/advanced/features/coupon/hooks/useCouponForm.ts b/src/advanced/features/coupon/hooks/useCouponForm.ts new file mode 100644 index 000000000..f1086bc1a --- /dev/null +++ b/src/advanced/features/coupon/hooks/useCouponForm.ts @@ -0,0 +1,76 @@ +import { useCallback, useState } from 'react'; +import { CouponForm } from '../admin/AdminCouponList'; +import { + validateDiscountValue, + validateCouponForm, +} from '../../../shared/utils/validators'; + +const initialForm: CouponForm = { + name: '', + code: '', + discountType: 'amount', + discountValue: 0, +}; + +interface UseCouponFormOptions { + onSubmit: (form: CouponForm) => void; + onValidationError?: (message: string) => void; +} + +export const useCouponForm = ({ + onSubmit, + onValidationError, +}: UseCouponFormOptions) => { + const [form, setForm] = useState(initialForm); + + const updateField = useCallback( + (field: K, value: CouponForm[K]) => { + setForm((prev) => ({ ...prev, [field]: value })); + }, + [], + ); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + const error = validateCouponForm(form); + if (error) { + onValidationError?.(error.message); + return; + } + + onSubmit(form); + setForm(initialForm); + }, + [form, onSubmit, onValidationError], + ); + + const handleDiscountValueBlur = useCallback( + (value: number) => { + const validation = validateDiscountValue(value, form.discountType); + + if (!validation.isValid) { + if (validation.correctedValue !== undefined) { + updateField('discountValue', validation.correctedValue); + } + if (validation.message) { + onValidationError?.(validation.message); + } + } + }, + [form.discountType, updateField, onValidationError], + ); + + const reset = useCallback(() => { + setForm(initialForm); + }, []); + + return { + form, + updateField, + handleSubmit, + handleDiscountValueBlur, + reset, + }; +}; diff --git a/src/advanced/features/coupon/hooks/useManageCoupon.ts b/src/advanced/features/coupon/hooks/useManageCoupon.ts new file mode 100644 index 000000000..9cfeece70 --- /dev/null +++ b/src/advanced/features/coupon/hooks/useManageCoupon.ts @@ -0,0 +1,84 @@ +import { useCallback, useState } from 'react'; +import { useNotification } from '../../notification/hooks/useNotification'; +import { + couponsWithStorageAtom, + selectedCouponAtom, +} from '../atoms/coupon.atom'; +import { useAtom } from 'jotai'; +import { Coupon } from '../../../../types'; + +export const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; + +export const useManageCoupon = () => { + const { addNotification } = useNotification(); + const [coupons, setCoupons] = useAtom(couponsWithStorageAtom); + const [selectedCoupon, setSelectedCoupon] = useAtom(selectedCouponAtom); + const [showCouponForm, setShowCouponForm] = useState(false); + + 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 toggleShowCouponForm = () => { + setShowCouponForm((prev) => !prev); + }; + + const deleteCoupon = useCallback((couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + }, []); + + const applyCoupon = useCallback( + (coupon: Coupon, { onSuccess }: { onSuccess?: () => void }) => { + setSelectedCoupon(coupon); + onSuccess?.(); + }, + [setSelectedCoupon], + ); + + const handleDeleteCoupon = ( + couponCode: string, + { + onSuccess, + }: { onSuccess?: (message: string, type: 'success' | 'error') => void }, + ) => { + deleteCoupon(couponCode); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + + onSuccess?.('쿠폰이 삭제되었습니다.', 'success'); + }; + + return { + coupons, + selectedCoupon, + setSelectedCoupon, + applyCoupon, + addCoupon, + toggleShowCouponForm, + handleDeleteCoupon, + showCouponForm, + }; +}; diff --git a/src/advanced/features/coupon/services/coupon.service.ts b/src/advanced/features/coupon/services/coupon.service.ts new file mode 100644 index 000000000..97904568d --- /dev/null +++ b/src/advanced/features/coupon/services/coupon.service.ts @@ -0,0 +1,38 @@ +import { Coupon } from '../../../../types'; + +export const checkCouponAvailability = ( + coupon: Coupon, + currentCartTotal: number, +) => { + if (currentCartTotal < 10000 && coupon.discountType === 'percentage') { + return false; + } + return true; +}; + +export const applyCouponDiscount = ( + selectedCoupon: Coupon, + { + totalBeforeDiscount, + totalAfterDiscount, + }: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }, +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + if (selectedCoupon.discountType === 'amount') { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue, + ); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100), + ); + } + + return { totalBeforeDiscount, totalAfterDiscount }; +}; diff --git a/src/advanced/features/notification/atoms/index.ts b/src/advanced/features/notification/atoms/index.ts new file mode 100644 index 000000000..6e85b3e77 --- /dev/null +++ b/src/advanced/features/notification/atoms/index.ts @@ -0,0 +1 @@ +export * from './notification.atom'; diff --git a/src/advanced/features/notification/atoms/notification.atom.ts b/src/advanced/features/notification/atoms/notification.atom.ts new file mode 100644 index 000000000..892db72cc --- /dev/null +++ b/src/advanced/features/notification/atoms/notification.atom.ts @@ -0,0 +1,5 @@ +import { atom } from 'jotai'; +import { NotificationType } from '../hooks/useNotification'; + +// 장바구니 atom +export const notificationAtom = atom([]); diff --git a/src/advanced/features/notification/components/NotificationToast.tsx b/src/advanced/features/notification/components/NotificationToast.tsx new file mode 100644 index 000000000..349df7782 --- /dev/null +++ b/src/advanced/features/notification/components/NotificationToast.tsx @@ -0,0 +1,42 @@ +import { NotificationType } from '../hooks/useNotification'; + +export const NotificationToast = ({ + notification, + closeNotification, +}: { + notification: NotificationType; + closeNotification: (id: string) => void; +}) => { + return ( +
+ {notification.message} + +
+ ); +}; diff --git a/src/advanced/features/notification/hooks/useNotification.ts b/src/advanced/features/notification/hooks/useNotification.ts new file mode 100644 index 000000000..162761970 --- /dev/null +++ b/src/advanced/features/notification/hooks/useNotification.ts @@ -0,0 +1,31 @@ +import { useCallback } from 'react'; +import { notificationAtom } from '../atoms'; +import { useAtom } from 'jotai'; + +export interface NotificationType { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export const useNotification = () => { + const [notifications, setNotifications] = useAtom(notificationAtom); + + 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 closeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((noti) => noti.id !== id)); + }, []); + + return { addNotification, notifications, closeNotification }; +}; diff --git a/src/advanced/features/notification/index.tsx b/src/advanced/features/notification/index.tsx new file mode 100644 index 000000000..7d5fcd08f --- /dev/null +++ b/src/advanced/features/notification/index.tsx @@ -0,0 +1,21 @@ +import { NotificationToast } from './components/NotificationToast'; +import { useNotification } from './hooks/useNotification'; + +export const Notification = () => { + const { notifications, closeNotification } = useNotification(); + return ( + <> + {notifications.length > 0 && ( +
+ {notifications.map((notif) => ( + + ))} +
+ )} + + ); +}; diff --git a/src/advanced/features/product/ProductItem.tsx b/src/advanced/features/product/ProductItem.tsx new file mode 100644 index 000000000..8c417a46c --- /dev/null +++ b/src/advanced/features/product/ProductItem.tsx @@ -0,0 +1,162 @@ +import { formatPrice } from '../../shared/utils/priceUtils'; +import { useNotification } from '../notification/hooks/useNotification'; +import { ProductWithUI } from './hooks/useProduct'; + +const ProductImage = () => { + return ( +
+ + + +
+ ); +}; + +const ProductLabel = ({ title, color }: { title: string; color: string }) => { + return ( + + {title} + + ); +}; + +const ProductInfo = ({ + product, + remainingStock, +}: { + product: ProductWithUI; + remainingStock: number; +}) => { + return ( + <> +

{product.name}

+ {product.description && ( +

+ {product.description} +

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

+ {formatPrice(product.price)} +

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

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

+ )} +
+ + {/* 재고 상태 */} + + + ); +}; +const StockStatus = ({ remainingStock }: { remainingStock: number }) => { + return ( +
+ {remainingStock <= 5 && remainingStock > 0 && ( +

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

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

재고 {remainingStock}개

+ )} +
+ ); +}; + +const CartButton = ({ + product, + remainingStock, + addToCart, +}: { + product: ProductWithUI; + remainingStock: number; + addToCart: ( + product: ProductWithUI, + onSuccess?: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void, + ) => void; +}) => { + const { addNotification } = useNotification(); + return ( + + ); +}; + +export const ProductItem = ({ + product, + remainingStock, + addToCart, +}: { + product: ProductWithUI; + remainingStock: number; + addToCart: ( + product: ProductWithUI, + onSuccess?: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void, + ) => void; +}) => { + return ( +
+
+ + {product.isRecommended && } + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ +
+ {/* 상품 정보 */} + + + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/advanced/features/product/ProductList.tsx b/src/advanced/features/product/ProductList.tsx new file mode 100644 index 000000000..f0c34913b --- /dev/null +++ b/src/advanced/features/product/ProductList.tsx @@ -0,0 +1,92 @@ +import { Product } from '../../../types'; +import { ProductItem } from './ProductItem'; + +export interface ProductListProps { + products: Product[]; + search: { + debouncedSearchTerm: string; + }; + cartActions: { + addToCart: ( + product: Product, + onSuccess?: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void, + ) => void; + getRemainingStock: (product: Product) => number; + }; +} + +const ProductListHeader = ({ + productsTotalCount, +}: { + productsTotalCount: number; +}) => { + return ( +
+

전체 상품

+
+ 총 {productsTotalCount}개 상품 +
+
+ ); +}; + +const ProductListEmpty = ({ + debouncedSearchTerm, +}: { + debouncedSearchTerm: string; +}) => { + return ( +
+

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

+
+ ); +}; + +export const ProductList = ({ + products, + search, + cartActions, +}: ProductListProps) => { + const filteredProducts = search.debouncedSearchTerm + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(search.debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(search.debouncedSearchTerm.toLowerCase())), + ) + : products; + + return ( +
+ +
+ {filteredProducts.length === 0 ? ( + + ) : ( +
+ {filteredProducts.map((product) => { + const remainingStock = cartActions.getRemainingStock(product); + return ( + + ); + })} +
+ )} +
+
+ ); +}; diff --git a/src/advanced/features/product/admin/AddNewProductButton.tsx b/src/advanced/features/product/admin/AddNewProductButton.tsx new file mode 100644 index 000000000..ffcd38edf --- /dev/null +++ b/src/advanced/features/product/admin/AddNewProductButton.tsx @@ -0,0 +1,10 @@ +export const AddNewProductButton = ({ onClick }: { onClick: () => void }) => { + return ( + + ); +}; diff --git a/src/advanced/features/product/admin/AdminProductList.tsx b/src/advanced/features/product/admin/AdminProductList.tsx new file mode 100644 index 000000000..043273786 --- /dev/null +++ b/src/advanced/features/product/admin/AdminProductList.tsx @@ -0,0 +1,76 @@ +import { Dispatch, SetStateAction } from 'react'; +import { ProductWithUI } from '../hooks/useProduct'; +import { AddNewProductButton } from './AddNewProductButton'; +import { ProductAddForm } from './ProductAddForm'; +import { AdminProductTable } from './AdminProductTable'; +import { useManageProducts } from './hooks/useManageProducts'; +import { useNotification } from '../../notification/hooks/useNotification'; + +export interface ProductForm { + name: string; + price: number; + stock: number; + description: string; + discounts: Array<{ quantity: number; rate: number }>; +} + +export const AdminProductList = ({ + products, + setProducts, +}: { + products: ProductWithUI[]; + setProducts: Dispatch>; +}) => { + const { + handleAddNewProduct, + startEditProduct, + deleteProduct, + editingProduct, + getProductFormData, + addProduct, + updateProduct, + setShowProductForm, + setEditingProduct, + showProductForm, + } = useManageProducts({ products, setProducts }); + + const { addNotification } = useNotification(); + return ( +
+
+
+

상품 목록

+ +
+
+ + + {showProductForm && ( + { + if (editingProduct && editingProduct !== 'new') { + updateProduct(editingProduct, form); + } else { + addProduct(form); + } + setEditingProduct(null); + setShowProductForm(false); + }} + onCancel={() => { + setEditingProduct(null); + setShowProductForm(false); + }} + onValidationError={(message) => addNotification(message, 'error')} + /> + )} +
+ ); +}; diff --git a/src/advanced/features/product/admin/AdminProductTable.tsx b/src/advanced/features/product/admin/AdminProductTable.tsx new file mode 100644 index 000000000..217f56c9e --- /dev/null +++ b/src/advanced/features/product/admin/AdminProductTable.tsx @@ -0,0 +1,100 @@ +import { formatPrice } from '../../../shared/utils/priceUtils'; +import { ProductWithUI } from '../hooks/useProduct'; + +export const AdminProductTable = ({ + products, + startEditProduct, + deleteProduct, +}: { + products: ProductWithUI[]; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +}) => { + console.log('AdminProductTable'); + return ( +
+ + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ 상품명2 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+ ); +}; + +const ProductTableRow = ({ + product, + startEditProduct, + deleteProduct, +}: { + product: ProductWithUI; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +}) => { + return ( + + + {product.name} + + + {`${formatPrice(product.price)}원`} + + + 10 + ? 'bg-green-100 text-green-800' + : product.stock > 0 + ? 'bg-yellow-100 text-yellow-800' + : 'bg-red-100 text-red-800' + }`} + > + {product.stock}개 + + + + {product.description || '-'} + + + + + + + ); +}; diff --git a/src/advanced/features/product/admin/ProductAddForm.tsx b/src/advanced/features/product/admin/ProductAddForm.tsx new file mode 100644 index 000000000..b9cf7e46e --- /dev/null +++ b/src/advanced/features/product/admin/ProductAddForm.tsx @@ -0,0 +1,186 @@ +import { Label, Input } from '../../../shared/component/ui'; +import { useProductForm } from './hooks/useProductForm'; +import { ProductForm } from './AdminProductList'; + +interface ProductAddFormProps { + editingProduct: string | null; + initialData?: ProductForm; + onSubmit: (form: ProductForm) => void; + onCancel: () => void; + onValidationError?: (message: string) => void; +} + +export const ProductAddForm = ({ + editingProduct, + initialData, + onSubmit, + onCancel, + onValidationError, +}: ProductAddFormProps) => { + const { + form, + updateField, + handleSubmit, + handlePriceBlur, + handleStockBlur, + updateDiscount, + addDiscount, + removeDiscount, + } = useProductForm({ + initialData, + onSubmit, + onValidationError, + }); + return ( +
+
+

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

+
+
+ + updateField('name', e.target.value)} + required + /> +
+
+ + updateField('description', e.target.value)} + /> +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + updateField('price', value === '' ? 0 : parseInt(value)); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + handlePriceBlur(value); + }} + placeholder="숫자만 입력" + required + /> +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + updateField('stock', value === '' ? 0 : parseInt(value)); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + handleStockBlur(value); + }} + placeholder="숫자만 입력" + required + /> +
+
+
+ +
+ {form.discounts.map((discount, index) => ( +
+ { + updateDiscount(index, { + quantity: parseInt(e.target.value) || 0, + }); + }} + className="w-20 px-2 py-1" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + updateDiscount(index, { + rate: (parseInt(e.target.value) || 0) / 100, + }); + }} + className="w-16 px-2 py-1" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/advanced/features/product/admin/hooks/useManageProducts.ts b/src/advanced/features/product/admin/hooks/useManageProducts.ts new file mode 100644 index 000000000..91c6c43fa --- /dev/null +++ b/src/advanced/features/product/admin/hooks/useManageProducts.ts @@ -0,0 +1,87 @@ +import { useState, useCallback, SetStateAction, Dispatch } from 'react'; +import { ProductWithUI } from '../../hooks/useProduct'; +import { ProductForm } from '../AdminProductList'; +import { useNotification } from '../../../notification/hooks/useNotification'; + +export const useManageProducts = ({ + products, + setProducts, +}: { + products: ProductWithUI[]; + setProducts: Dispatch>; +}) => { + const { addNotification } = useNotification(); + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + + const startEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setShowProductForm(true); + }; + + const handleAddNewProduct = () => { + setEditingProduct('new'); + setShowProductForm(true); + }; + + const getProductFormData = useCallback( + (productId: string): ProductForm | undefined => { + if (productId === 'new') return undefined; + const product = products.find((p) => p.id === productId); + if (!product) return undefined; + return { + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], + }; + }, + [products], + ); + + const addProduct = useCallback( + (form: ProductForm) => { + const product: ProductWithUI = { + ...form, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [setProducts, addNotification], + ); + + const updateProduct = useCallback( + (productId: string, form: ProductForm) => { + setProducts((prev) => + prev.map((product) => + product.id === productId ? { ...product, ...form } : product, + ), + ); + addNotification('상품이 수정되었습니다.', 'success'); + }, + [setProducts, addNotification], + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification('상품이 삭제되었습니다.', 'success'); + }, + [setProducts, addNotification], + ); + + return { + handleAddNewProduct, + startEditProduct, + deleteProduct, + editingProduct, + getProductFormData, + addProduct, + updateProduct, + setShowProductForm, + setEditingProduct, + showProductForm, + }; +}; diff --git a/src/advanced/features/product/admin/hooks/useProductForm.ts b/src/advanced/features/product/admin/hooks/useProductForm.ts new file mode 100644 index 000000000..63664157e --- /dev/null +++ b/src/advanced/features/product/admin/hooks/useProductForm.ts @@ -0,0 +1,137 @@ +import { useCallback, useState, useEffect } from 'react'; +import { ProductForm } from '../AdminProductList'; +import { + validateProductForm, + validatePrice, + validateStock, +} from '../../../../shared/utils/validators'; + +const initialForm: ProductForm = { + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], +}; + +interface UseProductFormOptions { + initialData?: ProductForm; + onSubmit: (form: ProductForm) => void; + onValidationError?: (message: string) => void; +} + +export const useProductForm = ({ + initialData, + onSubmit, + onValidationError, +}: UseProductFormOptions) => { + const [form, setForm] = useState(initialData || initialForm); + + useEffect(() => { + if (initialData) { + setForm(initialData); + } else { + setForm(initialForm); + } + }, [initialData]); + + const updateField = useCallback( + (field: K, value: ProductForm[K]) => { + setForm((prev) => ({ ...prev, [field]: value })); + }, + [], + ); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + const error = validateProductForm(form); + if (error) { + onValidationError?.(error.message); + return; + } + + onSubmit(form); + }, + [form, onSubmit, onValidationError], + ); + + const handlePriceBlur = useCallback( + (value: number) => { + const validation = validatePrice(value); + + if (!validation.isValid) { + if (validation.correctedValue !== undefined) { + updateField('price', validation.correctedValue); + } + if (validation.message) { + onValidationError?.(validation.message); + } + } + }, + [updateField, onValidationError], + ); + + const handleStockBlur = useCallback( + (value: number) => { + const validation = validateStock(value); + + if (!validation.isValid) { + if (validation.correctedValue !== undefined) { + updateField('stock', validation.correctedValue); + } + if (validation.message) { + onValidationError?.(validation.message); + } + } + }, + [updateField, onValidationError], + ); + + const updateDiscount = useCallback( + (index: number, updates: Partial) => { + setForm((prev) => { + const newDiscounts = [...prev.discounts]; + newDiscounts[index] = { ...newDiscounts[index], ...updates }; + return { ...prev, discounts: newDiscounts }; + }); + }, + [], + ); + + const addDiscount = useCallback(() => { + setForm((prev) => ({ + ...prev, + discounts: [...prev.discounts, { quantity: 10, rate: 0.1 }], + })); + }, []); + + const removeDiscount = useCallback((index: number) => { + setForm((prev) => ({ + ...prev, + discounts: prev.discounts.filter((_, i) => i !== index), + })); + }, []); + + const reset = useCallback(() => { + setForm(initialForm); + }, []); + + const setFormData = useCallback((data: ProductForm) => { + setForm(data); + }, []); + + return { + form, + updateField, + handleSubmit, + handlePriceBlur, + handleStockBlur, + updateDiscount, + addDiscount, + removeDiscount, + reset, + setFormData, + }; +}; diff --git a/src/advanced/features/product/atoms/index.ts b/src/advanced/features/product/atoms/index.ts new file mode 100644 index 000000000..d39972ee9 --- /dev/null +++ b/src/advanced/features/product/atoms/index.ts @@ -0,0 +1,2 @@ +export * from './product.atom'; + diff --git a/src/advanced/features/product/atoms/product.atom.ts b/src/advanced/features/product/atoms/product.atom.ts new file mode 100644 index 000000000..6ffbf6274 --- /dev/null +++ b/src/advanced/features/product/atoms/product.atom.ts @@ -0,0 +1,79 @@ +import { atom } from 'jotai'; +import { ProductWithUI } from '../hooks/useProduct'; + +// 초기 데이터 +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: '대용량과 고성능을 자랑하는 상품입니다.', + }, +]; + +// localStorage에서 초기값 로드 +const loadProductsFromStorage = (): ProductWithUI[] => { + if (typeof window === 'undefined') return initialProducts; + + const saved = localStorage.getItem('products'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialProducts; + } + } + return initialProducts; +}; + +// 상품 목록 atom +export const productsAtom = atom(loadProductsFromStorage()); + +// localStorage 동기화를 위한 atom +export const productsWithStorageAtom = atom( + (get) => get(productsAtom), + ( + get, + set, + newProducts: ProductWithUI[] | ((prev: ProductWithUI[]) => ProductWithUI[]), + ) => { + const currentProducts = get(productsAtom); + const updatedProducts = + typeof newProducts === 'function' + ? newProducts(currentProducts) + : newProducts; + + set(productsAtom, updatedProducts); + + // localStorage에 저장 + if (typeof window !== 'undefined') { + localStorage.setItem('products', JSON.stringify(updatedProducts)); + } + }, +); + diff --git a/src/advanced/features/product/hooks/useProduct.ts b/src/advanced/features/product/hooks/useProduct.ts new file mode 100644 index 000000000..ed1db3a86 --- /dev/null +++ b/src/advanced/features/product/hooks/useProduct.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { Product } from '../../../../types'; + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +// 초기 데이터 +const initialProducts: ProductWithUI[] = [ + { + id: 'p1', + name: '상품1', + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: '최고급 품질의 프리미엄 상품입니다.', + }, + { + id: 'p2', + name: '상품2', + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: '다양한 기능을 갖춘 실용적인 상품입니다.', + isRecommended: true, + }, + { + id: 'p3', + name: '상품3', + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: '대용량과 고성능을 자랑하는 상품입니다.', + }, +]; + +export const useProduct = () => { + const [products, setProducts] = useState(() => { + const saved = localStorage.getItem('products'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialProducts; + } + } + return initialProducts; + }); + + useEffect(() => { + localStorage.setItem('products', JSON.stringify(products)); + }, [products]); + + return { products, setProducts }; +}; diff --git a/src/advanced/features/product/hooks/useSearchProduct.ts b/src/advanced/features/product/hooks/useSearchProduct.ts new file mode 100644 index 000000000..c58257ddb --- /dev/null +++ b/src/advanced/features/product/hooks/useSearchProduct.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; + +export const useSearchProduct = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + return () => clearTimeout(timer); + }, [searchTerm]); + + return { + searchTerm, + setSearchTerm, + debouncedSearchTerm, + setDebouncedSearchTerm, + }; +}; diff --git a/src/advanced/pages/admin/AdminPage.tsx b/src/advanced/pages/admin/AdminPage.tsx new file mode 100644 index 000000000..c9e3d68f1 --- /dev/null +++ b/src/advanced/pages/admin/AdminPage.tsx @@ -0,0 +1,16 @@ +import { AdminDashboard } from './components/AdminDashboard'; +import { Header } from '../../shared/component/Header'; +import { useProduct } from '../../features/product/hooks/useProduct'; + +export const AdminPage = () => { + const { products, setProducts } = useProduct(); + + return ( + <> +
+
+ +
+ + ); +}; diff --git a/src/advanced/pages/admin/components/AdminDashboard.tsx b/src/advanced/pages/admin/components/AdminDashboard.tsx new file mode 100644 index 000000000..e9ad1741d --- /dev/null +++ b/src/advanced/pages/admin/components/AdminDashboard.tsx @@ -0,0 +1,48 @@ +import { Dispatch, SetStateAction, useState } from 'react'; +import { ProductWithUI } from '../../../features/product/hooks/useProduct'; +import { AdminTitle } from './AdminTitle'; +import { AdminViewTab } from './AdminViewTab'; +import { AdminProductList } from '../../../features/product/admin/AdminProductList'; +import { AdminCouponList } from '../../../features/coupon/admin/AdminCouponList'; + +export const AdminDashboard = ({ + products, + setProducts, +}: { + products: ProductWithUI[]; + setProducts: Dispatch>; +}) => { + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>( + 'products', + ); + + const onChangeActiveTab = (tab: 'products' | 'coupons') => { + switch (tab) { + case 'products': + setActiveTab('products'); + break; + case 'coupons': + setActiveTab('coupons'); + break; + } + }; + return ( +
+ + + + + {activeTab === 'products' ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/src/advanced/pages/admin/components/AdminTitle.tsx b/src/advanced/pages/admin/components/AdminTitle.tsx new file mode 100644 index 000000000..efa81be6e --- /dev/null +++ b/src/advanced/pages/admin/components/AdminTitle.tsx @@ -0,0 +1,14 @@ +export const AdminTitle = ({ + title, + description, +}: { + title: string; + description: string; +}) => { + return ( +
+

{title}

+

{description}

+
+ ); +}; diff --git a/src/advanced/pages/admin/components/AdminViewTab.tsx b/src/advanced/pages/admin/components/AdminViewTab.tsx new file mode 100644 index 000000000..022eb9fe9 --- /dev/null +++ b/src/advanced/pages/admin/components/AdminViewTab.tsx @@ -0,0 +1,34 @@ +export const AdminViewTab = ({ + activeTab, + onChangeActiveTab, +}: { + activeTab: string; + onChangeActiveTab: (tab: 'products' | 'coupons') => void; +}) => { + return ( +
+ +
+ ); +}; diff --git a/src/advanced/pages/shop/ShopPage.tsx b/src/advanced/pages/shop/ShopPage.tsx new file mode 100644 index 000000000..1ff8c9481 --- /dev/null +++ b/src/advanced/pages/shop/ShopPage.tsx @@ -0,0 +1,71 @@ +import { ProductList } from '../../features/product/ProductList'; +import { Cart } from '../../features/cart/Cart'; +import { Header } from '../../shared/component/Header'; +import { useProduct } from '../../features/product/hooks/useProduct'; +import { useSearchProduct } from '../../features/product/hooks/useSearchProduct'; +import { useCart } from '../../features/cart/hooks/useCart'; + +export const ShopPage = () => { + const { products } = useProduct(); + const { debouncedSearchTerm, searchTerm, setSearchTerm } = useSearchProduct(); + + const { + cart, + totalItemCount, + cartTotalPrice, + updateQuantity, + removeFromCart, + completeOrder, + addToCart, + getRemainingStock, + } = useCart({ + products, + }); + + return ( + <> +
+ 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" + /> + + ), + }} + /> +
+
+ getRemainingStock(cart, product), + }} + /> + + +
+
+ + ); +}; diff --git a/src/advanced/shared/atoms/app.atom.ts b/src/advanced/shared/atoms/app.atom.ts new file mode 100644 index 000000000..12a33ad2d --- /dev/null +++ b/src/advanced/shared/atoms/app.atom.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const isAdminAtom = atom(false); diff --git a/src/advanced/shared/atoms/index.ts b/src/advanced/shared/atoms/index.ts new file mode 100644 index 000000000..2768014ca --- /dev/null +++ b/src/advanced/shared/atoms/index.ts @@ -0,0 +1 @@ +export * from './app.atom'; diff --git a/src/advanced/shared/component/Header.tsx b/src/advanced/shared/component/Header.tsx new file mode 100644 index 000000000..046343c83 --- /dev/null +++ b/src/advanced/shared/component/Header.tsx @@ -0,0 +1,62 @@ +import { ReactNode } from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { totalCartItemCountAtom } from '../../features/cart/atoms/cart.atom'; +import { isAdminAtom } from '../atoms'; + +export const Header = ({ + search, +}: { + search?: { + searchInput?: ReactNode; + }; +}) => { + const [isAdmin, setIsAdmin] = useAtom(isAdminAtom); + const totalCartItemCount = useAtomValue(totalCartItemCountAtom); + return ( +
+
+
+
+

SHOP

+ {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} + {search?.searchInput} +
+ +
+
+
+ ); +}; diff --git a/src/advanced/shared/component/ui/Input.tsx b/src/advanced/shared/component/ui/Input.tsx new file mode 100644 index 000000000..67e21eb2a --- /dev/null +++ b/src/advanced/shared/component/ui/Input.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +interface InputProps + extends Omit, 'className'> { + className?: string; +} + +export const Input = ({ className = '', ...props }: InputProps) => { + return ( + + ); +}; diff --git a/src/advanced/shared/component/ui/Label.tsx b/src/advanced/shared/component/ui/Label.tsx new file mode 100644 index 000000000..35d3ad30d --- /dev/null +++ b/src/advanced/shared/component/ui/Label.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +interface LabelProps { + children: React.ReactNode; + htmlFor?: string; + className?: string; +} + +export const Label = ({ children, htmlFor, className = '' }: LabelProps) => { + return ( + + ); +}; diff --git a/src/advanced/shared/component/ui/Select.tsx b/src/advanced/shared/component/ui/Select.tsx new file mode 100644 index 000000000..ace3864e1 --- /dev/null +++ b/src/advanced/shared/component/ui/Select.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +interface SelectProps + extends Omit, 'className'> { + className?: string; + children: React.ReactNode; +} + +export const Select = ({ className = '', children, ...props }: SelectProps) => { + return ( + + ); +}; diff --git a/src/advanced/shared/component/ui/index.ts b/src/advanced/shared/component/ui/index.ts new file mode 100644 index 000000000..33689502e --- /dev/null +++ b/src/advanced/shared/component/ui/index.ts @@ -0,0 +1,3 @@ +export { Label } from './Label'; +export { Input } from './Input'; +export { Select } from './Select'; diff --git a/src/advanced/shared/utils/priceUtils.tsx b/src/advanced/shared/utils/priceUtils.tsx new file mode 100644 index 000000000..e1fd71d14 --- /dev/null +++ b/src/advanced/shared/utils/priceUtils.tsx @@ -0,0 +1,3 @@ +export const formatPrice = (price: number): string => { + return price.toLocaleString(); +}; diff --git a/src/advanced/shared/utils/validators.ts b/src/advanced/shared/utils/validators.ts new file mode 100644 index 000000000..12ca4b07a --- /dev/null +++ b/src/advanced/shared/utils/validators.ts @@ -0,0 +1,149 @@ +import { CouponForm } from '../../features/coupon/admin/AdminCouponList'; +import { ProductForm } from '../../features/product/admin/AdminProductList'; + +export interface ValidationError { + field: keyof CouponForm | keyof ProductForm; + message: string; +} + +export const validateCouponForm = ( + form: CouponForm, +): ValidationError | null => { + if (!form.name.trim()) { + return { field: 'name', message: '쿠폰명을 입력해주세요.' }; + } + + if (!form.code.trim()) { + return { field: 'code', message: '쿠폰 코드를 입력해주세요.' }; + } + + if (form.discountType === 'percentage') { + if (form.discountValue > 100) { + return { + field: 'discountValue', + message: '할인율은 100%를 초과할 수 없습니다', + }; + } + if (form.discountValue < 0) { + return { + field: 'discountValue', + message: '할인율은 0 이상이어야 합니다.', + }; + } + } else { + if (form.discountValue > 100000) { + return { + field: 'discountValue', + message: '할인 금액은 100,000원을 초과할 수 없습니다.', + }; + } + if (form.discountValue < 0) { + return { + field: 'discountValue', + message: '할인 금액은 0 이상이어야 합니다.', + }; + } + } + + return null; +}; + +export const validateDiscountValue = ( + value: number, + discountType: 'amount' | 'percentage', +): { isValid: boolean; correctedValue?: number; message?: string } => { + if (discountType === 'percentage') { + if (value > 100) { + return { + isValid: false, + correctedValue: 100, + message: '할인율은 100%를 초과할 수 없습니다', + }; + } + if (value < 0) { + return { + isValid: false, + correctedValue: 0, + message: '할인율은 0 이상이어야 합니다.', + }; + } + } else { + if (value > 100000) { + return { + isValid: false, + correctedValue: 100000, + message: '할인 금액은 100,000원을 초과할 수 없습니다.', + }; + } + if (value < 0) { + return { + isValid: false, + correctedValue: 0, + message: '할인 금액은 0 이상이어야 합니다.', + }; + } + } + + return { isValid: true }; +}; + +// ProductForm validation +export interface ProductValidationError { + field: keyof ProductForm; + message: string; +} + +export const validateProductForm = ( + form: ProductForm, +): ProductValidationError | null => { + if (!form.name.trim()) { + return { field: 'name', message: '상품명을 입력해주세요.' }; + } + + if (form.price <= 0) { + return { field: 'price', message: '가격은 0보다 커야 합니다.' }; + } + + if (form.stock < 0) { + return { field: 'stock', message: '재고는 0 이상이어야 합니다.' }; + } + + if (form.stock > 9999) { + return { field: 'stock', message: '재고는 9999개를 초과할 수 없습니다.' }; + } + + return null; +}; + +export const validatePrice = ( + value: number, +): { isValid: boolean; correctedValue?: number; message?: string } => { + if (value < 0) { + return { + isValid: false, + correctedValue: 0, + message: '가격은 0보다 커야 합니다.', + }; + } + return { isValid: true }; +}; + +export const validateStock = ( + value: number, +): { isValid: boolean; correctedValue?: number; message?: string } => { + if (value < 0) { + return { + isValid: false, + correctedValue: 0, + message: '재고는 0보다 커야 합니다.', + }; + } + if (value > 9999) { + return { + isValid: false, + correctedValue: 9999, + message: '재고는 9999개를 초과할 수 없습니다.', + }; + } + return { isValid: true }; +}; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..acbbcc9a0 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,1124 +1,35 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import { useState } from 'react'; +import { useNotification } from './features/notification/hooks/useNotification'; +import { Notification } from './features/notification/index'; +import { AdminPage } from './pages/admin/AdminPage'; +import { ShopPage } from './pages/shop/ShopPage'; const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const { notifications, addNotification, closeNotification } = + useNotification(); return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
+ + {isAdmin ? ( + + ) : ( + )} -
-
-
-
-

SHOP

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

관리자 대시보드

-

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

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

상품 목록

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

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

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

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

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

새 쿠폰 생성

-
-
- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === 'percentage') { - if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} - required - /> -
-
-
- - -
-
-
- )} -
-
- )} -
- ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

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

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

-
-
- - )} -
-
-
- )} -
); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/features/cart/Cart.tsx b/src/basic/features/cart/Cart.tsx new file mode 100644 index 000000000..415c32f7f --- /dev/null +++ b/src/basic/features/cart/Cart.tsx @@ -0,0 +1,58 @@ +import { CouponSection } from '../coupon/components/CouponSection'; +import { PaymentSection } from './components/payment/PaymentSection'; +import { CartSection } from './components/cart/CartSection'; +import { CartItem, Coupon } from '../../../types'; +import { Dispatch, SetStateAction } from 'react'; + +export interface CartProps { + cart: { + items: CartItem[]; + totalPrice: { totalBeforeDiscount: number; totalAfterDiscount: number }; + totalItemCount: number; + }; + cartActions: { + updateQuantity: (productId: string, quantity: number) => void; + removeFromCart: (productId: string) => void; + completeOrder: () => void; + }; + coupon: { + selectedCoupon: Coupon | null; + setSelectedCoupon: Dispatch>; + coupons: Coupon[]; + applyCoupon: ( + coupon: Coupon, + { onSuccess }: { onSuccess?: () => void }, + ) => void; + }; +} + +export const Cart = ({ cart, cartActions, coupon }: CartProps) => { + return ( +
+
+ + + {cart.items.length > 0 && ( + <> + + + + + )} +
+
+ ); +}; diff --git a/src/basic/features/cart/components/cart/CartEmptyList.tsx b/src/basic/features/cart/components/cart/CartEmptyList.tsx new file mode 100644 index 000000000..633bd11e6 --- /dev/null +++ b/src/basic/features/cart/components/cart/CartEmptyList.tsx @@ -0,0 +1,20 @@ +export const CartEmptyList = () => { + return ( +
+ + + +

장바구니가 비어있습니다

+
+ ); +}; diff --git a/src/basic/features/cart/components/cart/CartItem.tsx b/src/basic/features/cart/components/cart/CartItem.tsx new file mode 100644 index 000000000..158e4d4d0 --- /dev/null +++ b/src/basic/features/cart/components/cart/CartItem.tsx @@ -0,0 +1,123 @@ +import { CartItem } from '../../../../../types'; + +const DeleteButton = ({ onClick }: { onClick: () => void }) => { + return ( + + ); +}; + +const Button = ({ + onClick, + children, +}: { + onClick: () => void; + children: React.ReactNode; +}) => { + return ( + + ); +}; + +const CartQuantityHandler = ({ + item, + updateQuantity, +}: { + item: CartItem; + updateQuantity: (productId: string, quantity: number) => void; +}) => { + return ( +
+
+ ); +}; + +const CartItemPrice = ({ + hasDiscount, + discountRate, + itemTotal, +}: { + hasDiscount: boolean; + discountRate: number; + itemTotal: number; +}) => { + return ( +
+ {hasDiscount && ( + + -{discountRate}% + + )} +

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

+
+ ); +}; + +export const CartItemComponent = ({ + item, + itemTotal, + removeFromCart, + updateQuantity, + originalPrice, +}: { + item: CartItem; + itemTotal: number; + originalPrice: number; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +}) => { + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + + return ( +
+
+

+ {item.product.name} +

+ removeFromCart(item.product.id)} /> +
+
+ + +
+
+ ); +}; diff --git a/src/basic/features/cart/components/cart/CartSection.tsx b/src/basic/features/cart/components/cart/CartSection.tsx new file mode 100644 index 000000000..59c903604 --- /dev/null +++ b/src/basic/features/cart/components/cart/CartSection.tsx @@ -0,0 +1,43 @@ +import { calculateItemTotal } from '../../service/cart.service'; +import { CartItem } from '../../../../../types'; +import { CartTitle } from './CartTitle'; +import { CartEmptyList } from './CartEmptyList'; +import { CartItemComponent } from './CartItem'; + +export const CartSection = ({ + cart, + removeFromCart, + updateQuantity, +}: { + cart: CartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +}) => { + return ( +
+ + + {cart.length === 0 ? ( + + ) : ( +
+ {cart.map((item) => { + const itemTotal = calculateItemTotal(item, cart); + const originalPrice = item.product.price * item.quantity; + + return ( + + ); + })} +
+ )} +
+ ); +}; diff --git a/src/basic/features/cart/components/cart/CartTitle.tsx b/src/basic/features/cart/components/cart/CartTitle.tsx new file mode 100644 index 000000000..438472403 --- /dev/null +++ b/src/basic/features/cart/components/cart/CartTitle.tsx @@ -0,0 +1,20 @@ +export const CartTitle = () => { + return ( +

+ + + + 장바구니 +

+ ); +}; diff --git a/src/basic/features/cart/components/payment/PaymentCompleteButton.tsx b/src/basic/features/cart/components/payment/PaymentCompleteButton.tsx new file mode 100644 index 000000000..b85b3c2ca --- /dev/null +++ b/src/basic/features/cart/components/payment/PaymentCompleteButton.tsx @@ -0,0 +1,18 @@ +import { formatPrice } from '../../../../shared/utils/priceUtils'; + +export const PaymentCompleteButton = ({ + paymentAmount, + completeOrder, +}: { + paymentAmount: number; + completeOrder: () => void; +}) => { + return ( + + ); +}; diff --git a/src/basic/features/cart/components/payment/PaymentSection.tsx b/src/basic/features/cart/components/payment/PaymentSection.tsx new file mode 100644 index 000000000..1362dd679 --- /dev/null +++ b/src/basic/features/cart/components/payment/PaymentSection.tsx @@ -0,0 +1,70 @@ +import { formatPrice } from '../../../../shared/utils/priceUtils'; +import { PaymentCompleteButton } from './PaymentCompleteButton'; + +const PaymentTotalPrice = ({ + title, + value, +}: { + title: string; + value: number; +}) => { + return ( +
+ {title} + {formatPrice(value)}원 +
+ ); +}; + +const PaymentDiscountedAmount = ({ value }: { value: number }) => { + return ( +
+ 할인 금액 + -{formatPrice(value)}원 +
+ ); +}; + +const PaymentFinalAmount = ({ value }: { value: number }) => { + return ( +
+ 결제 예정 금액 + + {formatPrice(value)}원 + +
+ ); +}; +export const PaymentSection = ({ + cartTotalPrice, + completeOrder, +}: { + cartTotalPrice: { totalBeforeDiscount: number; totalAfterDiscount: number }; + completeOrder: () => void; +}) => { + const { totalBeforeDiscount, totalAfterDiscount } = cartTotalPrice; + + const discountedAmount = totalBeforeDiscount - totalAfterDiscount; + const paymentAmount = totalAfterDiscount; + + return ( +
+

결제 정보

+
+ + {discountedAmount > 0 && ( + + )} + +
+ + +
+

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

+
+
+ ); +}; diff --git a/src/basic/features/cart/hooks/useCart.ts b/src/basic/features/cart/hooks/useCart.ts new file mode 100644 index 000000000..beb072237 --- /dev/null +++ b/src/basic/features/cart/hooks/useCart.ts @@ -0,0 +1,180 @@ +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useState, +} from 'react'; +import { CartItem, Coupon, Product } from '../../../../types'; +import { calculateCartTotalPrice } from '../service/cart.service'; +import { applyCouponDiscount } from '../../coupon/services/coupon.service'; +import { ProductWithUI } from '../../product/hooks/useProduct'; + +export const useCart = ({ + products, + addNotification, + selectedCoupon, + setSelectedCoupon, +}: { + products: ProductWithUI[]; + + addNotification: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void; + + selectedCoupon: Coupon | null; + setSelectedCoupon: Dispatch>; +}) => { + const [cart, setCart] = useState(() => { + const saved = localStorage.getItem('cart'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return []; + } + } + return []; + }); + + const [totalItemCount, setTotalItemCount] = useState(0); + + useEffect(() => { + const count = cart.reduce((sum, item) => sum + item.quantity, 0); + setTotalItemCount(count); + }, [cart]); + + useEffect(() => { + if (cart.length > 0) { + localStorage.setItem('cart', JSON.stringify(cart)); + } else { + localStorage.removeItem('cart'); + } + }, [cart]); + + const 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, + ), + ); + }, + [cart, setCart, addNotification], + ); + + const removeFromCart = useCallback((productId: string) => { + setCart((prevCart) => + prevCart.filter((item) => item.product.id !== productId), + ); + }, []); + + const calculateCartTotal = ( + cart: CartItem[], + ): { + totalBeforeDiscount: number; + totalAfterDiscount: number; + } => { + const { totalBeforeDiscount, totalAfterDiscount } = + calculateCartTotalPrice(cart); + + if (selectedCoupon) { + return applyCouponDiscount(selectedCoupon, { + totalBeforeDiscount, + totalAfterDiscount, + }); + } + + return { totalBeforeDiscount, totalAfterDiscount }; + }; + + const cartTotalPrice = calculateCartTotal(cart); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + 'success', + ); + setCart([]); + setSelectedCoupon(null); + }, [addNotification]); + + const getRemainingStock = (cart: CartItem[], product: Product): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; + }; + + const addToCart = useCallback( + ( + product: ProductWithUI, + onSuccess?: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void, + ) => { + const remainingStock = getRemainingStock(cart, product); + if (remainingStock <= 0) { + onSuccess?.('재고가 부족합니다!', '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) { + onSuccess?.(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item, + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + onSuccess?.('장바구니에 담았습니다', 'success'); + }, + [cart, getRemainingStock], + ); + + return { + updateQuantity, + removeFromCart, + cartTotalPrice, + completeOrder, + cart, + setCart, + totalItemCount, + getRemainingStock, + addToCart, + }; +}; diff --git a/src/basic/features/cart/service/cart.service.ts b/src/basic/features/cart/service/cart.service.ts new file mode 100644 index 000000000..6d5444bb6 --- /dev/null +++ b/src/basic/features/cart/service/cart.service.ts @@ -0,0 +1,62 @@ +import { CartItem, Coupon } from '../../../../types'; + +export const checkCouponAvailability = ( + coupon: Coupon, + currentCartTotal: number, +) => { + if (currentCartTotal < 10000 && coupon.discountType === 'percentage') { + return false; + } + return true; +}; + +const checkHasBulkPurchase = (cart: CartItem[]): boolean => { + return cart.some((cartItem) => cartItem.quantity >= 10); +}; + +// 장바구니 자체 discount 적용 +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 = checkHasBulkPurchase(cart); + 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 calculateCartTotalPrice = ( + cart: CartItem[], +): { + 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); + }); + return { totalBeforeDiscount, totalAfterDiscount }; +}; diff --git a/src/basic/features/coupon/admin/AddNewCouponCard.tsx b/src/basic/features/coupon/admin/AddNewCouponCard.tsx new file mode 100644 index 000000000..84d14beb0 --- /dev/null +++ b/src/basic/features/coupon/admin/AddNewCouponCard.tsx @@ -0,0 +1,29 @@ +export const AddNewCouponCard = ({ + toggleShowCouponForm, +}: { + toggleShowCouponForm: () => void; +}) => { + return ( +
+ +
+ ); +}; diff --git a/src/basic/features/coupon/admin/AdminCouponList.tsx b/src/basic/features/coupon/admin/AdminCouponList.tsx new file mode 100644 index 000000000..3217858c2 --- /dev/null +++ b/src/basic/features/coupon/admin/AdminCouponList.tsx @@ -0,0 +1,63 @@ +import { useManageCoupon } from '../hooks/useManageCoupon'; +import { Coupon } from '../../../../types'; +import { AddNewCouponCard } from './AddNewCouponCard'; +import { CouponAddForm } from './CouponAddForm'; +import { CouponCard } from './CouponCard'; + +export interface CouponForm { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} +export const AdminCouponList = ({ + addNotification, +}: { + addNotification: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void; +}) => { + const { + coupons, + addCoupon, + toggleShowCouponForm, + handleDeleteCoupon, + showCouponForm, + } = useManageCoupon({ addNotification }); + + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon: Coupon) => ( + + handleDeleteCoupon(couponCode, { onSuccess: addNotification }) + } + /> + ))} + +
+ + {showCouponForm && ( + { + addCoupon(form as Coupon); + toggleShowCouponForm(); + }} + onCancel={toggleShowCouponForm} + onValidationError={(message: string) => + addNotification(message, 'error') + } + /> + )} +
+
+ ); +}; diff --git a/src/basic/features/coupon/admin/CouponAddForm.tsx b/src/basic/features/coupon/admin/CouponAddForm.tsx new file mode 100644 index 000000000..8582b1b5b --- /dev/null +++ b/src/basic/features/coupon/admin/CouponAddForm.tsx @@ -0,0 +1,107 @@ +import { Label, Input, Select } from '../../../shared/component/ui'; +import { useCouponForm } from '../hooks/useCouponForm'; +import { CouponForm } from './AdminCouponList'; + +interface CouponAddFormProps { + onSubmit: (form: CouponForm) => void; + onCancel: () => void; + onValidationError?: (message: string) => void; +} + +export const CouponAddForm = ({ + onSubmit, + onCancel, + onValidationError, +}: CouponAddFormProps) => { + const { form, updateField, handleSubmit, handleDiscountValueBlur } = + useCouponForm({ + onSubmit, + onValidationError, + }); + + return ( +
+
+

새 쿠폰 생성

+
+
+ + updateField('name', e.target.value)} + placeholder="신규 가입 쿠폰" + required + /> +
+
+ + + updateField('code', e.target.value.toUpperCase()) + } + className="font-mono" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + updateField( + 'discountValue', + value === '' ? 0 : parseInt(value), + ); + } + }} + onBlur={() => { + handleDiscountValueBlur(form.discountValue); + }} + placeholder={form.discountType === 'amount' ? '5000' : '10'} + required + /> +
+
+
+ + +
+
+
+ ); +}; diff --git a/src/basic/features/coupon/admin/CouponCard.tsx b/src/basic/features/coupon/admin/CouponCard.tsx new file mode 100644 index 000000000..5b4939d95 --- /dev/null +++ b/src/basic/features/coupon/admin/CouponCard.tsx @@ -0,0 +1,48 @@ +import { Coupon } from '../../../../types'; + +export const CouponCard = ({ + coupon, + handleDeleteCoupon, +}: { + coupon: Coupon; + handleDeleteCoupon: (couponCode: string) => void; +}) => { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +}; diff --git a/src/basic/features/coupon/components/CouponSection.tsx b/src/basic/features/coupon/components/CouponSection.tsx new file mode 100644 index 000000000..51c403e80 --- /dev/null +++ b/src/basic/features/coupon/components/CouponSection.tsx @@ -0,0 +1,39 @@ +import { Coupon } from '../../../../types'; +import { CouponSelector } from './CouponSelector'; + +export const CouponSection = ({ + coupons, + selectedCoupon, + setSelectedCoupon, + cartTotalPrice, + applyCoupon, +}: { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + cartTotalPrice: { totalBeforeDiscount: number; totalAfterDiscount: number }; + applyCoupon: ( + coupon: Coupon, + { onSuccess }: { onSuccess?: () => void }, + ) => void; +}) => { + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +}; diff --git a/src/basic/features/coupon/components/CouponSelector.tsx b/src/basic/features/coupon/components/CouponSelector.tsx new file mode 100644 index 000000000..1b37cd866 --- /dev/null +++ b/src/basic/features/coupon/components/CouponSelector.tsx @@ -0,0 +1,79 @@ +import { useCallback } from 'react'; +import { Coupon } from '../../../../types'; +import { checkCouponAvailability } from '../services/coupon.service'; +import { formatPrice } from '../../../shared/utils/priceUtils'; +import { useNotification } from '../../notification/hooks/useNotification'; +import { Select } from '../../../shared/component/ui'; + +export const CouponSelector = ({ + selectedCoupon, + setSelectedCoupon, + coupons, + cartTotalPrice, + applyCoupon, +}: { + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + coupons: Coupon[]; + cartTotalPrice: { totalBeforeDiscount: number; totalAfterDiscount: number }; + applyCoupon: ( + coupon: Coupon, + { onSuccess }: { onSuccess?: () => void }, + ) => void; +}) => { + const { addNotification } = useNotification(); + + const onCouponChange = useCallback( + (e: React.ChangeEvent) => { + const coupon = coupons.find((c) => c.code === e.target.value); + + if (!coupon) return; + + const isCouponAvailable = checkCouponAvailability( + coupon, + cartTotalPrice.totalAfterDiscount, + ); + + if (!isCouponAvailable) { + addNotification( + 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + 'error', + ); + setSelectedCoupon(null); + return; + } + + applyCoupon(coupon, { + onSuccess: () => { + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, + }); + }, + [ + coupons, + cartTotalPrice.totalAfterDiscount, + addNotification, + setSelectedCoupon, + applyCoupon, + ], + ); + + return ( + + ); +}; diff --git a/src/basic/features/coupon/hooks/useCouponForm.ts b/src/basic/features/coupon/hooks/useCouponForm.ts new file mode 100644 index 000000000..f1086bc1a --- /dev/null +++ b/src/basic/features/coupon/hooks/useCouponForm.ts @@ -0,0 +1,76 @@ +import { useCallback, useState } from 'react'; +import { CouponForm } from '../admin/AdminCouponList'; +import { + validateDiscountValue, + validateCouponForm, +} from '../../../shared/utils/validators'; + +const initialForm: CouponForm = { + name: '', + code: '', + discountType: 'amount', + discountValue: 0, +}; + +interface UseCouponFormOptions { + onSubmit: (form: CouponForm) => void; + onValidationError?: (message: string) => void; +} + +export const useCouponForm = ({ + onSubmit, + onValidationError, +}: UseCouponFormOptions) => { + const [form, setForm] = useState(initialForm); + + const updateField = useCallback( + (field: K, value: CouponForm[K]) => { + setForm((prev) => ({ ...prev, [field]: value })); + }, + [], + ); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + const error = validateCouponForm(form); + if (error) { + onValidationError?.(error.message); + return; + } + + onSubmit(form); + setForm(initialForm); + }, + [form, onSubmit, onValidationError], + ); + + const handleDiscountValueBlur = useCallback( + (value: number) => { + const validation = validateDiscountValue(value, form.discountType); + + if (!validation.isValid) { + if (validation.correctedValue !== undefined) { + updateField('discountValue', validation.correctedValue); + } + if (validation.message) { + onValidationError?.(validation.message); + } + } + }, + [form.discountType, updateField, onValidationError], + ); + + const reset = useCallback(() => { + setForm(initialForm); + }, []); + + return { + form, + updateField, + handleSubmit, + handleDiscountValueBlur, + reset, + }; +}; diff --git a/src/basic/features/coupon/hooks/useManageCoupon.ts b/src/basic/features/coupon/hooks/useManageCoupon.ts new file mode 100644 index 000000000..49e40084c --- /dev/null +++ b/src/basic/features/coupon/hooks/useManageCoupon.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Coupon } from '../../../types'; + +const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; + +interface UseManageCouponOptions { + addNotification: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void; +} + +export const useManageCoupon = ({ + addNotification, +}: UseManageCouponOptions) => { + const [coupons, setCoupons] = useState(() => { + const saved = localStorage.getItem('coupons'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialCoupons; + } + } + return initialCoupons; + }); + + useEffect(() => { + localStorage.setItem('coupons', JSON.stringify(coupons)); + }, [coupons]); + + const [selectedCoupon, setSelectedCoupon] = useState(null); + const [showCouponForm, setShowCouponForm] = useState(false); + + 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 toggleShowCouponForm = () => { + setShowCouponForm((prev) => !prev); + }; + + const deleteCoupon = useCallback((couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + }, []); + + const applyCoupon = useCallback( + (coupon: Coupon, { onSuccess }: { onSuccess?: () => void }) => { + setSelectedCoupon(coupon); + onSuccess?.(); + }, + [], + ); + + const handleDeleteCoupon = ( + couponCode: string, + { + onSuccess, + }: { onSuccess?: (message: string, type: 'success' | 'error') => void }, + ) => { + deleteCoupon(couponCode); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + + onSuccess?.('쿠폰이 삭제되었습니다.', 'success'); + }; + + return { + coupons, + selectedCoupon, + setSelectedCoupon, + applyCoupon, + addCoupon, + toggleShowCouponForm, + handleDeleteCoupon, + showCouponForm, + }; +}; diff --git a/src/basic/features/coupon/services/coupon.service.ts b/src/basic/features/coupon/services/coupon.service.ts new file mode 100644 index 000000000..97904568d --- /dev/null +++ b/src/basic/features/coupon/services/coupon.service.ts @@ -0,0 +1,38 @@ +import { Coupon } from '../../../../types'; + +export const checkCouponAvailability = ( + coupon: Coupon, + currentCartTotal: number, +) => { + if (currentCartTotal < 10000 && coupon.discountType === 'percentage') { + return false; + } + return true; +}; + +export const applyCouponDiscount = ( + selectedCoupon: Coupon, + { + totalBeforeDiscount, + totalAfterDiscount, + }: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }, +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + if (selectedCoupon.discountType === 'amount') { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue, + ); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100), + ); + } + + return { totalBeforeDiscount, totalAfterDiscount }; +}; diff --git a/src/basic/features/notification/components/NotificationToast.tsx b/src/basic/features/notification/components/NotificationToast.tsx new file mode 100644 index 000000000..349df7782 --- /dev/null +++ b/src/basic/features/notification/components/NotificationToast.tsx @@ -0,0 +1,42 @@ +import { NotificationType } from '../hooks/useNotification'; + +export const NotificationToast = ({ + notification, + closeNotification, +}: { + notification: NotificationType; + closeNotification: (id: string) => void; +}) => { + return ( +
+ {notification.message} + +
+ ); +}; diff --git a/src/basic/features/notification/hooks/useNotification.ts b/src/basic/features/notification/hooks/useNotification.ts new file mode 100644 index 000000000..4ef1a75c9 --- /dev/null +++ b/src/basic/features/notification/hooks/useNotification.ts @@ -0,0 +1,29 @@ +import { useCallback, useState } from 'react'; + +export interface NotificationType { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export const useNotification = () => { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: 'error' | 'success' | 'warning' = 'success') => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [], + ); + + const closeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((noti) => noti.id !== id)); + }, []); + + return { addNotification, notifications, closeNotification }; +}; diff --git a/src/basic/features/notification/index.tsx b/src/basic/features/notification/index.tsx new file mode 100644 index 000000000..5c76a316c --- /dev/null +++ b/src/basic/features/notification/index.tsx @@ -0,0 +1,26 @@ +import { NotificationToast } from './components/NotificationToast'; +import { NotificationType } from './hooks/useNotification'; + +export const Notification = ({ + notifications, + closeNotification, +}: { + notifications: NotificationType[]; + closeNotification: (id: string) => void; +}) => { + return ( + <> + {notifications.length > 0 && ( +
+ {notifications.map((notif) => ( + + ))} +
+ )} + + ); +}; diff --git a/src/basic/features/product/ProductItem.tsx b/src/basic/features/product/ProductItem.tsx new file mode 100644 index 000000000..cda35daa9 --- /dev/null +++ b/src/basic/features/product/ProductItem.tsx @@ -0,0 +1,171 @@ +import { formatPrice } from '../../shared/utils/priceUtils'; +import { ProductWithUI } from './hooks/useProduct'; + +const ProductImage = () => { + return ( +
+ + + +
+ ); +}; + +const ProductLabel = ({ title, color }: { title: string; color: string }) => { + return ( + + {title} + + ); +}; + +const ProductInfo = ({ + product, + remainingStock, +}: { + product: ProductWithUI; + remainingStock: number; +}) => { + return ( + <> +

{product.name}

+ {product.description && ( +

+ {product.description} +

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

+ {formatPrice(product.price)} +

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

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

+ )} +
+ + {/* 재고 상태 */} + + + ); +}; +const StockStatus = ({ remainingStock }: { remainingStock: number }) => { + return ( +
+ {remainingStock <= 5 && remainingStock > 0 && ( +

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

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

재고 {remainingStock}개

+ )} +
+ ); +}; + +const CartButton = ({ + product, + remainingStock, + addToCart, + addNotification, +}: { + product: ProductWithUI; + remainingStock: number; + addToCart: ( + product: ProductWithUI, + onSuccess?: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void, + ) => void; + addNotification: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void; +}) => { + return ( + + ); +}; + +export const ProductItem = ({ + product, + remainingStock, + addToCart, + addNotification, +}: { + product: ProductWithUI; + remainingStock: number; + addToCart: ( + product: ProductWithUI, + onSuccess?: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void, + ) => void; + addNotification: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void; +}) => { + return ( +
+
+ + {product.isRecommended && } + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ +
+ {/* 상품 정보 */} + + + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/basic/features/product/ProductList.tsx b/src/basic/features/product/ProductList.tsx new file mode 100644 index 000000000..96b82a2a9 --- /dev/null +++ b/src/basic/features/product/ProductList.tsx @@ -0,0 +1,100 @@ +import { Product } from '../../../types'; +import { ProductItem } from './ProductItem'; + +export interface ProductListProps { + products: Product[]; + search: { + debouncedSearchTerm: string; + }; + cartActions: { + addToCart: ( + product: Product, + onSuccess?: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void, + ) => void; + getRemainingStock: (product: Product) => number; + }; + notification: { + addNotification: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void; + }; +} + +const ProductListHeader = ({ + productsTotalCount, +}: { + productsTotalCount: number; +}) => { + return ( +
+

전체 상품

+
+ 총 {productsTotalCount}개 상품 +
+
+ ); +}; + +const ProductListEmpty = ({ + debouncedSearchTerm, +}: { + debouncedSearchTerm: string; +}) => { + return ( +
+

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

+
+ ); +}; + +export const ProductList = ({ + products, + search, + cartActions, + notification, +}: ProductListProps) => { + const filteredProducts = search.debouncedSearchTerm + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(search.debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(search.debouncedSearchTerm.toLowerCase())), + ) + : products; + + return ( +
+ +
+ {filteredProducts.length === 0 ? ( + + ) : ( +
+ {filteredProducts.map((product) => { + const remainingStock = cartActions.getRemainingStock(product); + return ( + + ); + })} +
+ )} +
+
+ ); +}; diff --git a/src/basic/features/product/admin/AddNewProductButton.tsx b/src/basic/features/product/admin/AddNewProductButton.tsx new file mode 100644 index 000000000..ffcd38edf --- /dev/null +++ b/src/basic/features/product/admin/AddNewProductButton.tsx @@ -0,0 +1,10 @@ +export const AddNewProductButton = ({ onClick }: { onClick: () => void }) => { + return ( + + ); +}; diff --git a/src/basic/features/product/admin/AdminProductList.tsx b/src/basic/features/product/admin/AdminProductList.tsx new file mode 100644 index 000000000..682508e55 --- /dev/null +++ b/src/basic/features/product/admin/AdminProductList.tsx @@ -0,0 +1,76 @@ +import { Dispatch, SetStateAction } from 'react'; +import { ProductWithUI } from '../hooks/useProduct'; +import { AddNewProductButton } from './AddNewProductButton'; +import { ProductAddForm } from './ProductAddForm'; +import { AdminProductTable } from './AdminProductTable'; +import { useManageProducts } from './hooks/useManageProducts'; + +export interface ProductForm { + name: string; + price: number; + stock: number; + description: string; + discounts: Array<{ quantity: number; rate: number }>; +} + +export const AdminProductList = ({ + products, + setProducts, + addNotification, +}: { + products: ProductWithUI[]; + setProducts: Dispatch>; + addNotification: (message: string, type: 'success' | 'error') => void; +}) => { + const { + handleAddNewProduct, + startEditProduct, + deleteProduct, + editingProduct, + getProductFormData, + addProduct, + updateProduct, + setShowProductForm, + setEditingProduct, + showProductForm, + } = useManageProducts({ products, setProducts, addNotification }); + + return ( +
+
+
+

상품 목록

+ +
+
+ + + {showProductForm && ( + { + if (editingProduct && editingProduct !== 'new') { + updateProduct(editingProduct, form); + } else { + addProduct(form); + } + setEditingProduct(null); + setShowProductForm(false); + }} + onCancel={() => { + setEditingProduct(null); + setShowProductForm(false); + }} + onValidationError={(message) => addNotification(message, 'error')} + /> + )} +
+ ); +}; diff --git a/src/basic/features/product/admin/AdminProductTable.tsx b/src/basic/features/product/admin/AdminProductTable.tsx new file mode 100644 index 000000000..f4efdb274 --- /dev/null +++ b/src/basic/features/product/admin/AdminProductTable.tsx @@ -0,0 +1,99 @@ +import { formatPrice } from '../../../../advanced/shared/utils/priceUtils'; +import { ProductWithUI } from '../hooks/useProduct'; + +export const AdminProductTable = ({ + products, + startEditProduct, + deleteProduct, +}: { + products: ProductWithUI[]; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +}) => { + return ( +
+ + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+ ); +}; + +const ProductTableRow = ({ + product, + startEditProduct, + deleteProduct, +}: { + product: ProductWithUI; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +}) => { + return ( + + + {product.name} + + + {`${formatPrice(product.price)}원`} + + + 10 + ? 'bg-green-100 text-green-800' + : product.stock > 0 + ? 'bg-yellow-100 text-yellow-800' + : 'bg-red-100 text-red-800' + }`} + > + {product.stock}개 + + + + {product.description || '-'} + + + + + + + ); +}; diff --git a/src/basic/features/product/admin/ProductAddForm.tsx b/src/basic/features/product/admin/ProductAddForm.tsx new file mode 100644 index 000000000..b9cf7e46e --- /dev/null +++ b/src/basic/features/product/admin/ProductAddForm.tsx @@ -0,0 +1,186 @@ +import { Label, Input } from '../../../shared/component/ui'; +import { useProductForm } from './hooks/useProductForm'; +import { ProductForm } from './AdminProductList'; + +interface ProductAddFormProps { + editingProduct: string | null; + initialData?: ProductForm; + onSubmit: (form: ProductForm) => void; + onCancel: () => void; + onValidationError?: (message: string) => void; +} + +export const ProductAddForm = ({ + editingProduct, + initialData, + onSubmit, + onCancel, + onValidationError, +}: ProductAddFormProps) => { + const { + form, + updateField, + handleSubmit, + handlePriceBlur, + handleStockBlur, + updateDiscount, + addDiscount, + removeDiscount, + } = useProductForm({ + initialData, + onSubmit, + onValidationError, + }); + return ( +
+
+

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

+
+
+ + updateField('name', e.target.value)} + required + /> +
+
+ + updateField('description', e.target.value)} + /> +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + updateField('price', value === '' ? 0 : parseInt(value)); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + handlePriceBlur(value); + }} + placeholder="숫자만 입력" + required + /> +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + updateField('stock', value === '' ? 0 : parseInt(value)); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + handleStockBlur(value); + }} + placeholder="숫자만 입력" + required + /> +
+
+
+ +
+ {form.discounts.map((discount, index) => ( +
+ { + updateDiscount(index, { + quantity: parseInt(e.target.value) || 0, + }); + }} + className="w-20 px-2 py-1" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + updateDiscount(index, { + rate: (parseInt(e.target.value) || 0) / 100, + }); + }} + className="w-16 px-2 py-1" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/basic/features/product/admin/hooks/useManageProducts.ts b/src/basic/features/product/admin/hooks/useManageProducts.ts new file mode 100644 index 000000000..e485c8ce0 --- /dev/null +++ b/src/basic/features/product/admin/hooks/useManageProducts.ts @@ -0,0 +1,87 @@ +import { useState, useCallback, SetStateAction, Dispatch } from 'react'; +import { ProductWithUI } from '../../hooks/useProduct'; +import { ProductForm } from '../AdminProductList'; + +export const useManageProducts = ({ + products, + setProducts, + addNotification, +}: { + products: ProductWithUI[]; + setProducts: Dispatch>; + addNotification: (message: string, type: 'success' | 'error') => void; +}) => { + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + + const startEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setShowProductForm(true); + }; + + const handleAddNewProduct = () => { + setEditingProduct('new'); + setShowProductForm(true); + }; + + const getProductFormData = useCallback( + (productId: string): ProductForm | undefined => { + if (productId === 'new') return undefined; + const product = products.find((p) => p.id === productId); + if (!product) return undefined; + return { + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], + }; + }, + [products], + ); + + const addProduct = useCallback( + (form: ProductForm) => { + const product: ProductWithUI = { + ...form, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [setProducts, addNotification], + ); + + const updateProduct = useCallback( + (productId: string, form: ProductForm) => { + setProducts((prev) => + prev.map((product) => + product.id === productId ? { ...product, ...form } : product, + ), + ); + addNotification('상품이 수정되었습니다.', 'success'); + }, + [setProducts, addNotification], + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification('상품이 삭제되었습니다.', 'success'); + }, + [setProducts, addNotification], + ); + + return { + handleAddNewProduct, + startEditProduct, + deleteProduct, + editingProduct, + getProductFormData, + addProduct, + updateProduct, + setShowProductForm, + setEditingProduct, + showProductForm, + }; +}; diff --git a/src/basic/features/product/admin/hooks/useProductForm.ts b/src/basic/features/product/admin/hooks/useProductForm.ts new file mode 100644 index 000000000..63664157e --- /dev/null +++ b/src/basic/features/product/admin/hooks/useProductForm.ts @@ -0,0 +1,137 @@ +import { useCallback, useState, useEffect } from 'react'; +import { ProductForm } from '../AdminProductList'; +import { + validateProductForm, + validatePrice, + validateStock, +} from '../../../../shared/utils/validators'; + +const initialForm: ProductForm = { + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], +}; + +interface UseProductFormOptions { + initialData?: ProductForm; + onSubmit: (form: ProductForm) => void; + onValidationError?: (message: string) => void; +} + +export const useProductForm = ({ + initialData, + onSubmit, + onValidationError, +}: UseProductFormOptions) => { + const [form, setForm] = useState(initialData || initialForm); + + useEffect(() => { + if (initialData) { + setForm(initialData); + } else { + setForm(initialForm); + } + }, [initialData]); + + const updateField = useCallback( + (field: K, value: ProductForm[K]) => { + setForm((prev) => ({ ...prev, [field]: value })); + }, + [], + ); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + const error = validateProductForm(form); + if (error) { + onValidationError?.(error.message); + return; + } + + onSubmit(form); + }, + [form, onSubmit, onValidationError], + ); + + const handlePriceBlur = useCallback( + (value: number) => { + const validation = validatePrice(value); + + if (!validation.isValid) { + if (validation.correctedValue !== undefined) { + updateField('price', validation.correctedValue); + } + if (validation.message) { + onValidationError?.(validation.message); + } + } + }, + [updateField, onValidationError], + ); + + const handleStockBlur = useCallback( + (value: number) => { + const validation = validateStock(value); + + if (!validation.isValid) { + if (validation.correctedValue !== undefined) { + updateField('stock', validation.correctedValue); + } + if (validation.message) { + onValidationError?.(validation.message); + } + } + }, + [updateField, onValidationError], + ); + + const updateDiscount = useCallback( + (index: number, updates: Partial) => { + setForm((prev) => { + const newDiscounts = [...prev.discounts]; + newDiscounts[index] = { ...newDiscounts[index], ...updates }; + return { ...prev, discounts: newDiscounts }; + }); + }, + [], + ); + + const addDiscount = useCallback(() => { + setForm((prev) => ({ + ...prev, + discounts: [...prev.discounts, { quantity: 10, rate: 0.1 }], + })); + }, []); + + const removeDiscount = useCallback((index: number) => { + setForm((prev) => ({ + ...prev, + discounts: prev.discounts.filter((_, i) => i !== index), + })); + }, []); + + const reset = useCallback(() => { + setForm(initialForm); + }, []); + + const setFormData = useCallback((data: ProductForm) => { + setForm(data); + }, []); + + return { + form, + updateField, + handleSubmit, + handlePriceBlur, + handleStockBlur, + updateDiscount, + addDiscount, + removeDiscount, + reset, + setFormData, + }; +}; diff --git a/src/basic/features/product/hooks/useProduct.ts b/src/basic/features/product/hooks/useProduct.ts new file mode 100644 index 000000000..ed1db3a86 --- /dev/null +++ b/src/basic/features/product/hooks/useProduct.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { Product } from '../../../../types'; + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +// 초기 데이터 +const initialProducts: ProductWithUI[] = [ + { + id: 'p1', + name: '상품1', + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: '최고급 품질의 프리미엄 상품입니다.', + }, + { + id: 'p2', + name: '상품2', + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: '다양한 기능을 갖춘 실용적인 상품입니다.', + isRecommended: true, + }, + { + id: 'p3', + name: '상품3', + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: '대용량과 고성능을 자랑하는 상품입니다.', + }, +]; + +export const useProduct = () => { + const [products, setProducts] = useState(() => { + const saved = localStorage.getItem('products'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialProducts; + } + } + return initialProducts; + }); + + useEffect(() => { + localStorage.setItem('products', JSON.stringify(products)); + }, [products]); + + return { products, setProducts }; +}; diff --git a/src/basic/features/product/hooks/useSearchProduct.ts b/src/basic/features/product/hooks/useSearchProduct.ts new file mode 100644 index 000000000..c58257ddb --- /dev/null +++ b/src/basic/features/product/hooks/useSearchProduct.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; + +export const useSearchProduct = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + return () => clearTimeout(timer); + }, [searchTerm]); + + return { + searchTerm, + setSearchTerm, + debouncedSearchTerm, + setDebouncedSearchTerm, + }; +}; diff --git a/src/basic/pages/admin/AdminPage.tsx b/src/basic/pages/admin/AdminPage.tsx new file mode 100644 index 000000000..16eb30f8b --- /dev/null +++ b/src/basic/pages/admin/AdminPage.tsx @@ -0,0 +1,41 @@ +import { AdminDashboard } from './components/AdminDashboard'; +import { Header } from '../../shared/component/Header'; +import { useProduct } from '../../features/product/hooks/useProduct'; + +interface AdminPageProps { + isAdmin: boolean; + setIsAdmin: (isAdmin: boolean) => void; + addNotification: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void; +} + +export const AdminPage = ({ + isAdmin, + setIsAdmin, + addNotification, +}: AdminPageProps) => { + const { products, setProducts } = useProduct(); + + return ( + <> +
+
+ +
+ + ); +}; diff --git a/src/basic/pages/admin/components/AdminDashboard.tsx b/src/basic/pages/admin/components/AdminDashboard.tsx new file mode 100644 index 000000000..d4a63f4d3 --- /dev/null +++ b/src/basic/pages/admin/components/AdminDashboard.tsx @@ -0,0 +1,57 @@ +import { Dispatch, SetStateAction, useState } from 'react'; +import { ProductWithUI } from '../../../features/product/hooks/useProduct'; +import { AdminTitle } from './AdminTitle'; +import { AdminViewTab } from './AdminViewTab'; +import { AdminProductList } from '../../../features/product/admin/AdminProductList'; +import { AdminCouponList } from '../../../features/coupon/admin/AdminCouponList'; + +export const AdminDashboard = ({ + products, + setProducts, + addNotification, +}: { + products: ProductWithUI[]; + setProducts: Dispatch>; + addNotification: ( + message: string, + type: 'error' | 'success' | 'warning', + ) => void; +}) => { + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>( + 'products', + ); + + const onChangeActiveTab = (tab: 'products' | 'coupons') => { + switch (tab) { + case 'products': + setActiveTab('products'); + break; + case 'coupons': + setActiveTab('coupons'); + break; + } + }; + return ( +
+ + + + + {activeTab === 'products' ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/src/basic/pages/admin/components/AdminTitle.tsx b/src/basic/pages/admin/components/AdminTitle.tsx new file mode 100644 index 000000000..efa81be6e --- /dev/null +++ b/src/basic/pages/admin/components/AdminTitle.tsx @@ -0,0 +1,14 @@ +export const AdminTitle = ({ + title, + description, +}: { + title: string; + description: string; +}) => { + return ( +
+

{title}

+

{description}

+
+ ); +}; diff --git a/src/basic/pages/admin/components/AdminViewTab.tsx b/src/basic/pages/admin/components/AdminViewTab.tsx new file mode 100644 index 000000000..022eb9fe9 --- /dev/null +++ b/src/basic/pages/admin/components/AdminViewTab.tsx @@ -0,0 +1,34 @@ +export const AdminViewTab = ({ + activeTab, + onChangeActiveTab, +}: { + activeTab: string; + onChangeActiveTab: (tab: 'products' | 'coupons') => void; +}) => { + return ( +
+ +
+ ); +}; diff --git a/src/basic/pages/shop/ShopPage.tsx b/src/basic/pages/shop/ShopPage.tsx new file mode 100644 index 000000000..db0ccc1e6 --- /dev/null +++ b/src/basic/pages/shop/ShopPage.tsx @@ -0,0 +1,106 @@ +import { ProductList } from '../../features/product/ProductList'; +import { Cart } from '../../features/cart/Cart'; +import { Header } from '../../shared/component/Header'; +import { useProduct } from '../../features/product/hooks/useProduct'; +import { useSearchProduct } from '../../features/product/hooks/useSearchProduct'; +import { useCart } from '../../features/cart/hooks/useCart'; +import { useManageCoupon } from '../../features/coupon/hooks/useManageCoupon'; + +interface ShopPageProps { + isAdmin: boolean; + setIsAdmin: (isAdmin: boolean) => void; + addNotification: ( + message: string, + type: 'success' | 'error' | 'warning', + ) => void; +} + +export const ShopPage = ({ + isAdmin, + setIsAdmin, + addNotification, +}: ShopPageProps) => { + const { products } = useProduct(); + const { debouncedSearchTerm, searchTerm, setSearchTerm } = useSearchProduct(); + const { coupons, applyCoupon, selectedCoupon, setSelectedCoupon } = + useManageCoupon({ addNotification }); + + const { + cart, + totalItemCount, + cartTotalPrice, + updateQuantity, + removeFromCart, + completeOrder, + addToCart, + getRemainingStock, + } = useCart({ + products, + addNotification, + selectedCoupon, + setSelectedCoupon, + }); + + return ( + <> +
+ 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" + /> + + ), + }} + /> +
+
+ getRemainingStock(cart, product), + }} + notification={{ + addNotification, + }} + /> + + +
+
+ + ); +}; diff --git a/src/basic/shared/component/Header.tsx b/src/basic/shared/component/Header.tsx new file mode 100644 index 000000000..c6e0be82e --- /dev/null +++ b/src/basic/shared/component/Header.tsx @@ -0,0 +1,66 @@ +import { ReactNode } from 'react'; + +export const Header = ({ + admin, + cart, + search, +}: { + admin: { + isAdmin: boolean; + setIsAdmin: (isAdmin: boolean) => void; + }; + cart: { + totalCartItemCount: number; + }; + search?: { + searchInput?: ReactNode; + }; +}) => { + return ( +
+
+
+
+

SHOP

+ {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} + {search?.searchInput} +
+ +
+
+
+ ); +}; diff --git a/src/basic/shared/component/ui/Input.tsx b/src/basic/shared/component/ui/Input.tsx new file mode 100644 index 000000000..67e21eb2a --- /dev/null +++ b/src/basic/shared/component/ui/Input.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +interface InputProps + extends Omit, 'className'> { + className?: string; +} + +export const Input = ({ className = '', ...props }: InputProps) => { + return ( + + ); +}; diff --git a/src/basic/shared/component/ui/Label.tsx b/src/basic/shared/component/ui/Label.tsx new file mode 100644 index 000000000..35d3ad30d --- /dev/null +++ b/src/basic/shared/component/ui/Label.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +interface LabelProps { + children: React.ReactNode; + htmlFor?: string; + className?: string; +} + +export const Label = ({ children, htmlFor, className = '' }: LabelProps) => { + return ( + + ); +}; diff --git a/src/basic/shared/component/ui/Select.tsx b/src/basic/shared/component/ui/Select.tsx new file mode 100644 index 000000000..ace3864e1 --- /dev/null +++ b/src/basic/shared/component/ui/Select.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +interface SelectProps + extends Omit, 'className'> { + className?: string; + children: React.ReactNode; +} + +export const Select = ({ className = '', children, ...props }: SelectProps) => { + return ( + + ); +}; diff --git a/src/basic/shared/component/ui/index.ts b/src/basic/shared/component/ui/index.ts new file mode 100644 index 000000000..33689502e --- /dev/null +++ b/src/basic/shared/component/ui/index.ts @@ -0,0 +1,3 @@ +export { Label } from './Label'; +export { Input } from './Input'; +export { Select } from './Select'; diff --git a/src/basic/shared/utils/priceUtils.tsx b/src/basic/shared/utils/priceUtils.tsx new file mode 100644 index 000000000..e1fd71d14 --- /dev/null +++ b/src/basic/shared/utils/priceUtils.tsx @@ -0,0 +1,3 @@ +export const formatPrice = (price: number): string => { + return price.toLocaleString(); +}; diff --git a/src/basic/shared/utils/validators.ts b/src/basic/shared/utils/validators.ts new file mode 100644 index 000000000..12ca4b07a --- /dev/null +++ b/src/basic/shared/utils/validators.ts @@ -0,0 +1,149 @@ +import { CouponForm } from '../../features/coupon/admin/AdminCouponList'; +import { ProductForm } from '../../features/product/admin/AdminProductList'; + +export interface ValidationError { + field: keyof CouponForm | keyof ProductForm; + message: string; +} + +export const validateCouponForm = ( + form: CouponForm, +): ValidationError | null => { + if (!form.name.trim()) { + return { field: 'name', message: '쿠폰명을 입력해주세요.' }; + } + + if (!form.code.trim()) { + return { field: 'code', message: '쿠폰 코드를 입력해주세요.' }; + } + + if (form.discountType === 'percentage') { + if (form.discountValue > 100) { + return { + field: 'discountValue', + message: '할인율은 100%를 초과할 수 없습니다', + }; + } + if (form.discountValue < 0) { + return { + field: 'discountValue', + message: '할인율은 0 이상이어야 합니다.', + }; + } + } else { + if (form.discountValue > 100000) { + return { + field: 'discountValue', + message: '할인 금액은 100,000원을 초과할 수 없습니다.', + }; + } + if (form.discountValue < 0) { + return { + field: 'discountValue', + message: '할인 금액은 0 이상이어야 합니다.', + }; + } + } + + return null; +}; + +export const validateDiscountValue = ( + value: number, + discountType: 'amount' | 'percentage', +): { isValid: boolean; correctedValue?: number; message?: string } => { + if (discountType === 'percentage') { + if (value > 100) { + return { + isValid: false, + correctedValue: 100, + message: '할인율은 100%를 초과할 수 없습니다', + }; + } + if (value < 0) { + return { + isValid: false, + correctedValue: 0, + message: '할인율은 0 이상이어야 합니다.', + }; + } + } else { + if (value > 100000) { + return { + isValid: false, + correctedValue: 100000, + message: '할인 금액은 100,000원을 초과할 수 없습니다.', + }; + } + if (value < 0) { + return { + isValid: false, + correctedValue: 0, + message: '할인 금액은 0 이상이어야 합니다.', + }; + } + } + + return { isValid: true }; +}; + +// ProductForm validation +export interface ProductValidationError { + field: keyof ProductForm; + message: string; +} + +export const validateProductForm = ( + form: ProductForm, +): ProductValidationError | null => { + if (!form.name.trim()) { + return { field: 'name', message: '상품명을 입력해주세요.' }; + } + + if (form.price <= 0) { + return { field: 'price', message: '가격은 0보다 커야 합니다.' }; + } + + if (form.stock < 0) { + return { field: 'stock', message: '재고는 0 이상이어야 합니다.' }; + } + + if (form.stock > 9999) { + return { field: 'stock', message: '재고는 9999개를 초과할 수 없습니다.' }; + } + + return null; +}; + +export const validatePrice = ( + value: number, +): { isValid: boolean; correctedValue?: number; message?: string } => { + if (value < 0) { + return { + isValid: false, + correctedValue: 0, + message: '가격은 0보다 커야 합니다.', + }; + } + return { isValid: true }; +}; + +export const validateStock = ( + value: number, +): { isValid: boolean; correctedValue?: number; message?: string } => { + if (value < 0) { + return { + isValid: false, + correctedValue: 0, + message: '재고는 0보다 커야 합니다.', + }; + } + if (value > 9999) { + return { + isValid: false, + correctedValue: 9999, + message: '재고는 9999개를 초과할 수 없습니다.', + }; + } + return { isValid: true }; +}; diff --git a/src/refactoring(hint)/utils/hooks/useDebounce.ts b/src/refactoring(hint)/utils/hooks/useDebounce.ts index 53c8a3746..d81b2f1bc 100644 --- a/src/refactoring(hint)/utils/hooks/useDebounce.ts +++ b/src/refactoring(hint)/utils/hooks/useDebounce.ts @@ -6,6 +6,18 @@ // // 사용 예시: 검색어 입력 디바운싱 +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; // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/types.ts b/src/types.ts index 5489e296e..710b17e6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,8 @@ export interface Product { price: number; stock: number; discounts: Discount[]; + description?: string; + isRecommended?: boolean; } export interface Discount { diff --git a/tsconfig.app.json b/tsconfig.app.json index d739292ae..51bb2f770 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,5 +23,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/refactoring(hint)/**", "src/basic/**"] } diff --git a/tsconfig.node.json b/tsconfig.node.json index 3afdd6e38..bccc62268 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -7,7 +7,8 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "strict": true, - "noEmit": true + "noEmit": true, + "types": ["node"] }, "include": ["vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index e6c4016bc..ca8434aae 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,27 @@ import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; +import { resolve } from 'path'; + +const base: string = '/front_7th_chapter3-2/'; export default mergeConfig( defineConfig({ + base, plugins: [react()], + build: { + rollupOptions: { + input: { + advanced: resolve(__dirname, 'index.advanced.html'), + }, + }, + }, }), defineTestConfig({ test: { globals: true, environment: 'jsdom', - setupFiles: './src/setupTests.ts' + setupFiles: './src/setupTests.ts', }, - }) -) + }), +);