diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..03acc9251 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,50 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "./dist" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/package.json b/package.json index 17b18de25..efe79dc73 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:basic": "vitest src/basic", "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", - "build": "tsc -b && vite build", + "build": "vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1d..bf437f382 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1124 +1,44 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; - -const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); +import { useState } from "react"; +import Notification from "./components/ui/Notification"; +import AdminPage from "./pages/AdminPage"; +import CartPage from "./pages/CartPage"; +import { CartProvider } from "./hooks/useCart"; +import { NotificationProvider } from "./hooks/useNotification"; +import { CouponProvider } from "./hooks/useCoupons"; +import { ProductProvider } from "./hooks/useProducts"; + +const AppContent = () => { 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; return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
+ {/* 알림 영역 */} + + + {/* 메인 컨텐츠 */} + {isAdmin ? ( + setIsAdmin(false)} /> + ) : ( + setIsAdmin(true)} /> )} -
-
-
-
-

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 +const App = () => { + return ( + + + + + + + + + + + + ); +}; + +export default App; diff --git a/src/advanced/__tests__/origin.test.tsx b/src/advanced/__tests__/origin.test.tsx index 3f5c3d55e..5bd864128 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -1,528 +1,538 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; -import { vi } from 'vitest'; -import App from '../App'; -import '../../setupTests'; +import { render as rtlRender, screen, fireEvent, within, waitFor } from "@testing-library/react"; +import { vi } from "vitest"; +import App from "../App"; +import { CouponProvider } from "../hooks/useCoupons"; +import "../../setupTests"; -describe('쇼핑몰 앱 통합 테스트', () => { +// CouponProvider로 감싼 커스텀 render 함수 +const render = (ui = ) => { + return rtlRender({ui}); +}; + +describe("쇼핑몰 앱 통합 테스트", () => { beforeEach(() => { // localStorage 초기화 localStorage.clear(); // console 경고 무시 - vi.spyOn(console, 'warn').mockImplementation(() => {}); - vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); - describe('고객 쇼핑 플로우', () => { - test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { + describe("고객 쇼핑 플로우", () => { + test("상품을 검색하고 장바구니에 추가할 수 있다", async () => { render(); - + // 검색창에 "프리미엄" 입력 - const searchInput = screen.getByPlaceholderText('상품 검색...'); - fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + const searchInput = screen.getByPlaceholderText("상품 검색..."); + fireEvent.change(searchInput, { target: { value: "프리미엄" } }); + // 디바운스 대기 - await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - }, { timeout: 600 }); - + await waitFor( + () => { + expect(screen.getByText("최고급 품질의 프리미엄 상품입니다.")).toBeInTheDocument(); + }, + { timeout: 600 } + ); + // 검색된 상품을 장바구니에 추가 (첫 번째 버튼 선택) - const addButtons = screen.getAllByText('장바구니 담기'); + const addButtons = screen.getAllByText("장바구니 담기"); fireEvent.click(addButtons[0]); - + // 알림 메시지 확인 await waitFor(() => { - expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); + expect(screen.getByText("장바구니에 담았습니다")).toBeInTheDocument(); }); - + // 장바구니에 추가됨 확인 (장바구니 섹션에서) - const cartSection = screen.getByText('장바구니').closest('section'); - expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); + const cartSection = screen.getByText("장바구니").closest("section"); + expect(within(cartSection).getByText("상품1")).toBeInTheDocument(); }); - test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { + test("장바구니에서 수량을 조절하고 할인을 확인할 수 있다", () => { render(); - + // 상품1을 장바구니에 추가 - const product1 = screen.getAllByText('장바구니 담기')[0]; + const product1 = screen.getAllByText("장바구니 담기")[0]; fireEvent.click(product1); - + // 수량을 10개로 증가 (10% 할인 적용) - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + for (let i = 0; i < 9; i++) { fireEvent.click(plusButton); } - + // 10% 할인 적용 확인 - 15% (대량 구매 시 추가 5% 포함) - expect(screen.getByText('-15%')).toBeInTheDocument(); + expect(screen.getByText("-15%")).toBeInTheDocument(); }); - test('쿠폰을 선택하고 적용할 수 있다', () => { + test("쿠폰을 선택하고 적용할 수 있다", () => { render(); - + // 상품 추가 - const addButton = screen.getAllByText('장바구니 담기')[0]; + const addButton = screen.getAllByText("장바구니 담기")[0]; fireEvent.click(addButton); - + // 쿠폰 선택 - const couponSelect = screen.getByRole('combobox'); - fireEvent.change(couponSelect, { target: { value: 'AMOUNT5000' } }); - + const couponSelect = screen.getByRole("combobox"); + fireEvent.change(couponSelect, { target: { value: "AMOUNT5000" } }); + // 결제 정보에서 할인 금액 확인 - const paymentSection = screen.getByText('결제 정보').closest('section'); - const discountRow = within(paymentSection).getByText('할인 금액').closest('div'); - expect(within(discountRow).getByText('-5,000원')).toBeInTheDocument(); + const paymentSection = screen.getByText("결제 정보").closest("section"); + const discountRow = within(paymentSection).getByText("할인 금액").closest("div"); + expect(within(discountRow).getByText("-5,000원")).toBeInTheDocument(); }); - test('품절 임박 상품에 경고가 표시된다', async () => { + test("품절 임박 상품에 경고가 표시된다", async () => { render(); - + // 관리자 모드로 전환 - fireEvent.click(screen.getByText('관리자 페이지로')); - + fireEvent.click(screen.getByText("관리자 페이지로")); + // 상품 수정 - const editButton = screen.getAllByText('수정')[0]; + const editButton = screen.getAllByText("수정")[0]; fireEvent.click(editButton); - + // 재고를 5개로 변경 - const stockInputs = screen.getAllByPlaceholderText('숫자만 입력'); + const stockInputs = screen.getAllByPlaceholderText("숫자만 입력"); const stockInput = stockInputs[1]; // 재고 입력 필드는 두 번째 - fireEvent.change(stockInput, { target: { value: '5' } }); + fireEvent.change(stockInput, { target: { value: "5" } }); fireEvent.blur(stockInput); - + // 수정 완료 버튼 클릭 - const editButtons = screen.getAllByText('수정'); + const editButtons = screen.getAllByText("수정"); const completeEditButton = editButtons[editButtons.length - 1]; // 마지막 수정 버튼 (완료 버튼) fireEvent.click(completeEditButton); - + // 쇼핑몰로 돌아가기 - fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + fireEvent.click(screen.getByText("쇼핑몰로 돌아가기")); + // 품절임박 메시지 확인 - 재고가 5개 이하면 품절임박 표시 await waitFor(() => { - expect(screen.getByText('품절임박! 5개 남음')).toBeInTheDocument(); + expect(screen.getByText("품절임박! 5개 남음")).toBeInTheDocument(); }); }); - test('주문을 완료할 수 있다', () => { + test("주문을 완료할 수 있다", () => { render(); - + // 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 결제하기 버튼 클릭 const orderButton = screen.getByText(/원 결제하기/); fireEvent.click(orderButton); - + // 주문 완료 알림 확인 expect(screen.getByText(/주문이 완료되었습니다/)).toBeInTheDocument(); - + // 장바구니가 비어있는지 확인 - expect(screen.getByText('장바구니가 비어있습니다')).toBeInTheDocument(); + expect(screen.getByText("장바구니가 비어있습니다")).toBeInTheDocument(); }); - test('장바구니에서 상품을 삭제할 수 있다', () => { + test("장바구니에서 상품을 삭제할 수 있다", () => { render(); - + // 상품 2개 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[1]); + // 장바구니 섹션 확인 - const cartSection = screen.getByText('장바구니').closest('section'); - expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); - expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); - + const cartSection = screen.getByText("장바구니").closest("section"); + expect(within(cartSection).getByText("상품1")).toBeInTheDocument(); + expect(within(cartSection).getByText("상품2")).toBeInTheDocument(); + // 첫 번째 상품 삭제 (X 버튼) - const deleteButtons = within(cartSection).getAllByRole('button').filter( - button => button.querySelector('svg') - ); + const deleteButtons = within(cartSection) + .getAllByRole("button") + .filter((button) => button.querySelector("svg")); fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되고 상품2만 남음 - expect(within(cartSection).queryByText('상품1')).not.toBeInTheDocument(); - expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); + expect(within(cartSection).queryByText("상품1")).not.toBeInTheDocument(); + expect(within(cartSection).getByText("상품2")).toBeInTheDocument(); }); - test('재고를 초과하여 구매할 수 없다', async () => { + test("재고를 초과하여 구매할 수 없다", async () => { render(); - + // 상품1 장바구니에 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 수량을 재고(20개) 이상으로 증가 시도 - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + // 19번 클릭하여 총 20개로 만듦 for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 한 번 더 클릭 시도 (21개가 되려고 함) fireEvent.click(plusButton); - + // 수량이 20개에서 멈춰있어야 함 - expect(within(cartSection).getByText('20')).toBeInTheDocument(); - + expect(within(cartSection).getByText("20")).toBeInTheDocument(); + // 재고 부족 메시지 확인 await waitFor(() => { expect(screen.getByText(/재고는.*개까지만 있습니다/)).toBeInTheDocument(); }); }); - test('장바구니에서 수량을 감소시킬 수 있다', () => { + test("장바구니에서 수량을 감소시킬 수 있다", () => { render(); - + // 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - const minusButton = within(cartSection).getByText('−'); // U+2212 마이너스 기호 - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + const minusButton = within(cartSection).getByText("−"); // U+2212 마이너스 기호 + // 수량 3개로 증가 fireEvent.click(plusButton); fireEvent.click(plusButton); - expect(within(cartSection).getByText('3')).toBeInTheDocument(); - + expect(within(cartSection).getByText("3")).toBeInTheDocument(); + // 수량 감소 fireEvent.click(minusButton); - expect(within(cartSection).getByText('2')).toBeInTheDocument(); - + expect(within(cartSection).getByText("2")).toBeInTheDocument(); + // 1개로 더 감소 fireEvent.click(minusButton); - expect(within(cartSection).getByText('1')).toBeInTheDocument(); - + expect(within(cartSection).getByText("1")).toBeInTheDocument(); + // 1개에서 한 번 더 감소하면 장바구니에서 제거될 수도 있음 fireEvent.click(minusButton); // 장바구니가 비었는지 확인 - const emptyMessage = screen.queryByText('장바구니가 비어있습니다'); + const emptyMessage = screen.queryByText("장바구니가 비어있습니다"); if (emptyMessage) { expect(emptyMessage).toBeInTheDocument(); } else { // 또는 수량이 1에서 멈춤 - expect(within(cartSection).getByText('1')).toBeInTheDocument(); + expect(within(cartSection).getByText("1")).toBeInTheDocument(); } }); - test('20개 이상 구매 시 최대 할인이 적용된다', async () => { + test("20개 이상 구매 시 최대 할인이 적용된다", async () => { render(); - + // 관리자 모드로 전환하여 상품1의 재고를 늘림 - fireEvent.click(screen.getByText('관리자 페이지로')); - fireEvent.click(screen.getAllByText('수정')[0]); - - const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; - fireEvent.change(stockInput, { target: { value: '30' } }); - - const editButtons = screen.getAllByText('수정'); + fireEvent.click(screen.getByText("관리자 페이지로")); + fireEvent.click(screen.getAllByText("수정")[0]); + + const stockInput = screen.getAllByPlaceholderText("숫자만 입력")[1]; + fireEvent.change(stockInput, { target: { value: "30" } }); + + const editButtons = screen.getAllByText("수정"); fireEvent.click(editButtons[editButtons.length - 1]); - + // 쇼핑몰로 돌아가기 - fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + fireEvent.click(screen.getByText("쇼핑몰로 돌아가기")); + // 상품1을 장바구니에 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 수량을 20개로 증가 - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 25% 할인 적용 확인 (또는 대량 구매 시 30%) await waitFor(() => { - const discount25 = screen.queryByText('-25%'); - const discount30 = screen.queryByText('-30%'); + const discount25 = screen.queryByText("-25%"); + const discount30 = screen.queryByText("-30%"); expect(discount25 || discount30).toBeTruthy(); }); }); }); - describe('관리자 기능', () => { + describe("관리자 기능", () => { beforeEach(() => { render(); // 관리자 모드로 전환 - fireEvent.click(screen.getByText('관리자 페이지로')); + fireEvent.click(screen.getByText("관리자 페이지로")); }); - test('새 상품을 추가할 수 있다', () => { + test("새 상품을 추가할 수 있다", () => { // 새 상품 추가 버튼 클릭 - fireEvent.click(screen.getByText('새 상품 추가')); - + fireEvent.click(screen.getByText("새 상품 추가")); + // 폼 입력 - 상품명 입력 - const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); - const nameInput = nameLabel.closest('div').querySelector('input'); - fireEvent.change(nameInput, { target: { value: '테스트 상품' } }); - - const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - fireEvent.change(priceInput, { target: { value: '25000' } }); - - const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; - fireEvent.change(stockInput, { target: { value: '50' } }); - - const descLabels = screen.getAllByText('설명'); - const descLabel = descLabels.find(el => el.tagName === 'LABEL'); - const descInput = descLabel.closest('div').querySelector('input'); - fireEvent.change(descInput, { target: { value: '테스트 설명' } }); - + const labels = screen.getAllByText("상품명"); + const nameLabel = labels.find((el) => el.tagName === "LABEL"); + const nameInput = nameLabel.closest("div").querySelector("input"); + fireEvent.change(nameInput, { target: { value: "테스트 상품" } }); + + const priceInput = screen.getAllByPlaceholderText("숫자만 입력")[0]; + fireEvent.change(priceInput, { target: { value: "25000" } }); + + const stockInput = screen.getAllByPlaceholderText("숫자만 입력")[1]; + fireEvent.change(stockInput, { target: { value: "50" } }); + + const descLabels = screen.getAllByText("설명"); + const descLabel = descLabels.find((el) => el.tagName === "LABEL"); + const descInput = descLabel.closest("div").querySelector("input"); + fireEvent.change(descInput, { target: { value: "테스트 설명" } }); + // 저장 - fireEvent.click(screen.getByText('추가')); - + fireEvent.click(screen.getByText("추가")); + // 추가된 상품 확인 - expect(screen.getByText('테스트 상품')).toBeInTheDocument(); - expect(screen.getByText('25,000원')).toBeInTheDocument(); + expect(screen.getByText("테스트 상품")).toBeInTheDocument(); + expect(screen.getByText("25,000원")).toBeInTheDocument(); }); - test('쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다', () => { + test("쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다", () => { // 쿠폰 관리 탭으로 전환 - fireEvent.click(screen.getByText('쿠폰 관리')); - + fireEvent.click(screen.getByText("쿠폰 관리")); + // 새 쿠폰 추가 버튼 클릭 - const addCouponButton = screen.getByText('새 쿠폰 추가'); + const addCouponButton = screen.getByText("새 쿠폰 추가"); fireEvent.click(addCouponButton); - + // 쿠폰 정보 입력 - fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { target: { value: '테스트 쿠폰' } }); - fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { target: { value: 'TEST2024' } }); - - const discountInput = screen.getByPlaceholderText('5000'); - fireEvent.change(discountInput, { target: { value: '7000' } }); - + fireEvent.change(screen.getByPlaceholderText("신규 가입 쿠폰"), { target: { value: "테스트 쿠폰" } }); + fireEvent.change(screen.getByPlaceholderText("WELCOME2024"), { target: { value: "TEST2024" } }); + + const discountInput = screen.getByPlaceholderText("5000"); + fireEvent.change(discountInput, { target: { value: "7000" } }); + // 쿠폰 생성 - fireEvent.click(screen.getByText('쿠폰 생성')); - + fireEvent.click(screen.getByText("쿠폰 생성")); + // 생성된 쿠폰 확인 - expect(screen.getByText('테스트 쿠폰')).toBeInTheDocument(); - expect(screen.getByText('TEST2024')).toBeInTheDocument(); - expect(screen.getByText('7,000원 할인')).toBeInTheDocument(); + expect(screen.getByText("테스트 쿠폰")).toBeInTheDocument(); + expect(screen.getByText("TEST2024")).toBeInTheDocument(); + expect(screen.getByText("7,000원 할인")).toBeInTheDocument(); }); - test('상품의 가격 입력 시 숫자만 허용된다', async () => { + test("상품의 가격 입력 시 숫자만 허용된다", async () => { // 상품 수정 - fireEvent.click(screen.getAllByText('수정')[0]); - - const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - + fireEvent.click(screen.getAllByText("수정")[0]); + + const priceInput = screen.getAllByPlaceholderText("숫자만 입력")[0]; + // 문자와 숫자 혼합 입력 시도 - 숫자만 남음 - fireEvent.change(priceInput, { target: { value: 'abc123def' } }); - expect(priceInput.value).toBe('10000'); // 유효하지 않은 입력은 무시됨 - + fireEvent.change(priceInput, { target: { value: "abc123def" } }); + expect(priceInput.value).toBe("10000"); // 유효하지 않은 입력은 무시됨 + // 숫자만 입력 - fireEvent.change(priceInput, { target: { value: '123' } }); - expect(priceInput.value).toBe('123'); - + fireEvent.change(priceInput, { target: { value: "123" } }); + expect(priceInput.value).toBe("123"); + // 음수 입력 시도 - regex가 매치되지 않아 값이 변경되지 않음 - fireEvent.change(priceInput, { target: { value: '-100' } }); - expect(priceInput.value).toBe('123'); // 이전 값 유지 - + fireEvent.change(priceInput, { target: { value: "-100" } }); + expect(priceInput.value).toBe("123"); // 이전 값 유지 + // 유효한 음수 입력하기 위해 먼저 1 입력 후 앞에 - 추가는 불가능 // 대신 blur 이벤트를 통해 음수 검증을 테스트 // parseInt()는 실제로 음수를 파싱할 수 있으므로 다른 방법으로 테스트 - + // 공백 입력 시도 - fireEvent.change(priceInput, { target: { value: ' ' } }); - expect(priceInput.value).toBe('123'); // 유효하지 않은 입력은 무시됨 + fireEvent.change(priceInput, { target: { value: " " } }); + expect(priceInput.value).toBe("123"); // 유효하지 않은 입력은 무시됨 }); - test('쿠폰 할인율 검증이 작동한다', async () => { + test("쿠폰 할인율 검증이 작동한다", async () => { // 쿠폰 관리 탭으로 전환 - fireEvent.click(screen.getByText('쿠폰 관리')); - + fireEvent.click(screen.getByText("쿠폰 관리")); + // 새 쿠폰 추가 - fireEvent.click(screen.getByText('새 쿠폰 추가')); - + fireEvent.click(screen.getByText("새 쿠폰 추가")); + // 퍼센트 타입으로 변경 - 쿠폰 폼 내의 select 찾기 - const couponFormSelects = screen.getAllByRole('combobox'); + const couponFormSelects = screen.getAllByRole("combobox"); const typeSelect = couponFormSelects[couponFormSelects.length - 1]; // 마지막 select가 타입 선택 - fireEvent.change(typeSelect, { target: { value: 'percentage' } }); - + fireEvent.change(typeSelect, { target: { value: "percentage" } }); + // 100% 초과 할인율 입력 - const discountInput = screen.getByPlaceholderText('10'); - fireEvent.change(discountInput, { target: { value: '150' } }); + const discountInput = screen.getByPlaceholderText("10"); + fireEvent.change(discountInput, { target: { value: "150" } }); fireEvent.blur(discountInput); - + // 에러 메시지 확인 await waitFor(() => { - expect(screen.getByText('할인율은 100%를 초과할 수 없습니다')).toBeInTheDocument(); + expect(screen.getByText("할인율은 100%를 초과할 수 없습니다")).toBeInTheDocument(); }); }); - test('상품을 삭제할 수 있다', () => { + test("상품을 삭제할 수 있다", () => { // 초기 상품명들 확인 (테이블에서) - const productTable = screen.getByRole('table'); - expect(within(productTable).getByText('상품1')).toBeInTheDocument(); - + const productTable = screen.getByRole("table"); + expect(within(productTable).getByText("상품1")).toBeInTheDocument(); + // 삭제 버튼들 찾기 - const deleteButtons = within(productTable).getAllByRole('button').filter( - button => button.textContent === '삭제' - ); - + const deleteButtons = within(productTable) + .getAllByRole("button") + .filter((button) => button.textContent === "삭제"); + // 첫 번째 상품 삭제 fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되었는지 확인 - expect(within(productTable).queryByText('상품1')).not.toBeInTheDocument(); - expect(within(productTable).getByText('상품2')).toBeInTheDocument(); + expect(within(productTable).queryByText("상품1")).not.toBeInTheDocument(); + expect(within(productTable).getByText("상품2")).toBeInTheDocument(); }); - test('쿠폰을 삭제할 수 있다', () => { + test("쿠폰을 삭제할 수 있다", () => { // 쿠폰 관리 탭으로 전환 - fireEvent.click(screen.getByText('쿠폰 관리')); - + fireEvent.click(screen.getByText("쿠폰 관리")); + // 초기 쿠폰들 확인 (h3 제목에서) - const couponTitles = screen.getAllByRole('heading', { level: 3 }); - const coupon5000 = couponTitles.find(el => el.textContent === '5000원 할인'); - const coupon10 = couponTitles.find(el => el.textContent === '10% 할인'); + const couponTitles = screen.getAllByRole("heading", { level: 3 }); + const coupon5000 = couponTitles.find((el) => el.textContent === "5000원 할인"); + const coupon10 = couponTitles.find((el) => el.textContent === "10% 할인"); expect(coupon5000).toBeInTheDocument(); expect(coupon10).toBeInTheDocument(); - + // 삭제 버튼 찾기 (SVG 아이콘을 포함한 버튼) - const deleteButtons = screen.getAllByRole('button').filter(button => { - return button.querySelector('svg') && - button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path + const deleteButtons = screen.getAllByRole("button").filter((button) => { + return button.querySelector("svg") && button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path }); - + // 첫 번째 쿠폰 삭제 fireEvent.click(deleteButtons[0]); - + // 쿠폰이 삭제되었는지 확인 - expect(screen.queryByText('5000원 할인')).not.toBeInTheDocument(); + expect(screen.queryByText("5000원 할인")).not.toBeInTheDocument(); }); - }); - describe('로컬스토리지 동기화', () => { - test('상품, 장바구니, 쿠폰이 localStorage에 저장된다', () => { + describe("로컬스토리지 동기화", () => { + test("상품, 장바구니, 쿠폰이 localStorage에 저장된다", () => { render(); - + // 상품을 장바구니에 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // localStorage 확인 - expect(localStorage.getItem('cart')).toBeTruthy(); - expect(JSON.parse(localStorage.getItem('cart'))).toHaveLength(1); - + expect(localStorage.getItem("cart")).toBeTruthy(); + expect(JSON.parse(localStorage.getItem("cart"))).toHaveLength(1); + // 관리자 모드로 전환하여 새 상품 추가 - fireEvent.click(screen.getByText('관리자 페이지로')); - fireEvent.click(screen.getByText('새 상품 추가')); - - const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); - const nameInput = nameLabel.closest('div').querySelector('input'); - fireEvent.change(nameInput, { target: { value: '저장 테스트' } }); - - const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - fireEvent.change(priceInput, { target: { value: '10000' } }); - - const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; - fireEvent.change(stockInput, { target: { value: '10' } }); - - fireEvent.click(screen.getByText('추가')); - + fireEvent.click(screen.getByText("관리자 페이지로")); + fireEvent.click(screen.getByText("새 상품 추가")); + + const labels = screen.getAllByText("상품명"); + const nameLabel = labels.find((el) => el.tagName === "LABEL"); + const nameInput = nameLabel.closest("div").querySelector("input"); + fireEvent.change(nameInput, { target: { value: "저장 테스트" } }); + + const priceInput = screen.getAllByPlaceholderText("숫자만 입력")[0]; + fireEvent.change(priceInput, { target: { value: "10000" } }); + + const stockInput = screen.getAllByPlaceholderText("숫자만 입력")[1]; + fireEvent.change(stockInput, { target: { value: "10" } }); + + fireEvent.click(screen.getByText("추가")); + // localStorage에 products가 저장되었는지 확인 - expect(localStorage.getItem('products')).toBeTruthy(); - const products = JSON.parse(localStorage.getItem('products')); - expect(products.some(p => p.name === '저장 테스트')).toBe(true); + expect(localStorage.getItem("products")).toBeTruthy(); + const products = JSON.parse(localStorage.getItem("products")); + expect(products.some((p) => p.name === "저장 테스트")).toBe(true); }); - test('페이지 새로고침 후에도 데이터가 유지된다', () => { + test("페이지 새로고침 후에도 데이터가 유지된다", () => { const { unmount } = render(); - + // 장바구니에 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[1]); + // 컴포넌트 unmount unmount(); - + // 다시 mount render(); - + // 장바구니 아이템이 유지되는지 확인 - const cartSection = screen.getByText('장바구니').closest('section'); - expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); - expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); + const cartSection = screen.getByText("장바구니").closest("section"); + expect(within(cartSection).getByText("상품1")).toBeInTheDocument(); + expect(within(cartSection).getByText("상품2")).toBeInTheDocument(); }); }); - describe('UI 상태 관리', () => { - test('할인이 있을 때 할인율이 표시된다', async () => { + describe("UI 상태 관리", () => { + test("할인이 있을 때 할인율이 표시된다", async () => { render(); - + // 상품을 10개 담아서 할인 발생 - const addButton = screen.getAllByText('장바구니 담기')[0]; + const addButton = screen.getAllByText("장바구니 담기")[0]; for (let i = 0; i < 10; i++) { fireEvent.click(addButton); } - + // 할인율 표시 확인 - 대량 구매로 15% 할인 await waitFor(() => { - expect(screen.getByText('-15%')).toBeInTheDocument(); + expect(screen.getByText("-15%")).toBeInTheDocument(); }); }); - test('장바구니 아이템 개수가 헤더에 표시된다', () => { + test("장바구니 아이템 개수가 헤더에 표시된다", () => { render(); - + // 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[1]); + // 헤더의 장바구니 아이콘 옆 숫자 확인 - const cartCount = screen.getByText('3'); + const cartCount = screen.getByText("3"); expect(cartCount).toBeInTheDocument(); }); - test('검색을 초기화할 수 있다', async () => { + test("검색을 초기화할 수 있다", async () => { render(); - + // 검색어 입력 - const searchInput = screen.getByPlaceholderText('상품 검색...'); - fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + const searchInput = screen.getByPlaceholderText("상품 검색..."); + fireEvent.change(searchInput, { target: { value: "프리미엄" } }); + // 검색 결과 확인 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); + expect(screen.getByText("최고급 품질의 프리미엄 상품입니다.")).toBeInTheDocument(); // 다른 상품들은 보이지 않음 - expect(screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.')).not.toBeInTheDocument(); + expect(screen.queryByText("다양한 기능을 갖춘 실용적인 상품입니다.")).not.toBeInTheDocument(); }); - + // 검색어 초기화 - fireEvent.change(searchInput, { target: { value: '' } }); - + fireEvent.change(searchInput, { target: { value: "" } }); + // 모든 상품이 다시 표시됨 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('다양한 기능을 갖춘 실용적인 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('대용량과 고성능을 자랑하는 상품입니다.')).toBeInTheDocument(); + expect(screen.getByText("최고급 품질의 프리미엄 상품입니다.")).toBeInTheDocument(); + expect(screen.getByText("다양한 기능을 갖춘 실용적인 상품입니다.")).toBeInTheDocument(); + expect(screen.getByText("대용량과 고성능을 자랑하는 상품입니다.")).toBeInTheDocument(); }); }); - test('알림 메시지가 자동으로 사라진다', async () => { + test("알림 메시지가 자동으로 사라진다", async () => { render(); - + // 상품 추가하여 알림 발생 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 알림 메시지 확인 - expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); - + expect(screen.getByText("장바구니에 담았습니다")).toBeInTheDocument(); + // 3초 후 알림이 사라짐 - await waitFor(() => { - expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); - }, { timeout: 4000 }); + await waitFor( + () => { + expect(screen.queryByText("장바구니에 담았습니다")).not.toBeInTheDocument(); + }, + { timeout: 4000 } + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/advanced/components/icons/CartIcon.tsx b/src/advanced/components/icons/CartIcon.tsx new file mode 100644 index 000000000..5ccd3fdaa --- /dev/null +++ b/src/advanced/components/icons/CartIcon.tsx @@ -0,0 +1,23 @@ +const CartIcon = ({ size = "small" }: { size?: "small" | "large" }) => { + return size === "small" ? ( + + + + ) : ( + + + + ); +}; + +export default CartIcon; diff --git a/src/advanced/components/icons/HeaderCartIcon.tsx b/src/advanced/components/icons/HeaderCartIcon.tsx new file mode 100644 index 000000000..e15d8c99e --- /dev/null +++ b/src/advanced/components/icons/HeaderCartIcon.tsx @@ -0,0 +1,14 @@ +const HeaderCartIcon = () => { + return ( + + + + ); +}; + +export default HeaderCartIcon; diff --git a/src/advanced/components/icons/TrashIcon.tsx b/src/advanced/components/icons/TrashIcon.tsx new file mode 100644 index 000000000..af04c5d18 --- /dev/null +++ b/src/advanced/components/icons/TrashIcon.tsx @@ -0,0 +1,14 @@ +const TrashIcon = () => { + return ( + + + + ); +}; + +export default TrashIcon; diff --git a/src/advanced/components/icons/index.tsx b/src/advanced/components/icons/index.tsx new file mode 100644 index 000000000..0abb4688a --- /dev/null +++ b/src/advanced/components/icons/index.tsx @@ -0,0 +1,4 @@ +import CartIcon from "./CartIcon"; +import TrashIcon from "./TrashIcon"; + +export { CartIcon, TrashIcon }; diff --git a/src/advanced/components/pages/adminPage/AdminHeader.tsx b/src/advanced/components/pages/adminPage/AdminHeader.tsx new file mode 100644 index 000000000..67e77bd18 --- /dev/null +++ b/src/advanced/components/pages/adminPage/AdminHeader.tsx @@ -0,0 +1,15 @@ +import Header from "../../ui/Header"; + +const AdminHeader = ({ goShoppingPage }: { goShoppingPage: () => void }) => { + return ( +
+ 쇼핑몰로 돌아가기 + + } + /> + ); +}; + +export default AdminHeader; diff --git a/src/advanced/components/pages/adminPage/CouponForm.tsx b/src/advanced/components/pages/adminPage/CouponForm.tsx new file mode 100644 index 000000000..0b372aab9 --- /dev/null +++ b/src/advanced/components/pages/adminPage/CouponForm.tsx @@ -0,0 +1,124 @@ +import { Coupon } from "../../../../types"; + +const CouponForm = ({ + couponForm, + setCouponForm, + handleCouponSubmit, + setShowCouponForm, + addNotification, +}: { + couponForm: Coupon; + setCouponForm: (coupon: Coupon) => void; + handleCouponSubmit: (e: React.FormEvent) => void; + setShowCouponForm: (show: boolean) => void; + addNotification: (message: string, type: "error" | "success" | "warning") => void; +}) => { + return ( +
+
+

새 쿠폰 생성

+
+
+ + 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 + /> +
+
+
+ + +
+
+
+ ); +}; + +export default CouponForm; diff --git a/src/advanced/components/pages/adminPage/CouponListItem.tsx b/src/advanced/components/pages/adminPage/CouponListItem.tsx new file mode 100644 index 000000000..b751ae113 --- /dev/null +++ b/src/advanced/components/pages/adminPage/CouponListItem.tsx @@ -0,0 +1,40 @@ +import { Coupon } from "../../../../types"; +import { useCart } from "../../../hooks/useCart"; +import { TrashIcon } from "../../icons"; + +const CouponListItem = ({ coupon, deleteCoupon }: { coupon: Coupon; deleteCoupon: () => void }) => { + const { selectedCoupon, deleteAppliedCoupon } = useCart(); // CartProvider 에서 제공하는 값 + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +}; + +export default CouponListItem; diff --git a/src/advanced/components/pages/adminPage/CouponSection.tsx b/src/advanced/components/pages/adminPage/CouponSection.tsx new file mode 100644 index 000000000..578ee7498 --- /dev/null +++ b/src/advanced/components/pages/adminPage/CouponSection.tsx @@ -0,0 +1,84 @@ +import CouponListItem from "./CouponListItem"; +import CouponForm from "./CouponForm"; +import { useState } from "react"; +import couponModel from "../../../models/coupon"; +import { useCoupons } from "../../../hooks/useCoupons"; +import { useNotification } from "../../../hooks/useNotification"; + +const CouponSection = ({}: {}) => { + const { coupons, addCoupon, deleteCoupon } = useCoupons(); + const { addNotification } = useNotification(); + + const [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, + }); + const [showCouponForm, setShowCouponForm] = useState(false); + + //coupon + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const isExistingCoupon = couponModel.checkExistingCoupon(coupons, couponForm); + if (isExistingCoupon) { + addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + + addCoupon(couponForm); + setCouponForm({ + name: "", + code: "", + discountType: "amount", + discountValue: 0, + }); + setShowCouponForm(false); + addNotification("쿠폰이 추가되었습니다.", "success"); + }; + + const handleDeleteCoupon = (couponCode: string) => { + deleteCoupon(couponCode); + addNotification("쿠폰이 삭제되었습니다.", "success"); + }; + + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( + handleDeleteCoupon(coupon.code)} /> + ))} + +
+ +
+
+ + {showCouponForm && ( + + )} +
+
+ ); +}; + +export default CouponSection; diff --git a/src/advanced/components/pages/adminPage/ProductForm.tsx b/src/advanced/components/pages/adminPage/ProductForm.tsx new file mode 100644 index 000000000..4bfe2e6e8 --- /dev/null +++ b/src/advanced/components/pages/adminPage/ProductForm.tsx @@ -0,0 +1,182 @@ +import type { ProductForm } from "../../../../types"; +import { FormEventHandler } from "react"; + +const ProductForm = ({ + handleProductSubmit, + editingProduct, + productForm, + setProductForm, + addNotification, + onCancel, +}: { + handleProductSubmit: FormEventHandler; + editingProduct: string | null; + productForm: ProductForm; + setProductForm: (productForm: ProductForm) => void; + addNotification: (message: string, type: "error" | "success" | "warning") => void; + onCancel: () => void; +}) => { + return ( +
+

{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="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+ ); +}; + +export default ProductForm; diff --git a/src/advanced/components/pages/adminPage/ProductSection.tsx b/src/advanced/components/pages/adminPage/ProductSection.tsx new file mode 100644 index 000000000..60536eca3 --- /dev/null +++ b/src/advanced/components/pages/adminPage/ProductSection.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { ProductWithUI } from "../../../../types"; +import ProductForm from "./ProductForm"; +import ProductTable from "./ProductTable"; +import useProducts from "../../../hooks/useProducts"; +import { useNotification } from "../../../hooks/useNotification"; + +const ProductSection = ({}: {}) => { + const { addNotification } = useNotification(); + const { products, addProduct, updateProduct, deleteProduct } = useProducts(); + + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + const [showProductForm, setShowProductForm] = useState(false); + + const resetForm = () => { + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + }; + + // 제춤 추가 + const startAddProduct = () => { + setEditingProduct("new"); + resetForm(); + setShowProductForm(true); + }; + + // 제품 수정 + 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 submitProductForm = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== "new") { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + addNotification("상품이 수정되었습니다.", "success"); + } else { + addProduct({ + ...productForm, + discounts: productForm.discounts, + }); + addNotification("상품이 추가되었습니다.", "success"); + } + + resetForm(); + setEditingProduct(null); + setShowProductForm(false); + }; + + return ( +
+
+
+

상품 목록

+ +
+
+ +
+ { + deleteProduct(productId); + addNotification("상품이 삭제되었습니다.", "success"); + }} + /> +
+ + {showProductForm && ( +
+ { + setEditingProduct(null); + resetForm(); + setShowProductForm(false); + }} + /> +
+ )} +
+ ); +}; + +export default ProductSection; diff --git a/src/advanced/components/pages/adminPage/ProductTable.tsx b/src/advanced/components/pages/adminPage/ProductTable.tsx new file mode 100644 index 000000000..71ed5df7d --- /dev/null +++ b/src/advanced/components/pages/adminPage/ProductTable.tsx @@ -0,0 +1,42 @@ +import { ProductWithUI } from "../../../../types"; +import ProductTableItem from "./ProductTableItem"; +const ProductTable = ({ + products, + startEditProduct, + deleteProduct, +}: { + products: ProductWithUI[]; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +}) => { + const TableHeader = ({ header }: { header: string }) => { + return {header}; + }; + + return ( + + + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ ); +}; + +export default ProductTable; diff --git a/src/advanced/components/pages/adminPage/ProductTableItem.tsx b/src/advanced/components/pages/adminPage/ProductTableItem.tsx new file mode 100644 index 000000000..699a6c45d --- /dev/null +++ b/src/advanced/components/pages/adminPage/ProductTableItem.tsx @@ -0,0 +1,45 @@ +import { ProductWithUI } from "../../../../types"; +import formatter from "../../../utils/formatter"; + +const ProductTableItem = ({ + product, + startEditProduct, + deleteProduct, +}: { + product: ProductWithUI; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +}) => { + return ( + + {product.name} + + {product.stock > 0 ? formatter.formatPriceWon(product.price) : "SOLD OUT"} + + + 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 || "-"} + + + + + + ); +}; + +export default ProductTableItem; diff --git a/src/advanced/components/pages/cartPage/CartHeader.tsx b/src/advanced/components/pages/cartPage/CartHeader.tsx new file mode 100644 index 000000000..1b126e037 --- /dev/null +++ b/src/advanced/components/pages/cartPage/CartHeader.tsx @@ -0,0 +1,41 @@ +import HeaderCartIcon from "../../icons/HeaderCartIcon"; +import Header from "../../ui/Header"; + +const CartHeader = ({ + goAdminPage, + cartCount, + searchTerm, + setSearchTerm, +}: { + goAdminPage: () => void; + cartCount: number; + searchTerm: string; + setSearchTerm: (value: string) => void; +}) => { + return ( +
+ + {"관리자 페이지로"} + + +
+ + {cartCount > 0 && ( + + {cartCount} + + )} +
+ + } + /> + ); +}; + +export default CartHeader; diff --git a/src/advanced/components/pages/cartPage/CartList.tsx b/src/advanced/components/pages/cartPage/CartList.tsx new file mode 100644 index 000000000..af8ffa2d2 --- /dev/null +++ b/src/advanced/components/pages/cartPage/CartList.tsx @@ -0,0 +1,44 @@ +import { CartItem } from "../../../../types"; +import cartModel from "../../../models/cart"; +import { CartIcon } from "../../icons"; +import CartListItem from "./CartListItem"; + +const CartList = ({ + cart, + removeFromCart, + updateQuantity, +}: { + cart: CartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +}) => { + return cart.length === 0 ? ( +
+ +

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = cartModel.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 ( + + ); + })} +
+ ); +}; + +export default CartList; diff --git a/src/advanced/components/pages/cartPage/CartListItem.tsx b/src/advanced/components/pages/cartPage/CartListItem.tsx new file mode 100644 index 000000000..9b6ad703f --- /dev/null +++ b/src/advanced/components/pages/cartPage/CartListItem.tsx @@ -0,0 +1,53 @@ +import { CartItem } from "../../../../types"; + +const CartListItem = ({ + item, + removeFromCart, + updateQuantity, + hasDiscount, + discountRate, + itemTotal, +}: { + item: CartItem; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; + hasDiscount: boolean; + discountRate: number; + itemTotal: number; +}) => { + return ( +
+
+

{item.product.name}

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

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

+
+
+
+ ); +}; + +export default CartListItem; diff --git a/src/advanced/components/pages/cartPage/ProductList.tsx b/src/advanced/components/pages/cartPage/ProductList.tsx new file mode 100644 index 000000000..810e5047d --- /dev/null +++ b/src/advanced/components/pages/cartPage/ProductList.tsx @@ -0,0 +1,14 @@ +import { Product } from "../../../../types"; +import ProductListItem from "./ProductListItem"; + +const ProductList = ({ products, addToCart }: { products: Product[]; addToCart: (product: Product) => void }) => { + return ( +
+ {products.map((product) => { + return addToCart(product)} />; + })} +
+ ); +}; + +export default ProductList; diff --git a/src/advanced/components/pages/cartPage/ProductListItem.tsx b/src/advanced/components/pages/cartPage/ProductListItem.tsx new file mode 100644 index 000000000..a0b165ae8 --- /dev/null +++ b/src/advanced/components/pages/cartPage/ProductListItem.tsx @@ -0,0 +1,84 @@ +import { Product } from "../../../../types"; +import formatter from "../../../utils/formatter"; +import { useCart } from "../../../hooks/useCart"; + +const ProductListItem = ({ + product, + handleAddToCart, +}: { + product: Product & { description?: string; isRecommended?: boolean }; + handleAddToCart: () => void; +}) => { + const { getRemainingStock } = useCart(); + 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}

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

+ {remainingStock > 0 ? formatter.formatPrice(product.price) : "SOLD OUT"} +

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

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

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

품절임박! {remainingStock}개 남음

+ )} + {remainingStock > 5 &&

재고 {remainingStock}개

} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; + +export default ProductListItem; diff --git a/src/advanced/components/ui/Card.tsx b/src/advanced/components/ui/Card.tsx new file mode 100644 index 000000000..4f4dae1cd --- /dev/null +++ b/src/advanced/components/ui/Card.tsx @@ -0,0 +1,5 @@ +const Card = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; + +export default Card; diff --git a/src/advanced/components/ui/Header.tsx b/src/advanced/components/ui/Header.tsx new file mode 100644 index 000000000..756920c13 --- /dev/null +++ b/src/advanced/components/ui/Header.tsx @@ -0,0 +1,63 @@ +import Input from "./Input"; + +const Button = ({ + children, + onClick, + type, +}: { + children: React.ReactNode; + onClick: () => void; + type: "primary" | "secondary"; +}) => { + return ( + + ); +}; + +const Header = ({ + hasSearch, + searchValue, + setSearchValue, + searchPlaceholder, + children, +}: { + hasSearch?: boolean; + searchValue?: string; + setSearchValue?: (value: string) => void; + searchPlaceholder?: string; + children: React.ReactNode; +}) => { + return ( +
+
+
+
+

SHOP

+ {hasSearch && ( +
+ setSearchValue?.(e.target.value)} + placeholder={searchPlaceholder ?? ""} + /> +
+ )} +
+ + +
+
+
+ ); +}; + +Header.Button = Button; + +export default Header; diff --git a/src/advanced/components/ui/Input.tsx b/src/advanced/components/ui/Input.tsx new file mode 100644 index 000000000..fa40f2476 --- /dev/null +++ b/src/advanced/components/ui/Input.tsx @@ -0,0 +1,21 @@ +const Input = ({ + input, + onChange, + placeholder, +}: { + input: string; + onChange: (e: React.ChangeEvent) => void; + placeholder: string; +}) => { + return ( + + ); +}; + +export default Input; diff --git a/src/advanced/components/ui/Notification.tsx b/src/advanced/components/ui/Notification.tsx new file mode 100644 index 000000000..4a0ff5415 --- /dev/null +++ b/src/advanced/components/ui/Notification.tsx @@ -0,0 +1,29 @@ +import { useNotification } from "../../hooks/useNotification"; + +const Notification = () => { + const { notifications, deleteNotification } = useNotification(); + + return ( + notifications.length > 0 && ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ) + ); +}; + +export default Notification; diff --git a/src/advanced/components/ui/SelectList.tsx b/src/advanced/components/ui/SelectList.tsx new file mode 100644 index 000000000..6002e76cb --- /dev/null +++ b/src/advanced/components/ui/SelectList.tsx @@ -0,0 +1,25 @@ +const SelectList = ({ + options, + onChange, + value, +}: { + options: { label: string; value: string }[]; + onChange: (e: React.ChangeEvent) => void; + value: string; +}) => { + return ( + + ); +}; + +export default SelectList; diff --git a/src/advanced/components/ui/index.tsx b/src/advanced/components/ui/index.tsx new file mode 100644 index 000000000..36cf59518 --- /dev/null +++ b/src/advanced/components/ui/index.tsx @@ -0,0 +1,5 @@ +import Card from "./Card"; +import SelectList from "./SelectList"; +import Notification from "./Notification"; + +export { Card, SelectList, Notification }; diff --git a/src/advanced/constants/coupon.ts b/src/advanced/constants/coupon.ts new file mode 100644 index 000000000..04721d836 --- /dev/null +++ b/src/advanced/constants/coupon.ts @@ -0,0 +1,18 @@ +import { Coupon } from "../../types"; + +const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; + +export { initialCoupons }; diff --git a/src/advanced/constants/index.ts b/src/advanced/constants/index.ts new file mode 100644 index 000000000..bdb86336e --- /dev/null +++ b/src/advanced/constants/index.ts @@ -0,0 +1,4 @@ +import { initialProducts } from "./products"; +import { initialCoupons } from "./coupon"; + +export { initialProducts, initialCoupons }; diff --git a/src/advanced/constants/products.ts b/src/advanced/constants/products.ts new file mode 100644 index 000000000..3e7c1d9d3 --- /dev/null +++ b/src/advanced/constants/products.ts @@ -0,0 +1,37 @@ +import { ProductWithUI } from "../../types"; + +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 { initialProducts }; diff --git a/src/advanced/hooks/useCart.tsx b/src/advanced/hooks/useCart.tsx new file mode 100644 index 000000000..22914b8ea --- /dev/null +++ b/src/advanced/hooks/useCart.tsx @@ -0,0 +1,114 @@ +import { createContext, ReactNode, useCallback, useContext, useState } from "react"; +import { CartItem, Coupon, Product } from "../../types"; +import cartModel from "../models/cart"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface CartContextType { + cart: CartItem[]; + selectedCoupon: Coupon | null; + addToCart: (product: ProductWithUI) => void; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + applyCoupon: (coupon: Coupon | null) => void; + deleteAppliedCoupon: () => void; + calculateTotal: () => { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + getRemainingStock: (product: Product) => number; + clearCart: () => void; +} + +interface CartProviderProps { + children: ReactNode; +} + +const CartContext = createContext(null); + +export function CartProvider({ children }: CartProviderProps) { + const [cart, setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return []; + } + } + return []; + }); + + useLocalStorage("cart", cart, { removeIfEmpty: true }); + + const [selectedCoupon, setSelectedCoupon] = useState(null); + + const addToCart = useCallback((product: ProductWithUI) => { + setCart((prevCart) => cartModel.addItemToCart(prevCart, product)); + }, []); + + const removeFromCart = useCallback((productId: string) => { + setCart((prevCart) => cartModel.removeItemFromCart(prevCart, productId)); + }, []); + + const updateQuantity = useCallback((productId: string, newQuantity: number) => { + setCart((prevCart) => cartModel.updateItemQuantity(prevCart, productId, newQuantity)); + }, []); + + const applyCoupon = useCallback((coupon: Coupon | null) => { + setSelectedCoupon(coupon); + }, []); + + const deleteAppliedCoupon = useCallback(() => { + setSelectedCoupon(null); + }, [selectedCoupon]); + + const calculateTotal = useCallback(() => { + return cartModel.calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + const getRemainingStock = useCallback( + (product: Product) => { + return cartModel.getRemainingStock(cart, product); + }, + [cart] + ); + + const clearCart = useCallback(() => { + setCart([]); + }, []); + + return ( + + {children} + + ); +} + +export function useCart() { + const context = useContext(CartContext); + + if (!context) { + throw new Error("useCart must be used within a CartProvider"); + } + + return context; +} diff --git a/src/advanced/hooks/useCoupons.tsx b/src/advanced/hooks/useCoupons.tsx new file mode 100644 index 000000000..6bedc3042 --- /dev/null +++ b/src/advanced/hooks/useCoupons.tsx @@ -0,0 +1,54 @@ +import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from "react"; +import { Coupon } from "../../types"; +import couponModel from "../models/coupon"; +import { initialCoupons } from "../constants"; + +interface CouponContextType { + coupons: Coupon[]; + addCoupon: (newCoupon: Coupon) => void; + deleteCoupon: (couponCode: string) => void; +} + +interface CouponProviderProps { + children: ReactNode; +} + +const CouponContext = createContext(null); + +export function CouponProvider({ children }: CouponProviderProps) { + const [coupons, setCoupons] = useState(() => { + const saved = localStorage.getItem("coupons"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialCoupons; + } + } + return initialCoupons; + }); + + useEffect(() => { + localStorage.setItem("coupons", JSON.stringify(coupons)); + }, [coupons]); + + const addCoupon = useCallback((newCoupon: Coupon) => { + setCoupons((prev) => couponModel.addCoupon(prev, newCoupon)); + }, []); + + const deleteCoupon = useCallback((couponCode: string) => { + setCoupons((prev) => couponModel.deleteCoupon(prev, couponCode)); + }, []); + + return {children}; +} + +export function useCoupons() { + const context = useContext(CouponContext); + + if (!context) { + throw new Error("useCoupons must be used within a CouponProvider"); + } + + return context; +} diff --git a/src/advanced/hooks/useNotification.tsx b/src/advanced/hooks/useNotification.tsx new file mode 100644 index 000000000..eae4ce6f9 --- /dev/null +++ b/src/advanced/hooks/useNotification.tsx @@ -0,0 +1,49 @@ +import { createContext, useContext, useState, useCallback, ReactNode } from "react"; +import { Notification } from "../../types"; + +interface NotificationContextType { + notifications: Notification[]; + deleteNotification: (id: string) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +} + +const NotificationContext = createContext(undefined); + +interface NotificationProviderProps { + children: ReactNode; + duration?: number; +} + +export const NotificationProvider = ({ children, duration = 3000 }: NotificationProviderProps) => { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, duration); + }, + [duration] + ); + + const deleteNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return ( + + {children} + + ); +}; + +export const useNotification = () => { + const context = useContext(NotificationContext); + if (!context) { + throw new Error("useNotification must be used within a NotificationProvider"); + } + return context; +}; diff --git a/src/advanced/hooks/useProducts.tsx b/src/advanced/hooks/useProducts.tsx new file mode 100644 index 000000000..df367be99 --- /dev/null +++ b/src/advanced/hooks/useProducts.tsx @@ -0,0 +1,78 @@ +import { createContext, useCallback, useEffect, useState, ReactNode } from "react"; +import { ProductWithUI } from "../../types"; +import productModel from "../models/product"; +import { initialProducts } from "../constants"; +import { useContext } from "react"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +export interface ProductContextType { + products: ProductWithUI[]; + addProduct: (newProduct: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; + updateProductStock: (productId: string, newStock: number) => void; +} + +export const ProductContext = createContext(null); + +interface ProductProviderProps { + children: ReactNode; +} + +export const ProductProvider = ({ children }: ProductProviderProps) => { + const [products, setProducts] = useState(() => { + const saved = localStorage.getItem("products"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialProducts; + } + } + return initialProducts; + }); + useLocalStorage("products", products); + + useEffect(() => { + localStorage.setItem("products", JSON.stringify(products)); + }, [products]); + + const addProduct = useCallback((newProduct: Omit) => { + const product: ProductWithUI = { ...newProduct, id: `p${Date.now()}` }; + setProducts((prev) => productModel.addProduct(prev, product)); + }, []); + + const updateProduct = useCallback((productId: string, updates: Partial) => { + setProducts((prev) => productModel.updateProduct(prev, productId, updates)); + }, []); + + const deleteProduct = useCallback((productId: string) => { + setProducts((prev) => productModel.deleteProduct(prev, productId)); + }, []); + + const updateProductStock = useCallback((productId: string, newStock: number) => { + setProducts((prev) => productModel.updateProductStock(prev, productId, newStock)); + }, []); + + const value: ProductContextType = { + products, + addProduct, + updateProduct, + deleteProduct, + updateProductStock, + }; + + return {children}; +}; + +const useProducts = () => { + const context = useContext(ProductContext); + + if (!context) { + throw new Error("useProducts must be used within a ProductProvider"); + } + + return context; +}; + +export default useProducts; diff --git a/src/advanced/main.tsx b/src/advanced/main.tsx index e63eef4a8..164cb8896 100644 --- a/src/advanced/main.tsx +++ b/src/advanced/main.tsx @@ -1,9 +1,4 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -) +ReactDOM.createRoot(document.getElementById("root")!).render(); diff --git a/src/advanced/models/cart.ts b/src/advanced/models/cart.ts new file mode 100644 index 000000000..cb40f5123 --- /dev/null +++ b/src/advanced/models/cart.ts @@ -0,0 +1,87 @@ +import { CartItem, Coupon, Product } from "../../types"; + +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)); +}; + +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; +}; + +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), + }; +}; + +const updateItemQuantity = (cart: CartItem[], productId: string, quantity: number): CartItem[] => { + return cart.map((item) => (item.product.id === productId ? { ...item, quantity } : item)); +}; + +const addItemToCart = (cart: CartItem[], product: Product): CartItem[] => { + return [...cart, { product, quantity: 1 }]; +}; + +const removeItemFromCart = (cart: CartItem[], productId: string): CartItem[] => { + return cart.filter((item) => item.product.id !== productId); +}; + +const getRemainingStock = (cart: CartItem[], product: Product): number => { + return product.stock - cart.reduce((sum, item) => sum + item.quantity, 0); +}; + +const getCartCount = (cart: CartItem[]): number => { + return cart.reduce((sum, item) => sum + item.quantity, 0); +}; + +export default { + calculateItemTotal, + calculateCartTotal, + getMaxApplicableDiscount, + updateItemQuantity, + addItemToCart, + removeItemFromCart, + getRemainingStock, + getCartCount, +}; diff --git a/src/advanced/models/coupon.ts b/src/advanced/models/coupon.ts new file mode 100644 index 000000000..2fc20f4ed --- /dev/null +++ b/src/advanced/models/coupon.ts @@ -0,0 +1,28 @@ +import { Coupon } from "../../types"; +import formatter from "../utils/formatter"; + +const addCoupon = (coupons: Coupon[], newCoupon: Coupon) => { + return [...coupons, newCoupon]; +}; + +const deleteCoupon = (coupons: Coupon[], couponCode: string) => { + return coupons.filter((coupon) => coupon.code !== couponCode); +}; + +const checkExistingCoupon = (coupons: Coupon[], newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + return !!existingCoupon; +}; + +const getCouponList = (coupons: Coupon[]) => { + return coupons.map((coupon) => ({ + label: `${coupon.name} (${ + coupon.discountType === "amount" + ? formatter.formatPrice(coupon.discountValue) + : formatter.formatPercentage(coupon.discountValue) + })`, + value: coupon.code, + })); +}; + +export default { addCoupon, deleteCoupon, checkExistingCoupon, getCouponList }; diff --git a/src/advanced/models/product.ts b/src/advanced/models/product.ts new file mode 100644 index 000000000..4b8f91a27 --- /dev/null +++ b/src/advanced/models/product.ts @@ -0,0 +1,63 @@ +import { ProductWithUI } from "../../types"; + +const addProduct = (products: ProductWithUI[], newProduct: Omit) => { + const product: ProductWithUI = { ...newProduct, id: `p${Date.now()}` }; + return [...products, product]; +}; + +const updateProduct = (products: ProductWithUI[], productId: string, updates: Partial) => { + return products.map((product) => (product.id === productId ? { ...product, ...updates } : product)); +}; + +const deleteProduct = (products: ProductWithUI[], productId: string) => { + return products.filter((p) => p.id !== productId); +}; + +const updateProductStock = (products: ProductWithUI[], productId: string, newStock: number) => { + return products.map((product) => (product.id === productId ? { ...product, stock: newStock } : product)); +}; + +const addProductDiscount = ( + products: ProductWithUI[], + productId: string, + discount: { quantity: number; rate: number } +) => { + return products.map((product) => + product.id === productId ? { ...product, discounts: [...product.discounts, discount] } : product + ); +}; + +const removeProductDiscount = ( + products: ProductWithUI[], + productId: string, + discount: { quantity: number; rate: number } +) => { + return products.map((product) => + product.id === productId + ? { + ...product, + discounts: product.discounts.filter((d) => d.quantity !== discount.quantity && d.rate !== discount.rate), + } + : product + ); +}; + +const filterSearch = (products: ProductWithUI[], searchTerm: string) => { + if (!searchTerm) return products; + + return products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + product.description?.toLowerCase().includes(searchTerm.toLowerCase()) + ); +}; + +export default { + addProduct, + updateProduct, + deleteProduct, + updateProductStock, + addProductDiscount, + removeProductDiscount, + filterSearch, +}; diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx new file mode 100644 index 000000000..2b855785b --- /dev/null +++ b/src/advanced/pages/AdminPage.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import CouponSection from "../components/pages/adminPage/CouponSection"; +import ProductSection from "../components/pages/adminPage/ProductSection"; +import AdminHeader from "../components/pages/adminPage/AdminHeader"; + +const AdminPage = ({ goShoppingPage }: { goShoppingPage: () => void }) => { + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); + + return ( + <> + +
+
+
+

관리자 대시보드

+

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

+
+ +
+ +
+ + {activeTab === "products" ? : } +
+
+ + ); +}; + +export default AdminPage; diff --git a/src/advanced/pages/CartPage.tsx b/src/advanced/pages/CartPage.tsx new file mode 100644 index 000000000..6d7f039a4 --- /dev/null +++ b/src/advanced/pages/CartPage.tsx @@ -0,0 +1,260 @@ +import { useMemo, useCallback, useState } from "react"; +import SelectList from "../components/ui/SelectList"; +import { CartIcon } from "../components/icons"; +import { Card } from "../components/ui"; +import CartList from "../components/pages/cartPage/CartList"; +import ProductList from "../components/pages/cartPage/ProductList"; +import CartHeader from "../components/pages/cartPage/CartHeader"; +import cartModel from "../models/cart"; +import productModel from "../models/product"; +import couponModel from "../models/coupon"; +import { useCoupons } from "../hooks/useCoupons"; +import useProducts from "../hooks/useProducts"; +import { useCart } from "../hooks/useCart"; +import { useNotification } from "../hooks/useNotification"; +import { Coupon, Product } from "../../types"; +import { isValidStock } from "../utils/validators"; +import { useDebounce } from "../utils/hooks/useDebounce"; + +interface CartPageProps { + goAdminPage: () => void; +} + +const CartPage = ({ goAdminPage }: CartPageProps) => { + const { + cart, + selectedCoupon, + removeFromCart, + applyCoupon, + calculateTotal, + getRemainingStock, + addToCart, + updateQuantity, + clearCart, + } = useCart(); + + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + + const { products } = useProducts(); + const filteredProducts = useMemo(() => { + if (!debouncedSearchTerm) return products; + + const filteredProducts = productModel.filterSearch(products, debouncedSearchTerm); + return filteredProducts; + }, [products, debouncedSearchTerm]); + + const { coupons } = useCoupons(); + const couponList = useMemo( + () => [{ label: "쿠폰 선택", value: "" }, ...couponModel.getCouponList(coupons)], + [coupons] + ); + + const totals = calculateTotal(); + const discountAmount = useMemo(() => totals.totalBeforeDiscount - totals.totalAfterDiscount, [totals]); + + const { addNotification } = useNotification(); + + const handleApplyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateTotal().totalAfterDiscount; + + // percentage 쿠폰은 최소 주문 금액 체크 + if (currentTotal < 10000 && coupon.discountType === "percentage") { + addNotification("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", "error"); + return; + } + + applyCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [cart, addNotification] + ); + + const handleAddToCart = useCallback( + (product: Product) => { + const remainingStock = getRemainingStock(product); + + // 재고 검증 + if (!isValidStock(remainingStock)) { + addNotification("재고가 부족합니다!", "error"); + return; + } + + const existingItem = cart.find((item) => item.product.id === product.id); + + // 신규 상품 추가 + if (!existingItem) { + addToCart(product); + addNotification("장바구니에 담았습니다", "success"); + return; + } + + // 기존 상품 수량 증가 + const newQuantity = existingItem.quantity + 1; + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return; + } + + handleUpdateQuantity(product.id, newQuantity); + addNotification("장바구니에 담았습니다", "success"); + }, + [cart, addNotification, updateQuantity] + ); + + // 수량 업데이트 핸들러 + const handleUpdateQuantity = useCallback( + (productId: string, newQuantity: number) => { + // 새 수량이 0 이하면 장바구니에서 제거 + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } + + // 상품이 없으면 early return + const product = products.find((p) => p.id === productId); + if (!product) return; + + // 재고 초과 검증 + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return; + } + + // 수량 업데이트 + updateQuantity(productId, newQuantity); + }, + [products, cart, addNotification] + ); + + // 주문 완료 핸들러 + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); + clearCart(); + applyCoupon(null); + }, [addNotification]); + + return ( + <> + +
+
+ {/* 상품 목록 영역 */} +
+
+ {/* 상품 목록 헤더 */} +
+

전체 상품

+
총 {products.length}개 상품
+
+ + {/* 상품 목록 또는 검색 결과 없음 메시지 */} + {filteredProducts.length === 0 ? ( +
+

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

+
+ ) : ( + + )} +
+
+ + {/* 장바구니 및 결제 영역 */} +
+
+ {/* 장바구니 카드 */} + +

+ + 장바구니 +

+ +
+ + {/* 장바구니에 상품이 있을 때만 표시 */} + {cart.length > 0 && ( + <> + {/* 쿠폰 할인 카드 */} + +
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + { + const coupon = coupons.find((c) => c.code === e.target.value); + if (coupon) { + handleApplyCoupon(coupon); + } else { + applyCoupon(null); + } + }} + /> + )} +
+ + {/* 결제 정보 카드 */} + +

결제 정보

+
+ {/* 상품 금액 */} +
+ 상품 금액 + {totals.totalBeforeDiscount.toLocaleString()}원 +
+ + {/* 할인 금액 (할인이 있을 때만 표시) */} + {discountAmount > 0 && ( +
+ 할인 금액 + -{discountAmount.toLocaleString()}원 +
+ )} + + {/* 결제 예정 금액 */} +
+ 결제 예정 금액 + + {totals.totalAfterDiscount.toLocaleString()}원 + +
+
+ + {/* 결제 버튼 */} + + + {/* 안내 문구 */} +
+

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

+
+
+ + )} +
+
+
+
+ + ); +}; + +export default CartPage; diff --git a/src/advanced/utils/formatter.ts b/src/advanced/utils/formatter.ts new file mode 100644 index 000000000..688d13349 --- /dev/null +++ b/src/advanced/utils/formatter.ts @@ -0,0 +1,25 @@ +// TODO: 포맷팅 유틸리티 함수들 +// 구현할 함수: +// - formatPrice(price: number): string - 가격을 한국 원화 형식으로 포맷 +// - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷 +// - formatPercentage(rate: number): string - 소수를 퍼센트로 변환 (0.1 → 10%) + +// TODO: 구현 + +const formatPrice = (price: number): string => { + return `₩${price.toLocaleString()}`; +}; + +const formatPriceWon = (price: number): string => { + return `${price.toLocaleString()}원`; +}; + +const formatDate = (date: Date): string => { + return date.toISOString().split("T")[0]; +}; + +const formatPercentage = (rate: number): string => { + return `${Math.round(rate)}%`; +}; + +export default { formatPrice, formatPriceWon, formatDate, formatPercentage }; diff --git a/src/advanced/utils/hooks/useDebounce.ts b/src/advanced/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..30644e3ee --- /dev/null +++ b/src/advanced/utils/hooks/useDebounce.ts @@ -0,0 +1,22 @@ +import { useState, useEffect } from 'react'; + +/** + * 입력 값에 디바운스를 적용하는 커스텀 훅 + * @param value 디바운스를 적용할 값 + * @param delay 지연 시간 (ms) + * @returns 디바운스가 적용된 값 + */ +export const useDebounce = (value: T, delay: number = 500): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +}; + diff --git a/src/advanced/utils/hooks/useLocalStorage.ts b/src/advanced/utils/hooks/useLocalStorage.ts new file mode 100644 index 000000000..a4cad8725 --- /dev/null +++ b/src/advanced/utils/hooks/useLocalStorage.ts @@ -0,0 +1,11 @@ +import { useEffect } from "react"; + +export const useLocalStorage = (key: string, value: T, options?: { removeIfEmpty?: boolean }) => { + useEffect(() => { + if (options?.removeIfEmpty && Array.isArray(value) && value.length === 0) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(value)); + } + }, [key, value, options?.removeIfEmpty]); +}; diff --git a/src/advanced/utils/validators.ts b/src/advanced/utils/validators.ts new file mode 100644 index 000000000..db6ca83db --- /dev/null +++ b/src/advanced/utils/validators.ts @@ -0,0 +1,14 @@ +// TODO: 검증 유틸리티 함수들 +// 구현할 함수: +// - isValidCouponCode(code: string): boolean - 쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자) +// - isValidStock(stock: number): boolean - 재고 수량 검증 (0 이상) +// - isValidPrice(price: number): boolean - 가격 검증 (양수) +// - extractNumbers(value: string): string - 문자열에서 숫자만 추출 + +// TODO: 구현 + +const isValidStock = (stock: number): boolean => { + return stock >= 0; +}; + +export { isValidStock }; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..0b9854c2a 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,1124 +1,75 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import { useState } from "react"; +import Notification from "./components/ui/Notification"; +import AdminPage from "./pages/AdminPage"; +import CartPage from "./pages/CartPage"; +import useProducts from "./hooks/useProducts"; +import useCart from "./hooks/useCart"; +import useCoupons from "./hooks/useCoupons"; +import { useDebounce } from "./utils/hooks/useDebounce"; +import { useLocalStorage } from "./utils/hooks/useLocalStorage"; +import { useNotification } from "./hooks/useNotification"; const App = () => { + // Notification 관리 + const { notifications, setNotifications, addNotification } = useNotification(); - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); + // 데이터 관리 + const products = useProducts(); + useLocalStorage("products", products.data); - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); + const cart = useCart(); + useLocalStorage("cart", cart.data, { removeIfEmpty: true }); - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); + const coupons = useCoupons(); + useLocalStorage("coupons", coupons.data); - 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 [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 500); return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
+ {/* 알림 영역 */} + + + {/* 메인 컨텐츠 */} + {isAdmin ? ( + setIsAdmin(false)} + /> + ) : ( + setIsAdmin(true)} + /> )} -
-
-
-
-

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/domain/adminPage/AdminHeader.tsx b/src/basic/components/domain/adminPage/AdminHeader.tsx new file mode 100644 index 000000000..67e77bd18 --- /dev/null +++ b/src/basic/components/domain/adminPage/AdminHeader.tsx @@ -0,0 +1,15 @@ +import Header from "../../ui/Header"; + +const AdminHeader = ({ goShoppingPage }: { goShoppingPage: () => void }) => { + return ( +
+ 쇼핑몰로 돌아가기 + + } + /> + ); +}; + +export default AdminHeader; diff --git a/src/basic/components/domain/adminPage/CouponForm.tsx b/src/basic/components/domain/adminPage/CouponForm.tsx new file mode 100644 index 000000000..0b372aab9 --- /dev/null +++ b/src/basic/components/domain/adminPage/CouponForm.tsx @@ -0,0 +1,124 @@ +import { Coupon } from "../../../../types"; + +const CouponForm = ({ + couponForm, + setCouponForm, + handleCouponSubmit, + setShowCouponForm, + addNotification, +}: { + couponForm: Coupon; + setCouponForm: (coupon: Coupon) => void; + handleCouponSubmit: (e: React.FormEvent) => void; + setShowCouponForm: (show: boolean) => void; + addNotification: (message: string, type: "error" | "success" | "warning") => void; +}) => { + return ( +
+
+

새 쿠폰 생성

+
+
+ + 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 + /> +
+
+
+ + +
+
+
+ ); +}; + +export default CouponForm; diff --git a/src/basic/components/domain/adminPage/CouponListItem.tsx b/src/basic/components/domain/adminPage/CouponListItem.tsx new file mode 100644 index 000000000..538f90fc4 --- /dev/null +++ b/src/basic/components/domain/adminPage/CouponListItem.tsx @@ -0,0 +1,54 @@ +import { Coupon } from "../../../../types"; + +const CouponListItem = ({ + coupon, + selectedCoupon, + setSelectedCoupon, + deleteCoupon, +}: { + coupon: Coupon; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + deleteCoupon: (couponCode: string) => void; +}) => { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +}; + +export default CouponListItem; diff --git a/src/basic/components/domain/adminPage/CouponSection.tsx b/src/basic/components/domain/adminPage/CouponSection.tsx new file mode 100644 index 000000000..3fda2e923 --- /dev/null +++ b/src/basic/components/domain/adminPage/CouponSection.tsx @@ -0,0 +1,100 @@ +import { Coupon } from "../../../../types"; +import CouponListItem from "./CouponListItem"; +import CouponForm from "./CouponForm"; +import { useState } from "react"; +import couponModel from "../../../models/coupon"; + +const CouponSection = ({ + coupons, + selectedCoupon, + setSelectedCoupon, + deleteCoupon, + addNotification, + addCoupon, +}: { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + deleteCoupon: (couponCode: string) => void; + addNotification: (message: string, type: "error" | "success" | "warning") => void; + addCoupon: (coupon: Coupon) => void; +}) => { + const [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, + }); + const [showCouponForm, setShowCouponForm] = useState(false); + + //coupon + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const isExistingCoupon = couponModel.checkExistingCoupon(coupons, couponForm); + if (isExistingCoupon) { + addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + + addCoupon(couponForm); + setCouponForm({ + name: "", + code: "", + discountType: "amount", + discountValue: 0, + }); + setShowCouponForm(false); + addNotification("쿠폰이 추가되었습니다.", "success"); + }; + + const handleDeleteCoupon = (couponCode: string) => { + deleteCoupon(couponCode); + addNotification("쿠폰이 삭제되었습니다.", "success"); + }; + + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( + + ))} + +
+ +
+
+ + {showCouponForm && ( + + )} +
+
+ ); +}; + +export default CouponSection; diff --git a/src/basic/components/domain/adminPage/ProductForm.tsx b/src/basic/components/domain/adminPage/ProductForm.tsx new file mode 100644 index 000000000..4bfe2e6e8 --- /dev/null +++ b/src/basic/components/domain/adminPage/ProductForm.tsx @@ -0,0 +1,182 @@ +import type { ProductForm } from "../../../../types"; +import { FormEventHandler } from "react"; + +const ProductForm = ({ + handleProductSubmit, + editingProduct, + productForm, + setProductForm, + addNotification, + onCancel, +}: { + handleProductSubmit: FormEventHandler; + editingProduct: string | null; + productForm: ProductForm; + setProductForm: (productForm: ProductForm) => void; + addNotification: (message: string, type: "error" | "success" | "warning") => void; + onCancel: () => void; +}) => { + return ( +
+

{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="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+ ); +}; + +export default ProductForm; diff --git a/src/basic/components/domain/adminPage/ProductSection.tsx b/src/basic/components/domain/adminPage/ProductSection.tsx new file mode 100644 index 000000000..5fcfb82a5 --- /dev/null +++ b/src/basic/components/domain/adminPage/ProductSection.tsx @@ -0,0 +1,124 @@ +import { useState } from "react"; +import { ProductWithUI } from "../../../../types"; +import ProductForm from "./ProductForm"; +import ProductTable from "./ProductTable"; + +const ProductSection = ({ + products, + deleteProduct, + updateProduct, + addProduct, + addNotification, +}: { + products: ProductWithUI[]; + deleteProduct: (productId: string) => void; + addNotification: (message: string, type: "error" | "success" | "warning") => void; + updateProduct: (productId: string, updates: Partial) => void; + addProduct: (newProduct: Omit) => void; +}) => { + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + const [showProductForm, setShowProductForm] = useState(false); + + const resetForm = () => { + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + }; + + // 제춤 추가 + const startAddProduct = () => { + setEditingProduct("new"); + resetForm(); + setShowProductForm(true); + }; + + // 제품 수정 + 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 submitProductForm = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== "new") { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + addNotification("상품이 수정되었습니다.", "success"); + } else { + addProduct({ + ...productForm, + discounts: productForm.discounts, + }); + addNotification("상품이 추가되었습니다.", "success"); + } + + resetForm(); + setEditingProduct(null); + setShowProductForm(false); + }; + + return ( +
+
+
+

상품 목록

+ +
+
+ +
+ { + deleteProduct(productId); + addNotification("상품이 삭제되었습니다.", "success"); + }} + /> +
+ + {showProductForm && ( +
+ { + setEditingProduct(null); + resetForm(); + setShowProductForm(false); + }} + /> +
+ )} +
+ ); +}; + +export default ProductSection; diff --git a/src/basic/components/domain/adminPage/ProductTable.tsx b/src/basic/components/domain/adminPage/ProductTable.tsx new file mode 100644 index 000000000..71ed5df7d --- /dev/null +++ b/src/basic/components/domain/adminPage/ProductTable.tsx @@ -0,0 +1,42 @@ +import { ProductWithUI } from "../../../../types"; +import ProductTableItem from "./ProductTableItem"; +const ProductTable = ({ + products, + startEditProduct, + deleteProduct, +}: { + products: ProductWithUI[]; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +}) => { + const TableHeader = ({ header }: { header: string }) => { + return {header}; + }; + + return ( + + + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ ); +}; + +export default ProductTable; diff --git a/src/basic/components/domain/adminPage/ProductTableItem.tsx b/src/basic/components/domain/adminPage/ProductTableItem.tsx new file mode 100644 index 000000000..699a6c45d --- /dev/null +++ b/src/basic/components/domain/adminPage/ProductTableItem.tsx @@ -0,0 +1,45 @@ +import { ProductWithUI } from "../../../../types"; +import formatter from "../../../utils/formatter"; + +const ProductTableItem = ({ + product, + startEditProduct, + deleteProduct, +}: { + product: ProductWithUI; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +}) => { + return ( + + {product.name} + + {product.stock > 0 ? formatter.formatPriceWon(product.price) : "SOLD OUT"} + + + 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 || "-"} + + + + + + ); +}; + +export default ProductTableItem; diff --git a/src/basic/components/domain/cartPage/CartHeader.tsx b/src/basic/components/domain/cartPage/CartHeader.tsx new file mode 100644 index 000000000..1b126e037 --- /dev/null +++ b/src/basic/components/domain/cartPage/CartHeader.tsx @@ -0,0 +1,41 @@ +import HeaderCartIcon from "../../icons/HeaderCartIcon"; +import Header from "../../ui/Header"; + +const CartHeader = ({ + goAdminPage, + cartCount, + searchTerm, + setSearchTerm, +}: { + goAdminPage: () => void; + cartCount: number; + searchTerm: string; + setSearchTerm: (value: string) => void; +}) => { + return ( +
+ + {"관리자 페이지로"} + + +
+ + {cartCount > 0 && ( + + {cartCount} + + )} +
+ + } + /> + ); +}; + +export default CartHeader; diff --git a/src/basic/components/domain/cartPage/CartList.tsx b/src/basic/components/domain/cartPage/CartList.tsx new file mode 100644 index 000000000..607a59026 --- /dev/null +++ b/src/basic/components/domain/cartPage/CartList.tsx @@ -0,0 +1,44 @@ +import { CartItem } from "../../../../types"; +import cartModel from "../../../models/cart"; +import { CartIcon } from "../../icons"; +import CartListItem from "../cartPage/CartListItem"; + +const CartList = ({ + cart, + removeFromCart, + updateQuantity, +}: { + cart: CartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +}) => { + return cart.length === 0 ? ( +
+ +

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = cartModel.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 ( + + ); + })} +
+ ); +}; + +export default CartList; diff --git a/src/basic/components/domain/cartPage/CartListItem.tsx b/src/basic/components/domain/cartPage/CartListItem.tsx new file mode 100644 index 000000000..9b6ad703f --- /dev/null +++ b/src/basic/components/domain/cartPage/CartListItem.tsx @@ -0,0 +1,53 @@ +import { CartItem } from "../../../../types"; + +const CartListItem = ({ + item, + removeFromCart, + updateQuantity, + hasDiscount, + discountRate, + itemTotal, +}: { + item: CartItem; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; + hasDiscount: boolean; + discountRate: number; + itemTotal: number; +}) => { + return ( +
+
+

{item.product.name}

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

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

+
+
+
+ ); +}; + +export default CartListItem; diff --git a/src/basic/components/domain/cartPage/ProductList.tsx b/src/basic/components/domain/cartPage/ProductList.tsx new file mode 100644 index 000000000..a1d9f5a0d --- /dev/null +++ b/src/basic/components/domain/cartPage/ProductList.tsx @@ -0,0 +1,29 @@ +import { Product } from "../../../../types"; +import ProductListItem from "./ProductListItem"; + +const ProductList = ({ + products, + addToCart, + getRemainingStock, +}: { + products: Product[]; + addToCart: (product: Product) => void; + getRemainingStock: (product: Product) => number; +}) => { + return ( +
+ {products.map((product) => { + return ( + addToCart(product)} + getRemainingStock={getRemainingStock} + /> + ); + })} +
+ ); +}; + +export default ProductList; diff --git a/src/basic/components/domain/cartPage/ProductListItem.tsx b/src/basic/components/domain/cartPage/ProductListItem.tsx new file mode 100644 index 000000000..822c32f5f --- /dev/null +++ b/src/basic/components/domain/cartPage/ProductListItem.tsx @@ -0,0 +1,84 @@ +import { Product } from "../../../../types"; +import formatter from "../../../utils/formatter"; + +const ProductListItem = ({ + product, + handleAddToCart, + getRemainingStock, +}: { + product: Product & { description?: string; isRecommended?: boolean }; + handleAddToCart: () => void; + getRemainingStock: (product: Product) => number; +}) => { + const remainingStock = getRemainingStock(product); + console.log(remainingStock); + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + BEST + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description &&

{product.description}

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

+ {remainingStock > 0 ? formatter.formatPrice(product.price) : "SOLD OUT"} +

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

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

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

품절임박! {remainingStock}개 남음

+ )} + {remainingStock > 5 &&

재고 {remainingStock}개

} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; + +export default ProductListItem; diff --git a/src/basic/components/icons/AdminIcon.tsx b/src/basic/components/icons/AdminIcon.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/components/icons/CartIcon.tsx b/src/basic/components/icons/CartIcon.tsx new file mode 100644 index 000000000..5ccd3fdaa --- /dev/null +++ b/src/basic/components/icons/CartIcon.tsx @@ -0,0 +1,23 @@ +const CartIcon = ({ size = "small" }: { size?: "small" | "large" }) => { + return size === "small" ? ( + + + + ) : ( + + + + ); +}; + +export default CartIcon; diff --git a/src/basic/components/icons/CheckIcon.tsx b/src/basic/components/icons/CheckIcon.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/components/icons/ChevronDownIcon.tsx b/src/basic/components/icons/ChevronDownIcon.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/components/icons/ChevronUpIcon.tsx b/src/basic/components/icons/ChevronUpIcon.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/components/icons/HeaderCartIcon.tsx b/src/basic/components/icons/HeaderCartIcon.tsx new file mode 100644 index 000000000..e15d8c99e --- /dev/null +++ b/src/basic/components/icons/HeaderCartIcon.tsx @@ -0,0 +1,14 @@ +const HeaderCartIcon = () => { + return ( + + + + ); +}; + +export default HeaderCartIcon; diff --git a/src/basic/components/icons/MinusIcon.tsx b/src/basic/components/icons/MinusIcon.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/components/icons/PlusIcon.tsx b/src/basic/components/icons/PlusIcon.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/components/icons/TrashIcon.tsx b/src/basic/components/icons/TrashIcon.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/components/icons/index.tsx b/src/basic/components/icons/index.tsx new file mode 100644 index 000000000..e971a7a44 --- /dev/null +++ b/src/basic/components/icons/index.tsx @@ -0,0 +1,3 @@ +import CartIcon from "./CartIcon"; + +export { CartIcon }; diff --git a/src/basic/components/ui/Card.tsx b/src/basic/components/ui/Card.tsx new file mode 100644 index 000000000..4f4dae1cd --- /dev/null +++ b/src/basic/components/ui/Card.tsx @@ -0,0 +1,5 @@ +const Card = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; + +export default Card; diff --git a/src/basic/components/ui/Header.tsx b/src/basic/components/ui/Header.tsx new file mode 100644 index 000000000..c7dae70e5 --- /dev/null +++ b/src/basic/components/ui/Header.tsx @@ -0,0 +1,63 @@ +import Input from "./Input"; + +const Button = ({ + children, + onClick, + type, +}: { + children: React.ReactNode; + onClick: () => void; + type: "primary" | "secondary"; +}) => { + return ( + + ); +}; + +const Header = ({ + hasSearch, + searchValue, + setSearchValue, + searchPlaceholder, + children, +}: { + hasSearch?: boolean; + searchValue?: string; + setSearchValue?: (value: string) => void; + searchPlaceholder?: string; + children: React.ReactNode; +}) => { + return ( +
+
+
+
+

SHOP

+ {hasSearch && ( +
+ setSearchValue?.(e.target.value)} + placeholder={searchPlaceholder || "검색"} + /> +
+ )} +
+ + +
+
+
+ ); +}; + +Header.Button = Button; + +export default Header; diff --git a/src/basic/components/ui/Input.tsx b/src/basic/components/ui/Input.tsx new file mode 100644 index 000000000..fa40f2476 --- /dev/null +++ b/src/basic/components/ui/Input.tsx @@ -0,0 +1,21 @@ +const Input = ({ + input, + onChange, + placeholder, +}: { + input: string; + onChange: (e: React.ChangeEvent) => void; + placeholder: string; +}) => { + return ( + + ); +}; + +export default Input; diff --git a/src/basic/components/ui/Notification.tsx b/src/basic/components/ui/Notification.tsx new file mode 100644 index 000000000..d863458fe --- /dev/null +++ b/src/basic/components/ui/Notification.tsx @@ -0,0 +1,38 @@ +import { Notification as NotificationType } from "../../types"; + +const Notification = ({ + notifications, + setNotifications, +}: { + notifications: NotificationType[]; + setNotifications: React.Dispatch>; +}) => { + return ( + notifications.length > 0 && ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ) + ); +}; + +export default Notification; diff --git a/src/basic/components/ui/SelectList.tsx b/src/basic/components/ui/SelectList.tsx new file mode 100644 index 000000000..6002e76cb --- /dev/null +++ b/src/basic/components/ui/SelectList.tsx @@ -0,0 +1,25 @@ +const SelectList = ({ + options, + onChange, + value, +}: { + options: { label: string; value: string }[]; + onChange: (e: React.ChangeEvent) => void; + value: string; +}) => { + return ( + + ); +}; + +export default SelectList; diff --git a/src/basic/components/ui/index.tsx b/src/basic/components/ui/index.tsx new file mode 100644 index 000000000..36cf59518 --- /dev/null +++ b/src/basic/components/ui/index.tsx @@ -0,0 +1,5 @@ +import Card from "./Card"; +import SelectList from "./SelectList"; +import Notification from "./Notification"; + +export { Card, SelectList, Notification }; diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..0400264a0 --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useState } from "react"; +import { CartItem, Coupon, Product } from "../../types"; +import cartModel from "../models/cart"; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +const useCart = () => { + const [cart, setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return []; + } + } + return []; + }); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + useEffect(() => { + localStorage.setItem("cart", JSON.stringify(cart)); + }, [cart]); + + const addToCart = useCallback((product: ProductWithUI) => { + setCart((prevCart) => cartModel.addItemToCart(prevCart, product)); + }, []); + + const removeFromCart = useCallback((productId: string) => { + setCart((prevCart) => cartModel.removeItemFromCart(prevCart, productId)); + }, []); + + const updateQuantity = useCallback((productId: string, newQuantity: number) => { + setCart((prevCart) => cartModel.updateItemQuantity(prevCart, productId, newQuantity)); + }, []); + + const applyCoupon = useCallback((coupon: Coupon | null) => { + setSelectedCoupon(coupon); + }, []); + + const calculateTotal = useCallback(() => { + return cartModel.calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + const getRemainingStock = useCallback( + (product: Product) => { + return cartModel.getRemainingStock(cart, product); + }, + [cart] + ); + + const clearCart = useCallback(() => { + setCart([]); + }, []); + + return { + data: cart, + selectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + calculateTotal, + getRemainingStock, + clearCart, + }; +}; + +export default useCart; diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 000000000..5de551c5d --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,44 @@ +import { useCallback, useState } from "react"; +import { Coupon } from "../../types"; +import couponModel from "../models/coupon"; + +const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; + +const useCoupons = () => { + const [coupons, setCoupons] = useState(() => { + const saved = localStorage.getItem("coupons"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialCoupons; + } + } + return initialCoupons; + }); + + const addCoupon = useCallback((newCoupon: Coupon) => { + setCoupons((prev) => couponModel.addCoupon(prev, newCoupon)); + }, []); + + const deleteCoupon = useCallback((couponCode: string) => { + setCoupons((prev) => couponModel.deleteCoupon(prev, couponCode)); + }, []); + + return { data: coupons, addCoupon, deleteCoupon }; +}; + +export default useCoupons; diff --git a/src/basic/hooks/useNotification.ts b/src/basic/hooks/useNotification.ts new file mode 100644 index 000000000..942cb3d93 --- /dev/null +++ b/src/basic/hooks/useNotification.ts @@ -0,0 +1,29 @@ +import { useState, useCallback } from "react"; +import { Notification } from "../../../types"; + +/** + * 알림 메시지를 관리하는 커스텀 훅 + * @param duration 알림이 표시되는 시간 (ms) + * @returns 알림 목록과 알림 추가 함수 + */ +export const useNotification = (duration: number = 3000) => { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, duration); + }, + [duration] + ); + + return { + notifications, + setNotifications, + addNotification, + }; +}; diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 000000000..d4cca2cf5 --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useState } from "react"; +import { Product } from "../../types"; +import productModel from "../models/product"; +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} // 초기 데이터 + +const initialProducts: ProductWithUI[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: "최고급 품질의 프리미엄 상품입니다.", + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; +const useProducts = () => { + const [_products, setProducts] = useState(() => { + const saved = localStorage.getItem("products"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialProducts; + } + } + return initialProducts; + }); + + useEffect(() => { + localStorage.setItem("products", JSON.stringify(_products)); + }, [_products]); + + const addProduct = useCallback((newProduct: Omit) => { + const product: ProductWithUI = { ...newProduct, id: `p${Date.now()}` }; + setProducts((prev) => productModel.addProduct(prev, product)); + }, []); + + const updateProduct = useCallback((productId: string, updates: Partial) => { + setProducts((prev) => productModel.updateProduct(prev, productId, updates)); + }, []); + + const deleteProduct = useCallback((productId: string) => { + setProducts((prev) => productModel.deleteProduct(prev, productId)); + }, []); + + const updateProductStock = useCallback((productId: string, newStock: number) => { + setProducts((prev) => productModel.updateProductStock(prev, productId, newStock)); + }, []); + + return { + data: _products, + addProduct, + updateProduct, + deleteProduct, + updateProductStock, + }; +}; + +export default useProducts; diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts new file mode 100644 index 000000000..cb40f5123 --- /dev/null +++ b/src/basic/models/cart.ts @@ -0,0 +1,87 @@ +import { CartItem, Coupon, Product } from "../../types"; + +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)); +}; + +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; +}; + +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), + }; +}; + +const updateItemQuantity = (cart: CartItem[], productId: string, quantity: number): CartItem[] => { + return cart.map((item) => (item.product.id === productId ? { ...item, quantity } : item)); +}; + +const addItemToCart = (cart: CartItem[], product: Product): CartItem[] => { + return [...cart, { product, quantity: 1 }]; +}; + +const removeItemFromCart = (cart: CartItem[], productId: string): CartItem[] => { + return cart.filter((item) => item.product.id !== productId); +}; + +const getRemainingStock = (cart: CartItem[], product: Product): number => { + return product.stock - cart.reduce((sum, item) => sum + item.quantity, 0); +}; + +const getCartCount = (cart: CartItem[]): number => { + return cart.reduce((sum, item) => sum + item.quantity, 0); +}; + +export default { + calculateItemTotal, + calculateCartTotal, + getMaxApplicableDiscount, + updateItemQuantity, + addItemToCart, + removeItemFromCart, + getRemainingStock, + getCartCount, +}; diff --git a/src/basic/models/coupon.ts b/src/basic/models/coupon.ts new file mode 100644 index 000000000..2fc20f4ed --- /dev/null +++ b/src/basic/models/coupon.ts @@ -0,0 +1,28 @@ +import { Coupon } from "../../types"; +import formatter from "../utils/formatter"; + +const addCoupon = (coupons: Coupon[], newCoupon: Coupon) => { + return [...coupons, newCoupon]; +}; + +const deleteCoupon = (coupons: Coupon[], couponCode: string) => { + return coupons.filter((coupon) => coupon.code !== couponCode); +}; + +const checkExistingCoupon = (coupons: Coupon[], newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + return !!existingCoupon; +}; + +const getCouponList = (coupons: Coupon[]) => { + return coupons.map((coupon) => ({ + label: `${coupon.name} (${ + coupon.discountType === "amount" + ? formatter.formatPrice(coupon.discountValue) + : formatter.formatPercentage(coupon.discountValue) + })`, + value: coupon.code, + })); +}; + +export default { addCoupon, deleteCoupon, checkExistingCoupon, getCouponList }; diff --git a/src/basic/models/product.ts b/src/basic/models/product.ts new file mode 100644 index 000000000..4b8f91a27 --- /dev/null +++ b/src/basic/models/product.ts @@ -0,0 +1,63 @@ +import { ProductWithUI } from "../../types"; + +const addProduct = (products: ProductWithUI[], newProduct: Omit) => { + const product: ProductWithUI = { ...newProduct, id: `p${Date.now()}` }; + return [...products, product]; +}; + +const updateProduct = (products: ProductWithUI[], productId: string, updates: Partial) => { + return products.map((product) => (product.id === productId ? { ...product, ...updates } : product)); +}; + +const deleteProduct = (products: ProductWithUI[], productId: string) => { + return products.filter((p) => p.id !== productId); +}; + +const updateProductStock = (products: ProductWithUI[], productId: string, newStock: number) => { + return products.map((product) => (product.id === productId ? { ...product, stock: newStock } : product)); +}; + +const addProductDiscount = ( + products: ProductWithUI[], + productId: string, + discount: { quantity: number; rate: number } +) => { + return products.map((product) => + product.id === productId ? { ...product, discounts: [...product.discounts, discount] } : product + ); +}; + +const removeProductDiscount = ( + products: ProductWithUI[], + productId: string, + discount: { quantity: number; rate: number } +) => { + return products.map((product) => + product.id === productId + ? { + ...product, + discounts: product.discounts.filter((d) => d.quantity !== discount.quantity && d.rate !== discount.rate), + } + : product + ); +}; + +const filterSearch = (products: ProductWithUI[], searchTerm: string) => { + if (!searchTerm) return products; + + return products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + product.description?.toLowerCase().includes(searchTerm.toLowerCase()) + ); +}; + +export default { + addProduct, + updateProduct, + deleteProduct, + updateProductStock, + addProductDiscount, + removeProductDiscount, + filterSearch, +}; diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx new file mode 100644 index 000000000..693725cac --- /dev/null +++ b/src/basic/pages/AdminPage.tsx @@ -0,0 +1,103 @@ +import { Coupon, Product } from "../../types"; +import { useState } from "react"; +import CouponSection from "../components/domain/adminPage/CouponSection"; +import ProductSection from "../components/domain/adminPage/ProductSection"; +import AdminHeader from "../components/domain/adminPage/AdminHeader"; +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +const AdminPage = ({ + // PRODUCTS + products, + addProduct, + updateProduct, + deleteProduct, + // COUPON + coupons, + addCoupon, + deleteCoupon, + // COUPONT FORM + selectedCoupon, + setSelectedCoupon, + // NOTIFICATION + addNotification, + goShoppingPage, +}: { + products: ProductWithUI[]; + addProduct: (newProduct: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; + + coupons: Coupon[]; + addCoupon: (newCoupon: Coupon) => void; + deleteCoupon: (couponCode: string) => void; + + selectedCoupon: Coupon | null; + setSelectedCoupon: (value: Coupon | null) => void; + + addNotification: (value: string, type: "error" | "success" | "warning") => void; + goShoppingPage: () => void; +}) => { + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); + + return ( + <> + +
+
+
+

관리자 대시보드

+

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

+
+
+ +
+ + {activeTab === "products" ? ( + + ) : ( + + )} +
+
+ + ); +}; + +export default AdminPage; diff --git a/src/basic/pages/CartPage.tsx b/src/basic/pages/CartPage.tsx new file mode 100644 index 000000000..eef572f18 --- /dev/null +++ b/src/basic/pages/CartPage.tsx @@ -0,0 +1,271 @@ +import { useMemo, useCallback } from "react"; +import { CartItem, ProductWithUI, Coupon, Product } from "../../types"; +import SelectList from "../components/ui/SelectList"; +import { CartIcon } from "../components/icons"; +import { Card } from "../components/ui"; +import CartList from "../components/domain/cartPage/CartList"; +import ProductList from "../components/domain/cartPage/ProductList"; +import { isValidStock } from "../utils/validators"; +import CartHeader from "../components/domain/cartPage/CartHeader"; +import cartModel from "../models/cart"; +import productModel from "../models/product"; +import couponModel from "../models/coupon"; + +interface CartPageProps { + products: ProductWithUI[]; + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + debouncedSearchTerm: string; + + applyCoupon: (coupon: Coupon | null) => void; + addToCart: (product: Product) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + removeFromCart: (productId: string) => void; + + getRemainingStock: (product: Product) => number; + calculateTotal: () => { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + addNotification: (message: string, type: "error" | "success" | "warning") => void; + clearCart: () => void; + goAdminPage: () => void; + setSearchTerm: (value: string) => void; +} + +const CartPage = ({ + products, + cart, + coupons, + selectedCoupon, + debouncedSearchTerm, + addToCart, + updateQuantity, + removeFromCart, + applyCoupon, + calculateTotal, + getRemainingStock, + addNotification, + clearCart, + goAdminPage, + setSearchTerm, +}: CartPageProps) => { + const filteredProducts = useMemo(() => { + if (!debouncedSearchTerm) return products; + + const filteredProducts = productModel.filterSearch(products, debouncedSearchTerm); + return filteredProducts; + }, [products, debouncedSearchTerm]); + + const couponList = useMemo( + () => [{ label: "쿠폰 선택", value: "" }, ...couponModel.getCouponList(coupons)], + [coupons] + ); + + const totals = calculateTotal(); + const discountAmount = useMemo(() => totals.totalBeforeDiscount - totals.totalAfterDiscount, [totals]); + + const handleApplyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateTotal().totalAfterDiscount; + + // percentage 쿠폰은 최소 주문 금액 체크 + if (currentTotal < 10000 && coupon.discountType === "percentage") { + addNotification("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", "error"); + return; + } + + applyCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [cart, addNotification] + ); + + const handleAddToCart = useCallback( + (product: Product) => { + const remainingStock = getRemainingStock(product); + + // 재고 검증 + if (!isValidStock(remainingStock)) { + addNotification("재고가 부족합니다!", "error"); + return; + } + + const existingItem = cart.find((item) => item.product.id === product.id); + + // 신규 상품 추가 + if (!existingItem) { + addToCart(product); + addNotification("장바구니에 담았습니다", "success"); + return; + } + + // 기존 상품 수량 증가 + const newQuantity = existingItem.quantity + 1; + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return; + } + + handleUpdateQuantity(product.id, newQuantity); + addNotification("장바구니에 담았습니다", "success"); + }, + [cart, addNotification, updateQuantity] + ); + + // 수량 업데이트 핸들러 + const handleUpdateQuantity = useCallback( + (productId: string, newQuantity: number) => { + // 새 수량이 0 이하면 장바구니에서 제거 + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } + + // 상품이 없으면 early return + const product = products.find((p) => p.id === productId); + if (!product) return; + + // 재고 초과 검증 + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return; + } + + // 수량 업데이트 + updateQuantity(productId, newQuantity); + }, + [products, cart, addNotification] + ); + + // 주문 완료 핸들러 + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); + clearCart(); + applyCoupon(null); + }, [addNotification]); + + return ( + <> + +
+
+ {/* 상품 목록 영역 */} +
+
+ {/* 상품 목록 헤더 */} +
+

전체 상품

+
총 {products.length}개 상품
+
+ + {/* 상품 목록 또는 검색 결과 없음 메시지 */} + {filteredProducts.length === 0 ? ( +
+

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

+
+ ) : ( + + )} +
+
+ + {/* 장바구니 및 결제 영역 */} +
+
+ {/* 장바구니 카드 */} + +

+ + 장바구니 +

+ +
+ + {/* 장바구니에 상품이 있을 때만 표시 */} + {cart.length > 0 && ( + <> + {/* 쿠폰 할인 카드 */} + +
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + { + const coupon = coupons.find((c) => c.code === e.target.value); + if (coupon) { + handleApplyCoupon(coupon); + } else { + applyCoupon(null); + } + }} + /> + )} +
+ + {/* 결제 정보 카드 */} + +

결제 정보

+
+ {/* 상품 금액 */} +
+ 상품 금액 + {totals.totalBeforeDiscount.toLocaleString()}원 +
+ + {/* 할인 금액 (할인이 있을 때만 표시) */} + {discountAmount > 0 && ( +
+ 할인 금액 + -{discountAmount.toLocaleString()}원 +
+ )} + + {/* 결제 예정 금액 */} +
+ 결제 예정 금액 + + {totals.totalAfterDiscount.toLocaleString()}원 + +
+
+ + {/* 결제 버튼 */} + + + {/* 안내 문구 */} +
+

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

+
+
+ + )} +
+
+
+
+ + ); +}; + +export default CartPage; diff --git a/src/basic/utils/formatter.ts b/src/basic/utils/formatter.ts new file mode 100644 index 000000000..688d13349 --- /dev/null +++ b/src/basic/utils/formatter.ts @@ -0,0 +1,25 @@ +// TODO: 포맷팅 유틸리티 함수들 +// 구현할 함수: +// - formatPrice(price: number): string - 가격을 한국 원화 형식으로 포맷 +// - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷 +// - formatPercentage(rate: number): string - 소수를 퍼센트로 변환 (0.1 → 10%) + +// TODO: 구현 + +const formatPrice = (price: number): string => { + return `₩${price.toLocaleString()}`; +}; + +const formatPriceWon = (price: number): string => { + return `${price.toLocaleString()}원`; +}; + +const formatDate = (date: Date): string => { + return date.toISOString().split("T")[0]; +}; + +const formatPercentage = (rate: number): string => { + return `${Math.round(rate)}%`; +}; + +export default { formatPrice, formatPriceWon, formatDate, formatPercentage }; diff --git a/src/basic/utils/hooks/useDebounce.ts b/src/basic/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..30644e3ee --- /dev/null +++ b/src/basic/utils/hooks/useDebounce.ts @@ -0,0 +1,22 @@ +import { useState, useEffect } from 'react'; + +/** + * 입력 값에 디바운스를 적용하는 커스텀 훅 + * @param value 디바운스를 적용할 값 + * @param delay 지연 시간 (ms) + * @returns 디바운스가 적용된 값 + */ +export const useDebounce = (value: T, delay: number = 500): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +}; + diff --git a/src/basic/utils/hooks/useLocalStorage.ts b/src/basic/utils/hooks/useLocalStorage.ts new file mode 100644 index 000000000..a4cad8725 --- /dev/null +++ b/src/basic/utils/hooks/useLocalStorage.ts @@ -0,0 +1,11 @@ +import { useEffect } from "react"; + +export const useLocalStorage = (key: string, value: T, options?: { removeIfEmpty?: boolean }) => { + useEffect(() => { + if (options?.removeIfEmpty && Array.isArray(value) && value.length === 0) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(value)); + } + }, [key, value, options?.removeIfEmpty]); +}; diff --git a/src/basic/utils/validators.ts b/src/basic/utils/validators.ts new file mode 100644 index 000000000..db6ca83db --- /dev/null +++ b/src/basic/utils/validators.ts @@ -0,0 +1,14 @@ +// TODO: 검증 유틸리티 함수들 +// 구현할 함수: +// - isValidCouponCode(code: string): boolean - 쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자) +// - isValidStock(stock: number): boolean - 재고 수량 검증 (0 이상) +// - isValidPrice(price: number): boolean - 가격 검증 (양수) +// - extractNumbers(value: string): string - 문자열에서 숫자만 추출 + +// TODO: 구현 + +const isValidStock = (stock: number): boolean => { + return stock >= 0; +}; + +export { isValidStock }; diff --git a/src/types.ts b/src/types.ts index 5489e296e..376cb481b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,11 @@ export interface Product { discounts: Discount[]; } +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + export interface Discount { quantity: number; rate: number; @@ -19,6 +24,20 @@ export interface CartItem { export interface Coupon { name: string; code: string; - discountType: 'amount' | 'percentage'; + discountType: "amount" | "percentage"; discountValue: number; } + +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +export interface ProductForm { + name: string; + price: number; + stock: number; + description: string; + discounts: { quantity: number; rate: number }[]; +} diff --git a/vite.config.ts b/vite.config.ts index e6c4016bc..96d1c9b87 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,12 @@ import react from '@vitejs/plugin-react-swc'; export default mergeConfig( defineConfig({ plugins: [react()], + base: '/front_7th_chapter3-2/', + build: { + rollupOptions: { + input: './index.advanced.html' + } + } }), defineTestConfig({ test: {