diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d6c953795..7c71cbda0 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -5,6 +5,7 @@ module.exports = { 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', + 'prettier', ], ignorePatterns: ['dist', '.eslintrc.cjs'], parser: '@typescript-eslint/parser', diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..e2cece85a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,57 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + - feature-* + workflow_dispatch: + inputs: + branch: + description: "Branch to deploy" + required: true + default: "main" + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + 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@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "dist" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..c8ad74eae --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "avoid", + "endOfLine": "lf" +} diff --git a/index.advanced.html b/index.html similarity index 100% rename from index.advanced.html rename to index.html diff --git a/package.json b/package.json index 17b18de25..11f212edf 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,14 @@ "scripts": { "dev:origin": "vite --open ./index.origin.html", "dev:basic": "vite --open ./index.basic.html", - "dev:advanced": "vite --open ./index.advanced.html", + "dev:advanced": "vite --open ./index.html", + "preview:advanced": "vite preview ./dist", "test": "vitest", "test:origin": "vitest src/origin", "test:basic": "vitest src/basic", "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", - "build": "tsc -b && vite build", + "build": "vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { @@ -30,9 +31,11 @@ "@vitejs/plugin-react-swc": "^3.11.0", "@vitest/ui": "^3.2.4", "eslint": "^9.32.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "jsdom": "^26.1.0", + "prettier": "^3.7.3", "typescript": "^5.9.2", "vite": "^7.0.6", "vitest": "^3.2.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85f..b0edc23d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: eslint: specifier: ^9.32.0 version: 9.32.0 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.32.0) eslint-plugin-react-hooks: specifier: ^5.2.0 version: 5.2.0(eslint@9.32.0) @@ -54,6 +57,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 + prettier: + specifier: ^3.7.3 + version: 3.7.3 typescript: specifier: ^5.9.2 version: 5.9.2 @@ -865,6 +871,12 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + eslint-plugin-react-hooks@5.2.0: resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} engines: {node: '>=10'} @@ -1211,6 +1223,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.7.3: + resolution: {integrity: sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==} + engines: {node: '>=14'} + hasBin: true + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -2216,6 +2233,10 @@ snapshots: escape-string-regexp@4.0.0: {} + eslint-config-prettier@10.1.8(eslint@9.32.0): + dependencies: + eslint: 9.32.0 + eslint-plugin-react-hooks@5.2.0(eslint@9.32.0): dependencies: eslint: 9.32.0 @@ -2559,6 +2580,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.7.3: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1d..3114ca3d0 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1124 +1,48 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState } from 'react'; +import AdminDashboardPage from './pages/adminDashboard/AdminDashboardPage'; +import { type Coupon } from './entities/coupon'; +import ProductPage from './pages/client/ProductPage'; +import { NotificationContainer } from './entities/notification'; +import { Header } from './components/Header'; +import { useDebounce } from './hooks/useDebounce'; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +const SEARCH_DEBOUNCE_DELAY = 500; 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); + // State 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 [selectedCoupon, setSelectedCoupon] = useState(null); 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 debouncedSearchTerm = useDebounce(searchTerm, SEARCH_DEBOUNCE_DELAY); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + // const { notifications, setNotifications, addNotification } = useNotification(); 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()}원 -
-
- - - -
-

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

-
-
- - )} -
-
-
+ )}
); }; -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..67bd72774 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -3,6 +3,11 @@ import { render, screen, fireEvent, within, waitFor } from '@testing-library/rea import { vi } from 'vitest'; import App from '../App'; import '../../setupTests'; +import { AppProvider } from '../providers/AppProvider'; + +const renderWithProvider = (app: React.ReactNode) => { + return render(app, { wrapper: AppProvider }); +}; describe('쇼핑몰 앱 통합 테스트', () => { beforeEach(() => { @@ -19,61 +24,64 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('고객 쇼핑 플로우', () => { test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { - render(); - + renderWithProvider(); + // 검색창에 "프리미엄" 입력 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(); }); test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { - render(); - + renderWithProvider(); + // 상품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(); - + renderWithProvider(); + // 상품 추가 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'); @@ -81,29 +89,29 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('품절 임박 상품에 경고가 표시된다', async () => { - render(); - + renderWithProvider(); + // 관리자 모드로 전환 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(); @@ -111,66 +119,66 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('주문을 완료할 수 있다', () => { - render(); - + renderWithProvider(); + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 결제하기 버튼 클릭 const orderButton = screen.getByText(/원 결제하기/); fireEvent.click(orderButton); - + // 주문 완료 알림 확인 expect(screen.getByText(/주문이 완료되었습니다/)).toBeInTheDocument(); - + // 장바구니가 비어있는지 확인 expect(screen.getByText('장바구니가 비어있습니다')).toBeInTheDocument(); }); test('장바구니에서 상품을 삭제할 수 있다', () => { - render(); - + renderWithProvider(); + // 상품 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(); }); test('재고를 초과하여 구매할 수 없다', async () => { - render(); - + renderWithProvider(); + // 상품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(); @@ -178,28 +186,28 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('장바구니에서 수량을 감소시킬 수 있다', () => { - render(); - + renderWithProvider(); + // 상품 추가 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); // 장바구니가 비었는지 확인 @@ -213,32 +221,32 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('20개 이상 구매 시 최대 할인이 적용된다', async () => { - render(); - + renderWithProvider(); + // 관리자 모드로 전환하여 상품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%'); @@ -250,7 +258,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('관리자 기능', () => { beforeEach(() => { - render(); + renderWithProvider(); // 관리자 모드로 전환 fireEvent.click(screen.getByText('관리자 페이지로')); }); @@ -258,27 +266,27 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('새 상품을 추가할 수 있다', () => { // 새 상품 추가 버튼 클릭 fireEvent.click(screen.getByText('새 상품 추가')); - + // 폼 입력 - 상품명 입력 const labels = screen.getAllByText('상품명'); 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 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 +295,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 +323,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,20 +350,20 @@ 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(); @@ -362,15 +374,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,57 +391,55 @@ 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% 할인'); 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 + 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(); - + renderWithProvider(); + // 상품을 장바구니에 추가 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 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')); @@ -437,18 +447,18 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('페이지 새로고침 후에도 데이터가 유지된다', () => { - const { unmount } = render(); - + const { unmount } = renderWithProvider(); + // 장바구니에 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 컴포넌트 unmount unmount(); - + // 다시 mount - render(); - + renderWithProvider(); + // 장바구니 아이템이 유지되는지 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -458,14 +468,14 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('UI 상태 관리', () => { test('할인이 있을 때 할인율이 표시된다', async () => { - render(); - + renderWithProvider(); + // 상품을 10개 담아서 할인 발생 const addButton = screen.getAllByText('장바구니 담기')[0]; for (let i = 0; i < 10; i++) { fireEvent.click(addButton); } - + // 할인율 표시 확인 - 대량 구매로 15% 할인 await waitFor(() => { expect(screen.getByText('-15%')).toBeInTheDocument(); @@ -473,35 +483,37 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('장바구니 아이템 개수가 헤더에 표시된다', () => { - render(); - + renderWithProvider(); + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 헤더의 장바구니 아이콘 옆 숫자 확인 const cartCount = screen.getByText('3'); expect(cartCount).toBeInTheDocument(); }); test('검색을 초기화할 수 있다', async () => { - render(); - + renderWithProvider(); + // 검색어 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 검색 결과 확인 await waitFor(() => { 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(); @@ -511,18 +523,21 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('알림 메시지가 자동으로 사라진다', async () => { - render(); - + renderWithProvider(); + // 상품 추가하여 알림 발생 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 알림 메시지 확인 expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); - + // 3초 후 알림이 사라짐 - await waitFor(() => { - expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); - }, { timeout: 4000 }); + await waitFor( + () => { + expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); + }, + { timeout: 4000 } + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/advanced/components/Header.tsx b/src/advanced/components/Header.tsx new file mode 100644 index 000000000..b4a64bc8d --- /dev/null +++ b/src/advanced/components/Header.tsx @@ -0,0 +1,69 @@ +import { useCartContext } from '../providers/CartProvider'; +import { useMemo } from 'react'; + +interface HeaderProps { + isAdmin: boolean; + searchTerm: string; + setIsAdmin: (value: boolean) => void; + setSearchTerm: (value: string) => void; +} + +export function Header({ isAdmin, searchTerm, setIsAdmin, setSearchTerm }: HeaderProps) { + const { cart } = useCartContext(); + const totalItemCount = useMemo(() => cart.reduce((sum, item) => sum + item.quantity, 0), [cart]); + return ( +
+
+
+
+

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" + /> +
+ )} +
+ +
+
+
+ ); +} diff --git a/src/advanced/entities/cart/hooks/useCartStorage.ts b/src/advanced/entities/cart/hooks/useCartStorage.ts new file mode 100644 index 000000000..934f474cd --- /dev/null +++ b/src/advanced/entities/cart/hooks/useCartStorage.ts @@ -0,0 +1,6 @@ +import { CartItem } from '../../../../types'; +import { useLocalStorageState } from '../../../hooks/useLocalStorageState'; + +export const useCartStorage = (initialCart: CartItem[]) => { + return useLocalStorageState('cart', initialCart); +}; diff --git a/src/advanced/entities/cart/index.ts b/src/advanced/entities/cart/index.ts new file mode 100644 index 000000000..566e4f0f0 --- /dev/null +++ b/src/advanced/entities/cart/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './utils'; +export * from './hooks/useCartStorage'; diff --git a/src/advanced/entities/cart/types.ts b/src/advanced/entities/cart/types.ts new file mode 100644 index 000000000..518d44d18 --- /dev/null +++ b/src/advanced/entities/cart/types.ts @@ -0,0 +1,6 @@ +import { Product } from '../product/types'; + +export interface CartItem { + product: Product; + quantity: number; +} diff --git a/src/advanced/entities/cart/utils.ts b/src/advanced/entities/cart/utils.ts new file mode 100644 index 000000000..66909f086 --- /dev/null +++ b/src/advanced/entities/cart/utils.ts @@ -0,0 +1,81 @@ +import { CartItem } from './types'; +import { Product } from '../product/types'; + +const BULK_PURCHASE_DISCOUNT = 0.05; +const BULK_PURCHASE_QUANTITY = 10; +const MAX_DISCOUNT = 0.5; + +// ADD TO CART +const getExistingItem = (cart: CartItem[], product: Product) => + cart.find(item => item.product.id === product.id); +export const getAddToCart = (cart: CartItem[], product: Product) => { + const existingItem = getExistingItem(cart, product); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + if (newQuantity > product.stock) { + return cart; + } + return cart.map(item => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); + } + return [...cart, { product, quantity: 1 }]; +}; + +export const canAddToCart = (cart: CartItem[], product: Product) => { + const existingItem = getExistingItem(cart, product); + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + return newQuantity <= product.stock; + } + return true; +}; + +// UPDATE CART QUANTITY +export const getUpdateCartQuantity = (cart: CartItem[], productId: string, newQuantity: number) => { + return cart.map(item => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ); +}; + +// CALCULATION +export const getApplicableDiscount = (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); + + return baseDiscount; +}; + +export const getBulkPurchaseDiscount = (cartItems: CartItem[]): number => { + return cartItems.some(cartItem => cartItem.quantity >= BULK_PURCHASE_QUANTITY) + ? BULK_PURCHASE_DISCOUNT + : 0; +}; + +export const calculateItemTotal = (item: CartItem, cartItems: CartItem[]): number => { + const { price } = item.product; + const { quantity } = item; + const baseDiscount = getApplicableDiscount(item); + const bulkPurchaseDiscount = getBulkPurchaseDiscount(cartItems); + const discount = Math.min(baseDiscount + bulkPurchaseDiscount, MAX_DISCOUNT); + + return Math.round(price * quantity * (1 - discount)); +}; + +export const getOriginTotal = (cartItems: CartItem[]): number => { + return cartItems.reduce((total, item) => { + return total + item.product.price * item.quantity; + }, 0); +}; +export const getTotalWithDiscount = (cartItems: CartItem[]): number => { + return cartItems.reduce((total, item) => { + return total + calculateItemTotal(item, cartItems); + }, 0); +}; diff --git a/src/advanced/entities/coupon/hooks/useCouponsStorage.ts b/src/advanced/entities/coupon/hooks/useCouponsStorage.ts new file mode 100644 index 000000000..8e22d156a --- /dev/null +++ b/src/advanced/entities/coupon/hooks/useCouponsStorage.ts @@ -0,0 +1,6 @@ +import { useLocalStorageState } from '../../../hooks/useLocalStorageState'; +import { Coupon } from '../types'; + +export const useCouponsStorage = (initialCoupons: Coupon[]) => { + return useLocalStorageState('coupons', initialCoupons); +}; diff --git a/src/advanced/entities/coupon/index.ts b/src/advanced/entities/coupon/index.ts new file mode 100644 index 000000000..9e9f2ea04 --- /dev/null +++ b/src/advanced/entities/coupon/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './utils'; +export * from './hooks/useCouponsStorage'; diff --git a/src/advanced/entities/coupon/types.ts b/src/advanced/entities/coupon/types.ts new file mode 100644 index 000000000..d8a2e6816 --- /dev/null +++ b/src/advanced/entities/coupon/types.ts @@ -0,0 +1,6 @@ +export interface Coupon { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} diff --git a/src/advanced/entities/coupon/utils.ts b/src/advanced/entities/coupon/utils.ts new file mode 100644 index 000000000..278e375d4 --- /dev/null +++ b/src/advanced/entities/coupon/utils.ts @@ -0,0 +1,22 @@ +import { Coupon } from './types'; +const MIN_TOTAL_FOR_PERCENTAGE_COUPON = 10000; + +// APPLY COUPON +export const canApplyCoupon = (total: number, coupon: Coupon) => { + if (total < MIN_TOTAL_FOR_PERCENTAGE_COUPON && coupon.discountType === 'percentage') return false; + return true; +}; + +export const getTotalWithCoupon = (total: number, coupon: Coupon | null) => { + if (!coupon) return Math.round(total); + return coupon.discountType === 'amount' + ? Math.max(0, total - coupon.discountValue) + : Math.round(total * (1 - coupon.discountValue / 100)); +}; + +// ADD COUPON +export const canAddCoupon = (coupons: Coupon[], newCoupon: Coupon) => { + const existingCoupon = coupons.find(c => c.code === newCoupon.code); + if (existingCoupon) return false; + return true; +}; diff --git a/src/advanced/entities/notification/components/NotificationContainer.tsx b/src/advanced/entities/notification/components/NotificationContainer.tsx new file mode 100644 index 000000000..f9b997379 --- /dev/null +++ b/src/advanced/entities/notification/components/NotificationContainer.tsx @@ -0,0 +1,14 @@ +import NotificationToast from './NotificationToast'; +import { useNotificationContext } from '../../../providers/NotificationProvider'; + +export function NotificationContainer() { + const { notifications } = useNotificationContext(); + if (notifications.length === 0) return null; + return ( +
+ {notifications.map(notif => ( + + ))} +
+ ); +} diff --git a/src/advanced/entities/notification/components/NotificationToast.tsx b/src/advanced/entities/notification/components/NotificationToast.tsx new file mode 100644 index 000000000..6e9a90ca6 --- /dev/null +++ b/src/advanced/entities/notification/components/NotificationToast.tsx @@ -0,0 +1,37 @@ +import { type Notification } from '../types'; +import { useNotificationContext } from '../../../providers/NotificationProvider'; + +interface NotificationToastProps { + notification: Notification; +} + +export default function NotificationToast({ notification }: NotificationToastProps) { + const { setNotifications } = useNotificationContext(); + const handleClose = () => { + setNotifications(prev => prev.filter(n => n.id !== notification.id)); + }; + + return ( +
+ {notification.message} + +
+ ); +} diff --git a/src/advanced/entities/notification/index.ts b/src/advanced/entities/notification/index.ts new file mode 100644 index 000000000..b15f1be9e --- /dev/null +++ b/src/advanced/entities/notification/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './hooks/useNotification'; +export * from './components/NotificationContainer'; diff --git a/src/advanced/entities/notification/types.ts b/src/advanced/entities/notification/types.ts new file mode 100644 index 000000000..38323b77d --- /dev/null +++ b/src/advanced/entities/notification/types.ts @@ -0,0 +1,5 @@ +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} diff --git a/src/advanced/entities/product/constants.ts b/src/advanced/entities/product/constants.ts new file mode 100644 index 000000000..f2e7b1d59 --- /dev/null +++ b/src/advanced/entities/product/constants.ts @@ -0,0 +1,9 @@ +import { ProductFormState } from './types'; + +export const INITIAL_PRODUCT_FORM_STATE: ProductFormState = { + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], +}; diff --git a/src/advanced/entities/product/hooks/useProductsStorage.ts b/src/advanced/entities/product/hooks/useProductsStorage.ts new file mode 100644 index 000000000..5092beba9 --- /dev/null +++ b/src/advanced/entities/product/hooks/useProductsStorage.ts @@ -0,0 +1,6 @@ +import { ProductWithUI } from '../types'; +import { useLocalStorageState } from '../../../hooks/useLocalStorageState'; + +export const useProductsStorage = (initialProducts: ProductWithUI[]) => { + return useLocalStorageState('products', initialProducts); +}; diff --git a/src/advanced/entities/product/index.ts b/src/advanced/entities/product/index.ts new file mode 100644 index 000000000..ecb2fcf5d --- /dev/null +++ b/src/advanced/entities/product/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './utils'; +export * from './constants'; +export * from './hooks/useProductsStorage'; diff --git a/src/advanced/entities/product/types.ts b/src/advanced/entities/product/types.ts new file mode 100644 index 000000000..81b04fd0b --- /dev/null +++ b/src/advanced/entities/product/types.ts @@ -0,0 +1,25 @@ +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface Discount { + quantity: number; + rate: number; +} + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export interface ProductFormState { + name: string; + price: number; + stock: number; + description: string; + discounts: Array; +} diff --git a/src/advanced/entities/product/utils.ts b/src/advanced/entities/product/utils.ts new file mode 100644 index 000000000..46cec12ca --- /dev/null +++ b/src/advanced/entities/product/utils.ts @@ -0,0 +1,59 @@ +import { Discount, Product, ProductWithUI } from './types'; +import { CartItem } from '../cart/types'; +import { generateId } from '../../utils/id-generator'; + +// GET REMAINING STOCK +export const getRemainingStock = (product: Product, cartItems: CartItem[]): number => { + const cartItem = cartItems.find(item => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +}; + +// FILTER PRODUCTS +export const filterProducts = ( + products: ProductWithUI[], + debouncedSearchTerm: string +): ProductWithUI[] => { + if (!debouncedSearchTerm) return products; + return products.filter(product => { + const name = product.name.toLowerCase(); + const description = product.description?.toLowerCase() || ''; + return ( + name.includes(debouncedSearchTerm.toLowerCase()) || + description.includes(debouncedSearchTerm.toLowerCase()) + ); + }); +}; + +// ADD PRODUCT +export const getNewProducts = ( + products: ProductWithUI[], + newProduct: Omit, + idGenerator: () => string = () => generateId('product') +): ProductWithUI[] => [...products, { ...newProduct, id: idGenerator() }]; + +// UPDATE PRODUCT +export const getUpdatedProducts = ( + products: ProductWithUI[], + productId: string, + updates: Partial +): ProductWithUI[] => + products.map(product => (product.id === productId ? { ...product, ...updates } : product)); + +// DELETE PRODUCT +export const getDeletedProducts = (products: ProductWithUI[], productId: string): ProductWithUI[] => + products.filter(product => product.id !== productId); + +// DISCOUNT FORM +export const getDeletedDiscounts = (discounts: Discount[], index: number): Discount[] => + discounts.filter((_, i) => i !== index); + +// SHOW +export const showSoldOutWarningMessage = (remainingStock: number): boolean => { + return remainingStock <= 5 && remainingStock > 0; +}; + +export const showInStockMessage = (remainingStock: number): boolean => { + return remainingStock > 5; +}; diff --git a/src/advanced/hooks/useDebounce.ts b/src/advanced/hooks/useDebounce.ts new file mode 100644 index 000000000..2a4f25aba --- /dev/null +++ b/src/advanced/hooks/useDebounce.ts @@ -0,0 +1,12 @@ +import { useState, useEffect } from 'react'; + +export const 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; +}; diff --git a/src/advanced/hooks/useLocalStorageState.ts b/src/advanced/hooks/useLocalStorageState.ts new file mode 100644 index 000000000..378993690 --- /dev/null +++ b/src/advanced/hooks/useLocalStorageState.ts @@ -0,0 +1,16 @@ +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; +export const useLocalStorageState = (key: string, initialValue: T) => { + const [state, setState] = useState(() => { + const saved = localStorage.getItem(key); + if (saved) { + return JSON.parse(saved); + } + return initialValue; + }); + + useEffect(() => { + localStorage.setItem(key, JSON.stringify(state)); + }, [state, key]); + + return [state, setState] as [T, Dispatch>]; +}; diff --git a/src/advanced/main.tsx b/src/advanced/main.tsx index e63eef4a8..9d6757a64 100644 --- a/src/advanced/main.tsx +++ b/src/advanced/main.tsx @@ -1,9 +1,12 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; +import { AppProvider } from './providers/AppProvider.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( - - , -) + + + + +); diff --git a/src/advanced/mock/coupon.ts b/src/advanced/mock/coupon.ts new file mode 100644 index 000000000..acf0e3624 --- /dev/null +++ b/src/advanced/mock/coupon.ts @@ -0,0 +1,15 @@ +import { Coupon } from '../entities/coupon/types'; +export const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; diff --git a/src/advanced/mock/product.ts b/src/advanced/mock/product.ts new file mode 100644 index 000000000..51d9ca823 --- /dev/null +++ b/src/advanced/mock/product.ts @@ -0,0 +1,35 @@ +import { ProductWithUI } from '../entities/product/types'; + +export const initialProducts: ProductWithUI[] = [ + { + id: 'p1', + name: '상품1', + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: '최고급 품질의 프리미엄 상품입니다.', + }, + { + id: 'p2', + name: '상품2', + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: '다양한 기능을 갖춘 실용적인 상품입니다.', + isRecommended: true, + }, + { + id: 'p3', + name: '상품3', + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: '대용량과 고성능을 자랑하는 상품입니다.', + }, +]; diff --git a/src/advanced/pages/adminDashboard/AdminDashboardPage.tsx b/src/advanced/pages/adminDashboard/AdminDashboardPage.tsx new file mode 100644 index 000000000..4e090368d --- /dev/null +++ b/src/advanced/pages/adminDashboard/AdminDashboardPage.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; +import { type Coupon } from '../../entities/coupon'; +import ProductTab from './product-tab/ProductTab'; +import CouponTab from './coupon-tab/CouponTab'; +import { Dispatch, SetStateAction } from 'react'; + +interface AdminDashboardPageProps { + selectedCoupon: Coupon | null; + setSelectedCoupon: Dispatch>; +} + +export default function AdminDashboardPage({ + selectedCoupon, + setSelectedCoupon, +}: AdminDashboardPageProps) { + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + + return ( +
+
+

관리자 대시보드

+

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

+
+
+ +
+ + {activeTab === 'products' ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/advanced/pages/adminDashboard/coupon-tab/CouponForm.tsx b/src/advanced/pages/adminDashboard/coupon-tab/CouponForm.tsx new file mode 100644 index 000000000..baa5b97ca --- /dev/null +++ b/src/advanced/pages/adminDashboard/coupon-tab/CouponForm.tsx @@ -0,0 +1,137 @@ +import { Dispatch, SetStateAction } from 'react'; +import { type Coupon } from '../../../entities/coupon'; +interface CouponFormProps { + couponForm: Coupon; + setCouponForm: Dispatch>; + setShowCouponForm: Dispatch>; + handleCouponSubmit: (e: React.FormEvent) => void; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export default function CouponForm({ + couponForm, + setCouponForm, + setShowCouponForm, + handleCouponSubmit, + addNotification, +}: CouponFormProps) { + const handleChangeFormName = (e: React.ChangeEvent) => { + setCouponForm({ ...couponForm, name: e.target.value }); + }; + + const handleChangeFormCode = (e: React.ChangeEvent) => { + setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }); + }; + + const handleChangeFormDiscountType = (e: React.ChangeEvent) => { + setCouponForm({ ...couponForm, discountType: e.target.value as 'amount' | 'percentage' }); + }; + + const handleChangeFormDiscountValue = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); + } + }; + + const handleBlurFormDiscountValue = (e: React.FocusEvent) => { + 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 }); + } + } + }; + + const handleCancelCoupon = () => { + setShowCouponForm(false); + setCouponForm({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + }; + + return ( +
+
+

새 쿠폰 생성

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ ); +} diff --git a/src/advanced/pages/adminDashboard/coupon-tab/CouponItem.tsx b/src/advanced/pages/adminDashboard/coupon-tab/CouponItem.tsx new file mode 100644 index 000000000..8eda0e764 --- /dev/null +++ b/src/advanced/pages/adminDashboard/coupon-tab/CouponItem.tsx @@ -0,0 +1,38 @@ +import { type Coupon } from '../../../entities/coupon'; +interface CouponItemProps { + coupon: Coupon; + deleteCoupon: (couponCode: string) => void; +} + +export default function CouponItem({ coupon, deleteCoupon }: CouponItemProps) { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +} diff --git a/src/advanced/pages/adminDashboard/coupon-tab/CouponTab.tsx b/src/advanced/pages/adminDashboard/coupon-tab/CouponTab.tsx new file mode 100644 index 000000000..2191230db --- /dev/null +++ b/src/advanced/pages/adminDashboard/coupon-tab/CouponTab.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import CouponItem from './CouponItem'; +import CouponForm from './CouponForm'; +import { type Coupon, canAddCoupon } from '../../../entities/coupon'; +import { Dispatch, SetStateAction } from 'react'; +import { useCouponContext } from '../../../providers/CouponProvider'; +import { useNotificationContext } from '../../../providers/NotificationProvider'; + +interface CouponTabProps { + selectedCoupon: Coupon | null; + setSelectedCoupon: Dispatch>; +} + +export default function CouponTab({ selectedCoupon, setSelectedCoupon }: CouponTabProps) { + const { coupons, setCoupons } = useCouponContext(); + const [showCouponForm, setShowCouponForm] = useState(false); + const { addNotification } = useNotificationContext(); + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + + const addCoupon = (newCoupon: Coupon) => { + if (!canAddCoupon(coupons, newCoupon)) { + addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); + return; + } + setCoupons(prev => [...prev, newCoupon]); + addNotification('쿠폰이 추가되었습니다.', 'success'); + }; + + const deleteCoupon = (couponCode: string) => { + setCoupons(prev => prev.filter(c => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }; + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + setShowCouponForm(false); + }; + + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map(coupon => ( + + ))} + +
+ +
+
+ + {showCouponForm && ( + + )} +
+
+ ); +} diff --git a/src/advanced/pages/adminDashboard/product-tab/DiscountForm.tsx b/src/advanced/pages/adminDashboard/product-tab/DiscountForm.tsx new file mode 100644 index 000000000..7d1f7bcdc --- /dev/null +++ b/src/advanced/pages/adminDashboard/product-tab/DiscountForm.tsx @@ -0,0 +1,91 @@ +import { + type Discount, + type ProductFormState, + getDeletedDiscounts, +} from '../../../entities/product'; +import { Dispatch, SetStateAction } from 'react'; + +interface DiscountFormProps { + index: number; + productForm: ProductFormState; + discount: Discount; + setProductForm: Dispatch>; +} + +export default function DiscountForm({ + index, + productForm, + discount, + setProductForm, +}: DiscountFormProps) { + const handleChangeFormQuantity = (index: number, e: React.ChangeEvent) => { + const value = e.target.value; + setProductForm(() => { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = parseInt(value) || 0; + return { ...productForm, discounts: newDiscounts }; + }); + }; + + const handleChangeFormRate = (index: number, e: React.ChangeEvent) => { + const value = e.target.value; + setProductForm(() => { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = (parseInt(value) || 0) / 100; + return { ...productForm, discounts: newDiscounts }; + }); + }; + + const handleDeleteDiscount = (index: number) => { + setProductForm({ + ...productForm, + discounts: getDeletedDiscounts(productForm.discounts, index), + }); + }; + + return ( +
+ { + handleChangeFormQuantity(index, e); + }} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + handleChangeFormRate(index, e); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ); +} diff --git a/src/advanced/pages/adminDashboard/product-tab/ProductForm.tsx b/src/advanced/pages/adminDashboard/product-tab/ProductForm.tsx new file mode 100644 index 000000000..84062cd49 --- /dev/null +++ b/src/advanced/pages/adminDashboard/product-tab/ProductForm.tsx @@ -0,0 +1,209 @@ +import { + type ProductFormState, + type ProductWithUI, + getNewProducts, + getUpdatedProducts, + INITIAL_PRODUCT_FORM_STATE, +} from '../../../entities/product'; +import DiscountForm from './DiscountForm'; +import { Dispatch, SetStateAction } from 'react'; + +interface ProductFormProps { + editingProduct: string | null; + productForm: ProductFormState; + products: ProductWithUI[]; + setProducts: Dispatch>; + setProductForm: Dispatch>; + setEditingProduct: Dispatch>; + setShowProductForm: Dispatch>; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export default function ProductForm({ + editingProduct, + productForm, + products, + setProducts, + setProductForm, + setEditingProduct, + setShowProductForm, + addNotification, +}: ProductFormProps) { + // Form event handlers + const handleChangeFormInputValue = (e: React.ChangeEvent) => { + const id = e.target.id; + const value = e.target.value; + if (id === 'price' || id === 'stock') { + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ ...productForm, [id]: value === '' ? 0 : parseInt(value) }); + } + } else { + setProductForm({ ...productForm, [id]: value }); + } + }; + const handleBlurFormPrice = (e: React.FocusEvent) => { + const value = e.target.value; + if (value === '') { + setProductForm({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification('가격은 0보다 커야 합니다', 'error'); + setProductForm({ ...productForm, price: 0 }); + } + }; + const handleBlurFormStock = (e: React.FocusEvent) => { + 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 }); + } + }; + + // Product event handlers + const addProduct = (newProduct: Omit) => { + setProducts(getNewProducts(products, newProduct)); + addNotification('상품이 추가되었습니다.', 'success'); + }; + + const updateProduct = (productId: string, updates: Partial) => { + setProducts(getUpdatedProducts(products, productId, updates)); + addNotification('상품이 수정되었습니다.', 'success'); + }; + + 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 handleAddDiscount = () => { + setProductForm({ + ...productForm, + discounts: [...productForm.discounts, { quantity: 10, rate: 0.1 }], + }); + }; + + const handleCancelProduct = () => { + setEditingProduct(null); + setProductForm(INITIAL_PRODUCT_FORM_STATE); + setShowProductForm(false); + }; + + return ( +
+
+

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

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ {productForm.discounts.map((discount, index) => ( + + ))} + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/advanced/pages/adminDashboard/product-tab/ProductListRowItem.tsx b/src/advanced/pages/adminDashboard/product-tab/ProductListRowItem.tsx new file mode 100644 index 000000000..f6e0b9276 --- /dev/null +++ b/src/advanced/pages/adminDashboard/product-tab/ProductListRowItem.tsx @@ -0,0 +1,54 @@ +import { formatPriceKRW } from '../../../utils/formatters'; +import { type ProductWithUI } from '../../../entities/product'; + +interface ProductListRowItemProps { + product: ProductWithUI; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +} +export default function ProductListRowItem({ + product, + startEditProduct, + deleteProduct, +}: ProductListRowItemProps) { + return ( + + + {product.name} + + + {formatPriceKRW(product.price, 'suffix')} + + + 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/pages/adminDashboard/product-tab/ProductTab.tsx b/src/advanced/pages/adminDashboard/product-tab/ProductTab.tsx new file mode 100644 index 000000000..ca9636d45 --- /dev/null +++ b/src/advanced/pages/adminDashboard/product-tab/ProductTab.tsx @@ -0,0 +1,105 @@ +import { + type ProductFormState, + type ProductWithUI, + getDeletedProducts, + INITIAL_PRODUCT_FORM_STATE, +} from '../../../entities/product'; +import { useState } from 'react'; +import ProductForm from './ProductForm'; +import ProductListRowItem from './ProductListRowItem'; +import { useProductContext } from '../../../providers/ProductProvider'; +import { useNotificationContext } from '../../../providers/NotificationProvider'; + +export default function ProductTab() { + const { products, setProducts } = useProductContext(); + const { addNotification } = useNotificationContext(); + const [editingProduct, setEditingProduct] = useState(null); + const [showProductForm, setShowProductForm] = useState(false); + + const [productForm, setProductForm] = useState(INITIAL_PRODUCT_FORM_STATE); + + 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 handleAddProduct = () => { + setEditingProduct('new'); + setProductForm(INITIAL_PRODUCT_FORM_STATE); + setShowProductForm(true); + }; + + const deleteProduct = (productId: string) => { + setProducts(getDeletedProducts(products, productId)); + addNotification('상품이 삭제되었습니다.', 'success'); + }; + + return ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map(product => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+ {showProductForm && ( + + )} +
+ ); +} diff --git a/src/advanced/pages/client/ProductPage.tsx b/src/advanced/pages/client/ProductPage.tsx new file mode 100644 index 000000000..45f37d802 --- /dev/null +++ b/src/advanced/pages/client/ProductPage.tsx @@ -0,0 +1,22 @@ +import CartView from './cart-view/CartView'; +import ProductSection from './product-section/ProductSection'; +import { type Coupon } from '../../entities/coupon'; +import { Dispatch, SetStateAction } from 'react'; +interface ProductPageProps { + selectedCoupon: Coupon | null; + debouncedSearchTerm: string; + setSelectedCoupon: Dispatch>; +} + +export default function ProductPage({ + selectedCoupon, + setSelectedCoupon, + debouncedSearchTerm, +}: ProductPageProps) { + return ( +
+ + +
+ ); +} diff --git a/src/advanced/pages/client/cart-view/CartListItem.tsx b/src/advanced/pages/client/cart-view/CartListItem.tsx new file mode 100644 index 000000000..6a5c4b2a0 --- /dev/null +++ b/src/advanced/pages/client/cart-view/CartListItem.tsx @@ -0,0 +1,61 @@ +import { formatPriceKRW } from '../../../utils/formatters'; +import { type CartItem } from '../../../entities/cart'; +interface CartListItemProps { + item: CartItem; + itemTotal: number; + discountRate: number; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +} + +export default function CartListItem({ + item, + itemTotal, + discountRate, + removeFromCart, + updateQuantity, +}: CartListItemProps) { + return ( +
+
+

{item.product.name}

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

{formatPriceKRW(itemTotal, 'suffix')}

+
+
+
+ ); +} diff --git a/src/advanced/pages/client/cart-view/CartSection.tsx b/src/advanced/pages/client/cart-view/CartSection.tsx new file mode 100644 index 000000000..aaa5855de --- /dev/null +++ b/src/advanced/pages/client/cart-view/CartSection.tsx @@ -0,0 +1,64 @@ +import { type CartItem, calculateItemTotal } from '../../../entities/cart'; +import CartListItem from './CartListItem'; + +interface CartSectionProps { + cart: CartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +} + +export default function CartSection({ cart, removeFromCart, updateQuantity }: CartSectionProps) { + return ( +
+

+ + + + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map(item => { + const itemTotal = calculateItemTotal(item, cart); + const originalPrice = item.product.price * item.quantity; + const discountRate = + itemTotal < originalPrice ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; + + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/advanced/pages/client/cart-view/CartView.tsx b/src/advanced/pages/client/cart-view/CartView.tsx new file mode 100644 index 000000000..819e2969c --- /dev/null +++ b/src/advanced/pages/client/cart-view/CartView.tsx @@ -0,0 +1,100 @@ +import { useMemo } from 'react'; +import PaymentInfoSection from './PaymentInfoSection'; +import CouponSection from './CouponSection'; +import CartSection from './CartSection'; +import { + getUpdateCartQuantity, + getTotalWithDiscount, + getOriginTotal, +} from '../../../entities/cart'; +import { type Coupon, canApplyCoupon, getTotalWithCoupon } from '../../../entities/coupon'; +import { Dispatch, SetStateAction } from 'react'; +import { useProductContext } from '../../../providers/ProductProvider'; +import { useCartContext } from '../../../providers/CartProvider'; +import { generateId } from '../../../utils/id-generator'; +import { useNotificationContext } from '../../../providers/NotificationProvider'; +interface CartViewProps { + selectedCoupon: Coupon | null; + setSelectedCoupon: Dispatch>; +} + +export default function CartView({ selectedCoupon, setSelectedCoupon }: CartViewProps) { + const { products } = useProductContext(); + const { cart, setCart } = useCartContext(); + const { addNotification } = useNotificationContext(); + // Computed Values + const originTotal = useMemo(() => { + const total = getOriginTotal(cart); + return Math.round(total); + }, [cart]); + + const caculatedTotal = useMemo(() => { + const total = getTotalWithDiscount(cart); + const totalWithCoupon = getTotalWithCoupon(total, selectedCoupon); + return Math.round(totalWithCoupon); + }, [cart, selectedCoupon]); + + // Events + const removeFromCart = (productId: string) => { + setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + }; + + const updateQuantity = (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 => getUpdateCartQuantity(prevCart, productId, newQuantity)); + }; + + const applyCoupon = (coupon: Coupon) => { + if (!canApplyCoupon(caculatedTotal, coupon)) { + addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); + return; + } + + setSelectedCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }; + + const completeOrder = () => { + const orderNumber = generateId('ORD'); + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + setCart([]); + setSelectedCoupon(null); + }; + + return ( +
+
+ + + {cart.length > 0 && ( + + )} + + {cart.length > 0 && ( + + )} +
+
+ ); +} diff --git a/src/advanced/pages/client/cart-view/CouponSection.tsx b/src/advanced/pages/client/cart-view/CouponSection.tsx new file mode 100644 index 000000000..bd4f1ec61 --- /dev/null +++ b/src/advanced/pages/client/cart-view/CouponSection.tsx @@ -0,0 +1,45 @@ +import { formatPriceKRW } from '../../../utils/formatters'; +import { type Coupon } from '../../../entities/coupon'; +import { useCouponContext } from '../../../providers/CouponProvider'; +import { Dispatch, SetStateAction } from 'react'; +interface CouponSectionProps { + selectedCoupon: Coupon | null; + setSelectedCoupon: Dispatch>; + applyCoupon: (coupon: Coupon) => void; +} + +export default function CouponSection({ + selectedCoupon, + setSelectedCoupon, + applyCoupon, +}: CouponSectionProps) { + const { coupons } = useCouponContext(); + return ( +
+
+

쿠폰 할인

+ +
+ +
+ ); +} diff --git a/src/advanced/pages/client/cart-view/PaymentInfoSection.tsx b/src/advanced/pages/client/cart-view/PaymentInfoSection.tsx new file mode 100644 index 000000000..5588e628d --- /dev/null +++ b/src/advanced/pages/client/cart-view/PaymentInfoSection.tsx @@ -0,0 +1,48 @@ +import { formatPriceKRW } from '../../../utils/formatters'; + +interface PaymentInfoSectionProps { + originTotal: number; + caculatedTotal: number; + completeOrder: () => void; +} + +export default function PaymentInfoSection({ + originTotal, + caculatedTotal, + completeOrder, +}: PaymentInfoSectionProps) { + return ( +
+

결제 정보

+
+
+ 상품 금액 + {formatPriceKRW(originTotal, 'suffix')} +
+ {originTotal - caculatedTotal > 0 && ( +
+ 할인 금액 + -{formatPriceKRW(originTotal - caculatedTotal, 'suffix')} +
+ )} +
+ 결제 예정 금액 + + {formatPriceKRW(caculatedTotal, 'suffix')} + +
+
+ + + +
+

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

+
+
+ ); +} diff --git a/src/advanced/pages/client/product-section/ProductCard.tsx b/src/advanced/pages/client/product-section/ProductCard.tsx new file mode 100644 index 000000000..2cb11d202 --- /dev/null +++ b/src/advanced/pages/client/product-section/ProductCard.tsx @@ -0,0 +1,91 @@ +import { + type ProductWithUI, + showSoldOutWarningMessage, + showInStockMessage, +} from '../../../entities/product'; +import { formatPriceKRW } from '../../../utils/formatters'; +interface ProductCardProps { + product: ProductWithUI; + remainingStock: number; + addToCart: (product: ProductWithUI) => void; +} + +export default function ProductCard({ product, remainingStock, addToCart }: ProductCardProps) { + const isSoldOut = remainingStock <= 0; + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

{product.description}

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

+ {isSoldOut ? 'SOLD OUT' : formatPriceKRW(product.price, 'prefix')} +

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

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

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

품절임박! {remainingStock}개 남음

+ )} + {showInStockMessage(remainingStock) && ( +

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +} diff --git a/src/advanced/pages/client/product-section/ProductSection.tsx b/src/advanced/pages/client/product-section/ProductSection.tsx new file mode 100644 index 000000000..9b20b1711 --- /dev/null +++ b/src/advanced/pages/client/product-section/ProductSection.tsx @@ -0,0 +1,63 @@ +import { canAddToCart, getAddToCart } from '../../../entities/cart'; +import { type ProductWithUI, filterProducts, getRemainingStock } from '../../../entities/product'; +import ProductCard from './ProductCard'; +import { useCartContext } from '../../../providers/CartProvider'; +import { useProductContext } from '../../../providers/ProductProvider'; +import { useNotificationContext } from '../../../providers/NotificationProvider'; +interface ProductSectionProps { + debouncedSearchTerm: string; +} + +export default function ProductSection({ debouncedSearchTerm }: ProductSectionProps) { + const { products } = useProductContext(); + const { cart, setCart } = useCartContext(); + const { addNotification } = useNotificationContext(); + const filteredProducts = filterProducts(products, debouncedSearchTerm); + + // Events + const addToCart = (product: ProductWithUI) => { + if (!canAddToCart(cart, product)) { + const remainingStock = getRemainingStock(product, cart); + if (remainingStock <= 0) { + addNotification('재고가 부족합니다!', 'error'); + } else { + addNotification(`재고는 ${remainingStock}개까지만 있습니다.`, 'error'); + } + return; + } + + setCart(getAddToCart(cart, product)); + addNotification('장바구니에 담았습니다', 'success'); + }; + return ( +
+ {/* 상품 목록 */} +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map(product => { + const remainingStock = getRemainingStock(product, cart); + + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/advanced/providers/AppProvider.tsx b/src/advanced/providers/AppProvider.tsx new file mode 100644 index 000000000..ab522ee6b --- /dev/null +++ b/src/advanced/providers/AppProvider.tsx @@ -0,0 +1,16 @@ +import { ProductProvider } from './ProductProvider'; +import { CouponProvider } from './CouponProvider'; +import { CartProvider } from './CartProvider'; +import { NotificationProvider } from './NotificationProvider'; + +export const AppProvider = ({ children }: { children: React.ReactNode }) => { + return ( + + + + {children} + + + + ); +}; diff --git a/src/advanced/providers/CartProvider.tsx b/src/advanced/providers/CartProvider.tsx new file mode 100644 index 000000000..1429f855a --- /dev/null +++ b/src/advanced/providers/CartProvider.tsx @@ -0,0 +1,26 @@ +import { createContext, type Dispatch, type SetStateAction, useContext, useMemo } from 'react'; +import { useCartStorage } from '../entities/cart/hooks/useCartStorage'; +import { type CartItem } from '../entities/cart/types'; + +interface CartContextType { + cart: CartItem[]; + setCart: Dispatch>; +} + +export const CartContext = createContext(null); + +export const CartProvider = ({ children }: { children: React.ReactNode }) => { + const [cart, setCart] = useCartStorage([]); + + const value = useMemo(() => ({ cart, setCart }), [cart, setCart]); + + return {children}; +}; + +export const useCartContext = () => { + const context = useContext(CartContext); + if (!context) { + throw new Error('useCartContext must be used within a CartProvider'); + } + return context; +}; diff --git a/src/advanced/providers/CouponProvider.tsx b/src/advanced/providers/CouponProvider.tsx new file mode 100644 index 000000000..3912f880e --- /dev/null +++ b/src/advanced/providers/CouponProvider.tsx @@ -0,0 +1,24 @@ +import { createContext, type Dispatch, type SetStateAction, useContext, useMemo } from 'react'; +import { useCouponsStorage, type Coupon } from '../entities/coupon'; +import { initialCoupons } from '../mock/coupon'; +interface CouponContextType { + coupons: Coupon[]; + setCoupons: Dispatch>; +} + +export const CouponContext = createContext(null); + +export const CouponProvider = ({ children }: { children: React.ReactNode }) => { + const [coupons, setCoupons] = useCouponsStorage(initialCoupons); + + const value = useMemo(() => ({ coupons, setCoupons }), [coupons, setCoupons]); + return {children}; +}; + +export const useCouponContext = () => { + const context = useContext(CouponContext); + if (!context) { + throw new Error('useCouponContext must be used within a CouponProvider'); + } + return context; +}; diff --git a/src/advanced/providers/NotificationProvider.tsx b/src/advanced/providers/NotificationProvider.tsx new file mode 100644 index 000000000..48ba558e8 --- /dev/null +++ b/src/advanced/providers/NotificationProvider.tsx @@ -0,0 +1,51 @@ +import { useState, useMemo, type Dispatch, type SetStateAction, useCallback } from 'react'; +import { Notification } from '../entities/notification/types'; +import { createContext, useContext } from 'react'; +import { generateId } from '../utils/id-generator'; + +const NOTIFICATION_TIMEOUT = 3000; + +interface NotificationContextType { + notifications: Notification[]; + setNotifications: Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning', + idGenerator?: () => string + ) => void; +} +export const NotificationContext = createContext(null); + +export const NotificationProvider = ({ children }: { children: React.ReactNode }) => { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + ( + message: string, + type: 'error' | 'success' | 'warning' = 'success', + idGenerator: () => string = () => generateId('notification') + ) => { + const id = idGenerator(); + setNotifications(prev => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, NOTIFICATION_TIMEOUT); + }, + [] + ); + + const value = useMemo( + () => ({ notifications, setNotifications, addNotification }), + [notifications, addNotification] + ); + return {children}; +}; + +export const useNotificationContext = () => { + const context = useContext(NotificationContext); + if (!context) { + throw new Error('useNotificationContext must be used within a NotificationProvider'); + } + return context; +}; diff --git a/src/advanced/providers/ProductProvider.tsx b/src/advanced/providers/ProductProvider.tsx new file mode 100644 index 000000000..93da6da77 --- /dev/null +++ b/src/advanced/providers/ProductProvider.tsx @@ -0,0 +1,25 @@ +import { createContext, type Dispatch, type SetStateAction, useContext, useMemo } from 'react'; +import { type ProductWithUI, useProductsStorage } from '../entities/product'; +import { initialProducts } from '../mock/product'; + +interface ProductContextType { + products: ProductWithUI[]; + setProducts: Dispatch>; +} + +export const ProductContext = createContext(null); + +export const ProductProvider = ({ children }: { children: React.ReactNode }) => { + const [products, setProducts] = useProductsStorage(initialProducts); + + const value = useMemo(() => ({ products, setProducts }), [products, setProducts]); + return {children}; +}; + +export const useProductContext = () => { + const context = useContext(ProductContext); + if (!context) { + throw new Error('useProductContext must be used within a ProductProvider'); + } + return context; +}; diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts new file mode 100644 index 000000000..f1b0ef598 --- /dev/null +++ b/src/advanced/utils/formatters.ts @@ -0,0 +1,11 @@ +type KRWFormatType = 'prefix' | 'suffix'; + +export const formatPriceKRW = (value: number, type: KRWFormatType = 'suffix'): string => { + if (value === null || value === undefined) return ''; + const num = Number(value); + if (Number.isNaN(num)) return ''; + + const formatted = num.toLocaleString('ko-KR'); + + return type === 'prefix' ? `₩${formatted}` : `${formatted}원`; +}; diff --git a/src/advanced/utils/id-generator.ts b/src/advanced/utils/id-generator.ts new file mode 100644 index 000000000..6f04d2de6 --- /dev/null +++ b/src/advanced/utils/id-generator.ts @@ -0,0 +1,2 @@ +export const generateId = (prefix: string) => + `${prefix ? `${prefix}-` : ''}${Date.now().toString()}`; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..88aa67e4a 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,1124 +1,68 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import { useState } from 'react'; +import AdminDashboardPage from './pages/adminDashboard/AdminDashboardPage'; +import { type Coupon, useCouponsStorage } from './entities/coupon'; +import ProductPage from './pages/client/ProductPage'; +import { initialProducts } from './mock/product'; +import { initialCoupons } from './mock/coupon'; +import { NotificationContainer, useNotification } from './entities/notification'; +import { useProductsStorage } from './entities/product'; +import { Header } from './components/Header'; +import { useDebounce } from './hooks/useDebounce'; +import { useCartStorage } from './entities/cart'; + +const SEARCH_DEBOUNCE_DELAY = 500; const App = () => { + // Storage State + const [products, setProducts] = useProductsStorage(initialProducts); + const [cart, setCart] = useCartStorage([]); + const [coupons, setCoupons] = useCouponsStorage(initialCoupons); - 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); + // State 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 [selectedCoupon, setSelectedCoupon] = useState(null); 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 debouncedSearchTerm = useDebounce(searchTerm, SEARCH_DEBOUNCE_DELAY); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const { notifications, setNotifications, addNotification } = useNotification(); 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()}원 -
-
- - - -
-

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

-
-
- - )} -
-
-
+ )}
); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/components/Header.tsx b/src/basic/components/Header.tsx new file mode 100644 index 000000000..ce161959a --- /dev/null +++ b/src/basic/components/Header.tsx @@ -0,0 +1,69 @@ +import { CartItem } from '../../types'; +import { useMemo } from 'react'; + +interface HeaderProps { + isAdmin: boolean; + searchTerm: string; + setIsAdmin: (value: boolean) => void; + setSearchTerm: (value: string) => void; + cart: CartItem[]; +} + +export function Header({ isAdmin, searchTerm, setIsAdmin, setSearchTerm, cart }: HeaderProps) { + const totalItemCount = useMemo(() => cart.reduce((sum, item) => sum + item.quantity, 0), [cart]); + return ( +
+
+
+
+

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" + /> +
+ )} +
+ +
+
+
+ ); +} diff --git a/src/basic/entities/cart/hooks/useCartStorage.ts b/src/basic/entities/cart/hooks/useCartStorage.ts new file mode 100644 index 000000000..934f474cd --- /dev/null +++ b/src/basic/entities/cart/hooks/useCartStorage.ts @@ -0,0 +1,6 @@ +import { CartItem } from '../../../../types'; +import { useLocalStorageState } from '../../../hooks/useLocalStorageState'; + +export const useCartStorage = (initialCart: CartItem[]) => { + return useLocalStorageState('cart', initialCart); +}; diff --git a/src/basic/entities/cart/index.ts b/src/basic/entities/cart/index.ts new file mode 100644 index 000000000..566e4f0f0 --- /dev/null +++ b/src/basic/entities/cart/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './utils'; +export * from './hooks/useCartStorage'; diff --git a/src/basic/entities/cart/types.ts b/src/basic/entities/cart/types.ts new file mode 100644 index 000000000..518d44d18 --- /dev/null +++ b/src/basic/entities/cart/types.ts @@ -0,0 +1,6 @@ +import { Product } from '../product/types'; + +export interface CartItem { + product: Product; + quantity: number; +} diff --git a/src/basic/entities/cart/utils.ts b/src/basic/entities/cart/utils.ts new file mode 100644 index 000000000..eb73378ef --- /dev/null +++ b/src/basic/entities/cart/utils.ts @@ -0,0 +1,68 @@ +import { CartItem } from './types'; +import { Product } from '../product/types'; + +const BULK_PURCHASE_DISCOUNT = 0.05; +const BULK_PURCHASE_QUANTITY = 10; +const MAX_DISCOUNT = 0.5; + +// ADD TO CART +export const getAddToCart = (cart: CartItem[], product: Product) => { + const existingItem = cart.find(item => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + if (newQuantity > product.stock) { + return cart; + } + return cart.map(item => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); + } + return [...cart, { product, quantity: 1 }]; +}; + +export const canAddToCart = (cart: CartItem[], product: Product) => { + const existingItem = cart.find(item => item.product.id === product.id); + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + return newQuantity <= product.stock; + } + return true; +}; + +// UPDATE CART QUANTITY +export const getUpdateCartQuantity = (cart: CartItem[], productId: string, newQuantity: number) => { + return cart.map(item => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ); +}; + +// CALCULATION +export const getApplicableDiscount = (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); + + return baseDiscount; +}; + +export const getBulkPurchaseDiscount = (cartItems: CartItem[]): number => { + return cartItems.some(cartItem => cartItem.quantity >= BULK_PURCHASE_QUANTITY) + ? BULK_PURCHASE_DISCOUNT + : 0; +}; + +export const calculateItemTotal = (item: CartItem, cartItems: CartItem[]): number => { + const { price } = item.product; + const { quantity } = item; + const baseDiscount = getApplicableDiscount(item); + const bulkPurchaseDiscount = getBulkPurchaseDiscount(cartItems); + const discount = Math.min(baseDiscount + bulkPurchaseDiscount, MAX_DISCOUNT); + + return Math.round(price * quantity * (1 - discount)); +}; diff --git a/src/basic/entities/coupon/hooks/useCouponsStorage.ts b/src/basic/entities/coupon/hooks/useCouponsStorage.ts new file mode 100644 index 000000000..8e22d156a --- /dev/null +++ b/src/basic/entities/coupon/hooks/useCouponsStorage.ts @@ -0,0 +1,6 @@ +import { useLocalStorageState } from '../../../hooks/useLocalStorageState'; +import { Coupon } from '../types'; + +export const useCouponsStorage = (initialCoupons: Coupon[]) => { + return useLocalStorageState('coupons', initialCoupons); +}; diff --git a/src/basic/entities/coupon/index.ts b/src/basic/entities/coupon/index.ts new file mode 100644 index 000000000..9e9f2ea04 --- /dev/null +++ b/src/basic/entities/coupon/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './utils'; +export * from './hooks/useCouponsStorage'; diff --git a/src/basic/entities/coupon/types.ts b/src/basic/entities/coupon/types.ts new file mode 100644 index 000000000..d8a2e6816 --- /dev/null +++ b/src/basic/entities/coupon/types.ts @@ -0,0 +1,6 @@ +export interface Coupon { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} diff --git a/src/basic/entities/coupon/utils.ts b/src/basic/entities/coupon/utils.ts new file mode 100644 index 000000000..4ea66a9a9 --- /dev/null +++ b/src/basic/entities/coupon/utils.ts @@ -0,0 +1,15 @@ +import { Coupon } from './types'; +const MIN_TOTAL_FOR_PERCENTAGE_COUPON = 10000; + +// APPLY COUPON +export const canApplyCoupon = (total: number, coupon: Coupon) => { + if (total < MIN_TOTAL_FOR_PERCENTAGE_COUPON && coupon.discountType === 'percentage') return false; + return true; +}; + +// ADD COUPON +export const canAddCoupon = (coupons: Coupon[], newCoupon: Coupon) => { + const existingCoupon = coupons.find(c => c.code === newCoupon.code); + if (existingCoupon) return false; + return true; +}; diff --git a/src/basic/entities/notification/components/NotificationContainer.tsx b/src/basic/entities/notification/components/NotificationContainer.tsx new file mode 100644 index 000000000..daa83f06b --- /dev/null +++ b/src/basic/entities/notification/components/NotificationContainer.tsx @@ -0,0 +1,22 @@ +import NotificationToast from './NotificationToast'; +import { Notification as NotificationType } from '../types'; + +interface NotificationProps { + notifications: NotificationType[]; + setNotifications: (notifications: NotificationType[]) => void; +} + +export function NotificationContainer({ notifications, setNotifications }: NotificationProps) { + if (notifications.length === 0) return null; + return ( +
+ {notifications.map(notif => ( + + ))} +
+ ); +} diff --git a/src/basic/entities/notification/components/NotificationToast.tsx b/src/basic/entities/notification/components/NotificationToast.tsx new file mode 100644 index 000000000..88c5afcda --- /dev/null +++ b/src/basic/entities/notification/components/NotificationToast.tsx @@ -0,0 +1,40 @@ +import { type Notification } from '../types'; +import { Dispatch, SetStateAction } from 'react'; + +interface NotificationToastProps { + notification: Notification; + setNotifications: Dispatch>; +} + +export default function NotificationToast({ + notification, + setNotifications, +}: NotificationToastProps) { + const handleClose = () => { + setNotifications(prev => prev.filter(n => n.id !== notification.id)); + }; + + return ( +
+ {notification.message} + +
+ ); +} diff --git a/src/basic/entities/notification/hooks/useNotification.tsx b/src/basic/entities/notification/hooks/useNotification.tsx new file mode 100644 index 000000000..3ad9bd233 --- /dev/null +++ b/src/basic/entities/notification/hooks/useNotification.tsx @@ -0,0 +1,29 @@ +import { useState } from 'react'; +import { Notification } from '../types'; + +const NOTIFICATION_TIMEOUT = 3000; + +export function useNotification() { + const [notifications, setNotifications] = useState([]); + + const addNotification = ( + message: string, + type: 'error' | 'success' | 'warning' = 'success', + idGenerator: () => string = generateId + ) => { + const id = idGenerator(); + setNotifications(prev => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, NOTIFICATION_TIMEOUT); + }; + + return { + notifications, + setNotifications, + addNotification, + }; +} + +const generateId = () => Date.now().toString(); diff --git a/src/basic/entities/notification/index.ts b/src/basic/entities/notification/index.ts new file mode 100644 index 000000000..b15f1be9e --- /dev/null +++ b/src/basic/entities/notification/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './hooks/useNotification'; +export * from './components/NotificationContainer'; diff --git a/src/basic/entities/notification/types.ts b/src/basic/entities/notification/types.ts new file mode 100644 index 000000000..38323b77d --- /dev/null +++ b/src/basic/entities/notification/types.ts @@ -0,0 +1,5 @@ +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} diff --git a/src/basic/entities/product/hooks/useProductsStorage.ts b/src/basic/entities/product/hooks/useProductsStorage.ts new file mode 100644 index 000000000..5092beba9 --- /dev/null +++ b/src/basic/entities/product/hooks/useProductsStorage.ts @@ -0,0 +1,6 @@ +import { ProductWithUI } from '../types'; +import { useLocalStorageState } from '../../../hooks/useLocalStorageState'; + +export const useProductsStorage = (initialProducts: ProductWithUI[]) => { + return useLocalStorageState('products', initialProducts); +}; diff --git a/src/basic/entities/product/index.ts b/src/basic/entities/product/index.ts new file mode 100644 index 000000000..e0dd56755 --- /dev/null +++ b/src/basic/entities/product/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './utils'; +export * from './hooks/useProductsStorage'; diff --git a/src/basic/entities/product/types.ts b/src/basic/entities/product/types.ts new file mode 100644 index 000000000..81b04fd0b --- /dev/null +++ b/src/basic/entities/product/types.ts @@ -0,0 +1,25 @@ +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface Discount { + quantity: number; + rate: number; +} + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export interface ProductFormState { + name: string; + price: number; + stock: number; + description: string; + discounts: Array; +} diff --git a/src/basic/entities/product/utils.ts b/src/basic/entities/product/utils.ts new file mode 100644 index 000000000..9bde10507 --- /dev/null +++ b/src/basic/entities/product/utils.ts @@ -0,0 +1,57 @@ +import { Discount, Product, ProductWithUI } from './types'; +import { CartItem } from '../cart/types'; + +// GET REMAINING STOCK +export const getRemainingStock = (product: Product, cartItems: CartItem[]): number => { + const cartItem = cartItems.find(item => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +}; + +// FILTER PRODUCTS +export const filterProducts = ( + products: ProductWithUI[], + debouncedSearchTerm: string +): ProductWithUI[] => { + if (!debouncedSearchTerm) return products; + return products.filter(product => { + const name = product.name.toLowerCase(); + const description = product.description?.toLowerCase() || ''; + return ( + name.includes(debouncedSearchTerm.toLowerCase()) || + description.includes(debouncedSearchTerm.toLowerCase()) + ); + }); +}; + +// ADD PRODUCT +export const getNewProducts = ( + products: ProductWithUI[], + newProduct: Omit +): ProductWithUI[] => [...products, { ...newProduct, id: `p${Date.now()}` }]; + +// UPDATE PRODUCT +export const getUpdatedProducts = ( + products: ProductWithUI[], + productId: string, + updates: Partial +): ProductWithUI[] => + products.map(product => (product.id === productId ? { ...product, ...updates } : product)); + +// DELETE PRODUCT +export const getDeletedProducts = (products: ProductWithUI[], productId: string): ProductWithUI[] => + products.filter(product => product.id !== productId); + +// DISCOUNT FORM +export const getDeletedDiscounts = (discounts: Discount[], index: number): Discount[] => + discounts.filter((_, i) => i !== index); + +// SHOW +export const showSoldOutWarningMessage = (remainingStock: number): boolean => { + return remainingStock <= 5 && remainingStock > 0; +}; + +export const showInStockMessage = (remainingStock: number): boolean => { + return remainingStock > 5; +}; diff --git a/src/basic/hooks/useDebounce.ts b/src/basic/hooks/useDebounce.ts new file mode 100644 index 000000000..2a4f25aba --- /dev/null +++ b/src/basic/hooks/useDebounce.ts @@ -0,0 +1,12 @@ +import { useState, useEffect } from 'react'; + +export const 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; +}; diff --git a/src/basic/hooks/useLocalStorageState.ts b/src/basic/hooks/useLocalStorageState.ts new file mode 100644 index 000000000..49a93d0e1 --- /dev/null +++ b/src/basic/hooks/useLocalStorageState.ts @@ -0,0 +1,16 @@ +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; +export const useLocalStorageState = (key: string, initialValue: T) => { + const [state, setState] = useState(() => { + const saved = localStorage.getItem(key); + if (saved) { + return JSON.parse(saved); + } + return initialValue; + }); + + useEffect(() => { + localStorage.setItem(key, JSON.stringify(state)); + }, [state]); + + return [state, setState] as [T, Dispatch>]; +}; diff --git a/src/basic/mock/coupon.ts b/src/basic/mock/coupon.ts new file mode 100644 index 000000000..acf0e3624 --- /dev/null +++ b/src/basic/mock/coupon.ts @@ -0,0 +1,15 @@ +import { Coupon } from '../entities/coupon/types'; +export const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; diff --git a/src/basic/mock/product.ts b/src/basic/mock/product.ts new file mode 100644 index 000000000..51d9ca823 --- /dev/null +++ b/src/basic/mock/product.ts @@ -0,0 +1,35 @@ +import { ProductWithUI } from '../entities/product/types'; + +export const initialProducts: ProductWithUI[] = [ + { + id: 'p1', + name: '상품1', + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: '최고급 품질의 프리미엄 상품입니다.', + }, + { + id: 'p2', + name: '상품2', + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: '다양한 기능을 갖춘 실용적인 상품입니다.', + isRecommended: true, + }, + { + id: 'p3', + name: '상품3', + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: '대용량과 고성능을 자랑하는 상품입니다.', + }, +]; diff --git a/src/basic/pages/adminDashboard/AdminDashboardPage.tsx b/src/basic/pages/adminDashboard/AdminDashboardPage.tsx new file mode 100644 index 000000000..0406c2eec --- /dev/null +++ b/src/basic/pages/adminDashboard/AdminDashboardPage.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import { type ProductWithUI } from '../../entities/product'; +import { type Coupon } from '../../entities/coupon'; +import ProductTab from './product-tab/ProductTab'; +import CouponTab from './coupon-tab/CouponTab'; +import { Dispatch, SetStateAction } from 'react'; + +interface AdminDashboardPageProps { + products: ProductWithUI[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: Dispatch>; + setProducts: Dispatch>; + setCoupons: Dispatch>; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export default function AdminDashboardPage({ + products, + coupons, + selectedCoupon, + setSelectedCoupon, + setProducts, + setCoupons, + addNotification, +}: AdminDashboardPageProps) { + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + + return ( +
+
+

관리자 대시보드

+

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

+
+
+ +
+ + {activeTab === 'products' ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/basic/pages/adminDashboard/coupon-tab/CouponForm.tsx b/src/basic/pages/adminDashboard/coupon-tab/CouponForm.tsx new file mode 100644 index 000000000..baa5b97ca --- /dev/null +++ b/src/basic/pages/adminDashboard/coupon-tab/CouponForm.tsx @@ -0,0 +1,137 @@ +import { Dispatch, SetStateAction } from 'react'; +import { type Coupon } from '../../../entities/coupon'; +interface CouponFormProps { + couponForm: Coupon; + setCouponForm: Dispatch>; + setShowCouponForm: Dispatch>; + handleCouponSubmit: (e: React.FormEvent) => void; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export default function CouponForm({ + couponForm, + setCouponForm, + setShowCouponForm, + handleCouponSubmit, + addNotification, +}: CouponFormProps) { + const handleChangeFormName = (e: React.ChangeEvent) => { + setCouponForm({ ...couponForm, name: e.target.value }); + }; + + const handleChangeFormCode = (e: React.ChangeEvent) => { + setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }); + }; + + const handleChangeFormDiscountType = (e: React.ChangeEvent) => { + setCouponForm({ ...couponForm, discountType: e.target.value as 'amount' | 'percentage' }); + }; + + const handleChangeFormDiscountValue = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); + } + }; + + const handleBlurFormDiscountValue = (e: React.FocusEvent) => { + 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 }); + } + } + }; + + const handleCancelCoupon = () => { + setShowCouponForm(false); + setCouponForm({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + }; + + return ( +
+
+

새 쿠폰 생성

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ ); +} diff --git a/src/basic/pages/adminDashboard/coupon-tab/CouponItem.tsx b/src/basic/pages/adminDashboard/coupon-tab/CouponItem.tsx new file mode 100644 index 000000000..8eda0e764 --- /dev/null +++ b/src/basic/pages/adminDashboard/coupon-tab/CouponItem.tsx @@ -0,0 +1,38 @@ +import { type Coupon } from '../../../entities/coupon'; +interface CouponItemProps { + coupon: Coupon; + deleteCoupon: (couponCode: string) => void; +} + +export default function CouponItem({ coupon, deleteCoupon }: CouponItemProps) { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +} diff --git a/src/basic/pages/adminDashboard/coupon-tab/CouponTab.tsx b/src/basic/pages/adminDashboard/coupon-tab/CouponTab.tsx new file mode 100644 index 000000000..a6cde7db4 --- /dev/null +++ b/src/basic/pages/adminDashboard/coupon-tab/CouponTab.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react'; +import CouponItem from './CouponItem'; +import CouponForm from './CouponForm'; +import { type Coupon, canAddCoupon } from '../../../entities/coupon'; +import { Dispatch, SetStateAction } from 'react'; + +interface CouponTabProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: Dispatch>; + setCoupons: Dispatch>; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export default function CouponTab({ + coupons, + setCoupons, + addNotification, + selectedCoupon, + setSelectedCoupon, +}: CouponTabProps) { + const [showCouponForm, setShowCouponForm] = useState(false); + + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + + const addCoupon = (newCoupon: Coupon) => { + if (!canAddCoupon(coupons, newCoupon)) { + addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); + return; + } + setCoupons(prev => [...prev, newCoupon]); + addNotification('쿠폰이 추가되었습니다.', 'success'); + }; + + const deleteCoupon = (couponCode: string) => { + setCoupons(prev => prev.filter(c => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }; + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + setShowCouponForm(false); + }; + + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map(coupon => ( + + ))} + +
+ +
+
+ + {showCouponForm && ( + + )} +
+
+ ); +} diff --git a/src/basic/pages/adminDashboard/product-tab/DiscountForm.tsx b/src/basic/pages/adminDashboard/product-tab/DiscountForm.tsx new file mode 100644 index 000000000..7d1f7bcdc --- /dev/null +++ b/src/basic/pages/adminDashboard/product-tab/DiscountForm.tsx @@ -0,0 +1,91 @@ +import { + type Discount, + type ProductFormState, + getDeletedDiscounts, +} from '../../../entities/product'; +import { Dispatch, SetStateAction } from 'react'; + +interface DiscountFormProps { + index: number; + productForm: ProductFormState; + discount: Discount; + setProductForm: Dispatch>; +} + +export default function DiscountForm({ + index, + productForm, + discount, + setProductForm, +}: DiscountFormProps) { + const handleChangeFormQuantity = (index: number, e: React.ChangeEvent) => { + const value = e.target.value; + setProductForm(() => { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = parseInt(value) || 0; + return { ...productForm, discounts: newDiscounts }; + }); + }; + + const handleChangeFormRate = (index: number, e: React.ChangeEvent) => { + const value = e.target.value; + setProductForm(() => { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = (parseInt(value) || 0) / 100; + return { ...productForm, discounts: newDiscounts }; + }); + }; + + const handleDeleteDiscount = (index: number) => { + setProductForm({ + ...productForm, + discounts: getDeletedDiscounts(productForm.discounts, index), + }); + }; + + return ( +
+ { + handleChangeFormQuantity(index, e); + }} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + handleChangeFormRate(index, e); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ); +} diff --git a/src/basic/pages/adminDashboard/product-tab/ProductForm.tsx b/src/basic/pages/adminDashboard/product-tab/ProductForm.tsx new file mode 100644 index 000000000..eca3099f8 --- /dev/null +++ b/src/basic/pages/adminDashboard/product-tab/ProductForm.tsx @@ -0,0 +1,214 @@ +import { + type ProductFormState, + type ProductWithUI, + getNewProducts, + getUpdatedProducts, +} from '../../../entities/product'; +import DiscountForm from './DiscountForm'; +import { Dispatch, SetStateAction } from 'react'; + +interface ProductFormProps { + editingProduct: string | null; + productForm: ProductFormState; + products: ProductWithUI[]; + setProducts: Dispatch>; + setProductForm: Dispatch>; + setEditingProduct: Dispatch>; + setShowProductForm: Dispatch>; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export default function ProductForm({ + editingProduct, + productForm, + products, + setProducts, + setProductForm, + setEditingProduct, + setShowProductForm, + addNotification, +}: ProductFormProps) { + // Form event handlers + const handleChangeFormInputValue = (e: React.ChangeEvent) => { + const id = e.target.id; + const value = e.target.value; + if (id === 'price' || id === 'stock') { + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ ...productForm, [id]: value === '' ? 0 : parseInt(value) }); + } + } else { + setProductForm({ ...productForm, [id]: value }); + } + }; + const handleBlurFormPrice = (e: React.FocusEvent) => { + const value = e.target.value; + if (value === '') { + setProductForm({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification('가격은 0보다 커야 합니다', 'error'); + setProductForm({ ...productForm, price: 0 }); + } + }; + const handleBlurFormStock = (e: React.FocusEvent) => { + 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 }); + } + }; + + // Product event handlers + const addProduct = (newProduct: Omit) => { + setProducts(getNewProducts(products, newProduct)); + addNotification('상품이 추가되었습니다.', 'success'); + }; + + const updateProduct = (productId: string, updates: Partial) => { + setProducts(getUpdatedProducts(products, productId, updates)); + addNotification('상품이 수정되었습니다.', 'success'); + }; + + 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 handleAddDiscount = () => { + setProductForm({ + ...productForm, + discounts: [...productForm.discounts, { quantity: 10, rate: 0.1 }], + }); + }; + + const handleCancelProduct = () => { + setEditingProduct(null); + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + }); + setShowProductForm(false); + }; + + return ( +
+
+

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

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ {productForm.discounts.map((discount, index) => ( + + ))} + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/basic/pages/adminDashboard/product-tab/ProductListRowItem.tsx b/src/basic/pages/adminDashboard/product-tab/ProductListRowItem.tsx new file mode 100644 index 000000000..f6e0b9276 --- /dev/null +++ b/src/basic/pages/adminDashboard/product-tab/ProductListRowItem.tsx @@ -0,0 +1,54 @@ +import { formatPriceKRW } from '../../../utils/formatters'; +import { type ProductWithUI } from '../../../entities/product'; + +interface ProductListRowItemProps { + product: ProductWithUI; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +} +export default function ProductListRowItem({ + product, + startEditProduct, + deleteProduct, +}: ProductListRowItemProps) { + return ( + + + {product.name} + + + {formatPriceKRW(product.price, 'suffix')} + + + 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/pages/adminDashboard/product-tab/ProductTab.tsx b/src/basic/pages/adminDashboard/product-tab/ProductTab.tsx new file mode 100644 index 000000000..45c343aa6 --- /dev/null +++ b/src/basic/pages/adminDashboard/product-tab/ProductTab.tsx @@ -0,0 +1,119 @@ +import { + type ProductFormState, + type ProductWithUI, + getDeletedProducts, +} from '../../../entities/product'; +import { useState } from 'react'; +import ProductForm from './ProductForm'; +import ProductListRowItem from './ProductListRowItem'; +import { Dispatch, SetStateAction } from 'react'; + +interface ProductTabProps { + products: ProductWithUI[]; + setProducts: Dispatch>; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export default function ProductTab({ products, setProducts, addNotification }: ProductTabProps) { + const [editingProduct, setEditingProduct] = useState(null); + const [showProductForm, setShowProductForm] = useState(false); + + const [productForm, setProductForm] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + }); + + 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 handleAddProduct = () => { + setEditingProduct('new'); + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + }); + setShowProductForm(true); + }; + + const deleteProduct = (productId: string) => { + setProducts(getDeletedProducts(products, productId)); + addNotification('상품이 삭제되었습니다.', 'success'); + }; + + return ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map(product => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+ {showProductForm && ( + + )} +
+ ); +} diff --git a/src/basic/pages/client/ProductPage.tsx b/src/basic/pages/client/ProductPage.tsx new file mode 100644 index 000000000..7af5e1d9a --- /dev/null +++ b/src/basic/pages/client/ProductPage.tsx @@ -0,0 +1,48 @@ +import { type ProductWithUI } from '../../entities/product'; +import CartView from './cart-view/CartView'; +import ProductSection from './product-section/ProductSection'; +import { type CartItem } from '../../entities/cart'; +import { type Coupon } from '../../entities/coupon'; +import { Dispatch, SetStateAction } from 'react'; +interface ProductPageProps { + products: ProductWithUI[]; + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + debouncedSearchTerm: string; + setSelectedCoupon: Dispatch>; + setCart: Dispatch>; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export default function ProductPage({ + products, + cart, + coupons, + selectedCoupon, + setSelectedCoupon, + debouncedSearchTerm, + addNotification, + setCart, +}: ProductPageProps) { + return ( +
+ + +
+ ); +} diff --git a/src/basic/pages/client/cart-view/CartListItem.tsx b/src/basic/pages/client/cart-view/CartListItem.tsx new file mode 100644 index 000000000..6a5c4b2a0 --- /dev/null +++ b/src/basic/pages/client/cart-view/CartListItem.tsx @@ -0,0 +1,61 @@ +import { formatPriceKRW } from '../../../utils/formatters'; +import { type CartItem } from '../../../entities/cart'; +interface CartListItemProps { + item: CartItem; + itemTotal: number; + discountRate: number; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +} + +export default function CartListItem({ + item, + itemTotal, + discountRate, + removeFromCart, + updateQuantity, +}: CartListItemProps) { + return ( +
+
+

{item.product.name}

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

{formatPriceKRW(itemTotal, 'suffix')}

+
+
+
+ ); +} diff --git a/src/basic/pages/client/cart-view/CartSection.tsx b/src/basic/pages/client/cart-view/CartSection.tsx new file mode 100644 index 000000000..aaa5855de --- /dev/null +++ b/src/basic/pages/client/cart-view/CartSection.tsx @@ -0,0 +1,64 @@ +import { type CartItem, calculateItemTotal } from '../../../entities/cart'; +import CartListItem from './CartListItem'; + +interface CartSectionProps { + cart: CartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +} + +export default function CartSection({ cart, removeFromCart, updateQuantity }: CartSectionProps) { + return ( +
+

+ + + + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map(item => { + const itemTotal = calculateItemTotal(item, cart); + const originalPrice = item.product.price * item.quantity; + const discountRate = + itemTotal < originalPrice ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; + + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/basic/pages/client/cart-view/CartView.tsx b/src/basic/pages/client/cart-view/CartView.tsx new file mode 100644 index 000000000..520c373a9 --- /dev/null +++ b/src/basic/pages/client/cart-view/CartView.tsx @@ -0,0 +1,116 @@ +import { useMemo } from 'react'; +import { type ProductWithUI } from '../../../entities/product'; +import PaymentInfoSection from './PaymentInfoSection'; +import CouponSection from './CouponSection'; +import CartSection from './CartSection'; +import { type CartItem, calculateItemTotal, getUpdateCartQuantity } from '../../../entities/cart'; +import { type Coupon, canApplyCoupon } from '../../../entities/coupon'; +import { Dispatch, SetStateAction } from 'react'; +interface CartViewProps { + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + products: ProductWithUI[]; + setSelectedCoupon: Dispatch>; + setCart: Dispatch>; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export default function CartView({ + cart, + coupons, + products, + setCart, + addNotification, + selectedCoupon, + setSelectedCoupon, +}: CartViewProps) { + // Computed Values + const originTotal = useMemo(() => { + const total = cart.reduce((total, item) => { + return total + item.product.price * item.quantity; + }, 0); + return Math.round(total); + }, [cart]); + + const caculatedTotal = useMemo(() => { + const total = cart.reduce((total, item) => { + return total + calculateItemTotal(item, cart); + }, 0); + + if (selectedCoupon) { + if (selectedCoupon.discountType === 'amount') { + return Math.max(0, total - selectedCoupon.discountValue); + } else { + return Math.round(total * (1 - selectedCoupon.discountValue / 100)); + } + } + + return Math.round(total); + }, [cart, selectedCoupon]); + + // Events + const removeFromCart = (productId: string) => { + setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + }; + + const updateQuantity = (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 => getUpdateCartQuantity(prevCart, productId, newQuantity)); + }; + + const applyCoupon = (coupon: Coupon) => { + if (!canApplyCoupon(caculatedTotal, coupon)) { + addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); + return; + } + + setSelectedCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }; + + const completeOrder = () => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + setCart([]); + setSelectedCoupon(null); + }; + + return ( +
+
+ + + {cart.length > 0 && ( + + )} + + {cart.length > 0 && ( + + )} +
+
+ ); +} diff --git a/src/basic/pages/client/cart-view/CouponSection.tsx b/src/basic/pages/client/cart-view/CouponSection.tsx new file mode 100644 index 000000000..bae17d82a --- /dev/null +++ b/src/basic/pages/client/cart-view/CouponSection.tsx @@ -0,0 +1,45 @@ +import { formatPriceKRW } from '../../../utils/formatters'; +import { type Coupon } from '../../../entities/coupon'; +import { Dispatch, SetStateAction } from 'react'; +interface CouponSectionProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: Dispatch>; + applyCoupon: (coupon: Coupon) => void; +} + +export default function CouponSection({ + coupons, + selectedCoupon, + setSelectedCoupon, + applyCoupon, +}: CouponSectionProps) { + return ( +
+
+

쿠폰 할인

+ +
+ +
+ ); +} diff --git a/src/basic/pages/client/cart-view/PaymentInfoSection.tsx b/src/basic/pages/client/cart-view/PaymentInfoSection.tsx new file mode 100644 index 000000000..5588e628d --- /dev/null +++ b/src/basic/pages/client/cart-view/PaymentInfoSection.tsx @@ -0,0 +1,48 @@ +import { formatPriceKRW } from '../../../utils/formatters'; + +interface PaymentInfoSectionProps { + originTotal: number; + caculatedTotal: number; + completeOrder: () => void; +} + +export default function PaymentInfoSection({ + originTotal, + caculatedTotal, + completeOrder, +}: PaymentInfoSectionProps) { + return ( +
+

결제 정보

+
+
+ 상품 금액 + {formatPriceKRW(originTotal, 'suffix')} +
+ {originTotal - caculatedTotal > 0 && ( +
+ 할인 금액 + -{formatPriceKRW(originTotal - caculatedTotal, 'suffix')} +
+ )} +
+ 결제 예정 금액 + + {formatPriceKRW(caculatedTotal, 'suffix')} + +
+
+ + + +
+

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

+
+
+ ); +} diff --git a/src/basic/pages/client/product-section/ProductCard.tsx b/src/basic/pages/client/product-section/ProductCard.tsx new file mode 100644 index 000000000..2cb11d202 --- /dev/null +++ b/src/basic/pages/client/product-section/ProductCard.tsx @@ -0,0 +1,91 @@ +import { + type ProductWithUI, + showSoldOutWarningMessage, + showInStockMessage, +} from '../../../entities/product'; +import { formatPriceKRW } from '../../../utils/formatters'; +interface ProductCardProps { + product: ProductWithUI; + remainingStock: number; + addToCart: (product: ProductWithUI) => void; +} + +export default function ProductCard({ product, remainingStock, addToCart }: ProductCardProps) { + const isSoldOut = remainingStock <= 0; + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

{product.description}

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

+ {isSoldOut ? 'SOLD OUT' : formatPriceKRW(product.price, 'prefix')} +

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

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

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

품절임박! {remainingStock}개 남음

+ )} + {showInStockMessage(remainingStock) && ( +

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +} diff --git a/src/basic/pages/client/product-section/ProductSection.tsx b/src/basic/pages/client/product-section/ProductSection.tsx new file mode 100644 index 000000000..d0b303c4c --- /dev/null +++ b/src/basic/pages/client/product-section/ProductSection.tsx @@ -0,0 +1,68 @@ +import { type CartItem, canAddToCart, getAddToCart } from '../../../entities/cart'; +import { type ProductWithUI, filterProducts, getRemainingStock } from '../../../entities/product'; +import ProductCard from './ProductCard'; +import { Dispatch, SetStateAction } from 'react'; +interface ProductSectionProps { + products: ProductWithUI[]; + debouncedSearchTerm: string; + cart: CartItem[]; + setCart: Dispatch>; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export default function ProductSection({ + products, + debouncedSearchTerm, + cart, + setCart, + addNotification, +}: ProductSectionProps) { + const filteredProducts = filterProducts(products, debouncedSearchTerm); + + // Events + const addToCart = (product: ProductWithUI) => { + if (!canAddToCart(cart, product)) { + const remainingStock = getRemainingStock(product, cart); + if (remainingStock <= 0) { + addNotification('재고가 부족합니다!', 'error'); + } else { + addNotification(`재고는 ${remainingStock}개까지만 있습니다.`, 'error'); + } + return; + } + + setCart(getAddToCart(cart, product)); + addNotification('장바구니에 담았습니다', 'success'); + }; + return ( +
+ {/* 상품 목록 */} +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map(product => { + const remainingStock = getRemainingStock(product, cart); + + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/basic/types.ts b/src/basic/types.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts new file mode 100644 index 000000000..f1b0ef598 --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,11 @@ +type KRWFormatType = 'prefix' | 'suffix'; + +export const formatPriceKRW = (value: number, type: KRWFormatType = 'suffix'): string => { + if (value === null || value === undefined) return ''; + const num = Number(value); + if (Number.isNaN(num)) return ''; + + const formatted = num.toLocaleString('ko-KR'); + + return type === 'prefix' ? `₩${formatted}` : `${formatted}원`; +}; diff --git a/vite.config.ts b/vite.config.ts index e6c4016bc..67b36a0b5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,15 +2,24 @@ import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; +const base: string = process.env.NODE_ENV === 'production' ? '/front_7th_chapter3-2/' : ''; + export default mergeConfig( defineConfig({ + base, plugins: [react()], + build: { + outDir: 'dist', + rollupOptions: { + external: ['**/*.test.ts', '**/*.test.tsx'], + }, + }, }), defineTestConfig({ test: { globals: true, environment: 'jsdom', - setupFiles: './src/setupTests.ts' + setupFiles: './src/setupTests.ts', }, }) -) +);