diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..3aeb55272 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,65 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + paths: + - 'src/basic/**' + - 'src/advanced/**' + - '.github/workflows/deploy.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: 'pages' + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + env: + NODE_ENV: production + VITE_BASE_PATH: /${{ github.event.repository.name }}/ + + - name: Set advanced as default + run: cp dist/index.advanced.html dist/index.html + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v4 + with: + path: ./dist + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/package.json b/package.json index 17b18de25..ea90096c9 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,14 @@ }, "dependencies": { "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "zustand": "^5.0.9" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", "@typescript-eslint/eslint-plugin": "^8.38.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85f..9edadd207 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: react-dom: specifier: ^19.1.1 version: 19.1.1(react@19.1.1) + zustand: + specifier: ^5.0.9 + version: 5.0.9(@types/react@19.1.9)(react@19.1.1) devDependencies: '@testing-library/jest-dom': specifier: ^6.6.4 @@ -24,6 +27,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 '@types/react': specifier: ^19.1.9 version: 19.1.9 @@ -38,7 +44,7 @@ importers: version: 8.38.0(eslint@9.32.0)(typescript@5.9.2) '@vitejs/plugin-react-swc': specifier: ^3.11.0 - version: 3.11.0(vite@7.0.6) + version: 3.11.0(vite@7.0.6(@types/node@24.10.1)) '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) @@ -59,10 +65,10 @@ importers: version: 5.9.2 vite: specifier: ^7.0.6 - version: 7.0.6 + version: 7.0.6(@types/node@24.10.1) vitest: specifier: ^3.2.4 - version: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) + version: 3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0) packages: @@ -583,6 +589,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/react-dom@19.1.7': resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} peerDependencies: @@ -1382,6 +1391,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1515,6 +1527,24 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adobe/css-tools@4.4.0': {} @@ -1886,6 +1916,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + '@types/react-dom@19.1.7(@types/react@19.1.9)': dependencies: '@types/react': 19.1.9 @@ -1987,11 +2021,11 @@ snapshots: '@typescript-eslint/types': 8.38.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6)': + '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6(@types/node@24.10.1))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 '@swc/core': 1.13.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) transitivePeerDependencies: - '@swc/helpers' @@ -2003,13 +2037,13 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.0.6)': + '@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.10.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -2040,7 +2074,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) + vitest: 3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0) '@vitest/utils@3.2.4': dependencies: @@ -2716,17 +2750,19 @@ snapshots: typescript@5.9.2: {} + undici-types@7.16.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite-node@3.2.4: + vite-node@3.2.4(@types/node@24.10.1): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) transitivePeerDependencies: - '@types/node' - jiti @@ -2741,7 +2777,7 @@ snapshots: - tsx - yaml - vite@7.0.6: + vite@7.0.6(@types/node@24.10.1): dependencies: esbuild: 0.25.8 fdir: 6.4.6(picomatch@4.0.3) @@ -2750,13 +2786,14 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.14 optionalDependencies: + '@types/node': 24.10.1 fsevents: 2.3.3 - vitest@3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0): + vitest@3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.0.6) + '@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.10.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -2774,10 +2811,11 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.6 - vite-node: 3.2.4 + vite: 7.0.6(@types/node@24.10.1) + vite-node: 3.2.4(@types/node@24.10.1) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 24.10.1 '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 26.1.0 transitivePeerDependencies: @@ -2829,3 +2867,8 @@ snapshots: xmlchars@2.2.0: {} yocto-queue@0.1.0: {} + + zustand@5.0.9(@types/react@19.1.9)(react@19.1.1): + optionalDependencies: + '@types/react': 19.1.9 + react: 19.1.1 diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1d..618ef99a0 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1124 +1,50 @@ -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, useEffect } from 'react'; +import { AdminPage } from './pages/admin/AdminPage'; +import { CartPage } from './pages/cart/CartPage'; +import { Header } from './components/Header'; +import { NotificationContainer } from './components/Notification'; +import { useNotificationStore } from './store/notificationStore'; +import { useProductStore } from './store/productStore'; +import { useCartStore } from './store/cartStore'; +import { useCouponStore } from './store/couponStore'; +import { initialProducts, initialCoupons } from './constants'; const App = () => { + const notifications = useNotificationStore(state => state.notifications); + const removeNotification = useNotificationStore(state => state.removeNotification); - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); + const productsData = localStorage.getItem('products'); + const cartData = localStorage.getItem('cart'); + const couponsData = localStorage.getItem('coupons'); - 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'); + if (!productsData && !cartData && !couponsData) { + useProductStore.setState({ products: initialProducts }); + useCartStore.setState({ cart: [], selectedCoupon: null }); + useCouponStore.setState({ coupons: initialCoupons }); + useNotificationStore.setState({ notifications: [] }); } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); }, []); - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; - return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

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

관리자 대시보드

-

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

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

상품 목록

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

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

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

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

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

새 쿠폰 생성

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

전체 상품

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

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

-
-
- - )} -
-
-
- )} +
+ {isAdmin ? : }
); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/advanced/components/Header.tsx b/src/advanced/components/Header.tsx new file mode 100644 index 000000000..9c46c4fdd --- /dev/null +++ b/src/advanced/components/Header.tsx @@ -0,0 +1,51 @@ +import { useMemo } from 'react'; +import { useCartStore } from '../store/cartStore'; + +interface HeaderProps { + isAdmin: boolean; + setIsAdmin: (value: boolean) => void; +} + +export function Header({ isAdmin, setIsAdmin }: HeaderProps) { + // Header에서 필요한 store 직접 가져오기 + const cart = useCartStore(state => state.cart); + + const cartItemCount = useMemo(() => { + return cart.reduce((sum, item) => sum + item.quantity, 0); + }, [cart]); + return ( +
+
+
+
+

SHOP

+
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/src/advanced/components/Notification.tsx b/src/advanced/components/Notification.tsx new file mode 100644 index 000000000..1a43f7aa0 --- /dev/null +++ b/src/advanced/components/Notification.tsx @@ -0,0 +1,35 @@ +import { Notification } from "../utils/hooks/useNotification"; + +interface NotificationContainerProps { + notifications: Notification[]; + onRemove: (id: string) => void; +} + +export function NotificationContainer({ notifications, onRemove }: NotificationContainerProps) { + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map(notif => ( +
+ {notif.message} + +
+ ))} +
+ ); +} diff --git a/src/advanced/components/icons/index.tsx b/src/advanced/components/icons/index.tsx new file mode 100644 index 000000000..f49a84da3 --- /dev/null +++ b/src/advanced/components/icons/index.tsx @@ -0,0 +1,157 @@ +interface IconProps { + className?: string; + size?: number; +} + +export const CartIcon = ({ className, size = 24 }: IconProps) => ( + + + + + +); + +export const AdminIcon = ({ className, size = 24 }: IconProps) => ( + + + + + + +); + +export const PlusIcon = ({ className, size = 24 }: IconProps) => ( + + + + +); + +export const MinusIcon = ({ className, size = 24 }: IconProps) => ( + + + +); + +export const TrashIcon = ({ className, size = 24 }: IconProps) => ( + + + + + + +); + +export const ChevronDownIcon = ({ className, size = 24 }: IconProps) => ( + + + +); + +export const ChevronUpIcon = ({ className, size = 24 }: IconProps) => ( + + + +); + +export const CheckIcon = ({ className, size = 24 }: IconProps) => ( + + + +); + +export const XIcon = ({ className, size = 24 }: IconProps) => ( + + + +); diff --git a/src/advanced/components/ui/Button.tsx b/src/advanced/components/ui/Button.tsx new file mode 100644 index 000000000..6ce5d0841 --- /dev/null +++ b/src/advanced/components/ui/Button.tsx @@ -0,0 +1,57 @@ +import { ButtonHTMLAttributes, ReactNode } from 'react'; + +type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'; +type ButtonSize = 'sm' | 'md' | 'lg'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + children: ReactNode; + fullWidth?: boolean; +} + +const variantStyles: Record = { + primary: 'bg-gray-900 text-white hover:bg-gray-800', + secondary: 'border border-gray-300 text-gray-700 hover:bg-gray-50', + danger: 'bg-red-600 text-white hover:bg-red-700', + ghost: 'text-gray-700 hover:bg-gray-100', +}; + +const sizeStyles: Record = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-3 text-base', +}; + +export function Button({ + variant = 'primary', + size = 'md', + children, + fullWidth = false, + className = '', + disabled, + ...props +}: ButtonProps) { + const baseStyles = 'rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500'; + const disabledStyles = disabled ? 'opacity-50 cursor-not-allowed' : ''; + const widthStyles = fullWidth ? 'w-full' : ''; + + const combinedClassName = ` + ${baseStyles} + ${variantStyles[variant]} + ${sizeStyles[size]} + ${disabledStyles} + ${widthStyles} + ${className} + `.trim().replace(/\s+/g, ' '); + + return ( + + ); +} diff --git a/src/advanced/components/ui/Input.tsx b/src/advanced/components/ui/Input.tsx new file mode 100644 index 000000000..8e19c6de7 --- /dev/null +++ b/src/advanced/components/ui/Input.tsx @@ -0,0 +1,46 @@ +import { InputHTMLAttributes, forwardRef } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; + helperText?: string; + fullWidth?: boolean; +} + +export const Input = forwardRef( + ({ label, error, helperText, fullWidth = false, className = '', ...props }, ref) => { + const baseStyles = 'border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border'; + const errorStyles = error ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : ''; + const widthStyles = fullWidth ? 'w-full' : ''; + + const combinedClassName = ` + ${baseStyles} + ${errorStyles} + ${widthStyles} + ${className} + `.trim().replace(/\s+/g, ' '); + + return ( +
+ {label && ( + + )} + + {error && ( +

{error}

+ )} + {helperText && !error && ( +

{helperText}

+ )} +
+ ); + } +); + +Input.displayName = 'Input'; diff --git a/src/advanced/constants/index.tsx b/src/advanced/constants/index.tsx new file mode 100644 index 000000000..962edae4e --- /dev/null +++ b/src/advanced/constants/index.tsx @@ -0,0 +1,50 @@ +import { Coupon, ProductWithUI } from "../../types"; + +export const initialProducts: ProductWithUI[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: "최고급 품질의 프리미엄 상품입니다.", + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +export const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; \ No newline at end of file diff --git a/src/advanced/models/cart.ts b/src/advanced/models/cart.ts new file mode 100644 index 000000000..ac9118df0 --- /dev/null +++ b/src/advanced/models/cart.ts @@ -0,0 +1,80 @@ +import { CartItem, Coupon, Product } from "../../types"; +import { getMaxApplicableDiscount } from "./discount"; + +// 개별 아이템의 할인 적용 후 총액 계산 +export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +// 장바구니 총액 계산 (할인 전/후, 할인액) +export const calculateCartTotal = (cart: CartItem[], coupon: Coupon | null): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach(item => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (coupon) { + if (coupon.discountType === 'amount') { + totalAfterDiscount = Math.max(0, totalAfterDiscount - coupon.discountValue); + } else { + totalAfterDiscount = Math.round(totalAfterDiscount * (1 - coupon.discountValue / 100)); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount) + }; +}; + +// 수량 변경 +// 장바구니에서 특정 상품의 수량만 변경을 책임 +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + quantity: number +): CartItem[] => { + // 수량 0 이하일때 배열에서 해당 아이템 제거 + if (quantity <= 0) { + return cart.filter(item => item.product.id !== productId) + } + + return cart.map(item => + item.product.id === productId + ? { ...item, quantity: quantity} + : item + ); +}; + +// 상품 추가 +// 장바구니에 상품 추가에 대한 책임만 존재해야함 (재고 검증 X) +export const addItemToCart = (cart: CartItem[], product: Product) => { + const existingItem = cart.find(item => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + return cart.map(item => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ); + } + return [ ...cart, { product, quantity: 1 }]; +}; + +// 상품 제거 +export const removeItemFromCart = (cart: CartItem[], productId: string): CartItem[] => { + return cart.filter(item => item.product.id !== productId); +}; \ No newline at end of file diff --git a/src/advanced/models/discount.ts b/src/advanced/models/discount.ts new file mode 100644 index 000000000..9f98262cf --- /dev/null +++ b/src/advanced/models/discount.ts @@ -0,0 +1,20 @@ +import { CartItem } from "../../types"; + +// 적용 가능한 최대 할인율 계산 +export const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; +}; \ No newline at end of file diff --git a/src/advanced/models/product.ts b/src/advanced/models/product.ts new file mode 100644 index 000000000..defd48f99 --- /dev/null +++ b/src/advanced/models/product.ts @@ -0,0 +1,9 @@ +import { CartItem, Product } from "../../types"; + +// 남은 재고 계산 +export const getRemainingStock = (product: Product, cart: CartItem[]): number => { + const cartItem = cart.find(item => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +} \ No newline at end of file diff --git a/src/advanced/pages/admin/AdminPage.tsx b/src/advanced/pages/admin/AdminPage.tsx new file mode 100644 index 000000000..f653fccc1 --- /dev/null +++ b/src/advanced/pages/admin/AdminPage.tsx @@ -0,0 +1,189 @@ +import { useState } from 'react'; +import { ProductTable } from './ProductTable'; +import { ProductForm } from './ProductForm'; +import { CouponList } from './CouponList'; +import { CouponForm } from './CouponForm'; +import { ProductWithUI, Coupon } from '../../../types'; +import { Button } from '../../components/ui/Button'; +import { useProductStore } from '../../store/productStore'; +import { useCouponStore } from '../../store/couponStore'; +import { useNotificationStore } from '../../store/notificationStore'; + +export function AdminPage() { + const products = useProductStore(state => state.products); + const addProduct = useProductStore(state => state.addProduct); + const updateProduct = useProductStore(state => state.updateProduct); + const deleteProduct = useProductStore(state => state.deleteProduct); + + const coupons = useCouponStore(state => state.coupons); + const addCoupon = useCouponStore(state => state.addCoupon); + const removeCoupon = useCouponStore(state => state.removeCoupon); + + const addNotification = useNotificationStore(state => state.addNotification); + + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [showProductForm, setShowProductForm] = useState(false); + const [showCouponForm, setShowCouponForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + + const [productForm, setProductForm] = useState>({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + + const [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount", + discountValue: 0, + }); + + return ( +
+
+

관리자 대시보드

+

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

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

상품 목록

+ +
+
+ + { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts, + isRecommended: product.isRecommended, + }); + setShowProductForm(true); + }} + onDelete={(productId) => { + deleteProduct(productId); + }} + /> + {showProductForm && ( + { + if (editingProduct === "new") { + addProduct(productForm); + } else if (editingProduct) { + updateProduct(editingProduct, productForm); + } + setShowProductForm(false); + setEditingProduct(null); + }} + onCancel={() => { + setShowProductForm(false); + setEditingProduct(null); + }} + /> + )} +
+ ) : ( +
+
+
+

쿠폰 관리

+ +
+
+
+ { + removeCoupon(couponCode); + }} + /> + + {showCouponForm && ( + { + const success = addCoupon(couponForm); + if (success) { + setShowCouponForm(false); + } else { + addNotification('이미 존재하는 쿠폰 코드입니다', 'error'); + } + }} + onCancel={() => { + setShowCouponForm(false); + }} + /> + )} +
+
+ )} +
+ ); +} diff --git a/src/advanced/pages/admin/CouponForm.tsx b/src/advanced/pages/admin/CouponForm.tsx new file mode 100644 index 000000000..cebc8b6ba --- /dev/null +++ b/src/advanced/pages/admin/CouponForm.tsx @@ -0,0 +1,156 @@ +import { Coupon } from "../../../types"; +import { Button } from "../../components/ui/Button"; +import { Input } from "../../components/ui/Input"; + +interface CouponFormProps { + couponForm: Coupon; + setCouponForm: (form: Coupon) => void; + addNotification?: (message: string, type: 'error' | 'success' | 'warning') => void; + onSave: () => void; + onCancel: () => void; +} + +export function CouponForm({ + couponForm, + setCouponForm, + addNotification, + onSave, + onCancel +}: CouponFormProps) { + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave(); + }; + return ( +
+
+

+ 새 쿠폰 생성 +

+
+ + setCouponForm({ ...couponForm, name: e.target.value }) + } + placeholder="신규 가입 쿠폰" + fullWidth + required + /> + + setCouponForm({ + ...couponForm, + code: e.target.value.toUpperCase(), + }) + } + className="font-mono" + placeholder="WELCOME2024" + fullWidth + 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 && addNotification( + "할인율은 100%를 초과할 수 없습니다", + "error" + ); + setCouponForm({ + ...couponForm, + discountValue: 100, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } else { + if (value > 100000) { + addNotification && addNotification( + "할인 금액은 100,000원을 초과할 수 없습니다", + "error" + ); + setCouponForm({ + ...couponForm, + discountValue: 100000, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } + }} + placeholder={ + couponForm.discountType === "amount" ? "5000" : "10" + } + fullWidth + required + /> +
+
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/advanced/pages/admin/CouponList.tsx b/src/advanced/pages/admin/CouponList.tsx new file mode 100644 index 000000000..c350bf99e --- /dev/null +++ b/src/advanced/pages/admin/CouponList.tsx @@ -0,0 +1,58 @@ +import { Coupon } from "../../../types"; + +interface CouponListProps { + coupons: Coupon[]; + onDelete: (code: string) => void; +} + +export function CouponList({ + coupons, + onDelete +}: CouponListProps) { + return ( +
+ {coupons.map((coupon) => ( +
+
+
+

+ {coupon.name} +

+

+ {coupon.code} +

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} +
+ ) +} \ No newline at end of file diff --git a/src/advanced/pages/admin/ProductForm.tsx b/src/advanced/pages/admin/ProductForm.tsx new file mode 100644 index 000000000..abc927074 --- /dev/null +++ b/src/advanced/pages/admin/ProductForm.tsx @@ -0,0 +1,232 @@ +import { ProductWithUI } from "../../../types"; +import { Button } from "../../components/ui/Button"; +import { Input } from "../../components/ui/Input"; + +interface ProductFormProps { + productForm: Omit; + setProductForm: (form: Omit) => void; + editingProduct: string | null; + addNotification?: (message: string, type: 'error' | 'success' | 'warning') => void; + onSave: () => void; + onCancel: () => void; +} + +export function ProductForm({ + productForm, + setProductForm, + editingProduct, + addNotification, + onSave, + onCancel +}: ProductFormProps) { + + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave(); + }; + return ( +
+
+

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

+
+ + setProductForm({ ...productForm, name: e.target.value }) + } + fullWidth + required + /> + + setProductForm({ + ...productForm, + description: e.target.value, + }) + } + fullWidth + /> + { + 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) { + if (addNotification) { + addNotification("가격은 0보다 커야 합니다", "error"); + } + setProductForm({ ...productForm, price: 0 }); + } + }} + placeholder="숫자만 입력" + fullWidth + 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) { + if (addNotification) { + addNotification("재고는 0보다 커야 합니다", "error"); + } + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + if (addNotification) { + addNotification("재고는 9999개를 초과할 수 없습니다", "error"); + } + setProductForm({ ...productForm, stock: 9999 }); + } + }} + placeholder="숫자만 입력" + fullWidth + 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="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/advanced/pages/admin/ProductTable.tsx b/src/advanced/pages/admin/ProductTable.tsx new file mode 100644 index 000000000..a05c9131c --- /dev/null +++ b/src/advanced/pages/admin/ProductTable.tsx @@ -0,0 +1,89 @@ +import { ProductWithUI } from "../../../types"; +import { formatPrice } from "../../utils/formatters"; +import { Button } from "../../components/ui/Button"; + +interface ProductTableProps { + products: ProductWithUI[]; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +} + +export function ProductTable({ + products, + onEdit, + onDelete +}: ProductTableProps) { + return ( +
+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {formatPrice(product.price)}원 + + 10 + ? "bg-green-100 text-green-800" + : product.stock > 0 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`} + > + {product.stock}개 + + + {product.description || "-"} + +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/advanced/pages/cart/CartItem.tsx b/src/advanced/pages/cart/CartItem.tsx new file mode 100644 index 000000000..518c4dfbe --- /dev/null +++ b/src/advanced/pages/cart/CartItem.tsx @@ -0,0 +1,120 @@ +import { ProductWithUI } from "../../../types"; +import { formatPrice } from "../../utils/formatters"; +import { getRemainingStock } from '../../models/product'; +import { useCartStore } from '../../store/cartStore'; +import { useNotificationStore } from '../../store/notificationStore'; + +interface CartItemProps { + product: ProductWithUI; +} + +export function CartItem({ product }: CartItemProps) { + const cart = useCartStore(state => state.cart); + const addToCart = useCartStore(state => state.addToCart); + const addNotification = useNotificationStore(state => state.addNotification); + + const remainingStock = getRemainingStock(product, cart); + + const handleAddToCart = () => { + const success = addToCart(product); + if (success) { + addNotification('장바구니에 담았습니다', 'success'); + } + }; + + 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.discounts.length > 0 && ( +

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

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

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

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

+ 재고 {remainingStock}개 +

+ )} + {remainingStock <= 0 && ( +

+ 품절 +

+ )} + +
+ + {/* 장바구니 버튼 */} + +
+
+ ) +} \ No newline at end of file diff --git a/src/advanced/pages/cart/CartItemView.tsx b/src/advanced/pages/cart/CartItemView.tsx new file mode 100644 index 000000000..5898d5fc4 --- /dev/null +++ b/src/advanced/pages/cart/CartItemView.tsx @@ -0,0 +1,107 @@ +import { Product } from "../../../types"; +import { calculateItemTotal } from "../../models/cart"; +import { CartIcon, MinusIcon, PlusIcon, XIcon } from "../../components/icons"; +import { useCartStore } from '../../store/cartStore'; +import { useNotificationStore } from '../../store/notificationStore'; + +interface CartItemViewProps { + products: Product[]; +} + +export function CartItemView({ products }: CartItemViewProps) { + const cart = useCartStore(state => state.cart); + const removeFromCart = useCartStore(state => state.removeFromCart); + const updateQuantity = useCartStore(state => state.updateQuantity); + const addNotification = useNotificationStore(state => state.addNotification); + + const handleUpdateQuantity = (productId: string, newQuantity: number) => { + const success = updateQuantity(products, productId, newQuantity); + if (!success && newQuantity > 0) { + const product = products.find(p => p.id === productId); + if (product) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + } + } + }; + return ( +
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = calculateItemTotal(item, cart); + 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()}원 +

+
+
+
+ ); + })} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/advanced/pages/cart/CartPage.tsx b/src/advanced/pages/cart/CartPage.tsx new file mode 100644 index 000000000..dc673f6ad --- /dev/null +++ b/src/advanced/pages/cart/CartPage.tsx @@ -0,0 +1,68 @@ +import { CartItemView } from './CartItemView'; +import { CartItem } from './CartItem'; +import { CouponView } from './CouponView'; +import { PaymentView } from './PaymentView'; +import { useProductStore } from '../../store/productStore'; +import { useCartStore } from '../../store/cartStore'; +import { useSearch } from '../../utils/hooks/useSearch'; + +export function CartPage() { + const products = useProductStore(state => state.products); + const cart = useCartStore(state => state.cart); + + const { filteredProducts, debouncedSearchTerm, searchTerm, setSearchTerm } = useSearch(products); + + return ( +
+
+ {/* 검색창 */} +
+ setSearchTerm(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ + {/* 상품 목록 */} +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map((product) => + + )} +
+ )} +
+
+
+
+ + {cart.length > 0 && ( + <> + + + + )} +
+
+
+ ); +} diff --git a/src/advanced/pages/cart/CouponView.tsx b/src/advanced/pages/cart/CouponView.tsx new file mode 100644 index 000000000..ee9b98c5f --- /dev/null +++ b/src/advanced/pages/cart/CouponView.tsx @@ -0,0 +1,60 @@ +import { useCartStore } from '../../store/cartStore'; +import { useCouponStore } from '../../store/couponStore'; +import { useNotificationStore } from '../../store/notificationStore'; + +export function CouponView() { + const selectedCoupon = useCartStore(state => state.selectedCoupon); + const applyCoupon = useCartStore(state => state.applyCoupon); + const setSelectedCoupon = useCartStore(state => state.setSelectedCoupon); + const coupons = useCouponStore(state => state.coupons); + const addNotification = useNotificationStore(state => state.addNotification); + + const handleApplyCoupon = (couponCode: string) => { + if (!couponCode) { + setSelectedCoupon(null); + return; + } + + const coupon = coupons.find(c => c.code === couponCode); + if (coupon) { + const success = applyCoupon(coupon); + if (!success) { + addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); + setSelectedCoupon(null); + return; + } + addNotification('쿠폰이 적용되었습니다.', 'success'); + } + }; + + return ( +
+
+

+ 쿠폰 할인 +

+ +
+ {coupons.length > 0 && ( + + )} +
+ ) +} diff --git a/src/advanced/pages/cart/PaymentView.tsx b/src/advanced/pages/cart/PaymentView.tsx new file mode 100644 index 000000000..7e1d2836f --- /dev/null +++ b/src/advanced/pages/cart/PaymentView.tsx @@ -0,0 +1,60 @@ +import { useCartStore } from '../../store/cartStore'; +import { useNotificationStore } from '../../store/notificationStore'; + +export function PaymentView() { + // Zustand stores + const calculateTotal = useCartStore(state => state.calculateTotal); + const completeOrder = useCartStore(state => state.completeOrder); + const addNotification = useNotificationStore(state => state.addNotification); + + const totals = calculateTotal(); + + const handleCompleteOrder = () => { + const orderNumber = completeOrder(); + addNotification(`주문이 완료되었습니다! 주문번호: ${orderNumber}`, 'success'); + }; + + return ( +
+

결제 정보

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

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

+
+
+ ) +} diff --git a/src/advanced/store/cartStore.ts b/src/advanced/store/cartStore.ts new file mode 100644 index 000000000..a03b5417a --- /dev/null +++ b/src/advanced/store/cartStore.ts @@ -0,0 +1,117 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { CartItem, Product, Coupon } from '../../types'; +import * as cartModel from '../models/cart'; +import { getRemainingStock } from '../models/product'; + +interface CartState { + cart: CartItem[]; + selectedCoupon: Coupon | null; + + // 메서드 + addToCart: (product: Product) => boolean; + removeFromCart: (productId: string) => void; + updateQuantity: (products: Product[], productId: string, newQuantity: number) => boolean; + applyCoupon: (coupon: Coupon) => boolean; + calculateTotal: () => ReturnType; + clearCart: () => void; + completeOrder: () => string; + setSelectedCoupon: (coupon: Coupon | null) => void; +} + +export const useCartStore = create()( + persist( + (set, get) => ({ + cart: [], + selectedCoupon: null, + + setSelectedCoupon: (coupon) => set({ selectedCoupon: coupon }), + + addToCart: (product) => { + const { cart } = get(); + const remainingStock = getRemainingStock(product, cart); + + if (remainingStock <= 0) { + return false; // 재고 부족 + } + + const newCart = cartModel.addItemToCart(cart, product); + set({ cart: newCart }); + return true; + }, + + removeFromCart: (productId) => { + const { cart } = get(); + const newCart = cartModel.removeItemFromCart(cart, productId); + set({ cart: newCart }); + }, + + updateQuantity: (products, productId, newQuantity) => { + const { cart, removeFromCart } = get(); + + if (newQuantity <= 0) { + removeFromCart(productId); + return true; + } + + const product = products.find(p => p.id === productId); + if (!product) return false; + + const maxStock = product.stock; + if (newQuantity > maxStock) { + return false; // 재고 초과 + } + + const newCart = cartModel.updateCartItemQuantity(cart, productId, newQuantity); + set({ cart: newCart }); + return true; + }, + + applyCoupon: (coupon) => { + const { calculateTotal } = get(); + const currentTotal = calculateTotal().totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + return false; + } + + set({ selectedCoupon: coupon }); + return true; + }, + + calculateTotal: () => { + const { cart, selectedCoupon } = get(); + return cartModel.calculateCartTotal(cart, selectedCoupon); + }, + + clearCart: () => { + set({ cart: [], selectedCoupon: null }); + }, + + completeOrder: () => { + const orderNumber = `ORD-${Date.now()}`; + set({ cart: [], selectedCoupon: null }); + return orderNumber; + }, + }), + { + name: 'cart', + // cart 배열만 localStorage에 저장 (Basic 버전과 동일하게) + partialize: (state) => ({ cart: state.cart }), + storage: { + getItem: (name) => { + const str = localStorage.getItem(name); + if (!str) return null; + // 배열 형식으로 저장/로드 + const data = JSON.parse(str); + return Array.isArray(data) ? data : data.cart || []; + }, + setItem: (name, value) => { + // cart 배열만 저장 + localStorage.setItem(name, JSON.stringify(value.state.cart)); + }, + removeItem: (name) => localStorage.removeItem(name) + } + } + ) +) \ No newline at end of file diff --git a/src/advanced/store/couponStore.ts b/src/advanced/store/couponStore.ts new file mode 100644 index 000000000..c81f8b942 --- /dev/null +++ b/src/advanced/store/couponStore.ts @@ -0,0 +1,56 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { Coupon } from "../../types"; +import { initialCoupons } from "../constants"; + +interface CouponState { + coupons: Coupon[]; + + // 메서드 + addCoupon: (coupon: Coupon) => boolean; + removeCoupon: (couponCode: string) => void; +} + +export const useCouponStore = create()( + persist( + (set, get) => ({ + coupons: initialCoupons, + + addCoupon: (newCoupon) => { + const { coupons } = get(); + const existingCoupon = coupons.find(c => c.code === newCoupon.code); + + if (existingCoupon) { + return false; // 중복 코드 + } + + set({ coupons: [...coupons, newCoupon] }); + return true; + }, + + // 쿠폰 삭제 + removeCoupon: (couponCode) => { + const { coupons } = get(); + set({ coupons: coupons.filter(c => c.code !== couponCode) }); + }, + }), + { + name: 'coupons', + // coupons 배열만 localStorage에 저장 (Basic 버전과 동일하게) + storage: { + getItem: (name) => { + const str = localStorage.getItem(name); + if (!str) return null; + // 배열 형식으로 저장/로드 + const data = JSON.parse(str); + return Array.isArray(data) ? data : data.coupons || []; + }, + setItem: (name, value) => { + // coupons 배열만 저장 + localStorage.setItem(name, JSON.stringify(value.state.coupons)); + }, + removeItem: (name) => localStorage.removeItem(name) + } + } + ) +); diff --git a/src/advanced/store/notificationStore.ts b/src/advanced/store/notificationStore.ts new file mode 100644 index 000000000..33a0680a8 --- /dev/null +++ b/src/advanced/store/notificationStore.ts @@ -0,0 +1,40 @@ +import { create } from "zustand"; + +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +interface NotificationState { + notifications: Notification[]; + + // 메서드 + addNotification: (message: string, type?: "error" | "success" | "warning") => void; + removeNotification: (id: string) => void; +} + +export const useNotificationStore = create((set) => ({ + notifications: [], + + addNotification: (message, type = "success") => { + const id = Date.now().toString(); + const notification: Notification = { id, message, type }; + + set((state) => ({ + notifications: [...state.notifications, notification], + })); + + setTimeout(() => { + set((state) => ({ + notifications: state.notifications.filter(n => n.id !== id), + })); + }, 3000); + }, + + removeNotification: (id) => { + set((state) => ({ + notifications: state.notifications.filter(n => n.id !== id), + })); + }, +})); diff --git a/src/advanced/store/productStore.ts b/src/advanced/store/productStore.ts new file mode 100644 index 000000000..0db663c42 --- /dev/null +++ b/src/advanced/store/productStore.ts @@ -0,0 +1,86 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { Product, ProductWithUI } from '../../types'; +import { initialProducts } from '../constants'; + +interface ProductState { + products: ProductWithUI[]; + + // 메서드 + addProduct: (product: Omit) => void; + updateProduct: (productId: string, product: Partial) => void; + deleteProduct: (productId: string) => void; + updateProductStock: (productId: string, newStock: number) => void; + addProductDiscount: (productId: string, + discount: { + quantity: number, + rate: number + }) => void; + removeProductDiscount: (productId: string, discountIndex: number) => void; +} + +export const useProductStore = create()( + persist( + (set) => ({ + products: initialProducts, + + addProduct: (product) => set((state) => ({ + products: [...state.products, { ...product, id: `p${Date.now()}` }] + })), + + updateProduct: (productId, updates) => set((state) => ({ + products: state.products.map(p => + p.id === productId + ? { ...p, ...updates } + : p + ) + })), + + deleteProduct: (productId) => set((state) => ({ + products: state.products.filter(p => p.id !== productId) + })), + + updateProductStock: (productId, newStock) => set((state) => ({ + products: state.products.map(p => + p.id === productId + ? { ...p, stock: newStock } + : p + ) + })), + + addProductDiscount: (productId, discount) => set((state) => ({ + products: state.products.map(p => + p.id === productId + ? { ...p, discounts: [...p.discounts, discount] } + : p + ) + })), + + removeProductDiscount: (productId, discountIndex) => set((state) => ({ + products: state.products.map(p => + p.id === productId + ? { ...p, discounts: p.discounts.filter((_, idx) => idx !== discountIndex) } + : p + ) + })), + }), + { + name: 'products', + // products 배열만 localStorage에 저장 (Basic 버전과 동일하게) + storage: { + getItem: (name) => { + const str = localStorage.getItem(name); + if (!str) return null; + // 배열 형식으로 저장/로드 + const data = JSON.parse(str); + return Array.isArray(data) ? data : data.products || []; + }, + setItem: (name, value) => { + // products 배열만 저장 + localStorage.setItem(name, JSON.stringify(value.state.products)); + }, + removeItem: (name) => localStorage.removeItem(name) + } + } + ) +) \ No newline at end of file diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts new file mode 100644 index 000000000..ebc4da0c8 --- /dev/null +++ b/src/advanced/utils/formatters.ts @@ -0,0 +1,19 @@ +// 가격을 한국 원화 형식으로 포맷 +export const formatPrice = (price: number): string => { + // soldout 추가하기~ + return `${price.toLocaleString()}`; +} + +// 날짜를 YYYY-MM-DD 형식으로 포맷 +export const formatDate = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +} + +// 소수를 퍼센트로 변환 (0.1 → 10%) +export const formatPercentage = (rate: number): string => { + return `${Math.round(rate * 100)}%`; +} \ No newline at end of file diff --git a/src/advanced/utils/hooks/useDebounce.ts b/src/advanced/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..7152fe642 --- /dev/null +++ b/src/advanced/utils/hooks/useDebounce.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; + +// 디바운스 Hook +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // value가 변경되면 이전 타이머 cleanup + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/advanced/utils/hooks/useNotification.ts b/src/advanced/utils/hooks/useNotification.ts new file mode 100644 index 000000000..ee5f4ffc4 --- /dev/null +++ b/src/advanced/utils/hooks/useNotification.ts @@ -0,0 +1,32 @@ +import { useCallback, useState } from 'react'; + +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export const useNotification = () => { + const [notifications, setNotifications] = useState([]); + + // 알림 추가 + const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { + const id = Date.now().toString(); + setNotifications(prev => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, 3000); + }, []); + + // 알림 제거 + const removeNotification = useCallback((id: string) => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, []); + + return { + notifications, + addNotification, + removeNotification, + }; +}; diff --git a/src/advanced/utils/hooks/useSearch.ts b/src/advanced/utils/hooks/useSearch.ts new file mode 100644 index 000000000..c077d0ae3 --- /dev/null +++ b/src/advanced/utils/hooks/useSearch.ts @@ -0,0 +1,30 @@ +import { useState, useMemo } from 'react'; +import { useDebounce } from './useDebounce'; +import { ProductWithUI } from '../../../types'; + +export function useSearch(products: ProductWithUI[], delay: number = 300) { + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounce(searchTerm, delay); + + // 검색어로 상품 필터링 + const filteredProducts = useMemo(() => { + if (!debouncedSearchTerm.trim()) { + return products; + } + + const lowerSearchTerm = debouncedSearchTerm.toLowerCase(); + + return products.filter(product => { + const nameMatch = product.name.toLowerCase().includes(lowerSearchTerm); + const descriptionMatch = product.description?.toLowerCase().includes(lowerSearchTerm); + return nameMatch || descriptionMatch; + }); + }, [products, debouncedSearchTerm]); + + return { + searchTerm, + setSearchTerm, + debouncedSearchTerm, + filteredProducts, + }; +} diff --git a/src/advanced/utils/hooks/useValidate.ts b/src/advanced/utils/hooks/useValidate.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/advanced/utils/validators.ts b/src/advanced/utils/validators.ts new file mode 100644 index 000000000..38c9570e2 --- /dev/null +++ b/src/advanced/utils/validators.ts @@ -0,0 +1,19 @@ +// 쿠폰 코드 형식 검증 (4-12자) +export const isValidCouponCode = (code: string): boolean => { + return /^[A-Z0-9]{4,12}$/.test(code); +}; + +// 재고 수량 검증(0 이상) +export const isValidStock = (stock: number): boolean => { + return stock >= 0; +}; + +// 가격 검증 (양수) +export const isValidPrice = (price: number): boolean => { + return price > 0; +}; + +// 문자열에서 숫자만 추출 +export const extractNumbers = (value: string): string => { + return value.replace(/\D/g, ""); +}; \ No newline at end of file diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..ffb7d010e 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,1124 +1,61 @@ -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, useMemo } from 'react'; +import { AdminPage } from './pages/admin/AdminPage'; +import { CartPage } from './pages/cart/CartPage'; +import { Header } from './components/Header'; +import { NotificationContainer } from './components/Notification'; +import { useNotification } from './utils/hooks/useNotification'; +import { useProducts } from './hooks/useProducts'; +import { useCart } from './hooks/useCart'; +import { useCoupons } from './hooks/useCoupons'; +import { useSearch } from './utils/hooks/useSearch'; const App = () => { + const { notifications, addNotification, removeNotification } = useNotification(); + const productsHook = useProducts(addNotification); + const cartHook = useCart(addNotification); + const couponsHook = useCoupons(addNotification); + const searchHook = useSearch(productsHook.products); - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + // 장바구니 총 아이템 수 계산 + const totalItemCount = useMemo(() => { + return cartHook.cart.reduce((sum, item) => sum + item.quantity, 0); + }, [cartHook.cart]); 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..ecaaccc58 --- /dev/null +++ b/src/basic/components/Header.tsx @@ -0,0 +1,62 @@ +interface HeaderProps { + isAdmin: boolean; + setIsAdmin: (value: boolean) => void; + searchTerm: string; + onSearchChange: (value: string) => void; + cartItemCount: number; +} + +export function Header({ + isAdmin, + setIsAdmin, + searchTerm, + onSearchChange, + cartItemCount +}: HeaderProps) { + return ( +
+
+
+
+

SHOP

+ {!isAdmin && ( +
+ onSearchChange(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ )} +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/src/basic/components/Notification.tsx b/src/basic/components/Notification.tsx new file mode 100644 index 000000000..1a43f7aa0 --- /dev/null +++ b/src/basic/components/Notification.tsx @@ -0,0 +1,35 @@ +import { Notification } from "../utils/hooks/useNotification"; + +interface NotificationContainerProps { + notifications: Notification[]; + onRemove: (id: string) => void; +} + +export function NotificationContainer({ notifications, onRemove }: NotificationContainerProps) { + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map(notif => ( +
+ {notif.message} + +
+ ))} +
+ ); +} diff --git a/src/basic/components/icons/index.tsx b/src/basic/components/icons/index.tsx new file mode 100644 index 000000000..f49a84da3 --- /dev/null +++ b/src/basic/components/icons/index.tsx @@ -0,0 +1,157 @@ +interface IconProps { + className?: string; + size?: number; +} + +export const CartIcon = ({ className, size = 24 }: IconProps) => ( + + + + + +); + +export const AdminIcon = ({ className, size = 24 }: IconProps) => ( + + + + + + +); + +export const PlusIcon = ({ className, size = 24 }: IconProps) => ( + + + + +); + +export const MinusIcon = ({ className, size = 24 }: IconProps) => ( + + + +); + +export const TrashIcon = ({ className, size = 24 }: IconProps) => ( + + + + + + +); + +export const ChevronDownIcon = ({ className, size = 24 }: IconProps) => ( + + + +); + +export const ChevronUpIcon = ({ className, size = 24 }: IconProps) => ( + + + +); + +export const CheckIcon = ({ className, size = 24 }: IconProps) => ( + + + +); + +export const XIcon = ({ className, size = 24 }: IconProps) => ( + + + +); diff --git a/src/basic/components/ui/Button.tsx b/src/basic/components/ui/Button.tsx new file mode 100644 index 000000000..6ce5d0841 --- /dev/null +++ b/src/basic/components/ui/Button.tsx @@ -0,0 +1,57 @@ +import { ButtonHTMLAttributes, ReactNode } from 'react'; + +type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'; +type ButtonSize = 'sm' | 'md' | 'lg'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + children: ReactNode; + fullWidth?: boolean; +} + +const variantStyles: Record = { + primary: 'bg-gray-900 text-white hover:bg-gray-800', + secondary: 'border border-gray-300 text-gray-700 hover:bg-gray-50', + danger: 'bg-red-600 text-white hover:bg-red-700', + ghost: 'text-gray-700 hover:bg-gray-100', +}; + +const sizeStyles: Record = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-3 text-base', +}; + +export function Button({ + variant = 'primary', + size = 'md', + children, + fullWidth = false, + className = '', + disabled, + ...props +}: ButtonProps) { + const baseStyles = 'rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500'; + const disabledStyles = disabled ? 'opacity-50 cursor-not-allowed' : ''; + const widthStyles = fullWidth ? 'w-full' : ''; + + const combinedClassName = ` + ${baseStyles} + ${variantStyles[variant]} + ${sizeStyles[size]} + ${disabledStyles} + ${widthStyles} + ${className} + `.trim().replace(/\s+/g, ' '); + + return ( + + ); +} diff --git a/src/basic/components/ui/Input.tsx b/src/basic/components/ui/Input.tsx new file mode 100644 index 000000000..8e19c6de7 --- /dev/null +++ b/src/basic/components/ui/Input.tsx @@ -0,0 +1,46 @@ +import { InputHTMLAttributes, forwardRef } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; + helperText?: string; + fullWidth?: boolean; +} + +export const Input = forwardRef( + ({ label, error, helperText, fullWidth = false, className = '', ...props }, ref) => { + const baseStyles = 'border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border'; + const errorStyles = error ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : ''; + const widthStyles = fullWidth ? 'w-full' : ''; + + const combinedClassName = ` + ${baseStyles} + ${errorStyles} + ${widthStyles} + ${className} + `.trim().replace(/\s+/g, ' '); + + return ( +
+ {label && ( + + )} + + {error && ( +

{error}

+ )} + {helperText && !error && ( +

{helperText}

+ )} +
+ ); + } +); + +Input.displayName = 'Input'; diff --git a/src/basic/constants/index.tsx b/src/basic/constants/index.tsx new file mode 100644 index 000000000..962edae4e --- /dev/null +++ b/src/basic/constants/index.tsx @@ -0,0 +1,50 @@ +import { Coupon, ProductWithUI } from "../../types"; + +export const initialProducts: ProductWithUI[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: "최고급 품질의 프리미엄 상품입니다.", + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +export const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; \ No newline at end of file diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..aa7783480 --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,94 @@ +import { useCallback, useState } from "react"; +import { CartItem, Coupon, Product } from "../../types"; +import * as cartModel from "../models/cart"; +import { getRemainingStock } from "../models/product"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +export const useCart = ( + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void +) => { + const [cart, setCart] = useLocalStorage('cart', []); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + // 상품 추가 함수 + const addToCart = useCallback((product: Product) => { + const remainingStock = getRemainingStock(product, cart); + if (remainingStock <= 0) { + addNotification('재고가 부족합니다!', 'error'); + return; + } + + const newCart = cartModel.addItemToCart(cart, product); + setCart(newCart); + addNotification('장바구니에 담았습니다', 'success'); + }, [cart, setCart, addNotification]); + + // 상품 제거 함수 + const removeFromCart = useCallback((productId: string) => { + setCart(cartModel.removeItemFromCart(cart, productId)); + }, [cart, setCart]); + + // 수량 변경 함수 + const updateQuantity = useCallback((products: Product[], 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; + } + + const newCart = cartModel.updateCartItemQuantity(cart, productId, newQuantity); + setCart(newCart); + }, [cart, setCart, removeFromCart, addNotification]); + + // 쿠폰 적용 함수 + const applyCoupon = useCallback((coupon: Coupon) => { + const currentTotal = calculateTotal().totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); + return; + } + setSelectedCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, [cart, selectedCoupon, addNotification]); + + // 총액 계산 함수 + const calculateTotal = useCallback(() => { + return cartModel.calculateCartTotal(cart, selectedCoupon); + },[cart, selectedCoupon]); + + // 장바구니 비우기 함수 + const clearCart = useCallback(() => { + setCart([]); + setSelectedCoupon(null); + }, [setCart]); + + // 주문 완료 함수 + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + setCart([]); + setSelectedCoupon(null); + }, [setCart, addNotification]); + + return { + cart, + selectedCoupon, + setSelectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + calculateTotal, + clearCart, + completeOrder, + }; +} diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 000000000..b7698de88 --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,35 @@ +import { useCallback } from "react" +import { Coupon } from "../../types"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; +import { initialCoupons } from "../constants"; + +export const useCoupons = ( + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void +) => { + const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); + + // 새 쿠폰 추가 + const addCoupon = useCallback((newCoupon: Coupon) => { + setCoupons(prev => { + const existingCoupon = prev.find(c => c.code === newCoupon.code); + if (existingCoupon) { + addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); + return prev; + } + addNotification('쿠폰이 추가되었습니다.', 'success'); + return [...prev, newCoupon]; + }); + }, [setCoupons, addNotification]); + + // 쿠폰 삭제 + const removeCoupon = useCallback((couponCode: string) => { + setCoupons(prev => prev.filter(coupon => coupon.code !== couponCode)); + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }, [setCoupons, addNotification]); + + return { + coupons, + addCoupon, + removeCoupon, + } +} diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 000000000..3259c7443 --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,77 @@ +import { useCallback } from "react"; +import { Product } from "../../types"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; +import { initialProducts } from "../constants"; + +export function useProducts( + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void +) { + const [products, setProducts] = useLocalStorage('products', initialProducts); + + // 새 상품 추가 + const addProduct = useCallback((newProduct: Omit) => { + const product = { + ...newProduct, + id: `p${Date.now()}` + }; + setProducts(prev => [...prev, product]); + addNotification('상품이 추가되었습니다.', 'success'); + }, [setProducts, addNotification]); + + // 상품 정보 수정 + const updateProduct = useCallback((productId: string, updates: Partial) => { + setProducts(prev => prev.map(p => + p.id === productId + ? {...p, ...updates} + : p + )); + addNotification('상품이 수정되었습니다.', 'success'); + }, [setProducts, addNotification]); + + // 상품 삭제 + const deleteProduct = useCallback((productId: string) => { + setProducts(prev => prev.filter(p => + p.id !== productId + )); + addNotification('상품이 삭제되었습니다.', 'success'); + }, [setProducts, addNotification]); + + // 재고 수정 + const updateProductStock = useCallback((productId:string, newStock: number) => { + updateProduct(productId, { stock: newStock }); + }, [updateProduct]); + + // 할인 규칙 추가 + const addProductDiscount = useCallback(( + productId: string, + discount: { + quantity: number, + rate: number + } + ) => { + setProducts(prev => prev.map(p => + p.id === productId + ? {...p, discounts: [...p.discounts, discount]} + : p + )); + }, [setProducts]); + + // 할인 규칙 삭제 + const removeProductDiscount = useCallback((productId: string, discountIndex: number) => { + setProducts(prev => prev.map(p => + p.id === productId + ? {...p, discounts: p.discounts.filter((_d, idx) => idx !== discountIndex)} + : p + )); + }, [setProducts]); + + return { + products, + addProduct, + updateProduct, + deleteProduct, + updateProductStock, + addProductDiscount, + removeProductDiscount, + } +} diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts new file mode 100644 index 000000000..ac9118df0 --- /dev/null +++ b/src/basic/models/cart.ts @@ -0,0 +1,80 @@ +import { CartItem, Coupon, Product } from "../../types"; +import { getMaxApplicableDiscount } from "./discount"; + +// 개별 아이템의 할인 적용 후 총액 계산 +export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +// 장바구니 총액 계산 (할인 전/후, 할인액) +export const calculateCartTotal = (cart: CartItem[], coupon: Coupon | null): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach(item => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (coupon) { + if (coupon.discountType === 'amount') { + totalAfterDiscount = Math.max(0, totalAfterDiscount - coupon.discountValue); + } else { + totalAfterDiscount = Math.round(totalAfterDiscount * (1 - coupon.discountValue / 100)); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount) + }; +}; + +// 수량 변경 +// 장바구니에서 특정 상품의 수량만 변경을 책임 +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + quantity: number +): CartItem[] => { + // 수량 0 이하일때 배열에서 해당 아이템 제거 + if (quantity <= 0) { + return cart.filter(item => item.product.id !== productId) + } + + return cart.map(item => + item.product.id === productId + ? { ...item, quantity: quantity} + : item + ); +}; + +// 상품 추가 +// 장바구니에 상품 추가에 대한 책임만 존재해야함 (재고 검증 X) +export const addItemToCart = (cart: CartItem[], product: Product) => { + const existingItem = cart.find(item => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + return cart.map(item => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ); + } + return [ ...cart, { product, quantity: 1 }]; +}; + +// 상품 제거 +export const removeItemFromCart = (cart: CartItem[], productId: string): CartItem[] => { + return cart.filter(item => item.product.id !== productId); +}; \ No newline at end of file diff --git a/src/basic/models/discount.ts b/src/basic/models/discount.ts new file mode 100644 index 000000000..9f98262cf --- /dev/null +++ b/src/basic/models/discount.ts @@ -0,0 +1,20 @@ +import { CartItem } from "../../types"; + +// 적용 가능한 최대 할인율 계산 +export const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; +}; \ No newline at end of file diff --git a/src/basic/models/product.ts b/src/basic/models/product.ts new file mode 100644 index 000000000..defd48f99 --- /dev/null +++ b/src/basic/models/product.ts @@ -0,0 +1,9 @@ +import { CartItem, Product } from "../../types"; + +// 남은 재고 계산 +export const getRemainingStock = (product: Product, cart: CartItem[]): number => { + const cartItem = cart.find(item => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +} \ No newline at end of file diff --git a/src/basic/pages/admin/AdminPage.tsx b/src/basic/pages/admin/AdminPage.tsx new file mode 100644 index 000000000..97ef6d317 --- /dev/null +++ b/src/basic/pages/admin/AdminPage.tsx @@ -0,0 +1,183 @@ +import { useState } from 'react'; +import { useProducts } from '../../hooks/useProducts'; +import { useCoupons } from '../../hooks/useCoupons'; +import { ProductTable } from './ProductTable'; +import { ProductForm } from './ProductForm'; +import { CouponList } from './CouponList'; +import { CouponForm } from './CouponForm'; +import { ProductWithUI, Coupon } from '../../../types'; +import { Button } from '../../components/ui/Button'; + +interface AdminPageProps { + productsHook: ReturnType; + couponsHook: ReturnType; + addNotification?: (message: string, type: 'error' | 'success' | 'warning') => void; +} + +export function AdminPage({ + productsHook, + couponsHook, + addNotification +}: AdminPageProps) { + + const { products, addProduct, updateProduct, deleteProduct } = productsHook; + const { coupons, addCoupon, removeCoupon } = couponsHook; + + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [showProductForm, setShowProductForm] = useState(false); + const [showCouponForm, setShowCouponForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + + const [productForm, setProductForm] = useState>({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + + const [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount", + discountValue: 0, + }); + + return ( +
+
+

관리자 대시보드

+

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

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

상품 목록

+ +
+
+ + { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts, + isRecommended: product.isRecommended, + }); + setShowProductForm(true); + }} + onDelete={deleteProduct} + /> + {showProductForm && ( + { + if (editingProduct === "new") { + addProduct(productForm); + } else if (editingProduct) { + updateProduct(editingProduct, productForm); + } + setShowProductForm(false); + setEditingProduct(null); + }} + onCancel={() => { + setShowProductForm(false); + setEditingProduct(null); + }} + /> + )} +
+ ) : ( +
+
+
+

쿠폰 관리

+ +
+
+
+ + + {showCouponForm && ( + { + addCoupon(couponForm); + setShowCouponForm(false); + }} + onCancel={() => { + setShowCouponForm(false); + }} + /> + )} +
+
+ )} +
+ ); +} diff --git a/src/basic/pages/admin/CouponForm.tsx b/src/basic/pages/admin/CouponForm.tsx new file mode 100644 index 000000000..cebc8b6ba --- /dev/null +++ b/src/basic/pages/admin/CouponForm.tsx @@ -0,0 +1,156 @@ +import { Coupon } from "../../../types"; +import { Button } from "../../components/ui/Button"; +import { Input } from "../../components/ui/Input"; + +interface CouponFormProps { + couponForm: Coupon; + setCouponForm: (form: Coupon) => void; + addNotification?: (message: string, type: 'error' | 'success' | 'warning') => void; + onSave: () => void; + onCancel: () => void; +} + +export function CouponForm({ + couponForm, + setCouponForm, + addNotification, + onSave, + onCancel +}: CouponFormProps) { + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave(); + }; + return ( +
+
+

+ 새 쿠폰 생성 +

+
+ + setCouponForm({ ...couponForm, name: e.target.value }) + } + placeholder="신규 가입 쿠폰" + fullWidth + required + /> + + setCouponForm({ + ...couponForm, + code: e.target.value.toUpperCase(), + }) + } + className="font-mono" + placeholder="WELCOME2024" + fullWidth + 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 && addNotification( + "할인율은 100%를 초과할 수 없습니다", + "error" + ); + setCouponForm({ + ...couponForm, + discountValue: 100, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } else { + if (value > 100000) { + addNotification && addNotification( + "할인 금액은 100,000원을 초과할 수 없습니다", + "error" + ); + setCouponForm({ + ...couponForm, + discountValue: 100000, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } + }} + placeholder={ + couponForm.discountType === "amount" ? "5000" : "10" + } + fullWidth + required + /> +
+
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/basic/pages/admin/CouponList.tsx b/src/basic/pages/admin/CouponList.tsx new file mode 100644 index 000000000..c350bf99e --- /dev/null +++ b/src/basic/pages/admin/CouponList.tsx @@ -0,0 +1,58 @@ +import { Coupon } from "../../../types"; + +interface CouponListProps { + coupons: Coupon[]; + onDelete: (code: string) => void; +} + +export function CouponList({ + coupons, + onDelete +}: CouponListProps) { + return ( +
+ {coupons.map((coupon) => ( +
+
+
+

+ {coupon.name} +

+

+ {coupon.code} +

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} +
+ ) +} \ No newline at end of file diff --git a/src/basic/pages/admin/ProductForm.tsx b/src/basic/pages/admin/ProductForm.tsx new file mode 100644 index 000000000..abc927074 --- /dev/null +++ b/src/basic/pages/admin/ProductForm.tsx @@ -0,0 +1,232 @@ +import { ProductWithUI } from "../../../types"; +import { Button } from "../../components/ui/Button"; +import { Input } from "../../components/ui/Input"; + +interface ProductFormProps { + productForm: Omit; + setProductForm: (form: Omit) => void; + editingProduct: string | null; + addNotification?: (message: string, type: 'error' | 'success' | 'warning') => void; + onSave: () => void; + onCancel: () => void; +} + +export function ProductForm({ + productForm, + setProductForm, + editingProduct, + addNotification, + onSave, + onCancel +}: ProductFormProps) { + + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave(); + }; + return ( +
+
+

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

+
+ + setProductForm({ ...productForm, name: e.target.value }) + } + fullWidth + required + /> + + setProductForm({ + ...productForm, + description: e.target.value, + }) + } + fullWidth + /> + { + 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) { + if (addNotification) { + addNotification("가격은 0보다 커야 합니다", "error"); + } + setProductForm({ ...productForm, price: 0 }); + } + }} + placeholder="숫자만 입력" + fullWidth + 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) { + if (addNotification) { + addNotification("재고는 0보다 커야 합니다", "error"); + } + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + if (addNotification) { + addNotification("재고는 9999개를 초과할 수 없습니다", "error"); + } + setProductForm({ ...productForm, stock: 9999 }); + } + }} + placeholder="숫자만 입력" + fullWidth + 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="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/basic/pages/admin/ProductTable.tsx b/src/basic/pages/admin/ProductTable.tsx new file mode 100644 index 000000000..a05c9131c --- /dev/null +++ b/src/basic/pages/admin/ProductTable.tsx @@ -0,0 +1,89 @@ +import { ProductWithUI } from "../../../types"; +import { formatPrice } from "../../utils/formatters"; +import { Button } from "../../components/ui/Button"; + +interface ProductTableProps { + products: ProductWithUI[]; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +} + +export function ProductTable({ + products, + onEdit, + onDelete +}: ProductTableProps) { + return ( +
+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {formatPrice(product.price)}원 + + 10 + ? "bg-green-100 text-green-800" + : product.stock > 0 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`} + > + {product.stock}개 + + + {product.description || "-"} + +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/basic/pages/cart/CartItem.tsx b/src/basic/pages/cart/CartItem.tsx new file mode 100644 index 000000000..be72ab334 --- /dev/null +++ b/src/basic/pages/cart/CartItem.tsx @@ -0,0 +1,114 @@ +import { ProductWithUI, } from "../../../types"; +import { useCart } from "../../hooks/useCart"; +import { formatPrice } from "../../utils/formatters"; +import { getRemainingStock } from '../../models/product'; + + +interface CartItemProps { + product: ProductWithUI; + cartHook: ReturnType; +} + +export function CartItem({ + product, + cartHook +}: CartItemProps) { + const { cart, addToCart } = cartHook; + const remainingStock = getRemainingStock(product, cart); + + 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.discounts.length > 0 && ( +

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

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

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

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

+ 재고 {remainingStock}개 +

+ )} + {remainingStock <= 0 && ( +

+ 품절 +

+ )} + +
+ + {/* 장바구니 버튼 */} + +
+
+ ) +} \ No newline at end of file diff --git a/src/basic/pages/cart/CartItemView.tsx b/src/basic/pages/cart/CartItemView.tsx new file mode 100644 index 000000000..f6379250e --- /dev/null +++ b/src/basic/pages/cart/CartItemView.tsx @@ -0,0 +1,98 @@ +import { useCart } from "../../hooks/useCart"; +import { Product } from "../../../types"; +import { calculateItemTotal } from "../../models/cart"; +import { CartIcon, MinusIcon, PlusIcon, XIcon } from "../../components/icons"; + +interface CartItemViewProps { + cartHook: ReturnType; + products: Product[]; +} + +export function CartItemView({ + cartHook, + products, +}: CartItemViewProps) { + + const { cart, removeFromCart, updateQuantity } = cartHook; + return ( +
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = calculateItemTotal(item, cart); + 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()}원 +

+
+
+
+ ); + })} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/basic/pages/cart/CartPage.tsx b/src/basic/pages/cart/CartPage.tsx new file mode 100644 index 000000000..b8a9c1992 --- /dev/null +++ b/src/basic/pages/cart/CartPage.tsx @@ -0,0 +1,79 @@ +import { useCart } from '../../hooks/useCart'; +import { useCoupons } from '../../hooks/useCoupons'; +import { useSearch } from '../../utils/hooks/useSearch'; +import { CartItemView } from './CartItemView'; +import { CartItem } from './CartItem'; +import { CouponView } from './CouponView'; +import { PaymentView } from './PaymentView'; +import { useProducts } from '../../hooks/useProducts'; + +interface CartPageProps { + cartHook: ReturnType; + productHook: ReturnType; + couponsHook: ReturnType; + searchHook: ReturnType; +} + +export function CartPage({ + cartHook, + productHook, + couponsHook, + searchHook +}: CartPageProps) { + + const { cart } = cartHook; + const { products } = productHook; + const { filteredProducts, debouncedSearchTerm } = searchHook; + + return ( +
+
+ {/* 상품 목록 */} +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map((product) => + + )} +
+ )} +
+
+
+
+ + {cart.length > 0 && ( + <> + + + + )} +
+
+
+ ); +} diff --git a/src/basic/pages/cart/CouponView.tsx b/src/basic/pages/cart/CouponView.tsx new file mode 100644 index 000000000..833acc01f --- /dev/null +++ b/src/basic/pages/cart/CouponView.tsx @@ -0,0 +1,52 @@ +import { useCart } from "../../hooks/useCart"; +import { useCoupons } from "../../hooks/useCoupons"; + +interface CouponViewProps { + cartHook: ReturnType; + couponsHook: ReturnType; +} + +export function CouponView({ + cartHook, + couponsHook +}: CouponViewProps) { + const { selectedCoupon, applyCoupon, setSelectedCoupon } = cartHook; + const { coupons } = couponsHook; + + return ( +
+
+

+ 쿠폰 할인 +

+ +
+ {coupons.length > 0 && ( + + )} +
+ ) +} diff --git a/src/basic/pages/cart/PaymentView.tsx b/src/basic/pages/cart/PaymentView.tsx new file mode 100644 index 000000000..8436187d3 --- /dev/null +++ b/src/basic/pages/cart/PaymentView.tsx @@ -0,0 +1,56 @@ +import { useCart } from "../../hooks/useCart"; + +interface PaymentViewProps { + cartHook: ReturnType; +} + +export function PaymentView({ + cartHook +}: PaymentViewProps) { + const { calculateTotal, completeOrder } = cartHook; + const totals = calculateTotal(); + + return ( +
+

결제 정보

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

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

+
+
+ ) +} diff --git a/src/basic/pages/cart/ProductCard.tsx b/src/basic/pages/cart/ProductCard.tsx 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..c1295ce60 --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,18 @@ +// 가격을 한국 원화 형식으로 포맷 +export const formatPrice = (price: number): string => { + return `${price.toLocaleString()}`; +} + +// 날짜를 YYYY-MM-DD 형식으로 포맷 +export const formatDate = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +} + +// 소수를 퍼센트로 변환 (0.1 → 10%) +export const formatPercentage = (rate: number): string => { + return `${Math.round(rate * 100)}%`; +} \ No newline at end of file diff --git a/src/basic/utils/hooks/useDebounce.ts b/src/basic/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..7152fe642 --- /dev/null +++ b/src/basic/utils/hooks/useDebounce.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; + +// 디바운스 Hook +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // value가 변경되면 이전 타이머 cleanup + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/basic/utils/hooks/useLocalStorage.ts b/src/basic/utils/hooks/useLocalStorage.ts new file mode 100644 index 000000000..c940e3991 --- /dev/null +++ b/src/basic/utils/hooks/useLocalStorage.ts @@ -0,0 +1,39 @@ +import { useState, useEffect } from 'react'; + +/** + * localStorage와 동기화되는 상태를 관리하는 hook + * @param key - localStorage의 키 + * @param initialValue - 초기값 (localStorage에 값이 없을 때 사용) + * @returns [state, setState] - useState와 동일한 인터페이스 + */ +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + // localStorage에서 초기값 가져오기 + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(`Error loading localStorage key "${key}":`, error); + return initialValue; + } + }); + + // 값이 변경될 때마다 localStorage에 저장 + useEffect(() => { + try { + // 빈 배열이면 localStorage에서 제거 + if (Array.isArray(storedValue) && storedValue.length === 0 && key === 'cart') { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(storedValue)); + } + } catch (error) { + console.error(`Error saving localStorage key "${key}":`, error); + } + }, [key, storedValue]); + + return [storedValue, setStoredValue]; +} diff --git a/src/basic/utils/hooks/useNotification.ts b/src/basic/utils/hooks/useNotification.ts new file mode 100644 index 000000000..ee5f4ffc4 --- /dev/null +++ b/src/basic/utils/hooks/useNotification.ts @@ -0,0 +1,32 @@ +import { useCallback, useState } from 'react'; + +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export const useNotification = () => { + const [notifications, setNotifications] = useState([]); + + // 알림 추가 + const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { + const id = Date.now().toString(); + setNotifications(prev => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, 3000); + }, []); + + // 알림 제거 + const removeNotification = useCallback((id: string) => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, []); + + return { + notifications, + addNotification, + removeNotification, + }; +}; diff --git a/src/basic/utils/hooks/useSearch.ts b/src/basic/utils/hooks/useSearch.ts new file mode 100644 index 000000000..c077d0ae3 --- /dev/null +++ b/src/basic/utils/hooks/useSearch.ts @@ -0,0 +1,30 @@ +import { useState, useMemo } from 'react'; +import { useDebounce } from './useDebounce'; +import { ProductWithUI } from '../../../types'; + +export function useSearch(products: ProductWithUI[], delay: number = 300) { + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounce(searchTerm, delay); + + // 검색어로 상품 필터링 + const filteredProducts = useMemo(() => { + if (!debouncedSearchTerm.trim()) { + return products; + } + + const lowerSearchTerm = debouncedSearchTerm.toLowerCase(); + + return products.filter(product => { + const nameMatch = product.name.toLowerCase().includes(lowerSearchTerm); + const descriptionMatch = product.description?.toLowerCase().includes(lowerSearchTerm); + return nameMatch || descriptionMatch; + }); + }, [products, debouncedSearchTerm]); + + return { + searchTerm, + setSearchTerm, + debouncedSearchTerm, + filteredProducts, + }; +} diff --git a/src/basic/utils/hooks/useValidate.ts b/src/basic/utils/hooks/useValidate.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/utils/validators.ts b/src/basic/utils/validators.ts new file mode 100644 index 000000000..38c9570e2 --- /dev/null +++ b/src/basic/utils/validators.ts @@ -0,0 +1,19 @@ +// 쿠폰 코드 형식 검증 (4-12자) +export const isValidCouponCode = (code: string): boolean => { + return /^[A-Z0-9]{4,12}$/.test(code); +}; + +// 재고 수량 검증(0 이상) +export const isValidStock = (stock: number): boolean => { + return stock >= 0; +}; + +// 가격 검증 (양수) +export const isValidPrice = (price: number): boolean => { + return price > 0; +}; + +// 문자열에서 숫자만 추출 +export const extractNumbers = (value: string): string => { + return value.replace(/\D/g, ""); +}; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 5489e296e..ca23db15f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,3 +22,8 @@ export interface Coupon { discountType: 'amount' | 'percentage'; discountValue: number; } + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} diff --git a/tsconfig.app.json b/tsconfig.app.json index d739292ae..d82c950cf 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,5 +23,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/refactoring(hint)"] } diff --git a/vite.config.ts b/vite.config.ts index e6c4016bc..5dbc1da22 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,13 @@ import react from '@vitejs/plugin-react-swc'; export default mergeConfig( defineConfig({ plugins: [react()], + base: process.env.VITE_BASE_PATH || '/', + build: { + rollupOptions: { + input: ['./index.advanced.html', './index.basic.html'] + }, + outDir: 'dist' + } }), defineTestConfig({ test: {