diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..2cb7b9ca4 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,68 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + paths: + - "src/advanced/**" + - "index.advanced.html" + - "vite.config.ts" + - ".github/workflows/deploy.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - 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 + env: + NODE_ENV: production + VITE_BASE_PATH: /${{ github.event.repository.name }}/ + + # [추가된 핵심 로직] + # 빌드된 dist 폴더 안의 index.advanced.html을 index.html로 이름 변경 + # 이렇게 하면 GitHub Pages가 index.html을 찾을 수 있게 됩니다. + - name: Rename entry file for GitHub Pages + run: mv dist/index.advanced.html dist/index.html + + - 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..03811e474 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "jotai": "^2.15.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85f..3f6655757 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + jotai: + specifier: ^2.15.2 + version: 2.15.2(@types/react@19.1.9)(react@19.1.1) react: specifier: ^19.1.1 version: 19.1.1 @@ -1056,6 +1059,24 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jotai@2.15.2: + resolution: {integrity: sha512-El86CCfXNMEOytp20NPfppqGGmcp6H6kIA+tJHdmASEUURJCYW4fh8nTHEnB8rUXEFAY1pm8PdHPwnrcPGwdEg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2413,6 +2434,11 @@ snapshots: isexe@2.0.0: {} + jotai@2.15.2(@types/react@19.1.9)(react@19.1.1): + optionalDependencies: + '@types/react': 19.1.9 + react: 19.1.1 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1d..862f0c572 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1124 +1,52 @@ -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 { useAtom, useAtomValue } from "jotai"; +import { Header } from "./components/Header"; +import { AdminPage } from "./pages/AdminPage"; +import { CartPage } from "./pages/CartPage"; +import { Toast } from "./components/ui/Toast"; +import { CartCounter } from "./components/CartCounter"; +import { ProductSearchBar } from "./components/ProductSearchBar"; +import { useToast } from "./utils/hooks/useToast"; +import { isAdminAtom, searchTermAtom, totalItemCountAtom } from "./store"; const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const [isAdmin, setIsAdmin] = useAtom(isAdminAtom); + const [searchTerm, setSearchTerm] = useAtom(searchTermAtom); + const totalItemCount = useAtomValue(totalItemCountAtom); + const { notifications } = useToast(); return (
{notifications.length > 0 && (
- {notifications.map(notif => ( -
- {notif.message} - -
+ {notifications.map((notification) => ( + ))}
)} -
-
-
-
-

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 && ( + + )} + +
- {isAdmin ? ( -
-
-

관리자 대시보드

-

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

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

상품 목록

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

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

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

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

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

새 쿠폰 생성

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

전체 상품

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

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

-
-
- - )} -
-
-
- )} + {isAdmin ? : }
); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/advanced/__tests__/origin.test.tsx b/src/advanced/__tests__/origin.test.tsx index 3f5c3d55e..edebb041e 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -1,528 +1,596 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; -import { vi } from 'vitest'; -import App from '../App'; -import '../../setupTests'; +import { + render, + screen, + fireEvent, + within, + waitFor, +} from "@testing-library/react"; +import { vi } from "vitest"; +import { useSetAtom } from "jotai"; +import App from "../App"; +import "../../setupTests"; +import { ToastProvider } from "../context/ToastProvider"; +import { isAdminAtom, searchTermAtom } from "../store"; -describe('쇼핑몰 앱 통합 테스트', () => { +const AppWithToast = () => { + return ( + + + + ); +}; + +// Atom 상태 초기화 헬퍼 컴포넌트 +const AtomReset = () => { + const setIsAdmin = useSetAtom(isAdminAtom); + const setSearchTerm = useSetAtom(searchTermAtom); + + setIsAdmin(false); + setSearchTerm(""); + + return null; +}; + +describe("쇼핑몰 앱 통합 테스트", () => { beforeEach(() => { // localStorage 초기화 localStorage.clear(); + // Atom 상태 초기화 + const { unmount } = render( + + + + ); + unmount(); // 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 () => { - render(); - + 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('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { - render(); - + 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('쿠폰을 선택하고 적용할 수 있다', () => { - render(); - + 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 () => { - render(); - + 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('주문을 완료할 수 있다', () => { - render(); - + 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('장바구니에서 상품을 삭제할 수 있다', () => { - render(); - + 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 () => { - render(); - + 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(); + expect( + screen.getByText(/재고는.*개까지만 있습니다/) + ).toBeInTheDocument(); }); }); - test('장바구니에서 수량을 감소시킬 수 있다', () => { - render(); - + 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 () => { - render(); - + 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(); + 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에 저장된다', () => { - render(); - + 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('페이지 새로고침 후에도 데이터가 유지된다', () => { - const { unmount } = render(); - + 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(); - + 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 () => { - render(); - + 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('장바구니 아이템 개수가 헤더에 표시된다', () => { - render(); - + 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 () => { - render(); - + 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 () => { - render(); - + 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/CartCounter.tsx b/src/advanced/components/CartCounter.tsx new file mode 100644 index 000000000..60dd98b06 --- /dev/null +++ b/src/advanced/components/CartCounter.tsx @@ -0,0 +1,14 @@ +import { IconCart } from "./icons"; + +export function CartCounter({ totalItemCount }: { totalItemCount: number }) { + return ( +
+ + {totalItemCount > 0 && ( + + {totalItemCount} + + )} +
+ ); +} diff --git a/src/advanced/components/CartListItem.tsx b/src/advanced/components/CartListItem.tsx new file mode 100644 index 000000000..f31251f95 --- /dev/null +++ b/src/advanced/components/CartListItem.tsx @@ -0,0 +1,67 @@ +import { CartItem, Product } from "../types"; +import { formatDiscount } from "../utils/formatDiscount"; +import { formatPriceWon } from "../utils/formatPriceWon"; +import { IconClose } from "./icons"; + +export function CartListItem({ + item, + itemTotal, + onRemove, + onChangeQuantity, +}: { + item: CartItem; + itemTotal: number; + onRemove: (productId: string) => void; + onChangeQuantity: (product: Product, newQuantity: number) => void; +}) { + 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 && ( + + -{formatDiscount(discountRate)} + + )} +

+ {formatPriceWon(itemTotal)} +

+
+
+
+ ); +} diff --git a/src/advanced/components/CartPayment.tsx b/src/advanced/components/CartPayment.tsx new file mode 100644 index 000000000..35b2a1aa4 --- /dev/null +++ b/src/advanced/components/CartPayment.tsx @@ -0,0 +1,54 @@ +import { formatPriceWon } from "../utils/formatPriceWon"; + +export function CartPayment({ + totals, + onPurchase, +}: { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + onPurchase: () => void; +}) { + return ( +
+

결제 정보

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

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

+
+
+ ); +} diff --git a/src/advanced/components/CouponCard.tsx b/src/advanced/components/CouponCard.tsx new file mode 100644 index 000000000..5e66549df --- /dev/null +++ b/src/advanced/components/CouponCard.tsx @@ -0,0 +1,34 @@ +import { IconTrash } from "./icons"; +import { Coupon } from "../types"; + +export function CouponCard({ + coupon, + onDelete, +}: { + coupon: Coupon; + onDelete: (code: string) => void; +}) { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +} diff --git a/src/advanced/components/CouponDashboard.tsx b/src/advanced/components/CouponDashboard.tsx new file mode 100644 index 000000000..5d626add8 --- /dev/null +++ b/src/advanced/components/CouponDashboard.tsx @@ -0,0 +1,82 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { IconPlus } from "./icons"; +import { Coupon } from "../types"; +import { CouponCard } from "./CouponCard"; +import { CouponForm } from "./CouponForm"; +import { useState } from "react"; +import { couponsAtom, addCouponAtom, deleteCouponAtom } from "../store"; +import { useToast } from "../utils/hooks/useToast"; + +const getInitialCouponForm = (): Coupon => { + return { + name: "", + code: "", + discountType: "amount", + discountValue: 0, + }; +}; + +export function CouponDashboard() { + const coupons = useAtomValue(couponsAtom); + const addCouponAction = useSetAtom(addCouponAtom); + const deleteCouponAction = useSetAtom(deleteCouponAtom); + const { notify } = useToast(); + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState(getInitialCouponForm()); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const existingCoupon = coupons.find((c) => c.code === couponForm.code); + if (existingCoupon) { + notify("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + addCouponAction(couponForm); + notify("쿠폰이 추가되었습니다.", "success"); + setCouponForm(getInitialCouponForm()); + setShowCouponForm(false); + }; + + const handleDelete = (code: string) => { + deleteCouponAction(code); + notify("쿠폰이 삭제되었습니다.", "success"); + }; + + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( + + ))} + +
+ +
+
+ + {showCouponForm && ( + + )} +
+
+ ); +} diff --git a/src/advanced/components/CouponForm.tsx b/src/advanced/components/CouponForm.tsx new file mode 100644 index 000000000..a7ec08441 --- /dev/null +++ b/src/advanced/components/CouponForm.tsx @@ -0,0 +1,141 @@ +import { validateCoupon } from "../models/validateCoupon"; +import { Coupon } from "../types"; +import { isValidNumber } from "../utils/isValidNumber"; +import { toNumber } from "../utils/toNumber"; +import { useToast } from "../utils/hooks/useToast"; + +export function CouponForm({ + couponForm, + onChange, + onCancel, + onSubmit, +}: { + onSubmit: (e: React.FormEvent) => void; + couponForm: Coupon; + onChange: (couponForm: Coupon) => void; + onCancel: (show: boolean) => void; +}) { + const { notify } = useToast(); + const handleDiscountBlur = (e: React.FocusEvent) => { + const value = toNumber(e.target.value); + const error = validateCoupon(couponForm.discountType, value); + switch (error) { + case "DISCOUNT_RATE_OVER_100": + notify("할인율은 100%를 초과할 수 없습니다", "error"); + onChange({ ...couponForm, discountValue: 100 }); + return; + case "DISCOUNT_RATE_UNDER_0": + notify("할인율은 0% 이하일 수 없습니다", "error"); + onChange({ ...couponForm, discountValue: 0 }); + return; + case "DISCOUNT_AMOUNT_OVER_100000": + notify("할인 금액은 100,000원을 초과할 수 없습니다", "error"); + onChange({ ...couponForm, discountValue: 100000 }); + return; + case "DISCOUNT_AMOUNT_UNDER_0": + notify("할인 금액은 0원 이하일 수 없습니다", "error"); + onChange({ ...couponForm, discountValue: 0 }); + return; + } + }; + + return ( +
+
+

새 쿠폰 생성

+
+
+ + + onChange({ ...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 + /> +
+
+ + + onChange({ + ...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 (isValidNumber(value)) { + onChange({ + ...couponForm, + discountValue: toNumber(value), + }); + } + }} + onBlur={handleDiscountBlur} + 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 + /> +
+
+
+ + +
+
+
+ ); +} diff --git a/src/advanced/components/CouponSelector.tsx b/src/advanced/components/CouponSelector.tsx new file mode 100644 index 000000000..ab96b998b --- /dev/null +++ b/src/advanced/components/CouponSelector.tsx @@ -0,0 +1,50 @@ +import { Coupon } from "../types"; +import { formatDiscount } from "../utils/formatDiscount"; +import { formatPriceWon } from "../utils/formatPriceWon"; + +export function CouponSelector({ + coupons, + selectedCoupon, + setSelectedCoupon, + applyCoupon, +}: { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + applyCoupon: (coupon: Coupon) => void; +}) { + const handleChange = (e: React.ChangeEvent) => { + const coupon = coupons.find((c) => c.code === e.target.value); + if (coupon) applyCoupon(coupon); + else setSelectedCoupon(null); + }; + + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/advanced/components/Header.tsx b/src/advanced/components/Header.tsx new file mode 100644 index 000000000..dffbb4e9e --- /dev/null +++ b/src/advanced/components/Header.tsx @@ -0,0 +1,14 @@ +export function Header({ children }: { children: React.ReactNode }) { + return ( +
+
+
+
+

SHOP

+ {children} +
+
+
+
+ ); +} diff --git a/src/advanced/components/ProductCard.tsx b/src/advanced/components/ProductCard.tsx new file mode 100644 index 000000000..df3ca440e --- /dev/null +++ b/src/advanced/components/ProductCard.tsx @@ -0,0 +1,81 @@ +import { ProductWithUI } from "../types"; +import { IconImage } from "./icons"; + +export function ProductCard({ + formattedPrice, + product, + remainingStock, + onAddToCart, +}: { + formattedPrice: string; + product: ProductWithUI; + remainingStock: number; + onAddToCart: (product: ProductWithUI) => void; +}) { + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

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

{formattedPrice}

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

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

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

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

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

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +} diff --git a/src/advanced/components/ProductDashboard.tsx b/src/advanced/components/ProductDashboard.tsx new file mode 100644 index 000000000..04923e6cd --- /dev/null +++ b/src/advanced/components/ProductDashboard.tsx @@ -0,0 +1,106 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { ProductForm as ProductFormType, ProductWithUI } from "../types"; +import { ProductForm } from "./ProductForm"; +import { ProductTable } from "./ProductTable"; +import { useState } from "react"; +import { + productsAtom, + addProductAtom, + updateProductAtom, + deleteProductAtom, +} from "../store"; +import { useToast } from "../utils/hooks/useToast"; + +export const getInitialProductForm = (): ProductFormType => ({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], +}); + +export function ProductDashboard() { + const products = useAtomValue(productsAtom); + const addProductAction = useSetAtom(addProductAtom); + const updateProductAction = useSetAtom(updateProductAtom); + const deleteProductAction = useSetAtom(deleteProductAtom); + const { notify } = useToast(); + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState(getInitialProductForm()); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== "new") { + updateProductAction(editingProduct, productForm); + notify("상품이 수정되었습니다.", "success"); + setEditingProduct(null); + } else { + addProductAction({ ...productForm, discounts: productForm.discounts }); + notify("상품이 추가되었습니다.", "success"); + } + setProductForm(getInitialProductForm()); + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleCreateProduct = () => { + setEditingProduct("new"); + setProductForm(getInitialProductForm()); + setShowProductForm(true); + }; + + const handleEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [], + }); + setShowProductForm(true); + }; + + const handleCancel = () => { + setEditingProduct(null); + setProductForm(getInitialProductForm()); + setShowProductForm(false); + }; + + return ( +
+
+
+

상품 목록

+ +
+
+ +
+ { + deleteProductAction(productId); + notify("상품이 삭제되었습니다.", "success"); + }} + /> +
+ {showProductForm && ( + + )} +
+ ); +} diff --git a/src/advanced/components/ProductForm.tsx b/src/advanced/components/ProductForm.tsx new file mode 100644 index 000000000..6f9644c67 --- /dev/null +++ b/src/advanced/components/ProductForm.tsx @@ -0,0 +1,202 @@ +import { IconClose } from "./icons"; +import { ProductForm as ProductFormType } from "../types"; +import { isValidNumber } from "../utils/isValidNumber"; +import { toNumber } from "../utils/toNumber"; +import { useToast } from "../utils/hooks/useToast"; + +export function ProductForm({ + onSubmit, + editingProduct, + productForm, + onChange, + onCancel, +}: { + onSubmit: (e: React.FormEvent) => void; + editingProduct: string | null; + productForm: ProductFormType; + onChange: (product: ProductFormType) => void; + onCancel: () => void; +}) { + const { notify } = useToast(); + const isNewProduct = editingProduct === "new"; + + return ( +
+
+

+ {isNewProduct ? "새 상품 추가" : "상품 수정"} +

+
+
+ + + onChange({ ...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 + /> +
+
+ + + onChange({ ...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 (isValidNumber(value)) { + onChange({ ...productForm, price: toNumber(value) }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + onChange({ ...productForm, price: 0 }); + } else if (toNumber(value) < 0) { + notify("가격은 0보다 커야 합니다", "error"); + onChange({ ...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 (isValidNumber(value)) { + onChange({ ...productForm, stock: toNumber(value) }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + onChange({ ...productForm, stock: 0 }); + } else if (toNumber(value) < 0) { + notify("재고는 0보다 커야 합니다", "error"); + onChange({ ...productForm, stock: 0 }); + } else if (toNumber(value) > 9999) { + notify("재고는 9999개를 초과할 수 없습니다", "error"); + onChange({ ...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 = toNumber(e.target.value); + onChange({ ...productForm, discounts: newDiscounts }); + }} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = toNumber(e.target.value) / 100; + onChange({ ...productForm, discounts: newDiscounts }); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/advanced/components/ProductSearchBar.tsx b/src/advanced/components/ProductSearchBar.tsx new file mode 100644 index 000000000..b36682899 --- /dev/null +++ b/src/advanced/components/ProductSearchBar.tsx @@ -0,0 +1,19 @@ +export function ProductSearchBar({ + searchTerm, + onChange, +}: { + searchTerm: string; + onChange: (value: string) => void; +}) { + return ( +
+ onChange(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ ); +} diff --git a/src/advanced/components/ProductTable.tsx b/src/advanced/components/ProductTable.tsx new file mode 100644 index 000000000..b6bad54b9 --- /dev/null +++ b/src/advanced/components/ProductTable.tsx @@ -0,0 +1,80 @@ +import { ProductWithUI } from "../types"; +import { formatPriceWon } from "../utils/formatPriceWon"; + +const getStockColor = (stock: number) => { + if (stock > 10) return "bg-green-100 text-green-800"; + if (stock > 0) return "bg-yellow-100 text-yellow-800"; + return "bg-red-100 text-red-800"; +}; + +export function ProductTable({ + products, + onEdit, + onDelete, +}: { + products: ProductWithUI[]; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +}) { + return ( + + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {formatPriceWon(product.price)} + + + {product.stock}개 + + + {product.description || "-"} + + + +
+ ); +} diff --git a/src/advanced/components/icons/IconCart.tsx b/src/advanced/components/icons/IconCart.tsx new file mode 100644 index 000000000..4667f1c9a --- /dev/null +++ b/src/advanced/components/icons/IconCart.tsx @@ -0,0 +1,17 @@ +export function IconCart({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/advanced/components/icons/IconClose.tsx b/src/advanced/components/icons/IconClose.tsx new file mode 100644 index 000000000..e7d9858ef --- /dev/null +++ b/src/advanced/components/icons/IconClose.tsx @@ -0,0 +1,17 @@ +export function IconClose({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/advanced/components/icons/IconImage.tsx b/src/advanced/components/icons/IconImage.tsx new file mode 100644 index 000000000..efa7a8869 --- /dev/null +++ b/src/advanced/components/icons/IconImage.tsx @@ -0,0 +1,17 @@ +export function IconImage({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/advanced/components/icons/IconPlus.tsx b/src/advanced/components/icons/IconPlus.tsx new file mode 100644 index 000000000..16cc44f37 --- /dev/null +++ b/src/advanced/components/icons/IconPlus.tsx @@ -0,0 +1,17 @@ +export function IconPlus({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/advanced/components/icons/IconShopping.tsx b/src/advanced/components/icons/IconShopping.tsx new file mode 100644 index 000000000..8e8b75c41 --- /dev/null +++ b/src/advanced/components/icons/IconShopping.tsx @@ -0,0 +1,17 @@ +export function IconShopping({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/advanced/components/icons/IconTrash.tsx b/src/advanced/components/icons/IconTrash.tsx new file mode 100644 index 000000000..d866dd118 --- /dev/null +++ b/src/advanced/components/icons/IconTrash.tsx @@ -0,0 +1,17 @@ +export function IconTrash({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/advanced/components/icons/index.ts b/src/advanced/components/icons/index.ts new file mode 100644 index 000000000..d1fd9f7ee --- /dev/null +++ b/src/advanced/components/icons/index.ts @@ -0,0 +1,18 @@ +// TODO: SVG 아이콘 컴포넌트들 +// 구현할 아이콘: +// - CartIcon: 장바구니 아이콘 +// - AdminIcon: 관리자 아이콘 +// - PlusIcon: 플러스 아이콘 +// - MinusIcon: 마이너스 아이콘 +// - TrashIcon: 삭제 아이콘 +// - ChevronDownIcon: 아래 화살표 +// - ChevronUpIcon: 위 화살표 +// - CheckIcon: 체크 아이콘 + +// TODO: 구현 +export { IconCart } from "./IconCart"; +export { IconImage } from "./IconImage"; +export { IconClose } from "./IconClose"; +export { IconPlus } from "./IconPlus"; +export { IconTrash } from "./IconTrash"; +export { IconShopping } from "./IconShopping"; diff --git a/src/advanced/components/ui/Toast.tsx b/src/advanced/components/ui/Toast.tsx new file mode 100644 index 000000000..abd718b87 --- /dev/null +++ b/src/advanced/components/ui/Toast.tsx @@ -0,0 +1,27 @@ +import { Notification } from "../../types"; +import { IconClose } from "../icons"; +import { useToast } from "../../utils/hooks/useToast"; + +export function Toast({ notification }: { notification: Notification }) { + const { removeNotification } = useToast(); + + return ( +
+ {notification.message} + +
+ ); +} diff --git a/src/advanced/constants/index.ts b/src/advanced/constants/index.ts new file mode 100644 index 000000000..b4e5b718c --- /dev/null +++ b/src/advanced/constants/index.ts @@ -0,0 +1,51 @@ +import { Coupon, ProductWithUI } from "../types"; + +// 초기 데이터 +export const initialProducts: ProductWithUI[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: "최고급 품질의 프리미엄 상품입니다.", + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +export const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; diff --git a/src/advanced/context/ToastProvider.tsx b/src/advanced/context/ToastProvider.tsx new file mode 100644 index 000000000..e434a62a3 --- /dev/null +++ b/src/advanced/context/ToastProvider.tsx @@ -0,0 +1,43 @@ +import { createContext, useState, useCallback } from "react"; +import type { ReactNode } from "react"; +import { Notification } from "../types"; + +export interface ToastContextType { + notifications: Notification[]; + notify: (message: string, type?: "error" | "success" | "warning") => void; + removeNotification: (id: string) => void; +} + +export const ToastContext = createContext({ + notifications: [], + notify: () => {}, + removeNotification: () => {}, +}); + +export const ToastProvider = ({ children }: { children: ReactNode }) => { + const [notifications, setNotifications] = useState([]); + + const notify = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); + + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts new file mode 100644 index 000000000..1c6f0c728 --- /dev/null +++ b/src/advanced/hooks/useCart.ts @@ -0,0 +1,87 @@ +import { useCallback } from "react"; +import { CartItem, Product } from "../types"; +import { calculateRemainingStock } from "../models/calculateRemainingStock"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; +import { useToast } from "../utils/hooks/useToast"; + +export const useCart = () => { + const [cart, setCart] = useLocalStorage("cart", []); + const { notify } = useToast(); + + const addToCart = useCallback( + (product: Product) => { + const remainingStock = calculateRemainingStock(product, cart); + if (remainingStock <= 0) { + notify("재고가 부족합니다!", "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) { + notify(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + notify("장바구니에 담았습니다", "success"); + }, + [cart, notify, setCart] + ); + + const removeFromCart = useCallback( + (productId: string) => { + setCart((prevCart) => + prevCart.filter((item) => item.product.id !== productId) + ); + }, + [setCart] + ); + + const updateQuantity = useCallback( + (product: Product, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(product.id); + return; + } + + const maxStock = product.stock; + if (newQuantity > maxStock) { + notify(`재고는 ${maxStock}개까지만 있습니다.`, "error"); + return; + } + + setCart((prevCart) => + prevCart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ) + ); + }, + [removeFromCart, notify, setCart] + ); + + return { + cart, + setCart, + addToCart, + removeFromCart, + updateQuantity, + }; +}; diff --git a/src/advanced/hooks/useCoupon.ts b/src/advanced/hooks/useCoupon.ts new file mode 100644 index 000000000..5405a6095 --- /dev/null +++ b/src/advanced/hooks/useCoupon.ts @@ -0,0 +1,70 @@ +import { useCallback, useState } from "react"; +import { CartItem, Coupon } from "../types"; +import { initialCoupons } from "../constants"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; +import { calculateCartTotal } from "../models/calculateCartTotal"; +import { useToast } from "../utils/hooks/useToast"; + +export const useCoupon = () => { + const [selectedCoupon, setSelectedCoupon] = useState(null); + const [coupons, setCoupons] = useLocalStorage( + "coupons", + initialCoupons + ); + const { notify } = useToast(); + + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + notify("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + notify("쿠폰이 추가되었습니다.", "success"); + }, + [coupons, notify, setCoupons] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + notify("쿠폰이 삭제되었습니다.", "success"); + }, + [selectedCoupon, notify, setCoupons] + ); + + const applyCoupon = useCallback( + (cart: CartItem[], coupon: Coupon) => { + const currentTotal = calculateCartTotal( + cart, + selectedCoupon + ).totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + notify( + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", + "error" + ); + return; + } + + setSelectedCoupon(coupon); + notify("쿠폰이 적용되었습니다.", "success"); + }, + [selectedCoupon, notify] + ); + + return { + coupons, + setCoupons, + selectedCoupon, + setSelectedCoupon, + addCoupon, + deleteCoupon, + applyCoupon, + }; +}; diff --git a/src/advanced/hooks/useProduct.ts b/src/advanced/hooks/useProduct.ts new file mode 100644 index 000000000..df52c9fb1 --- /dev/null +++ b/src/advanced/hooks/useProduct.ts @@ -0,0 +1,74 @@ +import { useCallback, useState } from "react"; +import { ProductWithUI } from "../types"; +import { initialProducts } from "../constants"; +import { useDebounce } from "../utils/hooks/useDebounce"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; +import { useToast } from "../utils/hooks/useToast"; + +export const useProduct = () => { + const [products, setProducts] = useLocalStorage( + "products", + initialProducts + ); + const { notify } = useToast(); + + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + notify("상품이 추가되었습니다.", "success"); + }, + [notify, setProducts] + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => + product.id === productId ? { ...product, ...updates } : product + ) + ); + notify("상품이 수정되었습니다.", "success"); + }, + [notify, setProducts] + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + notify("상품이 삭제되었습니다.", "success"); + }, + [notify, setProducts] + ); + + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + + const filteredProducts = debouncedSearchTerm + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) + ) + : products; + + return { + products, + setProducts, + addProduct, + updateProduct, + deleteProduct, + searchTerm, + setSearchTerm, + debouncedSearchTerm, + filteredProducts, + }; +}; diff --git a/src/advanced/main.tsx b/src/advanced/main.tsx index e63eef4a8..a384687f3 100644 --- a/src/advanced/main.tsx +++ b/src/advanced/main.tsx @@ -1,9 +1,12 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import { ToastProvider } from "./context/ToastProvider.tsx"; -ReactDOM.createRoot(document.getElementById('root')!).render( +ReactDOM.createRoot(document.getElementById("root")!).render( - - , -) + + + + +); diff --git a/src/advanced/models/calculateCartTotal.ts b/src/advanced/models/calculateCartTotal.ts new file mode 100644 index 000000000..06c3af486 --- /dev/null +++ b/src/advanced/models/calculateCartTotal.ts @@ -0,0 +1,37 @@ +import { CartItem, Coupon } from "../types"; +import { calculateItemTotal } from "./calculateItemTotal"; + +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: 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(cart, 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), + }; +}; diff --git a/src/advanced/models/calculateItemTotal.ts b/src/advanced/models/calculateItemTotal.ts new file mode 100644 index 000000000..9039c8e1b --- /dev/null +++ b/src/advanced/models/calculateItemTotal.ts @@ -0,0 +1,13 @@ +import { CartItem } from "../types"; +import { getMaxApplicableDiscount } from "./getMaxApplicableDiscount"; + +export const calculateItemTotal = ( + cart: CartItem[], + item: CartItem +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(cart, item); + + return Math.round(price * quantity * (1 - discount)); +}; diff --git a/src/advanced/models/calculateRemainingStock.ts b/src/advanced/models/calculateRemainingStock.ts new file mode 100644 index 000000000..62048fb68 --- /dev/null +++ b/src/advanced/models/calculateRemainingStock.ts @@ -0,0 +1,11 @@ +import { CartItem, Product } from "../types"; + +export const calculateRemainingStock = ( + product: Product, + cart: CartItem[] +): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +}; diff --git a/src/advanced/models/getMaxApplicableDiscount.ts b/src/advanced/models/getMaxApplicableDiscount.ts new file mode 100644 index 000000000..8ab720e91 --- /dev/null +++ b/src/advanced/models/getMaxApplicableDiscount.ts @@ -0,0 +1,22 @@ +import { CartItem } from "../types"; + +export const getMaxApplicableDiscount = ( + cart: CartItem[], + 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; +}; diff --git a/src/advanced/models/isSoldOut.ts b/src/advanced/models/isSoldOut.ts new file mode 100644 index 000000000..5fb054691 --- /dev/null +++ b/src/advanced/models/isSoldOut.ts @@ -0,0 +1,6 @@ +import { CartItem, Product } from "../types"; +import { calculateRemainingStock } from "./calculateRemainingStock"; + +export const isSoldOut = (product: Product, cart: CartItem[]): boolean => { + return calculateRemainingStock(product, cart) <= 0; +}; diff --git a/src/advanced/models/validateCoupon.ts b/src/advanced/models/validateCoupon.ts new file mode 100644 index 000000000..7f9e9e3cf --- /dev/null +++ b/src/advanced/models/validateCoupon.ts @@ -0,0 +1,20 @@ +export const validateCoupon = ( + type: "percentage" | "amount", + value: number +) => { + if (type === "percentage") { + if (value > 100) { + return "DISCOUNT_RATE_OVER_100"; + } else if (value < 0) { + return "DISCOUNT_RATE_UNDER_0"; + } + } else { + if (value > 100000) { + return "DISCOUNT_AMOUNT_OVER_100000"; + } else if (value < 0) { + return "DISCOUNT_AMOUNT_UNDER_0"; + } + } + + return null; +}; diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx new file mode 100644 index 000000000..31fd94675 --- /dev/null +++ b/src/advanced/pages/AdminPage.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import { CouponDashboard } from "../components/CouponDashboard"; +import { ProductDashboard } from "../components/ProductDashboard"; + +export function AdminPage() { + const [activeTab, setActiveTab] = useState<"products" | "coupons">( + "products" + ); + + return ( +
+
+

관리자 대시보드

+

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

+
+
+ +
+ + {activeTab === "products" ? : } +
+ ); +} diff --git a/src/advanced/pages/CartPage.tsx b/src/advanced/pages/CartPage.tsx new file mode 100644 index 000000000..b07960437 --- /dev/null +++ b/src/advanced/pages/CartPage.tsx @@ -0,0 +1,217 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useMemo } from "react"; +import { useDebounce } from "../utils/hooks/useDebounce"; +import { CouponSelector } from "../components/CouponSelector"; +import { CartPayment } from "../components/CartPayment"; +import { calculateCartTotal } from "../models/calculateCartTotal"; +import { ProductCard } from "../components/ProductCard"; +import { calculateRemainingStock } from "../models/calculateRemainingStock"; +import { CartListItem } from "../components/CartListItem"; +import { IconShopping } from "../components/icons"; +import { calculateItemTotal } from "../models/calculateItemTotal"; +import { isSoldOut } from "../models/isSoldOut"; +import { formatPriceWon } from "../utils/formatPriceWon"; +import { useToast } from "../utils/hooks/useToast"; +import { + cartAtom, + productsAtom, + searchTermAtom, + couponsAtom, + selectedCouponAtom, + addToCartAtom, + removeFromCartAtom, + updateQuantityAtom, + applyCouponAtom, + purchaseAtom, +} from "../store"; + +export function CartPage() { + const products = useAtomValue(productsAtom); + const searchTerm = useAtomValue(searchTermAtom); + // 초기 렌더링에서는 즉시 반환, 이후 변경사항만 debounce + const debouncedSearchTerm = useDebounce(searchTerm, 500); + const cart = useAtomValue(cartAtom); + const coupons = useAtomValue(couponsAtom); + const selectedCoupon = useAtomValue(selectedCouponAtom); + const setSelectedCoupon = useSetAtom(selectedCouponAtom); + const addToCartAction = useSetAtom(addToCartAtom); + const removeFromCartAction = useSetAtom(removeFromCartAtom); + const updateQuantityAction = useSetAtom(updateQuantityAtom); + const applyCouponAction = useSetAtom(applyCouponAtom); + const purchase = useSetAtom(purchaseAtom); + const { notify } = useToast(); + + const handlePurchase = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + notify(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); + purchase(); + }, [notify, purchase]); + + const handleAddToCart = useCallback( + (product: (typeof products)[0]) => { + const remainingStock = calculateRemainingStock(product, cart); + if (remainingStock <= 0) { + notify("재고가 부족합니다!", "error"); + return; + } + + const existingItem = cart.find((item) => item.product.id === product.id); + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + if (newQuantity > product.stock) { + notify(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return; + } + } + + addToCartAction(product); + notify("장바구니에 담았습니다", "success"); + }, + [cart, addToCartAction, notify] + ); + + const handleRemoveFromCart = useCallback( + (productId: string) => { + removeFromCartAction(productId); + }, + [removeFromCartAction] + ); + + const handleUpdateQuantity = useCallback( + (product: (typeof products)[0], newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCartAction(product.id); + return; + } + + const maxStock = product.stock; + if (newQuantity > maxStock) { + notify(`재고는 ${maxStock}개까지만 있습니다.`, "error"); + return; + } + + updateQuantityAction(product, newQuantity); + }, + [updateQuantityAction, removeFromCartAction, notify] + ); + + const handleApplyCoupon = useCallback( + (coupon: (typeof coupons)[0]) => { + const currentTotal = calculateCartTotal( + cart, + selectedCoupon + ).totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + notify( + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", + "error" + ); + return; + } + + applyCouponAction(coupon); + notify("쿠폰이 적용되었습니다.", "success"); + }, + [cart, selectedCoupon, applyCouponAction, notify] + ); + // 컴포넌트 레벨에서 필터링 처리 + const filteredProducts = useMemo(() => { + if (!debouncedSearchTerm) return products; + + return products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) + ); + }, [products, debouncedSearchTerm]); + + const formatPrice = (product: (typeof products)[0]) => { + return isSoldOut(product, cart) + ? "SOLD OUT" + : formatPriceWon(product.price); + }; + + return ( +
+
+
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+
+ +
+
+
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => ( + + ))} +
+ )} +
+ + {cart.length > 0 && ( + <> + + + + )} +
+
+
+ ); +} diff --git a/src/advanced/store/cart.ts b/src/advanced/store/cart.ts new file mode 100644 index 000000000..6fc37ffbd --- /dev/null +++ b/src/advanced/store/cart.ts @@ -0,0 +1,85 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; +import { CartItem, ProductWithUI } from "../types"; +import { calculateRemainingStock } from "../models/calculateRemainingStock"; + +// 기본 상태 atoms +export const cartAtom = atomWithStorage("cart", []); + +// Derived atoms +export const totalItemCountAtom = atom((get) => { + const cart = get(cartAtom); + return cart.reduce((sum, item) => sum + item.quantity, 0); +}); + +// Actions +export const addToCartAtom = atom(null, (get, set, product: ProductWithUI) => { + const cart = get(cartAtom); + const remainingStock = calculateRemainingStock(product, cart); + + if (remainingStock <= 0) { + // Toast는 컴포넌트에서 처리 + return; + } + + const existingItem = cart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + // Toast는 컴포넌트에서 처리 + return; + } + + set( + cartAtom, + cart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ) + ); + } else { + set(cartAtom, [...cart, { product, quantity: 1 }]); + } +}); + +export const removeFromCartAtom = atom(null, (get, set, productId: string) => { + const cart = get(cartAtom); + set( + cartAtom, + cart.filter((item) => item.product.id !== productId) + ); +}); + +export const updateQuantityAtom = atom( + null, + (get, set, product: ProductWithUI, newQuantity: number) => { + const cart = get(cartAtom); + + if (newQuantity <= 0) { + set( + cartAtom, + cart.filter((item) => item.product.id !== product.id) + ); + return; + } + + const maxStock = product.stock; + if (newQuantity > maxStock) { + // Toast는 컴포넌트에서 처리 + return; + } + + set( + cartAtom, + cart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ) + ); + } +); + diff --git a/src/advanced/store/coupon.ts b/src/advanced/store/coupon.ts new file mode 100644 index 000000000..0f249845f --- /dev/null +++ b/src/advanced/store/coupon.ts @@ -0,0 +1,47 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; +import { Coupon } from "../types"; +import { initialCoupons } from "../constants"; +import { calculateCartTotal } from "../models/calculateCartTotal"; +import { cartAtom } from "./cart"; + +// 기본 상태 atoms +export const couponsAtom = atomWithStorage("coupons", initialCoupons); +export const selectedCouponAtom = atom(null); + +// Actions +export const addCouponAtom = atom(null, (get, set, newCoupon: Coupon) => { + const coupons = get(couponsAtom); + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + // Toast는 컴포넌트에서 처리 + return; + } + set(couponsAtom, [...coupons, newCoupon]); +}); + +export const deleteCouponAtom = atom(null, (get, set, couponCode: string) => { + const coupons = get(couponsAtom); + const selectedCoupon = get(selectedCouponAtom); + set(couponsAtom, coupons.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + set(selectedCouponAtom, null); + } +}); + +export const applyCouponAtom = atom(null, (get, set, coupon: Coupon) => { + const cart = get(cartAtom); + const selectedCoupon = get(selectedCouponAtom); + const currentTotal = calculateCartTotal( + cart, + selectedCoupon + ).totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + // Toast는 컴포넌트에서 처리 + return; + } + + set(selectedCouponAtom, coupon); +}); + diff --git a/src/advanced/store/index.ts b/src/advanced/store/index.ts new file mode 100644 index 000000000..288a94e5d --- /dev/null +++ b/src/advanced/store/index.ts @@ -0,0 +1,40 @@ +import { atom } from "jotai"; + +// 공통 상태 atoms +export const searchTermAtom = atom(""); +export const isAdminAtom = atom(false); + +// Cart atoms +export { + cartAtom, + totalItemCountAtom, + addToCartAtom, + removeFromCartAtom, + updateQuantityAtom, +} from "./cart"; + +// Product atoms +export { + productsAtom, + addProductAtom, + updateProductAtom, + deleteProductAtom, +} from "./product"; + +// Coupon atoms +export { + couponsAtom, + selectedCouponAtom, + addCouponAtom, + deleteCouponAtom, + applyCouponAtom, +} from "./coupon"; + +// Purchase action (cart와 coupon을 함께 사용) +import { cartAtom } from "./cart"; +import { selectedCouponAtom } from "./coupon"; + +export const purchaseAtom = atom(null, (_get, set) => { + set(cartAtom, []); + set(selectedCouponAtom, null); +}); diff --git a/src/advanced/store/product.ts b/src/advanced/store/product.ts new file mode 100644 index 000000000..f817617e9 --- /dev/null +++ b/src/advanced/store/product.ts @@ -0,0 +1,44 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; +import { ProductWithUI } from "../types"; +import { initialProducts } from "../constants"; + +// 기본 상태 atoms +export const productsAtom = atomWithStorage( + "products", + initialProducts +); + +// Actions +export const addProductAtom = atom( + null, + (get, set, newProduct: Omit) => { + const products = get(productsAtom); + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + set(productsAtom, [...products, product]); + } +); + +export const updateProductAtom = atom( + null, + (get, set, productId: string, updates: Partial) => { + const products = get(productsAtom); + set( + productsAtom, + products.map((product) => + product.id === productId ? { ...product, ...updates } : product + ) + ); + } +); + +export const deleteProductAtom = atom(null, (get, set, productId: string) => { + const products = get(productsAtom); + set( + productsAtom, + products.filter((p) => p.id !== productId) + ); +}); diff --git a/src/advanced/types/index.ts b/src/advanced/types/index.ts new file mode 100644 index 000000000..f788fc77a --- /dev/null +++ b/src/advanced/types/index.ts @@ -0,0 +1,43 @@ +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface Discount { + quantity: number; + rate: number; +} + +export interface CartItem { + product: Product; + quantity: number; +} + +export interface Coupon { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +} + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export interface ProductForm { + name: string; + price: number; + stock: number; + description: string; + discounts: Array<{ quantity: number; rate: number }>; +} + +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} diff --git a/src/advanced/utils/formatDiscount.ts b/src/advanced/utils/formatDiscount.ts new file mode 100644 index 000000000..01c81f7ed --- /dev/null +++ b/src/advanced/utils/formatDiscount.ts @@ -0,0 +1,3 @@ +export const formatDiscount = (discount: number): string => { + return `${discount.toLocaleString()}%`; +}; diff --git a/src/advanced/utils/formatPriceKRW.ts b/src/advanced/utils/formatPriceKRW.ts new file mode 100644 index 000000000..76fae64b8 --- /dev/null +++ b/src/advanced/utils/formatPriceKRW.ts @@ -0,0 +1,3 @@ +export const formatPriceKRW = (price: number): string => { + return `₩${price.toLocaleString()}`; +}; diff --git a/src/advanced/utils/formatPriceWon.ts b/src/advanced/utils/formatPriceWon.ts new file mode 100644 index 000000000..ccfb3378b --- /dev/null +++ b/src/advanced/utils/formatPriceWon.ts @@ -0,0 +1,3 @@ +export const formatPriceWon = (price: number): string => { + return `${price.toLocaleString()}원`; +}; diff --git a/src/advanced/utils/hooks/useDebounce.ts b/src/advanced/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..cf37215f1 --- /dev/null +++ b/src/advanced/utils/hooks/useDebounce.ts @@ -0,0 +1,22 @@ +import { useState, useEffect } from "react"; + +// 디바운스 Hook +// 1. 값이 변경되어도 지정된 시간 동안 대기 +// 2. 대기 시간 동안 값이 다시 변경되면 타이머 리셋 +// 3. 최종적으로 안정된 값만 반환 +// +// 사용 예시: 검색어 입력 디바운싱 + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} \ No newline at end of file diff --git a/src/advanced/utils/hooks/useLocalStorage.ts b/src/advanced/utils/hooks/useLocalStorage.ts new file mode 100644 index 000000000..7a19db02c --- /dev/null +++ b/src/advanced/utils/hooks/useLocalStorage.ts @@ -0,0 +1,69 @@ +import { useState, useEffect, useCallback } from "react"; + +// LocalStorage Hook +// 1. localStorage와 React state 동기화 +// 2. 초기값 로드 시 에러 처리 +// 3. 저장 시 JSON 직렬화/역직렬화 +// 4. 빈 배열이나 undefined는 삭제 +// +// 반환값: [저장된 값, 값 설정 함수] + +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + // 초기값 로드 + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + if (item) { + return JSON.parse(item); + } + return initialValue; + } catch (error) { + console.error(`Error loading localStorage key "${key}":`, error); + return initialValue; + } + }); + + // 값 설정 함수 + const setValue = useCallback( + (value: T | ((val: T) => T)) => { + try { + // 함수형 업데이트 지원 + const valueToStore = + value instanceof Function ? value(storedValue) : value; + + setStoredValue(valueToStore); + + // localStorage에 저장 + if (valueToStore === undefined || valueToStore === null) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } + } catch (error) { + console.error(`Error setting localStorage key "${key}":`, error); + } + }, + [key, storedValue] + ); + + // localStorage 변경 감지 (다른 탭에서 변경된 경우 동기화) + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === key && e.newValue) { + try { + setStoredValue(JSON.parse(e.newValue)); + } catch (error) { + console.error(`Error parsing localStorage key "${key}":`, error); + } + } + }; + + window.addEventListener("storage", handleStorageChange); + return () => window.removeEventListener("storage", handleStorageChange); + }, [key]); + + return [storedValue, setValue]; +} diff --git a/src/advanced/utils/hooks/useToast.ts b/src/advanced/utils/hooks/useToast.ts new file mode 100644 index 000000000..a7c18d4cd --- /dev/null +++ b/src/advanced/utils/hooks/useToast.ts @@ -0,0 +1,6 @@ +import { useContext } from "react"; +import { ToastContext, ToastContextType } from "../../context/ToastProvider"; + +export const useToast = (): ToastContextType => { + return useContext(ToastContext); +}; diff --git a/src/advanced/utils/isValidNumber.ts b/src/advanced/utils/isValidNumber.ts new file mode 100644 index 000000000..d343c4644 --- /dev/null +++ b/src/advanced/utils/isValidNumber.ts @@ -0,0 +1,3 @@ +export const isValidNumber = (value: string): boolean => { + return value === "" || /^\d+$/.test(value); +}; diff --git a/src/advanced/utils/toNumber.ts b/src/advanced/utils/toNumber.ts new file mode 100644 index 000000000..e92ee0c36 --- /dev/null +++ b/src/advanced/utils/toNumber.ts @@ -0,0 +1,3 @@ +export const toNumber = (value: string): number => { + return value === "" ? 0 : parseInt(value); +}; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..203450114 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,1124 +1,110 @@ -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, useCallback } from "react"; +import { Header } from "./components/Header"; +import { AdminPage } from "./pages/AdminPage"; +import { CartPage } from "./pages/CartPage"; +import { Toast } from "./components/ui/Toast"; +import { useCart } from "./hooks/useCart"; +import { useProduct } from "./hooks/useProduct"; +import { useCoupon } from "./hooks/useCoupon"; +import { CartCounter } from "./components/CartCounter"; +import { ProductSearchBar } from "./components/ProductSearchBar"; +import { Coupon } from "../types"; +import { useToast } from "./utils/hooks/useToast"; const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { + const { notifications, notify } = useToast(); + + const { cart, setCart, addToCart, removeFromCart, updateQuantity } = + useCart(); + + const { + products, + addProduct, + updateProduct, + deleteProduct, + searchTerm, + setSearchTerm, + debouncedSearchTerm, + filteredProducts, + } = useProduct(); + + const { + coupons, + selectedCoupon, + setSelectedCoupon, + addCoupon, + deleteCoupon, + applyCoupon, + } = useCoupon(); + + const totalItemCount = cart.reduce((sum, item) => sum + item.quantity, 0); + + const handlePurchase = useCallback(() => { const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + notify(`주문이 완료되었습니다. 주문번호: ${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; + }, [notify, setCart, setSelectedCoupon]); return (
{notifications.length > 0 && (
- {notifications.map(notif => ( -
- {notif.message} - -
+ {notifications.map((notification) => ( + ))}
)} -
-
-
-
-

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 && ( + + )} + +
{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()}원 -
-
- - - -
-

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

-
-
- - )} -
-
-
+ applyCoupon(cart, coupon)} + onPurchase={handlePurchase} + removeFromCart={removeFromCart} + updateQuantity={updateQuantity} + /> )}
); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/__tests__/origin.test.tsx b/src/basic/__tests__/origin.test.tsx index 3f5c3d55e..fe2295133 100644 --- a/src/basic/__tests__/origin.test.tsx +++ b/src/basic/__tests__/origin.test.tsx @@ -1,528 +1,576 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; -import { vi } from 'vitest'; -import App from '../App'; -import '../../setupTests'; +import { + render, + screen, + fireEvent, + within, + waitFor, +} from "@testing-library/react"; +import { vi } from "vitest"; +import App from "../App"; +import "../../setupTests"; +import { ToastProvider } from "../context/ToastProvider"; -describe('쇼핑몰 앱 통합 테스트', () => { +const AppWithToast = () => { + return ( + + + + ); +}; + +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 () => { - render(); - + 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('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { - render(); - + 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('쿠폰을 선택하고 적용할 수 있다', () => { - render(); - + 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 () => { - render(); - + 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('주문을 완료할 수 있다', () => { - render(); - + 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('장바구니에서 상품을 삭제할 수 있다', () => { - render(); - + 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 () => { - render(); - + 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(); + expect( + screen.getByText(/재고는.*개까지만 있습니다/) + ).toBeInTheDocument(); }); }); - test('장바구니에서 수량을 감소시킬 수 있다', () => { - render(); - + 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 () => { - render(); - + 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(); + 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에 저장된다', () => { - render(); - + 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('페이지 새로고침 후에도 데이터가 유지된다', () => { - const { unmount } = render(); - + 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(); - + 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 () => { - render(); - + 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('장바구니 아이템 개수가 헤더에 표시된다', () => { - render(); - + 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 () => { - render(); - + 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 () => { - render(); - + 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/basic/components/CartCounter.tsx b/src/basic/components/CartCounter.tsx new file mode 100644 index 000000000..60dd98b06 --- /dev/null +++ b/src/basic/components/CartCounter.tsx @@ -0,0 +1,14 @@ +import { IconCart } from "./icons"; + +export function CartCounter({ totalItemCount }: { totalItemCount: number }) { + return ( +
+ + {totalItemCount > 0 && ( + + {totalItemCount} + + )} +
+ ); +} diff --git a/src/basic/components/CartListItem.tsx b/src/basic/components/CartListItem.tsx new file mode 100644 index 000000000..f31251f95 --- /dev/null +++ b/src/basic/components/CartListItem.tsx @@ -0,0 +1,67 @@ +import { CartItem, Product } from "../types"; +import { formatDiscount } from "../utils/formatDiscount"; +import { formatPriceWon } from "../utils/formatPriceWon"; +import { IconClose } from "./icons"; + +export function CartListItem({ + item, + itemTotal, + onRemove, + onChangeQuantity, +}: { + item: CartItem; + itemTotal: number; + onRemove: (productId: string) => void; + onChangeQuantity: (product: Product, newQuantity: number) => void; +}) { + 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 && ( + + -{formatDiscount(discountRate)} + + )} +

+ {formatPriceWon(itemTotal)} +

+
+
+
+ ); +} diff --git a/src/basic/components/CartPayment.tsx b/src/basic/components/CartPayment.tsx new file mode 100644 index 000000000..35b2a1aa4 --- /dev/null +++ b/src/basic/components/CartPayment.tsx @@ -0,0 +1,54 @@ +import { formatPriceWon } from "../utils/formatPriceWon"; + +export function CartPayment({ + totals, + onPurchase, +}: { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + onPurchase: () => void; +}) { + return ( +
+

결제 정보

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

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

+
+
+ ); +} diff --git a/src/basic/components/CouponCard.tsx b/src/basic/components/CouponCard.tsx new file mode 100644 index 000000000..5e66549df --- /dev/null +++ b/src/basic/components/CouponCard.tsx @@ -0,0 +1,34 @@ +import { IconTrash } from "./icons"; +import { Coupon } from "../types"; + +export function CouponCard({ + coupon, + onDelete, +}: { + coupon: Coupon; + onDelete: (code: string) => void; +}) { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +} diff --git a/src/basic/components/CouponDashboard.tsx b/src/basic/components/CouponDashboard.tsx new file mode 100644 index 000000000..1c0cdf7d6 --- /dev/null +++ b/src/basic/components/CouponDashboard.tsx @@ -0,0 +1,72 @@ +import { IconPlus } from "./icons"; +import { Coupon } from "../types"; +import { CouponCard } from "./CouponCard"; +import { CouponForm } from "./CouponForm"; +import { useState } from "react"; + +const getInitialCouponForm = (): Coupon => { + return { + name: "", + code: "", + discountType: "amount", + discountValue: 0, + }; +}; + +export function CouponDashboard({ + coupons, + addCoupon, + deleteCoupon, +}: { + coupons: Coupon[]; + addCoupon: (coupon: Coupon) => void; + deleteCoupon: (code: string) => void; +}) { + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState(getInitialCouponForm()); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm(getInitialCouponForm()); + setShowCouponForm(false); + }; + + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( + + ))} + +
+ +
+
+ + {showCouponForm && ( + + )} +
+
+ ); +} diff --git a/src/basic/components/CouponForm.tsx b/src/basic/components/CouponForm.tsx new file mode 100644 index 000000000..a7ec08441 --- /dev/null +++ b/src/basic/components/CouponForm.tsx @@ -0,0 +1,141 @@ +import { validateCoupon } from "../models/validateCoupon"; +import { Coupon } from "../types"; +import { isValidNumber } from "../utils/isValidNumber"; +import { toNumber } from "../utils/toNumber"; +import { useToast } from "../utils/hooks/useToast"; + +export function CouponForm({ + couponForm, + onChange, + onCancel, + onSubmit, +}: { + onSubmit: (e: React.FormEvent) => void; + couponForm: Coupon; + onChange: (couponForm: Coupon) => void; + onCancel: (show: boolean) => void; +}) { + const { notify } = useToast(); + const handleDiscountBlur = (e: React.FocusEvent) => { + const value = toNumber(e.target.value); + const error = validateCoupon(couponForm.discountType, value); + switch (error) { + case "DISCOUNT_RATE_OVER_100": + notify("할인율은 100%를 초과할 수 없습니다", "error"); + onChange({ ...couponForm, discountValue: 100 }); + return; + case "DISCOUNT_RATE_UNDER_0": + notify("할인율은 0% 이하일 수 없습니다", "error"); + onChange({ ...couponForm, discountValue: 0 }); + return; + case "DISCOUNT_AMOUNT_OVER_100000": + notify("할인 금액은 100,000원을 초과할 수 없습니다", "error"); + onChange({ ...couponForm, discountValue: 100000 }); + return; + case "DISCOUNT_AMOUNT_UNDER_0": + notify("할인 금액은 0원 이하일 수 없습니다", "error"); + onChange({ ...couponForm, discountValue: 0 }); + return; + } + }; + + return ( +
+
+

새 쿠폰 생성

+
+
+ + + onChange({ ...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 + /> +
+
+ + + onChange({ + ...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 (isValidNumber(value)) { + onChange({ + ...couponForm, + discountValue: toNumber(value), + }); + } + }} + onBlur={handleDiscountBlur} + 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 + /> +
+
+
+ + +
+
+
+ ); +} diff --git a/src/basic/components/CouponSelector.tsx b/src/basic/components/CouponSelector.tsx new file mode 100644 index 000000000..ab96b998b --- /dev/null +++ b/src/basic/components/CouponSelector.tsx @@ -0,0 +1,50 @@ +import { Coupon } from "../types"; +import { formatDiscount } from "../utils/formatDiscount"; +import { formatPriceWon } from "../utils/formatPriceWon"; + +export function CouponSelector({ + coupons, + selectedCoupon, + setSelectedCoupon, + applyCoupon, +}: { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + applyCoupon: (coupon: Coupon) => void; +}) { + const handleChange = (e: React.ChangeEvent) => { + const coupon = coupons.find((c) => c.code === e.target.value); + if (coupon) applyCoupon(coupon); + else setSelectedCoupon(null); + }; + + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/basic/components/Header.tsx b/src/basic/components/Header.tsx new file mode 100644 index 000000000..dffbb4e9e --- /dev/null +++ b/src/basic/components/Header.tsx @@ -0,0 +1,14 @@ +export function Header({ children }: { children: React.ReactNode }) { + return ( +
+
+
+
+

SHOP

+ {children} +
+
+
+
+ ); +} diff --git a/src/basic/components/ProductCard.tsx b/src/basic/components/ProductCard.tsx new file mode 100644 index 000000000..df3ca440e --- /dev/null +++ b/src/basic/components/ProductCard.tsx @@ -0,0 +1,81 @@ +import { ProductWithUI } from "../types"; +import { IconImage } from "./icons"; + +export function ProductCard({ + formattedPrice, + product, + remainingStock, + onAddToCart, +}: { + formattedPrice: string; + product: ProductWithUI; + remainingStock: number; + onAddToCart: (product: ProductWithUI) => void; +}) { + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

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

{formattedPrice}

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

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

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

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

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

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +} diff --git a/src/basic/components/ProductDashboard.tsx b/src/basic/components/ProductDashboard.tsx new file mode 100644 index 000000000..8ecd3aec8 --- /dev/null +++ b/src/basic/components/ProductDashboard.tsx @@ -0,0 +1,98 @@ +import { ProductForm as ProductFormType, ProductWithUI } from "../types"; +import { ProductForm } from "./ProductForm"; +import { ProductTable } from "./ProductTable"; +import { useState } from "react"; + +export const getInitialProductForm = (): ProductFormType => ({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], +}); + +export function ProductDashboard({ + products, + deleteProduct, + updateProduct, + addProduct, +}: { + products: ProductWithUI[]; + deleteProduct: (productId: string) => void; + updateProduct: (productId: string, product: ProductFormType) => void; + addProduct: (product: ProductFormType) => void; +}) { + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState(getInitialProductForm()); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== "new") { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProduct({ ...productForm, discounts: productForm.discounts }); + } + setProductForm(getInitialProductForm()); + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleCreateProduct = () => { + setEditingProduct("new"); + setProductForm(getInitialProductForm()); + setShowProductForm(true); + }; + + const handleEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [], + }); + setShowProductForm(true); + }; + + const handleCancel = () => { + setEditingProduct(null); + setProductForm(getInitialProductForm()); + setShowProductForm(false); + }; + + return ( +
+
+
+

상품 목록

+ +
+
+ +
+ +
+ {showProductForm && ( + + )} +
+ ); +} diff --git a/src/basic/components/ProductForm.tsx b/src/basic/components/ProductForm.tsx new file mode 100644 index 000000000..6f9644c67 --- /dev/null +++ b/src/basic/components/ProductForm.tsx @@ -0,0 +1,202 @@ +import { IconClose } from "./icons"; +import { ProductForm as ProductFormType } from "../types"; +import { isValidNumber } from "../utils/isValidNumber"; +import { toNumber } from "../utils/toNumber"; +import { useToast } from "../utils/hooks/useToast"; + +export function ProductForm({ + onSubmit, + editingProduct, + productForm, + onChange, + onCancel, +}: { + onSubmit: (e: React.FormEvent) => void; + editingProduct: string | null; + productForm: ProductFormType; + onChange: (product: ProductFormType) => void; + onCancel: () => void; +}) { + const { notify } = useToast(); + const isNewProduct = editingProduct === "new"; + + return ( +
+
+

+ {isNewProduct ? "새 상품 추가" : "상품 수정"} +

+
+
+ + + onChange({ ...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 + /> +
+
+ + + onChange({ ...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 (isValidNumber(value)) { + onChange({ ...productForm, price: toNumber(value) }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + onChange({ ...productForm, price: 0 }); + } else if (toNumber(value) < 0) { + notify("가격은 0보다 커야 합니다", "error"); + onChange({ ...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 (isValidNumber(value)) { + onChange({ ...productForm, stock: toNumber(value) }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + onChange({ ...productForm, stock: 0 }); + } else if (toNumber(value) < 0) { + notify("재고는 0보다 커야 합니다", "error"); + onChange({ ...productForm, stock: 0 }); + } else if (toNumber(value) > 9999) { + notify("재고는 9999개를 초과할 수 없습니다", "error"); + onChange({ ...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 = toNumber(e.target.value); + onChange({ ...productForm, discounts: newDiscounts }); + }} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = toNumber(e.target.value) / 100; + onChange({ ...productForm, discounts: newDiscounts }); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/basic/components/ProductSearchBar.tsx b/src/basic/components/ProductSearchBar.tsx new file mode 100644 index 000000000..b36682899 --- /dev/null +++ b/src/basic/components/ProductSearchBar.tsx @@ -0,0 +1,19 @@ +export function ProductSearchBar({ + searchTerm, + onChange, +}: { + searchTerm: string; + onChange: (value: string) => void; +}) { + return ( +
+ onChange(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ ); +} diff --git a/src/basic/components/ProductTable.tsx b/src/basic/components/ProductTable.tsx new file mode 100644 index 000000000..b6bad54b9 --- /dev/null +++ b/src/basic/components/ProductTable.tsx @@ -0,0 +1,80 @@ +import { ProductWithUI } from "../types"; +import { formatPriceWon } from "../utils/formatPriceWon"; + +const getStockColor = (stock: number) => { + if (stock > 10) return "bg-green-100 text-green-800"; + if (stock > 0) return "bg-yellow-100 text-yellow-800"; + return "bg-red-100 text-red-800"; +}; + +export function ProductTable({ + products, + onEdit, + onDelete, +}: { + products: ProductWithUI[]; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +}) { + return ( + + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {formatPriceWon(product.price)} + + + {product.stock}개 + + + {product.description || "-"} + + + +
+ ); +} diff --git a/src/basic/components/icons/IconCart.tsx b/src/basic/components/icons/IconCart.tsx new file mode 100644 index 000000000..4667f1c9a --- /dev/null +++ b/src/basic/components/icons/IconCart.tsx @@ -0,0 +1,17 @@ +export function IconCart({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/basic/components/icons/IconClose.tsx b/src/basic/components/icons/IconClose.tsx new file mode 100644 index 000000000..e7d9858ef --- /dev/null +++ b/src/basic/components/icons/IconClose.tsx @@ -0,0 +1,17 @@ +export function IconClose({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/basic/components/icons/IconImage.tsx b/src/basic/components/icons/IconImage.tsx new file mode 100644 index 000000000..efa7a8869 --- /dev/null +++ b/src/basic/components/icons/IconImage.tsx @@ -0,0 +1,17 @@ +export function IconImage({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/basic/components/icons/IconPlus.tsx b/src/basic/components/icons/IconPlus.tsx new file mode 100644 index 000000000..16cc44f37 --- /dev/null +++ b/src/basic/components/icons/IconPlus.tsx @@ -0,0 +1,17 @@ +export function IconPlus({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/basic/components/icons/IconShopping.tsx b/src/basic/components/icons/IconShopping.tsx new file mode 100644 index 000000000..8e8b75c41 --- /dev/null +++ b/src/basic/components/icons/IconShopping.tsx @@ -0,0 +1,17 @@ +export function IconShopping({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/basic/components/icons/IconTrash.tsx b/src/basic/components/icons/IconTrash.tsx new file mode 100644 index 000000000..d866dd118 --- /dev/null +++ b/src/basic/components/icons/IconTrash.tsx @@ -0,0 +1,17 @@ +export function IconTrash({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/basic/components/icons/index.ts b/src/basic/components/icons/index.ts new file mode 100644 index 000000000..d1fd9f7ee --- /dev/null +++ b/src/basic/components/icons/index.ts @@ -0,0 +1,18 @@ +// TODO: SVG 아이콘 컴포넌트들 +// 구현할 아이콘: +// - CartIcon: 장바구니 아이콘 +// - AdminIcon: 관리자 아이콘 +// - PlusIcon: 플러스 아이콘 +// - MinusIcon: 마이너스 아이콘 +// - TrashIcon: 삭제 아이콘 +// - ChevronDownIcon: 아래 화살표 +// - ChevronUpIcon: 위 화살표 +// - CheckIcon: 체크 아이콘 + +// TODO: 구현 +export { IconCart } from "./IconCart"; +export { IconImage } from "./IconImage"; +export { IconClose } from "./IconClose"; +export { IconPlus } from "./IconPlus"; +export { IconTrash } from "./IconTrash"; +export { IconShopping } from "./IconShopping"; diff --git a/src/basic/components/ui/Toast.tsx b/src/basic/components/ui/Toast.tsx new file mode 100644 index 000000000..abd718b87 --- /dev/null +++ b/src/basic/components/ui/Toast.tsx @@ -0,0 +1,27 @@ +import { Notification } from "../../types"; +import { IconClose } from "../icons"; +import { useToast } from "../../utils/hooks/useToast"; + +export function Toast({ notification }: { notification: Notification }) { + const { removeNotification } = useToast(); + + return ( +
+ {notification.message} + +
+ ); +} diff --git a/src/basic/constants/index.ts b/src/basic/constants/index.ts new file mode 100644 index 000000000..b4e5b718c --- /dev/null +++ b/src/basic/constants/index.ts @@ -0,0 +1,51 @@ +import { Coupon, ProductWithUI } from "../types"; + +// 초기 데이터 +export const initialProducts: ProductWithUI[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: "최고급 품질의 프리미엄 상품입니다.", + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +export const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; diff --git a/src/basic/context/ToastProvider.tsx b/src/basic/context/ToastProvider.tsx new file mode 100644 index 000000000..e434a62a3 --- /dev/null +++ b/src/basic/context/ToastProvider.tsx @@ -0,0 +1,43 @@ +import { createContext, useState, useCallback } from "react"; +import type { ReactNode } from "react"; +import { Notification } from "../types"; + +export interface ToastContextType { + notifications: Notification[]; + notify: (message: string, type?: "error" | "success" | "warning") => void; + removeNotification: (id: string) => void; +} + +export const ToastContext = createContext({ + notifications: [], + notify: () => {}, + removeNotification: () => {}, +}); + +export const ToastProvider = ({ children }: { children: ReactNode }) => { + const [notifications, setNotifications] = useState([]); + + const notify = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); + + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..1c6f0c728 --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,87 @@ +import { useCallback } from "react"; +import { CartItem, Product } from "../types"; +import { calculateRemainingStock } from "../models/calculateRemainingStock"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; +import { useToast } from "../utils/hooks/useToast"; + +export const useCart = () => { + const [cart, setCart] = useLocalStorage("cart", []); + const { notify } = useToast(); + + const addToCart = useCallback( + (product: Product) => { + const remainingStock = calculateRemainingStock(product, cart); + if (remainingStock <= 0) { + notify("재고가 부족합니다!", "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) { + notify(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + notify("장바구니에 담았습니다", "success"); + }, + [cart, notify, setCart] + ); + + const removeFromCart = useCallback( + (productId: string) => { + setCart((prevCart) => + prevCart.filter((item) => item.product.id !== productId) + ); + }, + [setCart] + ); + + const updateQuantity = useCallback( + (product: Product, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(product.id); + return; + } + + const maxStock = product.stock; + if (newQuantity > maxStock) { + notify(`재고는 ${maxStock}개까지만 있습니다.`, "error"); + return; + } + + setCart((prevCart) => + prevCart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ) + ); + }, + [removeFromCart, notify, setCart] + ); + + return { + cart, + setCart, + addToCart, + removeFromCart, + updateQuantity, + }; +}; diff --git a/src/basic/hooks/useCoupon.ts b/src/basic/hooks/useCoupon.ts new file mode 100644 index 000000000..5405a6095 --- /dev/null +++ b/src/basic/hooks/useCoupon.ts @@ -0,0 +1,70 @@ +import { useCallback, useState } from "react"; +import { CartItem, Coupon } from "../types"; +import { initialCoupons } from "../constants"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; +import { calculateCartTotal } from "../models/calculateCartTotal"; +import { useToast } from "../utils/hooks/useToast"; + +export const useCoupon = () => { + const [selectedCoupon, setSelectedCoupon] = useState(null); + const [coupons, setCoupons] = useLocalStorage( + "coupons", + initialCoupons + ); + const { notify } = useToast(); + + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + notify("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + notify("쿠폰이 추가되었습니다.", "success"); + }, + [coupons, notify, setCoupons] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + notify("쿠폰이 삭제되었습니다.", "success"); + }, + [selectedCoupon, notify, setCoupons] + ); + + const applyCoupon = useCallback( + (cart: CartItem[], coupon: Coupon) => { + const currentTotal = calculateCartTotal( + cart, + selectedCoupon + ).totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + notify( + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", + "error" + ); + return; + } + + setSelectedCoupon(coupon); + notify("쿠폰이 적용되었습니다.", "success"); + }, + [selectedCoupon, notify] + ); + + return { + coupons, + setCoupons, + selectedCoupon, + setSelectedCoupon, + addCoupon, + deleteCoupon, + applyCoupon, + }; +}; diff --git a/src/basic/hooks/useProduct.ts b/src/basic/hooks/useProduct.ts new file mode 100644 index 000000000..df52c9fb1 --- /dev/null +++ b/src/basic/hooks/useProduct.ts @@ -0,0 +1,74 @@ +import { useCallback, useState } from "react"; +import { ProductWithUI } from "../types"; +import { initialProducts } from "../constants"; +import { useDebounce } from "../utils/hooks/useDebounce"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; +import { useToast } from "../utils/hooks/useToast"; + +export const useProduct = () => { + const [products, setProducts] = useLocalStorage( + "products", + initialProducts + ); + const { notify } = useToast(); + + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + notify("상품이 추가되었습니다.", "success"); + }, + [notify, setProducts] + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => + product.id === productId ? { ...product, ...updates } : product + ) + ); + notify("상품이 수정되었습니다.", "success"); + }, + [notify, setProducts] + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + notify("상품이 삭제되었습니다.", "success"); + }, + [notify, setProducts] + ); + + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + + const filteredProducts = debouncedSearchTerm + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) + ) + : products; + + return { + products, + setProducts, + addProduct, + updateProduct, + deleteProduct, + searchTerm, + setSearchTerm, + debouncedSearchTerm, + filteredProducts, + }; +}; diff --git a/src/basic/main.tsx b/src/basic/main.tsx index e63eef4a8..a384687f3 100644 --- a/src/basic/main.tsx +++ b/src/basic/main.tsx @@ -1,9 +1,12 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import { ToastProvider } from "./context/ToastProvider.tsx"; -ReactDOM.createRoot(document.getElementById('root')!).render( +ReactDOM.createRoot(document.getElementById("root")!).render( - - , -) + + + + +); diff --git a/src/basic/models/calculateCartTotal.ts b/src/basic/models/calculateCartTotal.ts new file mode 100644 index 000000000..06c3af486 --- /dev/null +++ b/src/basic/models/calculateCartTotal.ts @@ -0,0 +1,37 @@ +import { CartItem, Coupon } from "../types"; +import { calculateItemTotal } from "./calculateItemTotal"; + +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: 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(cart, 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), + }; +}; diff --git a/src/basic/models/calculateItemTotal.ts b/src/basic/models/calculateItemTotal.ts new file mode 100644 index 000000000..9039c8e1b --- /dev/null +++ b/src/basic/models/calculateItemTotal.ts @@ -0,0 +1,13 @@ +import { CartItem } from "../types"; +import { getMaxApplicableDiscount } from "./getMaxApplicableDiscount"; + +export const calculateItemTotal = ( + cart: CartItem[], + item: CartItem +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(cart, item); + + return Math.round(price * quantity * (1 - discount)); +}; diff --git a/src/basic/models/calculateRemainingStock.ts b/src/basic/models/calculateRemainingStock.ts new file mode 100644 index 000000000..62048fb68 --- /dev/null +++ b/src/basic/models/calculateRemainingStock.ts @@ -0,0 +1,11 @@ +import { CartItem, Product } from "../types"; + +export const calculateRemainingStock = ( + product: Product, + cart: CartItem[] +): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +}; diff --git a/src/basic/models/getMaxApplicableDiscount.ts b/src/basic/models/getMaxApplicableDiscount.ts new file mode 100644 index 000000000..8ab720e91 --- /dev/null +++ b/src/basic/models/getMaxApplicableDiscount.ts @@ -0,0 +1,22 @@ +import { CartItem } from "../types"; + +export const getMaxApplicableDiscount = ( + cart: CartItem[], + 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; +}; diff --git a/src/basic/models/isSoldOut.ts b/src/basic/models/isSoldOut.ts new file mode 100644 index 000000000..5fb054691 --- /dev/null +++ b/src/basic/models/isSoldOut.ts @@ -0,0 +1,6 @@ +import { CartItem, Product } from "../types"; +import { calculateRemainingStock } from "./calculateRemainingStock"; + +export const isSoldOut = (product: Product, cart: CartItem[]): boolean => { + return calculateRemainingStock(product, cart) <= 0; +}; diff --git a/src/basic/models/validateCoupon.ts b/src/basic/models/validateCoupon.ts new file mode 100644 index 000000000..7f9e9e3cf --- /dev/null +++ b/src/basic/models/validateCoupon.ts @@ -0,0 +1,20 @@ +export const validateCoupon = ( + type: "percentage" | "amount", + value: number +) => { + if (type === "percentage") { + if (value > 100) { + return "DISCOUNT_RATE_OVER_100"; + } else if (value < 0) { + return "DISCOUNT_RATE_UNDER_0"; + } + } else { + if (value > 100000) { + return "DISCOUNT_AMOUNT_OVER_100000"; + } else if (value < 0) { + return "DISCOUNT_AMOUNT_UNDER_0"; + } + } + + return null; +}; diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx new file mode 100644 index 000000000..bb05bb193 --- /dev/null +++ b/src/basic/pages/AdminPage.tsx @@ -0,0 +1,74 @@ +import { CouponDashboard } from "../components/CouponDashboard"; +import { ProductDashboard } from "../components/ProductDashboard"; +import { Coupon, ProductForm, ProductWithUI } from "../types"; +import { useState } from "react"; + +export function AdminPage({ + products, + coupons, + addCoupon, + deleteCoupon, + deleteProduct, + updateProduct, + addProduct, +}: { + products: ProductWithUI[]; + coupons: Coupon[]; + addCoupon: (coupon: Coupon) => void; + deleteCoupon: (code: string) => void; + deleteProduct: (productId: string) => void; + updateProduct: (productId: string, product: ProductForm) => void; + addProduct: (product: ProductForm) => void; +}) { + const [activeTab, setActiveTab] = useState<"products" | "coupons">( + "products" + ); + + return ( +
+
+

관리자 대시보드

+

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

+
+
+ +
+ + {activeTab === "products" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/basic/pages/CartPage.tsx b/src/basic/pages/CartPage.tsx new file mode 100644 index 000000000..c2b963ca7 --- /dev/null +++ b/src/basic/pages/CartPage.tsx @@ -0,0 +1,123 @@ +import { CartItem, Coupon, Product, ProductWithUI } from "../types"; +import { CouponSelector } from "../components/CouponSelector"; +import { CartPayment } from "../components/CartPayment"; +import { calculateCartTotal } from "../models/calculateCartTotal"; +import { ProductCard } from "../components/ProductCard"; +import { calculateRemainingStock } from "../models/calculateRemainingStock"; +import { CartListItem } from "../components/CartListItem"; +import { IconShopping } from "../components/icons"; +import { calculateItemTotal } from "../models/calculateItemTotal"; +import { isSoldOut } from "../models/isSoldOut"; +import { formatPriceWon } from "../utils/formatPriceWon"; + +export function CartPage({ + products, + filteredProducts, + debouncedSearchTerm, + addToCart, + cart, + coupons, + selectedCoupon, + setSelectedCoupon, + applyCoupon, + onPurchase, + removeFromCart, + updateQuantity, +}: { + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + addToCart: (product: ProductWithUI) => void; + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + applyCoupon: (coupon: Coupon) => void; + onPurchase: () => void; + removeFromCart: (productId: string) => void; + updateQuantity: (product: Product, newQuantity: number) => void; +}) { + const formatPrice = (product: ProductWithUI) => { + return isSoldOut(product, cart) + ? "SOLD OUT" + : formatPriceWon(product.price); + }; + + return ( +
+
+
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+
+ +
+
+
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => ( + + ))} +
+ )} +
+ + {cart.length > 0 && ( + <> + + + + )} +
+
+
+ ); +} diff --git a/src/basic/types/index.ts b/src/basic/types/index.ts new file mode 100644 index 000000000..f788fc77a --- /dev/null +++ b/src/basic/types/index.ts @@ -0,0 +1,43 @@ +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface Discount { + quantity: number; + rate: number; +} + +export interface CartItem { + product: Product; + quantity: number; +} + +export interface Coupon { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +} + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export interface ProductForm { + name: string; + price: number; + stock: number; + description: string; + discounts: Array<{ quantity: number; rate: number }>; +} + +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} diff --git a/src/basic/utils/formatDiscount.ts b/src/basic/utils/formatDiscount.ts new file mode 100644 index 000000000..01c81f7ed --- /dev/null +++ b/src/basic/utils/formatDiscount.ts @@ -0,0 +1,3 @@ +export const formatDiscount = (discount: number): string => { + return `${discount.toLocaleString()}%`; +}; diff --git a/src/basic/utils/formatPriceKRW.ts b/src/basic/utils/formatPriceKRW.ts new file mode 100644 index 000000000..76fae64b8 --- /dev/null +++ b/src/basic/utils/formatPriceKRW.ts @@ -0,0 +1,3 @@ +export const formatPriceKRW = (price: number): string => { + return `₩${price.toLocaleString()}`; +}; diff --git a/src/basic/utils/formatPriceWon.ts b/src/basic/utils/formatPriceWon.ts new file mode 100644 index 000000000..ccfb3378b --- /dev/null +++ b/src/basic/utils/formatPriceWon.ts @@ -0,0 +1,3 @@ +export const formatPriceWon = (price: number): string => { + return `${price.toLocaleString()}원`; +}; diff --git a/src/basic/utils/hooks/useDebounce.ts b/src/basic/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..cf37215f1 --- /dev/null +++ b/src/basic/utils/hooks/useDebounce.ts @@ -0,0 +1,22 @@ +import { useState, useEffect } from "react"; + +// 디바운스 Hook +// 1. 값이 변경되어도 지정된 시간 동안 대기 +// 2. 대기 시간 동안 값이 다시 변경되면 타이머 리셋 +// 3. 최종적으로 안정된 값만 반환 +// +// 사용 예시: 검색어 입력 디바운싱 + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} \ No newline at end of file diff --git a/src/basic/utils/hooks/useLocalStorage.ts b/src/basic/utils/hooks/useLocalStorage.ts new file mode 100644 index 000000000..7a19db02c --- /dev/null +++ b/src/basic/utils/hooks/useLocalStorage.ts @@ -0,0 +1,69 @@ +import { useState, useEffect, useCallback } from "react"; + +// LocalStorage Hook +// 1. localStorage와 React state 동기화 +// 2. 초기값 로드 시 에러 처리 +// 3. 저장 시 JSON 직렬화/역직렬화 +// 4. 빈 배열이나 undefined는 삭제 +// +// 반환값: [저장된 값, 값 설정 함수] + +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + // 초기값 로드 + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + if (item) { + return JSON.parse(item); + } + return initialValue; + } catch (error) { + console.error(`Error loading localStorage key "${key}":`, error); + return initialValue; + } + }); + + // 값 설정 함수 + const setValue = useCallback( + (value: T | ((val: T) => T)) => { + try { + // 함수형 업데이트 지원 + const valueToStore = + value instanceof Function ? value(storedValue) : value; + + setStoredValue(valueToStore); + + // localStorage에 저장 + if (valueToStore === undefined || valueToStore === null) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } + } catch (error) { + console.error(`Error setting localStorage key "${key}":`, error); + } + }, + [key, storedValue] + ); + + // localStorage 변경 감지 (다른 탭에서 변경된 경우 동기화) + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === key && e.newValue) { + try { + setStoredValue(JSON.parse(e.newValue)); + } catch (error) { + console.error(`Error parsing localStorage key "${key}":`, error); + } + } + }; + + window.addEventListener("storage", handleStorageChange); + return () => window.removeEventListener("storage", handleStorageChange); + }, [key]); + + return [storedValue, setValue]; +} diff --git a/src/basic/utils/hooks/useToast.ts b/src/basic/utils/hooks/useToast.ts new file mode 100644 index 000000000..a7c18d4cd --- /dev/null +++ b/src/basic/utils/hooks/useToast.ts @@ -0,0 +1,6 @@ +import { useContext } from "react"; +import { ToastContext, ToastContextType } from "../../context/ToastProvider"; + +export const useToast = (): ToastContextType => { + return useContext(ToastContext); +}; diff --git a/src/basic/utils/isValidNumber.ts b/src/basic/utils/isValidNumber.ts new file mode 100644 index 000000000..d343c4644 --- /dev/null +++ b/src/basic/utils/isValidNumber.ts @@ -0,0 +1,3 @@ +export const isValidNumber = (value: string): boolean => { + return value === "" || /^\d+$/.test(value); +}; diff --git a/src/basic/utils/toNumber.ts b/src/basic/utils/toNumber.ts new file mode 100644 index 000000000..e92ee0c36 --- /dev/null +++ b/src/basic/utils/toNumber.ts @@ -0,0 +1,3 @@ +export const toNumber = (value: string): number => { + return value === "" ? 0 : parseInt(value); +}; diff --git a/src/refactoring(hint)/models/cart.ts b/src/refactoring(hint)/models/cart.ts index 5c6810483..590feb7a9 100644 --- a/src/refactoring(hint)/models/cart.ts +++ b/src/refactoring(hint)/models/cart.ts @@ -15,4 +15,4 @@ // - 외부 상태에 의존하지 않음 // - 모든 필요한 데이터는 파라미터로 전달받음 -// TODO: 구현 \ No newline at end of file +// TODO: 구현 diff --git a/src/refactoring(hint)/utils/hooks/useDebounce.ts b/src/refactoring(hint)/utils/hooks/useDebounce.ts index 53c8a3746..ef397bac2 100644 --- a/src/refactoring(hint)/utils/hooks/useDebounce.ts +++ b/src/refactoring(hint)/utils/hooks/useDebounce.ts @@ -6,6 +6,7 @@ // // 사용 예시: 검색어 입력 디바운싱 -export function useDebounce(value: T, delay: number): T { +export function useDebounce(value: T, _delay: number): T { // TODO: 구현 -} \ No newline at end of file + return value; +} diff --git a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts index 5dc72c501..b71a37d6d 100644 --- a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts +++ b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts @@ -8,8 +8,9 @@ // 반환값: [저장된 값, 값 설정 함수] export function useLocalStorage( - key: string, + _key: string, initialValue: T ): [T, (value: T | ((val: T) => T)) => void] { // TODO: 구현 -} \ No newline at end of file + return [initialValue, () => {}]; +} diff --git a/vite.config.ts b/vite.config.ts index e6c4016bc..9ebfc36d8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,32 @@ -import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react-swc'; +import { defineConfig as defineTestConfig, mergeConfig } from "vitest/config"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; + +// 환경 변수 타입 선언 +declare const process: { + env: { + VITE_BASE_PATH?: string; + }; +}; + +const basePath = + (typeof process !== "undefined" && process.env?.VITE_BASE_PATH) || "/"; export default mergeConfig( defineConfig({ plugins: [react()], + build: { + rollupOptions: { + input: "./index.advanced.html", + }, + }, + base: basePath, }), defineTestConfig({ test: { globals: true, - environment: 'jsdom', - setupFiles: './src/setupTests.ts' + environment: "jsdom", + setupFiles: "./src/setupTests.ts", }, }) -) +);