diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..cc4e43ff6 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,463 +1,16 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState } from 'react'; +import CartPage from './components/CartPage'; +import AdminPage from './components/AdminPage' -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; - -const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); +function App() { const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )}
-
-

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" - /> -
- )} -
+

SHOP

- {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/basic/components/AdminPage.tsx b/src/basic/components/AdminPage.tsx new file mode 100644 index 000000000..7028a831d --- /dev/null +++ b/src/basic/components/AdminPage.tsx @@ -0,0 +1,524 @@ +import { useState, useCallback } from "react"; +import { useProducts } from "../hooks/useProducts"; +import { useCoupons } from "../hooks/useCoupons"; +import { Product } from "../../types"; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +const AdminPage = () => { + const { products, addProduct: addProductHook, updateProduct, deleteProduct } = useProducts(); + const { coupons, addCoupon, removeCoupon } = useCoupons(); + + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [showProductForm, setShowProductForm] = useState(false); + const [showCouponForm, setShowCouponForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [notifications, setNotifications] = useState([]); + + 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 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 formatPrice = (price: number): string => { + return `${price.toLocaleString()}원`; + }; + + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== 'new') { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProductHook({ + ...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); + }; + + + return ( +
+ {notifications.length > 0 && ( +
+ {notifications.map(notif => ( +
+ {notif.message} + +
+ ))} +
+ )} +
+

관리자 대시보드

+

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

+
+
+ +
+ + {activeTab === 'products' ? ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map(product => ( + + + + + + + + ))} + +
상품명가격재고설명작업
{product.name}{formatPrice(product.price)} + 10 ? 'bg-green-100 text-green-800' : + product.stock > 0 ? 'bg-yellow-100 text-yellow-800' : + 'bg-red-100 text-red-800' + }`}> + {product.stock}개 + + {(product as ProductWithUI).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) { + 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) { + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + 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 + /> +
+
+
+ + +
+
+
+ )} +
+
+ )} +
+ ); +}; + +export default AdminPage; diff --git a/src/basic/components/CartPage.tsx b/src/basic/components/CartPage.tsx new file mode 100644 index 000000000..3b86edf12 --- /dev/null +++ b/src/basic/components/CartPage.tsx @@ -0,0 +1,383 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useCart } from '../hooks/useCart'; +import { useProducts } from '../hooks/useProducts'; +import { useCoupons } from '../hooks/useCoupons'; +import { useDebounce } from '../utils/hooks/useDebounce'; +import { formatPrice } from '../utils/formatters'; + +interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +function CartPage() { + const { products } = useProducts(); + const { + cart, + selectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + calculateTotal, + getRemainingStock, + clearCart + } = useCart(); + const { coupons } = useCoupons(); + + const [notifications, setNotifications] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + const [totalItemCount, setTotalItemCount] = useState(0); + + 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); + }, []); + + useEffect(() => { + const count = cart.reduce((sum, item) => sum + item.quantity, 0); + setTotalItemCount(count); + }, [cart]); + + const handleAddToCart = useCallback((product: typeof products[0]) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + addNotification('재고가 부족합니다!', 'error'); + return; + } + + const item = cart.find(item => item.product.id === product.id); + if (item && item.quantity + 1 > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + return; + } + + addToCart(product); + addNotification('장바구니에 담았습니다', 'success'); + }, [cart, addToCart, getRemainingStock, addNotification]); + + const handleApplyCoupon = useCallback((coupon: typeof coupons[0]) => { + const currentTotal = calculateTotal().totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); + return; + } + + applyCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, [applyCoupon, calculateTotal, addNotification]); + + const handleCompleteOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + clearCart(); + }, [clearCart, addNotification]); + + const handleUpdateQuantity = useCallback((productId: string, newQuantity: number) => { + const product = products.find(p => p.id === productId); + if (!product) return; + + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + return; + } + + updateQuantity(productId, newQuantity); + }, [products, updateQuantity, addNotification]); + + const filteredProducts = debouncedSearchTerm + ? products.filter(product => + product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ) + : products; + + const totals = calculateTotal(); + + return ( + <> + {notifications.length > 0 && ( +
+ {notifications.map(notif => ( +
+ {notif.message} + +
+ ))} +
+ )} + + {/* 검색창 헤더 */} +
+
+
+ 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" + /> +
+
+ + + + {cart.length > 0 && ( + + {totalItemCount} + + )} +
+
+
+ +
+
+ {/* 상품 목록 */} +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map(product => { + const remainingStock = getRemainingStock(product); + + return ( +
+
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% + + )} +
+ +
+

{product.name}

+ {product.description && ( +

{product.description}

+ )} + +
+

+ {remainingStock <= 0 ? 'SOLD OUT' : formatPrice(product.price)} +

+ {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 itemCart = cart; // 전체 cart를 전달하기 위해 + const itemDiscount = item.product.discounts.reduce((max, d) => { + return item.quantity >= d.quantity && d.rate > max ? d.rate : max; + }, 0); + const hasBulk = cart.some(c => c.quantity >= 10); + const finalDiscount = hasBulk ? Math.min(itemDiscount + 0.05, 0.5) : itemDiscount; + const itemTotal = Math.round(item.product.price * item.quantity * (1 - finalDiscount)); + 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}% + )} +

+ {itemTotal.toLocaleString()}원 +

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

쿠폰 할인

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

결제 정보

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

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

+
+
+ + )} +
+
+
+ + ); +} + +export default CartPage; diff --git a/src/basic/constants/index.ts b/src/basic/constants/index.ts new file mode 100644 index 000000000..b4f7cc55c --- /dev/null +++ b/src/basic/constants/index.ts @@ -0,0 +1,59 @@ +import { Coupon, Product } from '../../types'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +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 + } +]; + +export type { ProductWithUI }; diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..079e9c4d5 --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,65 @@ +import { useState, useCallback } from 'react'; +import { CartItem, Coupon, Product } from '../../types'; +import { useLocalStorage } from '../utils/hooks/useLocalStorage'; +import { + addItemToCart, + removeItemFromCart, + updateCartItemQuantity, + calculateCartTotal, + getRemainingStock +} from '../models/cart'; + +/** + * 장바구니 관리 Hook + * 장바구니 상태 관리, 쿠폰 적용, 총액 계산 등 제공 + */ +export function useCart() { + const [cart, setCart] = useLocalStorage('cart', []); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + const addToCart = useCallback((product: Product) => { + setCart(prevCart => addItemToCart(prevCart, product)); + }, [setCart]); + + const removeFromCart = useCallback((productId: string) => { + setCart(prevCart => removeItemFromCart(prevCart, productId)); + }, [setCart]); + + const updateQuantity = useCallback((productId: string, newQuantity: number) => { + setCart(prevCart => updateCartItemQuantity(prevCart, productId, newQuantity)); + }, [setCart]); + + const applyCoupon = useCallback((coupon: Coupon) => { + setSelectedCoupon(coupon); + }, []); + + const removeCouponSelection = useCallback(() => { + setSelectedCoupon(null); + }, []); + + const calculateTotal = useCallback(() => { + return calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + const getProductRemainingStock = useCallback((product: Product) => { + return getRemainingStock(product, cart); + }, [cart]); + + const clearCart = useCallback(() => { + setCart([]); + setSelectedCoupon(null); + }, [setCart]); + + return { + cart, + selectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + removeCouponSelection, + calculateTotal, + getRemainingStock: getProductRemainingStock, + clearCart + }; +} diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 000000000..fdfab7eec --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import { Coupon } from '../../types'; +import { initialCoupons } from '../constants'; +import { useLocalStorage } from '../utils/hooks/useLocalStorage'; + +/** + * 쿠폰 관리 Hook + * 쿠폰 목록 상태 관리 및 추가/삭제 기능 제공 + */ +export function useCoupons() { + const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); + + const addCoupon = useCallback((newCoupon: Coupon) => { + setCoupons(prev => [...prev, newCoupon]); + return newCoupon; + }, [setCoupons]); + + const removeCoupon = useCallback((couponCode: string) => { + setCoupons(prev => prev.filter(c => c.code !== couponCode)); + }, [setCoupons]); + + return { + coupons, + addCoupon, + removeCoupon + }; +} diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 000000000..2f07b55a8 --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,46 @@ +import { useCallback } from 'react'; +import { ProductWithUI, initialProducts } from '../constants'; +import { useLocalStorage } from '../utils/hooks/useLocalStorage'; + +/** + * 상품 관리 Hook + * 상품 목록 상태 관리 및 CRUD 작업 제공 + */ +export function useProducts() { + const [products, setProducts] = useLocalStorage('products', initialProducts); + + const addProduct = useCallback((newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + setProducts(prev => [...prev, product]); + return product; + }, [setProducts]); + + const updateProduct = useCallback((productId: string, updates: Partial) => { + setProducts(prev => + prev.map(product => + product.id === productId + ? { ...product, ...updates } + : product + ) + ); + }, [setProducts]); + + const deleteProduct = useCallback((productId: string) => { + setProducts(prev => prev.filter(p => p.id !== productId)); + }, [setProducts]); + + const updateProductStock = useCallback((productId: string, stock: number) => { + updateProduct(productId, { stock }); + }, [updateProduct]); + + return { + products, + addProduct, + updateProduct, + deleteProduct, + updateProductStock + }; +} diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts new file mode 100644 index 000000000..18a67b523 --- /dev/null +++ b/src/basic/models/cart.ts @@ -0,0 +1,123 @@ +import { CartItem, Coupon, Product } from '../../types'; + +/** + * 장바구니 아이템에 적용 가능한 최대 할인율을 계산합니다. + * 대량 구매 시 추가 5% 할인이 적용됩니다. + */ +export function getMaxApplicableDiscount(item: CartItem, cart: CartItem[]): number { + const { discounts } = item.product; + const { quantity } = item; + + // 상품 자체 할인율 계산 + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + // 대량 구매 보너스: 장바구니 내 10개 이상 구매 시 추가 5% 할인 + const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 최대 50% 할인 + } + + return baseDiscount; +} + +/** + * 개별 장바구니 아이템의 할인 적용 후 총액을 계산합니다. + */ +export function calculateItemTotal(item: CartItem, cart: CartItem[]): number { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +} + +/** + * 장바구니 전체의 할인 전/후 총액을 계산합니다. + */ +export function calculateCartTotal( + cart: CartItem[], + selectedCoupon: Coupon | null +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; + totalDiscount: number; +} { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach(item => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + // 쿠폰 적용 + 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), + totalDiscount: Math.round(totalBeforeDiscount - totalAfterDiscount) + }; +} + +/** + * 상품의 남은 재고를 계산합니다. + */ +export function getRemainingStock(product: Product, cart: CartItem[]): number { + const cartItem = cart.find(item => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); +} + +/** + * 장바구니에 상품을 추가하거나 수량을 증가시킵니다. + */ +export function addItemToCart(cart: CartItem[], product: Product): CartItem[] { + const existingItem = cart.find(item => item.product.id === product.id); + + if (existingItem) { + return cart.map(item => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ); + } + + return [...cart, { product, quantity: 1 }]; +} + +/** + * 장바구니에서 상품을 제거합니다. + */ +export function removeItemFromCart(cart: CartItem[], productId: string): CartItem[] { + return cart.filter(item => item.product.id !== productId); +} + +/** + * 장바구니 아이템의 수량을 업데이트합니다. + */ +export function updateCartItemQuantity( + cart: CartItem[], + productId: string, + quantity: number +): CartItem[] { + if (quantity <= 0) { + return removeItemFromCart(cart, productId); + } + + return cart.map(item => + item.product.id === productId + ? { ...item, quantity } + : item + ); +} diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts new file mode 100644 index 000000000..e0bdc1985 --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,24 @@ +/** + * 가격을 한국 원화 형식으로 포맷팅합니다. + */ +export function formatPrice(price: number): string { + return `₩${price.toLocaleString()}`; +} + +/** + * 날짜를 YYYY-MM-DD 형식으로 포맷팅합니다. + */ +export function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** + * 소수를 퍼센트 문자열로 변환합니다. + * @example formatPercentage(0.1) // "10%" + */ +export function formatPercentage(rate: number): string { + return `${Math.round(rate * 100)}%`; +} diff --git a/src/basic/utils/hooks/useDebounce.ts b/src/basic/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..4b898bcd4 --- /dev/null +++ b/src/basic/utils/hooks/useDebounce.ts @@ -0,0 +1,23 @@ +import { useState, useEffect } from 'react'; + +/** + * 값 변경을 지연시키는 디바운스 Hook + * 지정된 시간 동안 값이 변경되지 않을 때만 업데이트됩니다. + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + // delay 후에 값을 업데이트하는 타이머 설정 + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // 값이 변경되거나 컴포넌트가 언마운트되면 타이머 정리 + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/basic/utils/hooks/useLocalStorage.ts b/src/basic/utils/hooks/useLocalStorage.ts new file mode 100644 index 000000000..98e036ec7 --- /dev/null +++ b/src/basic/utils/hooks/useLocalStorage.ts @@ -0,0 +1,54 @@ +import { useState, useEffect } from 'react'; + +/** + * localStorage와 React state를 동기화하는 Hook + * JSON 직렬화/역직렬화 및 에러 처리 포함 + */ +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + // State 초기화 - localStorage에서 읽기 시도 + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + if (item) { + return JSON.parse(item); + } + return initialValue; + } catch (error) { + console.warn(`Error reading localStorage key "${key}":`, error); + return initialValue; + } + }); + + // State가 변경되면 localStorage에 저장 + useEffect(() => { + try { + // 빈 배열이나 null/undefined는 삭제 + if ( + storedValue === null || + storedValue === undefined || + (Array.isArray(storedValue) && storedValue.length === 0) + ) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(storedValue)); + } + } catch (error) { + console.warn(`Error setting localStorage key "${key}":`, error); + } + }, [key, storedValue]); + + // setValue 함수 - 함수형 업데이트 지원 + const setValue = (value: T | ((val: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + } catch (error) { + console.warn(`Error updating localStorage key "${key}":`, error); + } + }; + + return [storedValue, setValue]; +} diff --git a/src/basic/utils/validators.ts b/src/basic/utils/validators.ts new file mode 100644 index 000000000..bd6f213e7 --- /dev/null +++ b/src/basic/utils/validators.ts @@ -0,0 +1,30 @@ +/** + * 쿠폰 코드 형식을 검증합니다. + * 4-12자의 영문 대문자와 숫자만 허용됩니다. + */ +export function isValidCouponCode(code: string): boolean { + return /^[A-Z0-9]{4,12}$/.test(code); +} + +/** + * 재고 수량이 유효한지 검증합니다. + * 0 이상의 정수여야 합니다. + */ +export function isValidStock(stock: number): boolean { + return Number.isInteger(stock) && stock >= 0; +} + +/** + * 가격이 유효한지 검증합니다. + * 양수여야 합니다. + */ +export function isValidPrice(price: number): boolean { + return typeof price === 'number' && price > 0; +} + +/** + * 문자열에서 숫자만 추출합니다. + */ +export function extractNumbers(value: string): string { + return value.replace(/\D/g, ''); +}