From a036e46238722409a3becaa7bb3227ebb5a6f39e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Mon, 4 Aug 2025 17:36:13 +0900 Subject: [PATCH 01/68] =?UTF-8?q?chore:=20=EC=BD=94=EB=93=9C=EB=B2=A0?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=B6=84=EC=84=9D=20=ED=9B=84=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 1860 ++++++++++++++++++++++++++++---------------- src/origin/App.tsx | 1860 ++++++++++++++++++++++++++++---------------- 2 files changed, 2366 insertions(+), 1354 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1..73760c6b 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,6 +1,9 @@ import { useState, useCallback, useEffect } from 'react'; import { CartItem, Coupon, Product } from '../types'; +// ============================================================================ +// 타입 정의 - UI 확장을 위한 Product 타입 확장 +// ============================================================================ interface ProductWithUI extends Product { description?: string; isRecommended?: boolean; @@ -12,7 +15,9 @@ interface Notification { type: 'error' | 'success' | 'warning'; } -// 초기 데이터 +// ============================================================================ +// 초기 데이터 - 하드코딩된 상품 및 쿠폰 데이터 +// ============================================================================ const initialProducts: ProductWithUI[] = [ { id: 'p1', @@ -21,20 +26,18 @@ const initialProducts: ProductWithUI[] = [ stock: 20, discounts: [ { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } + { quantity: 20, rate: 0.2 }, ], - description: '최고급 품질의 프리미엄 상품입니다.' + description: '최고급 품질의 프리미엄 상품입니다.', }, { id: 'p2', name: '상품2', price: 20000, stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], + discounts: [{ quantity: 10, rate: 0.15 }], description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true + isRecommended: true, }, { id: 'p3', @@ -43,10 +46,10 @@ const initialProducts: ProductWithUI[] = [ stock: 20, discounts: [ { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } + { quantity: 30, rate: 0.25 }, ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } + description: '대용량과 고성능을 자랑하는 상품입니다.', + }, ]; const initialCoupons: Coupon[] = [ @@ -54,18 +57,22 @@ const initialCoupons: Coupon[] = [ name: '5000원 할인', code: 'AMOUNT5000', discountType: 'amount', - discountValue: 5000 + discountValue: 5000, }, { name: '10% 할인', code: 'PERCENT10', discountType: 'percentage', - discountValue: 10 - } + discountValue: 10, + }, ]; const App = () => { + // ============================================================================ + // 상태 관리 - localStorage와 연동된 데이터 상태들 + // ============================================================================ + // 상품 목록 상태 (localStorage에서 복원) const [products, setProducts] = useState(() => { const saved = localStorage.getItem('products'); if (saved) { @@ -78,6 +85,7 @@ const App = () => { return initialProducts; }); + // 장바구니 상태 (localStorage에서 복원) const [cart, setCart] = useState(() => { const saved = localStorage.getItem('cart'); if (saved) { @@ -90,6 +98,7 @@ const App = () => { return []; }); + // 쿠폰 목록 상태 (localStorage에서 복원) const [coupons, setCoupons] = useState(() => { const saved = localStorage.getItem('coupons'); if (saved) { @@ -102,36 +111,52 @@ const App = () => { 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); + // ============================================================================ + // UI 상태 관리 - 화면 표시 및 사용자 인터랙션 관련 상태들 + // ============================================================================ + + 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(''); // 디바운스된 검색어 + + // ============================================================================ + // 관리자 폼 상태 - 상품/쿠폰 편집을 위한 폼 데이터 + // ============================================================================ + + const [editingProduct, setEditingProduct] = useState(null); // 편집 중인 상품 ID + + // 상품 폼 데이터 const [productForm, setProductForm] = useState({ name: '', price: 0, stock: 0, description: '', - discounts: [] as Array<{ quantity: number; rate: number }> + discounts: [] as Array<{ quantity: number; rate: number }>, }); + // 쿠폰 폼 데이터 const [couponForm, setCouponForm] = useState({ name: '', code: '', discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 + discountValue: 0, }); + // ============================================================================ + // 유틸리티 함수들 - 데이터 포맷팅 및 계산 로직 + // ============================================================================ + // 가격 포맷팅 함수 (관리자/일반 사용자 구분, 품절 처리) const formatPrice = (price: number, productId?: string): string => { if (productId) { - const product = products.find(p => p.id === productId); + const product = products.find((p) => p.id === productId); if (product && getRemainingStock(product) <= 0) { return 'SOLD OUT'; } @@ -140,36 +165,41 @@ const App = () => { 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 + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate : maxDiscount; }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + + // 대량구매 시 추가 할인 (10개 이상 구매 시 5% 추가) + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + return Math.min(baseDiscount + 0.05, 0.5); // 최대 50% 제한 } - + 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; @@ -177,50 +207,74 @@ const App = () => { let totalBeforeDiscount = 0; let totalAfterDiscount = 0; - cart.forEach(item => { + // 각 상품별 금액 계산 + 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); + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue + ); } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); } } return { totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) + totalAfterDiscount: Math.round(totalAfterDiscount), }; }; + // 남은 재고 계산 (전체 재고 - 장바구니 수량) const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); + 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); - }, []); + // ============================================================================ + // 알림 시스템 - 사용자에게 피드백 제공 + // ============================================================================ + + // 알림 추가 함수 (3초 후 자동 제거) + 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); - + const [totalItemCount, setTotalItemCount] = useState(0); // 장바구니 총 아이템 수 + // 장바구니 아이템 수 업데이트 useEffect(() => { const count = cart.reduce((sum, item) => sum + item.quantity, 0); setTotalItemCount(count); }, [cart]); + // ============================================================================ + // localStorage 동기화 Effects - 상태 변경 시 localStorage에 저장 + // ============================================================================ + useEffect(() => { localStorage.setItem('products', JSON.stringify(products)); }, [products]); @@ -237,6 +291,10 @@ const App = () => { } }, [cart]); + // ============================================================================ + // 검색 기능 - 디바운스를 통한 성능 최적화 + // ============================================================================ + useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchTerm(searchTerm); @@ -244,127 +302,198 @@ const App = () => { 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; - } + // 장바구니에 상품 추가 + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + addNotification('재고가 부족합니다!', 'error'); + return; + } - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item + setCart((prevCart) => { + const existingItem = prevCart.find( + (item) => item.product.id === product.id ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); + 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)); + setCart((prevCart) => + prevCart.filter((item) => item.product.id !== productId) + ); }, []); - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } + // 장바구니 상품 수량 업데이트 + 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 product = products.find((p) => p.id === productId); + if (!product) return; - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - 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; - } + 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; - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + // 퍼센트 쿠폰 최소 주문 금액 검증 + 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'); + 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 addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [addNotification] + ); - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - 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 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 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') { @@ -373,14 +502,21 @@ const App = () => { } else { addProduct({ ...productForm, - discounts: productForm.discounts + discounts: productForm.discounts, }); } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + }); setEditingProduct(null); setShowProductForm(false); }; + // 쿠폰 폼 제출 const handleCouponSubmit = (e: React.FormEvent) => { e.preventDefault(); addCoupon(couponForm); @@ -388,11 +524,12 @@ const App = () => { name: '', code: '', discountType: 'amount', - discountValue: 0 + discountValue: 0, }); setShowCouponForm(false); }; + // 상품 편집 시작 const startEditProduct = (product: ProductWithUI) => { setEditingProduct(product.id); setProductForm({ @@ -400,82 +537,126 @@ const App = () => { price: product.price, stock: product.stock, description: product.description || '', - discounts: product.discounts || [] + discounts: product.discounts || [], }); setShowProductForm(true); }; - const totals = calculateCartTotal(); + // ============================================================================ + // 계산된 값들 - 렌더링에 필요한 파생 데이터 + // ============================================================================ + + const totals = calculateCartTotal(); // 장바구니 총액 계산 결과 + // 검색 필터링된 상품 목록 const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) ) : products; + // ============================================================================ + // 거대한 JSX 렌더링 - 모든 UI 컴포넌트가 인라인으로 작성됨 + // ============================================================================ + return ( -
+
+ {/* 알림 시스템 - 우상단 고정 위치 */} {notifications.length > 0 && ( -
- {notifications.map(notif => ( +
+ {notifications.map((notif) => (
- {notif.message} -
))}
)} -
-
-
-
-

SHOP

+ + {/* 헤더 - 검색바, 관리자 모드 토글, 장바구니 아이콘 */} +
+
+
+
+

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" + placeholder='상품 검색...' + className='w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500' />
)}
-
-
+ {/* 메인 컨텐츠 - 관리자 모드와 쇼핑몰 모드 조건부 렌더링 */} +
{isAdmin ? ( -
-
-

관리자 대시보드

-

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

+ // ============================================================================ + // 관리자 페이지 전체 UI (상품 관리 + 쿠폰 관리) + // ============================================================================ +
+
+

+ 관리자 대시보드 +

+

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

-
-
{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) }); + + + {(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, + }) } - }} - 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' + 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" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(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 === '') { - 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 }); + onChange={(e) => { + 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 + /> +
+
+ + + onChange={(e) => { + 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="%" - /> - % 할인 - -
- ))} + { + 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 - /> -
-
-
- +
+
+

+ {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}"에 대한 검색 결과가 없습니다.

+
+

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

+
+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(product); + + return ( + // 개별 상품 카드 +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} {product.discounts.length > 0 && ( -

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

+ + ~ + {Math.max( + ...product.discounts.map((d) => d.rate) + ) * 100} + % + )}
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

+ + {/* 상품 정보 */} +
+

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

{item.product.name}

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

+

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

@@ -1049,64 +1534,85 @@ const App = () => { )}
+ {/* 쿠폰 할인 섹션 */} {cart.length > 0 && ( <> -
-
-

쿠폰 할인

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

결제 정보

-
-
- 상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 + {/* 결제 정보 섹션 */} +
+

결제 정보

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

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

@@ -1121,4 +1627,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/origin/App.tsx b/src/origin/App.tsx index a4369fe1..73760c6b 100644 --- a/src/origin/App.tsx +++ b/src/origin/App.tsx @@ -1,6 +1,9 @@ import { useState, useCallback, useEffect } from 'react'; import { CartItem, Coupon, Product } from '../types'; +// ============================================================================ +// 타입 정의 - UI 확장을 위한 Product 타입 확장 +// ============================================================================ interface ProductWithUI extends Product { description?: string; isRecommended?: boolean; @@ -12,7 +15,9 @@ interface Notification { type: 'error' | 'success' | 'warning'; } -// 초기 데이터 +// ============================================================================ +// 초기 데이터 - 하드코딩된 상품 및 쿠폰 데이터 +// ============================================================================ const initialProducts: ProductWithUI[] = [ { id: 'p1', @@ -21,20 +26,18 @@ const initialProducts: ProductWithUI[] = [ stock: 20, discounts: [ { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } + { quantity: 20, rate: 0.2 }, ], - description: '최고급 품질의 프리미엄 상품입니다.' + description: '최고급 품질의 프리미엄 상품입니다.', }, { id: 'p2', name: '상품2', price: 20000, stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], + discounts: [{ quantity: 10, rate: 0.15 }], description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true + isRecommended: true, }, { id: 'p3', @@ -43,10 +46,10 @@ const initialProducts: ProductWithUI[] = [ stock: 20, discounts: [ { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } + { quantity: 30, rate: 0.25 }, ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } + description: '대용량과 고성능을 자랑하는 상품입니다.', + }, ]; const initialCoupons: Coupon[] = [ @@ -54,18 +57,22 @@ const initialCoupons: Coupon[] = [ name: '5000원 할인', code: 'AMOUNT5000', discountType: 'amount', - discountValue: 5000 + discountValue: 5000, }, { name: '10% 할인', code: 'PERCENT10', discountType: 'percentage', - discountValue: 10 - } + discountValue: 10, + }, ]; const App = () => { + // ============================================================================ + // 상태 관리 - localStorage와 연동된 데이터 상태들 + // ============================================================================ + // 상품 목록 상태 (localStorage에서 복원) const [products, setProducts] = useState(() => { const saved = localStorage.getItem('products'); if (saved) { @@ -78,6 +85,7 @@ const App = () => { return initialProducts; }); + // 장바구니 상태 (localStorage에서 복원) const [cart, setCart] = useState(() => { const saved = localStorage.getItem('cart'); if (saved) { @@ -90,6 +98,7 @@ const App = () => { return []; }); + // 쿠폰 목록 상태 (localStorage에서 복원) const [coupons, setCoupons] = useState(() => { const saved = localStorage.getItem('coupons'); if (saved) { @@ -102,36 +111,52 @@ const App = () => { 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); + // ============================================================================ + // UI 상태 관리 - 화면 표시 및 사용자 인터랙션 관련 상태들 + // ============================================================================ + + 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(''); // 디바운스된 검색어 + + // ============================================================================ + // 관리자 폼 상태 - 상품/쿠폰 편집을 위한 폼 데이터 + // ============================================================================ + + const [editingProduct, setEditingProduct] = useState(null); // 편집 중인 상품 ID + + // 상품 폼 데이터 const [productForm, setProductForm] = useState({ name: '', price: 0, stock: 0, description: '', - discounts: [] as Array<{ quantity: number; rate: number }> + discounts: [] as Array<{ quantity: number; rate: number }>, }); + // 쿠폰 폼 데이터 const [couponForm, setCouponForm] = useState({ name: '', code: '', discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 + discountValue: 0, }); + // ============================================================================ + // 유틸리티 함수들 - 데이터 포맷팅 및 계산 로직 + // ============================================================================ + // 가격 포맷팅 함수 (관리자/일반 사용자 구분, 품절 처리) const formatPrice = (price: number, productId?: string): string => { if (productId) { - const product = products.find(p => p.id === productId); + const product = products.find((p) => p.id === productId); if (product && getRemainingStock(product) <= 0) { return 'SOLD OUT'; } @@ -140,36 +165,41 @@ const App = () => { 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 + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate : maxDiscount; }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + + // 대량구매 시 추가 할인 (10개 이상 구매 시 5% 추가) + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + return Math.min(baseDiscount + 0.05, 0.5); // 최대 50% 제한 } - + 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; @@ -177,50 +207,74 @@ const App = () => { let totalBeforeDiscount = 0; let totalAfterDiscount = 0; - cart.forEach(item => { + // 각 상품별 금액 계산 + 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); + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue + ); } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); } } return { totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) + totalAfterDiscount: Math.round(totalAfterDiscount), }; }; + // 남은 재고 계산 (전체 재고 - 장바구니 수량) const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); + 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); - }, []); + // ============================================================================ + // 알림 시스템 - 사용자에게 피드백 제공 + // ============================================================================ + + // 알림 추가 함수 (3초 후 자동 제거) + 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); - + const [totalItemCount, setTotalItemCount] = useState(0); // 장바구니 총 아이템 수 + // 장바구니 아이템 수 업데이트 useEffect(() => { const count = cart.reduce((sum, item) => sum + item.quantity, 0); setTotalItemCount(count); }, [cart]); + // ============================================================================ + // localStorage 동기화 Effects - 상태 변경 시 localStorage에 저장 + // ============================================================================ + useEffect(() => { localStorage.setItem('products', JSON.stringify(products)); }, [products]); @@ -237,6 +291,10 @@ const App = () => { } }, [cart]); + // ============================================================================ + // 검색 기능 - 디바운스를 통한 성능 최적화 + // ============================================================================ + useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchTerm(searchTerm); @@ -244,127 +302,198 @@ const App = () => { 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; - } + // 장바구니에 상품 추가 + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + addNotification('재고가 부족합니다!', 'error'); + return; + } - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item + setCart((prevCart) => { + const existingItem = prevCart.find( + (item) => item.product.id === product.id ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); + 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)); + setCart((prevCart) => + prevCart.filter((item) => item.product.id !== productId) + ); }, []); - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } + // 장바구니 상품 수량 업데이트 + 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 product = products.find((p) => p.id === productId); + if (!product) return; - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - 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; - } + 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; - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + // 퍼센트 쿠폰 최소 주문 금액 검증 + 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'); + 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 addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [addNotification] + ); - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - 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 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 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') { @@ -373,14 +502,21 @@ const App = () => { } else { addProduct({ ...productForm, - discounts: productForm.discounts + discounts: productForm.discounts, }); } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + }); setEditingProduct(null); setShowProductForm(false); }; + // 쿠폰 폼 제출 const handleCouponSubmit = (e: React.FormEvent) => { e.preventDefault(); addCoupon(couponForm); @@ -388,11 +524,12 @@ const App = () => { name: '', code: '', discountType: 'amount', - discountValue: 0 + discountValue: 0, }); setShowCouponForm(false); }; + // 상품 편집 시작 const startEditProduct = (product: ProductWithUI) => { setEditingProduct(product.id); setProductForm({ @@ -400,82 +537,126 @@ const App = () => { price: product.price, stock: product.stock, description: product.description || '', - discounts: product.discounts || [] + discounts: product.discounts || [], }); setShowProductForm(true); }; - const totals = calculateCartTotal(); + // ============================================================================ + // 계산된 값들 - 렌더링에 필요한 파생 데이터 + // ============================================================================ + + const totals = calculateCartTotal(); // 장바구니 총액 계산 결과 + // 검색 필터링된 상품 목록 const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) ) : products; + // ============================================================================ + // 거대한 JSX 렌더링 - 모든 UI 컴포넌트가 인라인으로 작성됨 + // ============================================================================ + return ( -
+
+ {/* 알림 시스템 - 우상단 고정 위치 */} {notifications.length > 0 && ( -
- {notifications.map(notif => ( +
+ {notifications.map((notif) => (
- {notif.message} -
))}
)} -
-
-
-
-

SHOP

+ + {/* 헤더 - 검색바, 관리자 모드 토글, 장바구니 아이콘 */} +
+
+
+
+

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" + placeholder='상품 검색...' + className='w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500' />
)}
-
-
+ {/* 메인 컨텐츠 - 관리자 모드와 쇼핑몰 모드 조건부 렌더링 */} +
{isAdmin ? ( -
-
-

관리자 대시보드

-

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

+ // ============================================================================ + // 관리자 페이지 전체 UI (상품 관리 + 쿠폰 관리) + // ============================================================================ +
+
+

+ 관리자 대시보드 +

+

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

-
-
{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) }); + + + {(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, + }) } - }} - 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' + 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" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(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 === '') { - 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 }); + onChange={(e) => { + 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 + /> +
+
+ + + onChange={(e) => { + 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="%" - /> - % 할인 - -
- ))} + { + 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 - /> -
-
-
- +
+
+

+ {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}"에 대한 검색 결과가 없습니다.

+
+

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

+
+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(product); + + return ( + // 개별 상품 카드 +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} {product.discounts.length > 0 && ( -

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

+ + ~ + {Math.max( + ...product.discounts.map((d) => d.rate) + ) * 100} + % + )}
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

+ + {/* 상품 정보 */} +
+

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

{item.product.name}

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

+

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

@@ -1049,64 +1534,85 @@ const App = () => { )}
+ {/* 쿠폰 할인 섹션 */} {cart.length > 0 && ( <> -
-
-

쿠폰 할인

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

결제 정보

-
-
- 상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 + {/* 결제 정보 섹션 */} +
+

결제 정보

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

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

@@ -1121,4 +1627,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; From c03c22a6f6a7966765e4ffd3248e0e384ed22160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Mon, 4 Aug 2025 22:42:58 +0900 Subject: [PATCH 02/68] =?UTF-8?q?feat:=20entities/product=20=ED=83=80?= =?UTF-8?q?=EC=9E=85,=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/entities/product/data.ts | 38 +++++++++++++++++++++++++++++ src/basic/entities/product/index.ts | 5 ++++ src/basic/entities/product/types.ts | 6 +++++ 3 files changed, 49 insertions(+) create mode 100644 src/basic/entities/product/data.ts create mode 100644 src/basic/entities/product/index.ts create mode 100644 src/basic/entities/product/types.ts diff --git a/src/basic/entities/product/data.ts b/src/basic/entities/product/data.ts new file mode 100644 index 00000000..cf0bfc59 --- /dev/null +++ b/src/basic/entities/product/data.ts @@ -0,0 +1,38 @@ +import { 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: '대용량과 고성능을 자랑하는 상품입니다.', + }, +]; diff --git a/src/basic/entities/product/index.ts b/src/basic/entities/product/index.ts new file mode 100644 index 00000000..0d4ee263 --- /dev/null +++ b/src/basic/entities/product/index.ts @@ -0,0 +1,5 @@ +// 타입 관련 +export type { ProductWithUI } from './types'; + +// 데이터 관련 +export { initialProducts } from './data'; diff --git a/src/basic/entities/product/types.ts b/src/basic/entities/product/types.ts new file mode 100644 index 00000000..dfa1402c --- /dev/null +++ b/src/basic/entities/product/types.ts @@ -0,0 +1,6 @@ +import { Product } from '../../../types'; + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} From c766b4dde261e209ce57f0eb0a3b3b3cf51cec57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Mon, 4 Aug 2025 22:44:45 +0900 Subject: [PATCH 03/68] =?UTF-8?q?feat:=20entities/cart=20=ED=83=80?= =?UTF-8?q?=EC=9E=85,=20=EC=9C=A0=ED=8B=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/entities/cart/index.ts | 11 ++++ src/basic/entities/cart/types.ts | 13 ++++ src/basic/entities/cart/utils.ts | 103 +++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 src/basic/entities/cart/index.ts create mode 100644 src/basic/entities/cart/types.ts create mode 100644 src/basic/entities/cart/utils.ts diff --git a/src/basic/entities/cart/index.ts b/src/basic/entities/cart/index.ts new file mode 100644 index 00000000..05920651 --- /dev/null +++ b/src/basic/entities/cart/index.ts @@ -0,0 +1,11 @@ +// 타입 관련 +export type { CartTotal, CartCalculationOptions } from './types'; + +// 유틸리티 함수 관련 +export { + getProductDiscount, + getBulkPurchaseDiscount, + getMaxApplicableDiscount, + calculateItemTotal, + calculateCartTotal, +} from './utils'; diff --git a/src/basic/entities/cart/types.ts b/src/basic/entities/cart/types.ts new file mode 100644 index 00000000..7b9e8f54 --- /dev/null +++ b/src/basic/entities/cart/types.ts @@ -0,0 +1,13 @@ +import { CartItem, Coupon } from '../../../types'; + +// 장바구니 총액 계산 결과 +export interface CartTotal { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} + +// 장바구니 계산에 필요한 옵션들 +export interface CartCalculationOptions { + cart: CartItem[]; + selectedCoupon?: Coupon | null; +} diff --git a/src/basic/entities/cart/utils.ts b/src/basic/entities/cart/utils.ts new file mode 100644 index 00000000..b0dca1a1 --- /dev/null +++ b/src/basic/entities/cart/utils.ts @@ -0,0 +1,103 @@ +import { CartItem, Coupon } from '../../../types'; +import { CartTotal } from './types'; + +/** + * 개별 상품의 할인율 계산 + * 상품별로 설정된 수량 할인 정책에 따라 할인율을 결정 + */ +export const getProductDiscount = (item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + return discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); +}; + +/** + * 대량구매 할인 체크 + * 장바구니에 10개 이상 구매한 상품이 하나라도 있으면 모든 상품에 5% 추가 할인 + */ +export const getBulkPurchaseDiscount = (cart: CartItem[]): number => { + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + return hasBulkPurchase ? 0.05 : 0; +}; + +/** + * 개별 아이템의 최대 적용 가능한 할인율 계산 + * @param item 계산할 장바구니 아이템 + * @param cart 전체 장바구니 + */ +export const getMaxApplicableDiscount = ( + item: CartItem, + cart: CartItem[] +): number => { + const baseDiscount = getProductDiscount(item); + const bulkDiscount = getBulkPurchaseDiscount(cart); + return Math.min(baseDiscount + bulkDiscount, 0.5); +}; + +/** + * 개별 상품의 할인 적용된 총액 계산 + * @param item 계산할 장바구니 아이템 + * @param cart 전체 장바구니 + */ +export const calculateItemTotal = ( + item: CartItem, + cart: CartItem[] +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +/** + * 장바구니 전체 금액 계산 (쿠폰 할인 포함) + * 대량구매 할인을 한 번만 계산하여 모든 아이템에 적용 + */ +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null = null +): CartTotal => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + // 대량구매 할인은 한 번만 계산 + const bulkDiscount = getBulkPurchaseDiscount(cart); + + // 각 상품별 금액 계산 + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + + // 개별 상품 할인 + 대량구매 할인 조합 + const productDiscount = getProductDiscount(item); + const totalDiscount = Math.min(productDiscount + bulkDiscount, 0.5); + const itemTotal = Math.round(itemPrice * (1 - totalDiscount)); + + totalAfterDiscount += itemTotal; + }); + + // 쿠폰 할인 적용 + 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), + }; +}; From 5fd54466662c7a119ee3a65230e7fc4d329ae9ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Mon, 4 Aug 2025 23:00:43 +0900 Subject: [PATCH 04/68] =?UTF-8?q?feat:=20entities/coupon=20=ED=83=80?= =?UTF-8?q?=EC=9E=85,=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=20=EB=B0=8F=20App=20=EB=A9=94=EC=9D=B8=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 133 +++-------------------------- src/basic/entities/coupon/data.ts | 19 +++++ src/basic/entities/coupon/index.ts | 2 + 3 files changed, 31 insertions(+), 123 deletions(-) create mode 100644 src/basic/entities/coupon/data.ts create mode 100644 src/basic/entities/coupon/index.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 73760c6b..02297999 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,13 +1,9 @@ import { useState, useCallback, useEffect } from 'react'; import { CartItem, Coupon, Product } from '../types'; -// ============================================================================ -// 타입 정의 - UI 확장을 위한 Product 타입 확장 -// ============================================================================ -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} +import { ProductWithUI, initialProducts } from './entities/product'; +import { initialCoupons } from './entities/coupon'; +import { calculateItemTotal, calculateCartTotal } from './entities/cart'; interface Notification { id: string; @@ -15,58 +11,6 @@ interface Notification { 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 = () => { // ============================================================================ // 상태 관리 - localStorage와 연동된 데이터 상태들 @@ -169,69 +113,12 @@ const App = () => { 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); - - // 대량구매 시 추가 할인 (10개 이상 구매 시 5% 추가) - const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 최대 50% 제한 - } - - 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 = (): { + // 장바구니 전체 금액 계산 (쿠폰 할인 포함) - entities/cart 함수 사용 + const calculateCartTotalWithCoupon = (): { 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), - }; + return calculateCartTotal(cart, selectedCoupon); }; // 남은 재고 계산 (전체 재고 - 장바구니 수량) @@ -388,7 +275,7 @@ const App = () => { // 쿠폰 적용 const applyCoupon = useCallback( (coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; + const currentTotal = calculateCartTotalWithCoupon().totalAfterDiscount; // 퍼센트 쿠폰 최소 주문 금액 검증 if (currentTotal < 10000 && coupon.discountType === 'percentage') { @@ -402,7 +289,7 @@ const App = () => { setSelectedCoupon(coupon); addNotification('쿠폰이 적용되었습니다.', 'success'); }, - [addNotification, calculateCartTotal] + [addNotification, calculateCartTotalWithCoupon] ); // ============================================================================ @@ -546,7 +433,7 @@ const App = () => { // 계산된 값들 - 렌더링에 필요한 파생 데이터 // ============================================================================ - const totals = calculateCartTotal(); // 장바구니 총액 계산 결과 + const totals = calculateCartTotalWithCoupon(); // 장바구니 총액 계산 결과 // 검색 필터링된 상품 목록 const filteredProducts = debouncedSearchTerm @@ -1452,7 +1339,7 @@ const App = () => { ) : (
{cart.map((item) => { - const itemTotal = calculateItemTotal(item); + const itemTotal = calculateItemTotal(item, cart); const originalPrice = item.product.price * item.quantity; const hasDiscount = itemTotal < originalPrice; diff --git a/src/basic/entities/coupon/data.ts b/src/basic/entities/coupon/data.ts new file mode 100644 index 00000000..cbc4813d --- /dev/null +++ b/src/basic/entities/coupon/data.ts @@ -0,0 +1,19 @@ +import { Coupon } from '../../../types'; + +/** + * 초기 쿠폰 데이터 + */ +export const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; diff --git a/src/basic/entities/coupon/index.ts b/src/basic/entities/coupon/index.ts new file mode 100644 index 00000000..5e90635a --- /dev/null +++ b/src/basic/entities/coupon/index.ts @@ -0,0 +1,2 @@ +// 데이터 관련 +export { initialCoupons } from './data'; From f15c6781890676c2d92f20159cf363bf47e9c6d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Mon, 4 Aug 2025 23:41:36 +0900 Subject: [PATCH 05/68] =?UTF-8?q?feat:=20shared/hooks=20=ED=9B=85=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/shared/hooks/index.ts | 9 ++++ src/basic/shared/hooks/useDebounce.ts | 23 ++++++++++ src/basic/shared/hooks/useLocalStorage.ts | 41 +++++++++++++++++ src/basic/shared/hooks/useNotification.ts | 55 +++++++++++++++++++++++ 4 files changed, 128 insertions(+) create mode 100644 src/basic/shared/hooks/index.ts create mode 100644 src/basic/shared/hooks/useDebounce.ts create mode 100644 src/basic/shared/hooks/useLocalStorage.ts create mode 100644 src/basic/shared/hooks/useNotification.ts diff --git a/src/basic/shared/hooks/index.ts b/src/basic/shared/hooks/index.ts new file mode 100644 index 00000000..403a7e87 --- /dev/null +++ b/src/basic/shared/hooks/index.ts @@ -0,0 +1,9 @@ +// localStorage 훅 +export { useLocalStorage } from './useLocalStorage'; + +// 알림 시스템 훅 +export { useNotification } from './useNotification'; +export type { Notification, UseNotificationReturn } from './useNotification'; + +// 디바운스 훅 +export { useDebounce } from './useDebounce'; diff --git a/src/basic/shared/hooks/useDebounce.ts b/src/basic/shared/hooks/useDebounce.ts new file mode 100644 index 00000000..cf979960 --- /dev/null +++ b/src/basic/shared/hooks/useDebounce.ts @@ -0,0 +1,23 @@ +import { useState, useEffect } from 'react'; + +/** + * 값의 변경을 지연시키는 디바운스 훅 + * @param value 디바운스할 값 + * @param delay 지연 시간 (밀리초) + * @returns 디바운스된 값 + */ +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; +} diff --git a/src/basic/shared/hooks/useLocalStorage.ts b/src/basic/shared/hooks/useLocalStorage.ts new file mode 100644 index 00000000..5488dc7a --- /dev/null +++ b/src/basic/shared/hooks/useLocalStorage.ts @@ -0,0 +1,41 @@ +import { useState, useEffect } from 'react'; + +/** + * localStorage와 연동된 상태를 관리하는 커스텀 훅 + * @param key localStorage 키 + * @param defaultValue 기본값 + * @returns [상태값, 상태변경함수] + */ +export function useLocalStorage( + key: string, + defaultValue: T +): [T, React.Dispatch>] { + // 초기값 설정 - localStorage에서 복원 + const [state, setState] = useState(() => { + try { + const saved = localStorage.getItem(key); + if (saved) { + return JSON.parse(saved); + } + } catch (error) { + console.warn(`localStorage에서 ${key} 읽기 실패:`, error); + } + return defaultValue; + }); + + // 상태가 변경될 때마다 localStorage에 저장 + useEffect(() => { + try { + if (key === 'cart' && Array.isArray(state) && state.length === 0) { + // 장바구니가 비어있으면 localStorage에서 제거 + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(state)); + } + } catch (error) { + console.warn(`localStorage에 ${key} 저장 실패:`, error); + } + }, [key, state]); + + return [state, setState]; +} diff --git a/src/basic/shared/hooks/useNotification.ts b/src/basic/shared/hooks/useNotification.ts new file mode 100644 index 00000000..7b9aa844 --- /dev/null +++ b/src/basic/shared/hooks/useNotification.ts @@ -0,0 +1,55 @@ +import { useState, useCallback } from 'react'; + +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export interface UseNotificationReturn { + notifications: Notification[]; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; + removeNotification: (id: string) => void; +} + +/** + * 알림 시스템을 관리하는 커스텀 훅 + * 알림 추가, 자동 제거, 수동 제거 기능 제공 + */ +export function useNotification(): UseNotificationReturn { + const [notifications, setNotifications] = useState([]); + + /** + * 새 알림 추가 (3초 후 자동 제거) + */ + const addNotification = useCallback( + (message: string, type: 'error' | 'success' | 'warning' = 'success') => { + const id = Date.now().toString(); + const newNotification: Notification = { id, message, type }; + + setNotifications((prev) => [...prev, newNotification]); + + // 3초 후 자동 제거 + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); + + /** + * 특정 알림 수동 제거 + */ + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return { + notifications, + addNotification, + removeNotification, + }; +} From ea6fe2aa94f7c7ea3634bc3319d6dce3c472724b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Mon, 4 Aug 2025 23:43:51 +0900 Subject: [PATCH 06/68] =?UTF-8?q?refactor:=20shared/hooks=20=ED=9B=85=20Ap?= =?UTF-8?q?p=20=EC=83=81=ED=83=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 113 ++++++---------------------------------------- 1 file changed, 14 insertions(+), 99 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 02297999..5134882f 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -5,55 +5,25 @@ import { ProductWithUI, initialProducts } from './entities/product'; import { initialCoupons } from './entities/coupon'; import { calculateItemTotal, calculateCartTotal } from './entities/cart'; -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} +import { useLocalStorage, useNotification, useDebounce } from './shared/hooks'; const App = () => { // ============================================================================ // 상태 관리 - localStorage와 연동된 데이터 상태들 // ============================================================================ - // 상품 목록 상태 (localStorage에서 복원) - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); + // localStorage와 연동된 데이터 상태들 + const [products, setProducts] = useLocalStorage('products', initialProducts); + const [cart, setCart] = useLocalStorage('cart', []); + const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); - // 장바구니 상태 (localStorage에서 복원) - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); + // 알림 시스템 + const { notifications, addNotification, removeNotification } = + useNotification(); - // 쿠폰 목록 상태 (localStorage에서 복원) - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); + // 검색 기능 + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounce(searchTerm, 500); // ============================================================================ // UI 상태 관리 - 화면 표시 및 사용자 인터랙션 관련 상태들 @@ -61,14 +31,11 @@ const App = () => { 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(''); // 디바운스된 검색어 // ============================================================================ // 관리자 폼 상태 - 상품/쿠폰 편집을 위한 폼 데이터 @@ -114,12 +81,12 @@ const App = () => { }; // 장바구니 전체 금액 계산 (쿠폰 할인 포함) - entities/cart 함수 사용 - const calculateCartTotalWithCoupon = (): { + const calculateCartTotalWithCoupon = useCallback((): { totalBeforeDiscount: number; totalAfterDiscount: number; } => { return calculateCartTotal(cart, selectedCoupon); - }; + }, [cart, selectedCoupon]); // 남은 재고 계산 (전체 재고 - 장바구니 수량) const getRemainingStock = (product: Product): number => { @@ -129,23 +96,6 @@ const App = () => { return remaining; }; - // ============================================================================ - // 알림 시스템 - 사용자에게 피드백 제공 - // ============================================================================ - - // 알림 추가 함수 (3초 후 자동 제거) - 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); - }, - [] - ); - // ============================================================================ // 파생 상태 - 다른 상태로부터 계산되는 값들 // ============================================================================ @@ -158,37 +108,6 @@ const App = () => { setTotalItemCount(count); }, [cart]); - // ============================================================================ - // localStorage 동기화 Effects - 상태 변경 시 localStorage에 저장 - // ============================================================================ - - 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]); - // ============================================================================ // 장바구니 관련 비즈니스 로직 // ============================================================================ @@ -471,11 +390,7 @@ const App = () => { > {notif.message} + ); +}; + +export default Button; From 5f3395fdfed1644de3af509ee6e7574b4bdcdc0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Tue, 5 Aug 2025 02:31:05 +0900 Subject: [PATCH 09/68] =?UTF-8?q?feat:=20shared/components=20NotificationT?= =?UTF-8?q?oast=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/components/NotificationToast.tsx | 51 +++++++++++++++++++ src/basic/shared/components/index.ts | 8 +++ 2 files changed, 59 insertions(+) create mode 100644 src/basic/shared/components/NotificationToast.tsx create mode 100644 src/basic/shared/components/index.ts diff --git a/src/basic/shared/components/NotificationToast.tsx b/src/basic/shared/components/NotificationToast.tsx new file mode 100644 index 00000000..ec858603 --- /dev/null +++ b/src/basic/shared/components/NotificationToast.tsx @@ -0,0 +1,51 @@ +import { Notification } from '../hooks/useNotification'; + +interface NotificationToastProps { + notifications: Notification[]; + onRemove: (id: string) => void; +} + +export default function NotificationToast({ + notifications, + onRemove, +}: NotificationToastProps) { + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ); +} diff --git a/src/basic/shared/components/index.ts b/src/basic/shared/components/index.ts new file mode 100644 index 00000000..eec11c23 --- /dev/null +++ b/src/basic/shared/components/index.ts @@ -0,0 +1,8 @@ +// 알림 관련 +export { default as NotificationToast } from './NotificationToast'; + +// 입력 관련 +export { default as SearchInput } from './SearchInput'; + +// 버튼 관련 +export { default as Button } from './Button'; From 0abf881684af899197c8951e738920eeed596ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Tue, 5 Aug 2025 02:31:31 +0900 Subject: [PATCH 10/68] =?UTF-8?q?refactor:=20shared/components=20App=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 181 ++++++++++++++++++---------------------------- 1 file changed, 72 insertions(+), 109 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 5134882f..918e0800 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -7,6 +7,8 @@ import { calculateItemTotal, calculateCartTotal } from './entities/cart'; import { useLocalStorage, useNotification, useDebounce } from './shared/hooks'; +import { NotificationToast, SearchInput, Button } from './shared/components'; + const App = () => { // ============================================================================ // 상태 관리 - localStorage와 연동된 데이터 상태들 @@ -36,13 +38,10 @@ const App = () => { 'products' ); // 관리자 탭 const [showProductForm, setShowProductForm] = useState(false); // 상품 폼 표시 여부 - // ============================================================================ // 관리자 폼 상태 - 상품/쿠폰 편집을 위한 폼 데이터 // ============================================================================ - const [editingProduct, setEditingProduct] = useState(null); // 편집 중인 상품 ID - // 상품 폼 데이터 const [productForm, setProductForm] = useState({ name: '', @@ -374,43 +373,11 @@ const App = () => { return (
- {/* 알림 시스템 - 우상단 고정 위치 */} - {notifications.length > 0 && ( -
- {notifications.map((notif) => ( -
- {notif.message} - -
- ))} -
- )} + {/* 알림 시스템 */} + {/* 헤더 - 검색바, 관리자 모드 토글, 장바구니 아이콘 */}
@@ -418,30 +385,24 @@ const App = () => {

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' - /> -
+ )}
- - + +
@@ -886,9 +849,9 @@ const App = () => {
- +
))}
- +

@@ -1065,19 +1029,17 @@ const App = () => {
- - + +
@@ -1191,17 +1153,15 @@ const App = () => {
{/* 장바구니 버튼 */} - +

); @@ -1271,9 +1231,10 @@ const App = () => {

{item.product.name}

- +
@@ -1344,9 +1305,9 @@ const App = () => {

쿠폰 할인

- +
{coupons.length > 0 && ( + 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='%' + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+ +
+ )} + + ); +} diff --git a/src/basic/features/products/management/ui/index.ts b/src/basic/features/products/management/ui/index.ts new file mode 100644 index 00000000..62296d4c --- /dev/null +++ b/src/basic/features/products/management/ui/index.ts @@ -0,0 +1 @@ +export { default as ProductManagement } from './ProductManagement'; From 4f9c882ca16d321baccd8016137aed3a88593d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Tue, 5 Aug 2025 22:29:50 +0900 Subject: [PATCH 15/68] =?UTF-8?q?feat:=20features/products/management/hook?= =?UTF-8?q?s=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useProducts --- .../products/management/hooks/index.ts | 1 + .../products/management/hooks/useProducts.ts | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 src/basic/features/products/management/hooks/index.ts create mode 100644 src/basic/features/products/management/hooks/useProducts.ts diff --git a/src/basic/features/products/management/hooks/index.ts b/src/basic/features/products/management/hooks/index.ts new file mode 100644 index 00000000..5977d886 --- /dev/null +++ b/src/basic/features/products/management/hooks/index.ts @@ -0,0 +1 @@ +export { useProducts } from './useProducts'; diff --git a/src/basic/features/products/management/hooks/useProducts.ts b/src/basic/features/products/management/hooks/useProducts.ts new file mode 100644 index 00000000..2ae395e3 --- /dev/null +++ b/src/basic/features/products/management/hooks/useProducts.ts @@ -0,0 +1,59 @@ +import { useCallback } from 'react'; +import { ProductWithUI } from '../../../../entities/product'; + +interface UseProductsProps { + products: ProductWithUI[]; + setProducts: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export function useProducts({ + products, + setProducts, + addNotification, +}: UseProductsProps) { + // 상품 추가 + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [setProducts, addNotification] + ); + + // 상품 업데이트 + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => + product.id === productId ? { ...product, ...updates } : product + ) + ); + addNotification('상품이 수정되었습니다.', 'success'); + }, + [setProducts, addNotification] + ); + + // 상품 삭제 + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification('상품이 삭제되었습니다.', 'success'); + }, + [setProducts, addNotification] + ); + + return { + products, + addProduct, + updateProduct, + deleteProduct, + }; +} From 9342589627347bcd5e0a41221cc5cb71fc95cc8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Tue, 5 Aug 2025 22:30:26 +0900 Subject: [PATCH 16/68] =?UTF-8?q?refactor:=20features/products=20App=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 620 ++++------------------------------------------ 1 file changed, 47 insertions(+), 573 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 97290fad..0c800cf1 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -5,10 +5,14 @@ import { ProductWithUI, initialProducts } from './entities/product'; import { initialCoupons } from './entities/coupon'; import { calculateItemTotal, calculateCartTotal } from './entities/cart'; -import { useLocalStorage, useNotification, useDebounce } from './shared/hooks'; - +import { useLocalStorage, useNotification } from './shared/hooks'; import { NotificationToast, SearchInput, Button } from './shared/ui'; +import { useProductSearch } from './features/products/list/hooks'; +import { useProducts } from './features/products/management/hooks'; +import { ProductManagement } from './features/products/management/ui'; +import { ProductList } from './features/products/list/ui'; + const App = () => { // ============================================================================ // 상태 관리 - localStorage와 연동된 데이터 상태들 @@ -23,35 +27,30 @@ const App = () => { const { notifications, addNotification, removeNotification } = useNotification(); - // 검색 기능 - const [searchTerm, setSearchTerm] = useState(''); - const debouncedSearchTerm = useDebounce(searchTerm, 500); + const { searchTerm, setSearchTerm, filteredProducts } = + useProductSearch(products); + + const { addProduct, updateProduct, deleteProduct } = useProducts({ + products, + setProducts, + addNotification, + }); // ============================================================================ // UI 상태 관리 - 화면 표시 및 사용자 인터랙션 관련 상태들 // ============================================================================ - const [selectedCoupon, setSelectedCoupon] = useState(null); // 선택된 쿠폰 - const [isAdmin, setIsAdmin] = useState(false); // 관리자 모드 여부 - const [showCouponForm, setShowCouponForm] = useState(false); // 쿠폰 폼 표시 여부 + const [selectedCoupon, setSelectedCoupon] = useState(null); + const [isAdmin, setIsAdmin] = useState(false); + const [showCouponForm, setShowCouponForm] = useState(false); const [activeTab, setActiveTab] = useState<'products' | 'coupons'>( 'products' - ); // 관리자 탭 - const [showProductForm, setShowProductForm] = useState(false); // 상품 폼 표시 여부 + ); + // ============================================================================ - // 관리자 폼 상태 - 상품/쿠폰 편집을 위한 폼 데이터 + // 관리자 폼 상태 - 쿠폰 편집을 위한 폼 데이터만 남음 (상품 폼은 ProductManagement에서 관리) // ============================================================================ - const [editingProduct, setEditingProduct] = useState(null); // 편집 중인 상품 ID - // 상품 폼 데이터 - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }>, - }); - // 쿠폰 폼 데이터 const [couponForm, setCouponForm] = useState({ name: '', code: '', @@ -91,7 +90,6 @@ const App = () => { const getRemainingStock = (product: Product): number => { const cartItem = cart.find((item) => item.product.id === product.id); const remaining = product.stock - (cartItem?.quantity || 0); - return remaining; }; @@ -99,7 +97,7 @@ const App = () => { // 파생 상태 - 다른 상태로부터 계산되는 값들 // ============================================================================ - const [totalItemCount, setTotalItemCount] = useState(0); // 장바구니 총 아이템 수 + const [totalItemCount, setTotalItemCount] = useState(0); // 장바구니 아이템 수 업데이트 useEffect(() => { @@ -195,7 +193,6 @@ const App = () => { (coupon: Coupon) => { const currentTotal = calculateCartTotalWithCoupon().totalAfterDiscount; - // 퍼센트 쿠폰 최소 주문 금액 검증 if (currentTotal < 10000 && coupon.discountType === 'percentage') { addNotification( 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', @@ -226,46 +223,7 @@ const App = () => { }, [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] - ); - - // ============================================================================ - // 관리자 - 쿠폰 관리 로직 + // 관리자 - 쿠폰 관리 로직 (상품 관리는 features/products로 이동) // ============================================================================ // 새 쿠폰 추가 @@ -295,32 +253,9 @@ const App = () => { ); // ============================================================================ - // 폼 제출 핸들러들 - 관리자 페이지 폼 처리 + // 폼 제출 핸들러들 - 쿠폰 폼만 남음 (상품 폼은 ProductManagement에서 관리) // ============================================================================ - // 상품 폼 제출 - 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(); @@ -334,41 +269,14 @@ const App = () => { 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 = calculateCartTotalWithCoupon(); // 장바구니 총액 계산 결과 - - // 검색 필터링된 상품 목록 - const filteredProducts = debouncedSearchTerm - ? products.filter( - (product) => - product.name - .toLowerCase() - .includes(debouncedSearchTerm.toLowerCase()) || - (product.description && - product.description - .toLowerCase() - .includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const totals = calculateCartTotalWithCoupon(); // ============================================================================ - // 거대한 JSX 렌더링 - 모든 UI 컴포넌트가 인라인으로 작성됨 + // 거대한 JSX 렌더링 - ProductList, ProductManagement 컴포넌트로 교체 // ============================================================================ return ( @@ -379,7 +287,7 @@ const App = () => { onRemove={removeNotification} /> - {/* 헤더 - 검색바, 관리자 모드 토글, 장바구니 아이콘 */} + {/* 헤더 */}
@@ -430,11 +338,11 @@ const App = () => {
- {/* 메인 컨텐츠 - 관리자 모드와 쇼핑몰 모드 조건부 렌더링 */} + {/* 메인 컨텐츠 */}
{isAdmin ? ( // ============================================================================ - // 관리자 페이지 전체 UI (상품 관리 + 쿠폰 관리) + // 관리자 페이지 // ============================================================================
@@ -473,355 +381,16 @@ const App = () => {
{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='%' - /> - % 할인 - -
- ))} - -
-
- -
- - -
-
-
- )} -
+ ) : ( - // ============================================================================ - // 쿠폰 관리 탭 - 쿠폰 카드 목록 + 쿠폰 추가 폼 - // ============================================================================ + // 쿠폰 관리 탭

쿠폰 관리

@@ -1050,7 +619,7 @@ const App = () => {
) : ( // ============================================================================ - // 쇼핑몰 메인 페이지 - 상품 목록 + 장바구니 사이드바 + // 쇼핑몰 메인 페이지 // ============================================================================
@@ -1064,114 +633,19 @@ const App = () => { 총 {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}개 -

- )} -
- {/* 장바구니 버튼 */} - -
-
- ); - })} -
- )} +
- {/* 장바구니 사이드바 */} + {/* 장바구니 사이드바 (기존 코드 유지) */}
{/* 장바구니 섹션 */} From f5de7d5cb3567a2825f4c737cfc26d69932cd463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Wed, 6 Aug 2025 14:49:57 +0900 Subject: [PATCH 17/68] =?UTF-8?q?feat:=20features/cart/hooks=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useCart --- src/basic/features/cart/hooks/index.ts | 1 + src/basic/features/cart/hooks/useCart.ts | 116 +++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/basic/features/cart/hooks/index.ts create mode 100644 src/basic/features/cart/hooks/useCart.ts diff --git a/src/basic/features/cart/hooks/index.ts b/src/basic/features/cart/hooks/index.ts new file mode 100644 index 00000000..bbe99fcd --- /dev/null +++ b/src/basic/features/cart/hooks/index.ts @@ -0,0 +1 @@ +export { useCart } from './useCart'; \ No newline at end of file diff --git a/src/basic/features/cart/hooks/useCart.ts b/src/basic/features/cart/hooks/useCart.ts new file mode 100644 index 00000000..4f512484 --- /dev/null +++ b/src/basic/features/cart/hooks/useCart.ts @@ -0,0 +1,116 @@ +import { useCallback } from 'react'; +import { CartItem, Product } from '../../../../types'; +import { ProductWithUI } from '../../../entities/product'; + +interface UseCartProps { + cart: CartItem[]; + setCart: React.Dispatch>; + products: ProductWithUI[]; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export function useCart({ + cart, + setCart, + products, + addNotification, +}: UseCartProps) { + // 남은 재고 계산 (전체 재고 - 장바구니 수량) + const getRemainingStock = useCallback( + (product: Product): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + return remaining; + }, + [cart] + ); + + // 장바구니에 상품 추가 + 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'); + }, + [addNotification, getRemainingStock, setCart] + ); + + // 장바구니에서 상품 제거 + const removeFromCart = useCallback( + (productId: string) => { + setCart((prevCart) => + prevCart.filter((item) => item.product.id !== productId) + ); + }, + [setCart] + ); + + // 장바구니 상품 수량 업데이트 + 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, setCart] + ); + + return { + cart, + addToCart, + removeFromCart, + updateQuantity, + getRemainingStock, + }; +} \ No newline at end of file From 4dfd567214f727198a144548c0525a79027b0dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Wed, 6 Aug 2025 14:50:19 +0900 Subject: [PATCH 18/68] =?UTF-8?q?feat:=20features/cart/ui=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CartSidebar --- src/basic/features/cart/ui/CartSidebar.tsx | 228 +++++++++++++++++++++ src/basic/features/cart/ui/index.ts | 1 + 2 files changed, 229 insertions(+) create mode 100644 src/basic/features/cart/ui/CartSidebar.tsx create mode 100644 src/basic/features/cart/ui/index.ts diff --git a/src/basic/features/cart/ui/CartSidebar.tsx b/src/basic/features/cart/ui/CartSidebar.tsx new file mode 100644 index 00000000..cbc64c01 --- /dev/null +++ b/src/basic/features/cart/ui/CartSidebar.tsx @@ -0,0 +1,228 @@ +import { CartItem, Coupon } from '../../../../types'; +import { calculateItemTotal } from '../../../entities/cart'; +import { Button } from '../../../shared/ui'; + +interface CartSidebarProps { + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + onRemoveFromCart: (productId: string) => void; + onUpdateQuantity: (productId: string, newQuantity: number) => void; + onApplyCoupon: (coupon: Coupon) => void; + onSetSelectedCoupon: (coupon: Coupon | null) => void; + onCompleteOrder: () => void; +} + +export function CartSidebar({ + cart, + coupons, + selectedCoupon, + totals, + onRemoveFromCart, + onUpdateQuantity, + onApplyCoupon, + onSetSelectedCoupon, + onCompleteOrder, +}: CartSidebarProps) { + return ( +
+ {/* 장바구니 섹션 */} +
+

+ + + + 장바구니 +

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

장바구니가 비어있습니다

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

+ {item.product.name} +

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

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

+
+
+
+ ); + })} +
+ )} +
+ + {/* 쿠폰 할인 섹션 */} + {cart.length > 0 && ( + <> +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ + {/* 결제 정보 섹션 */} +
+

결제 정보

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

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

+
+
+ + )} +
+ ); +} \ No newline at end of file diff --git a/src/basic/features/cart/ui/index.ts b/src/basic/features/cart/ui/index.ts new file mode 100644 index 00000000..239c5b3a --- /dev/null +++ b/src/basic/features/cart/ui/index.ts @@ -0,0 +1 @@ +export { CartSidebar } from './CartSidebar'; \ No newline at end of file From f41d9390efef7e14b58794cc9e4a4c14bf079c6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Wed, 6 Aug 2025 14:50:30 +0900 Subject: [PATCH 19/68] =?UTF-8?q?refactor:=20features/cart=20App=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 321 ++++------------------------------------------ 1 file changed, 23 insertions(+), 298 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 0c800cf1..c7db87ee 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -13,6 +13,9 @@ import { useProducts } from './features/products/management/hooks'; import { ProductManagement } from './features/products/management/ui'; import { ProductList } from './features/products/list/ui'; +import { useCart } from './features/cart/hooks'; +import { CartSidebar } from './features/cart/ui'; + const App = () => { // ============================================================================ // 상태 관리 - localStorage와 연동된 데이터 상태들 @@ -36,6 +39,14 @@ const App = () => { addNotification, }); + const { addToCart, removeFromCart, updateQuantity, getRemainingStock } = + useCart({ + cart, + setCart, + products, + addNotification, + }); + // ============================================================================ // UI 상태 관리 - 화면 표시 및 사용자 인터랙션 관련 상태들 // ============================================================================ @@ -86,13 +97,6 @@ const App = () => { return calculateCartTotal(cart, selectedCoupon); }, [cart, selectedCoupon]); - // 남은 재고 계산 (전체 재고 - 장바구니 수량) - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find((item) => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - return remaining; - }; - // ============================================================================ // 파생 상태 - 다른 상태로부터 계산되는 값들 // ============================================================================ @@ -105,85 +109,6 @@ const App = () => { setTotalItemCount(count); }, [cart]); - // ============================================================================ - // 장바구니 관련 비즈니스 로직 - // ============================================================================ - - // 장바구니에 상품 추가 - 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] - ); - // ============================================================================ // 쿠폰 관련 비즈니스 로직 // ============================================================================ @@ -645,219 +570,19 @@ const App = () => {
- {/* 장바구니 사이드바 (기존 코드 유지) */} + {/* 장바구니 사이드바 */}
-
- {/* 장바구니 섹션 */} -
-

- - - - 장바구니 -

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

- 장바구니가 비어있습니다 -

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

- {item.product.name} -

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

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

-
-
-
- ); - })} -
- )} -
- - {/* 쿠폰 할인 섹션 */} - {cart.length > 0 && ( - <> -
-
-

- 쿠폰 할인 -

- -
- {coupons.length > 0 && ( - - )} -
- - {/* 결제 정보 섹션 */} -
-

결제 정보

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

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

-
-
- - )} -
+
)} From 806d3793289c2bbe8be69e0376eab914255f06d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Wed, 6 Aug 2025 15:19:24 +0900 Subject: [PATCH 20/68] =?UTF-8?q?feat:=20features/coupons/ui=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CouponManagement --- .../features/coupons/ui/CouponManagement.tsx | 261 ++++++++++++++++++ src/basic/features/coupons/ui/index.ts | 1 + 2 files changed, 262 insertions(+) create mode 100644 src/basic/features/coupons/ui/CouponManagement.tsx create mode 100644 src/basic/features/coupons/ui/index.ts diff --git a/src/basic/features/coupons/ui/CouponManagement.tsx b/src/basic/features/coupons/ui/CouponManagement.tsx new file mode 100644 index 00000000..69866058 --- /dev/null +++ b/src/basic/features/coupons/ui/CouponManagement.tsx @@ -0,0 +1,261 @@ +import { useState } from 'react'; +import { Coupon } from '../../../../types'; +import { Button } from '../../../shared/ui'; + +interface CouponManagementProps { + coupons: Coupon[]; + onAddCoupon: (coupon: Coupon) => void; + onDeleteCoupon: (couponCode: string) => void; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export function CouponManagement({ + coupons, + onAddCoupon, + onDeleteCoupon, + addNotification, +}: CouponManagementProps) { + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0, + }); + + // 쿠폰 폼 제출 + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onAddCoupon(couponForm); + setCouponForm({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + setShowCouponForm(false); + }; + + return ( +
+
+

쿠폰 관리

+
+
+
+ {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 + /> +
+
+
+ + +
+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/basic/features/coupons/ui/index.ts b/src/basic/features/coupons/ui/index.ts new file mode 100644 index 00000000..0f0da3bc --- /dev/null +++ b/src/basic/features/coupons/ui/index.ts @@ -0,0 +1 @@ +export { CouponManagement } from './CouponManagement'; \ No newline at end of file From f897d28cab400c61de4e3a8184ec6f1d07e432ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Wed, 6 Aug 2025 15:19:47 +0900 Subject: [PATCH 21/68] =?UTF-8?q?feat:=20features/coupons/hooks=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useCoupons --- src/basic/features/coupons/hooks/index.ts | 1 + .../features/coupons/hooks/useCoupons.ts | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/basic/features/coupons/hooks/index.ts create mode 100644 src/basic/features/coupons/hooks/useCoupons.ts diff --git a/src/basic/features/coupons/hooks/index.ts b/src/basic/features/coupons/hooks/index.ts new file mode 100644 index 00000000..c12e168c --- /dev/null +++ b/src/basic/features/coupons/hooks/index.ts @@ -0,0 +1 @@ +export { useCoupons } from './useCoupons'; \ No newline at end of file diff --git a/src/basic/features/coupons/hooks/useCoupons.ts b/src/basic/features/coupons/hooks/useCoupons.ts new file mode 100644 index 00000000..1b2630ce --- /dev/null +++ b/src/basic/features/coupons/hooks/useCoupons.ts @@ -0,0 +1,80 @@ +import { useCallback } from 'react'; +import { Coupon } from '../../../../types'; + +interface UseCouponsProps { + coupons: Coupon[]; + setCoupons: React.Dispatch>; + selectedCoupon: Coupon | null; + setSelectedCoupon: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; + calculateCartTotalWithCoupon: () => { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; +} + +export function useCoupons({ + coupons, + setCoupons, + selectedCoupon, + setSelectedCoupon, + addNotification, + calculateCartTotalWithCoupon, +}: UseCouponsProps) { + // 새 쿠폰 추가 + 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, setCoupons] + ); + + // 쿠폰 삭제 + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }, + [selectedCoupon, addNotification, setCoupons, setSelectedCoupon] + ); + + // 쿠폰 적용 + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotalWithCoupon().totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + addNotification( + 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + 'error' + ); + return; + } + + setSelectedCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, + [addNotification, calculateCartTotalWithCoupon, setSelectedCoupon] + ); + + return { + coupons, + selectedCoupon, + addCoupon, + deleteCoupon, + applyCoupon, + setSelectedCoupon, + }; +} \ No newline at end of file From 9c009454c50c275dcf4316aef0d88a4041d920c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Wed, 6 Aug 2025 15:20:03 +0900 Subject: [PATCH 22/68] =?UTF-8?q?refactor:=20features/coupons=20App=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 372 ++++++---------------------------------------- 1 file changed, 42 insertions(+), 330 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index c7db87ee..65516715 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -3,7 +3,7 @@ import { CartItem, Coupon, Product } from '../types'; import { ProductWithUI, initialProducts } from './entities/product'; import { initialCoupons } from './entities/coupon'; -import { calculateItemTotal, calculateCartTotal } from './entities/cart'; +import { calculateCartTotal } from './entities/cart'; import { useLocalStorage, useNotification } from './shared/hooks'; import { NotificationToast, SearchInput, Button } from './shared/ui'; @@ -16,6 +16,9 @@ import { ProductList } from './features/products/list/ui'; import { useCart } from './features/cart/hooks'; import { CartSidebar } from './features/cart/ui'; +import { useCoupons } from './features/coupons/hooks'; +import { CouponManagement } from './features/coupons/ui'; + const App = () => { // ============================================================================ // 상태 관리 - localStorage와 연동된 데이터 상태들 @@ -26,49 +29,17 @@ const App = () => { const [cart, setCart] = useLocalStorage('cart', []); const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); - // 알림 시스템 - const { notifications, addNotification, removeNotification } = - useNotification(); - - const { searchTerm, setSearchTerm, filteredProducts } = - useProductSearch(products); - - const { addProduct, updateProduct, deleteProduct } = useProducts({ - products, - setProducts, - addNotification, - }); - - const { addToCart, removeFromCart, updateQuantity, getRemainingStock } = - useCart({ - cart, - setCart, - products, - addNotification, - }); - // ============================================================================ // UI 상태 관리 - 화면 표시 및 사용자 인터랙션 관련 상태들 // ============================================================================ + // UI 상태 관리 const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [showCouponForm, setShowCouponForm] = useState(false); const [activeTab, setActiveTab] = useState<'products' | 'coupons'>( 'products' ); - // ============================================================================ - // 관리자 폼 상태 - 쿠폰 편집을 위한 폼 데이터만 남음 (상품 폼은 ProductManagement에서 관리) - // ============================================================================ - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0, - }); - // ============================================================================ // 유틸리티 함수들 - 데이터 포맷팅 및 계산 로직 // ============================================================================ @@ -97,6 +68,36 @@ const App = () => { return calculateCartTotal(cart, selectedCoupon); }, [cart, selectedCoupon]); + // 알림 시스템 + const { notifications, addNotification, removeNotification } = + useNotification(); + + const { searchTerm, setSearchTerm, filteredProducts } = + useProductSearch(products); + + const { addProduct, updateProduct, deleteProduct } = useProducts({ + products, + setProducts, + addNotification, + }); + + const { addToCart, removeFromCart, updateQuantity, getRemainingStock } = + useCart({ + cart, + setCart, + products, + addNotification, + }); + + const { addCoupon, deleteCoupon, applyCoupon } = useCoupons({ + coupons, + setCoupons, + selectedCoupon, + setSelectedCoupon, + addNotification, + calculateCartTotalWithCoupon, + }); + // ============================================================================ // 파생 상태 - 다른 상태로부터 계산되는 값들 // ============================================================================ @@ -109,29 +110,6 @@ const App = () => { setTotalItemCount(count); }, [cart]); - // ============================================================================ - // 쿠폰 관련 비즈니스 로직 - // ============================================================================ - - // 쿠폰 적용 - const applyCoupon = useCallback( - (coupon: Coupon) => { - const currentTotal = calculateCartTotalWithCoupon().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification( - 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', - 'error' - ); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, - [addNotification, calculateCartTotalWithCoupon] - ); - // ============================================================================ // 주문 처리 로직 // ============================================================================ @@ -145,54 +123,7 @@ const App = () => { ); setCart([]); setSelectedCoupon(null); - }, [addNotification]); - - // ============================================================================ - // 관리자 - 쿠폰 관리 로직 (상품 관리는 features/products로 이동) - // ============================================================================ - - // 새 쿠폰 추가 - 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] - ); - - // ============================================================================ - // 폼 제출 핸들러들 - 쿠폰 폼만 남음 (상품 폼은 ProductManagement에서 관리) - // ============================================================================ - - // 쿠폰 폼 제출 - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0, - }); - setShowCouponForm(false); - }; + }, [addNotification, setCart]); // ============================================================================ // 계산된 값들 - 렌더링에 필요한 파생 데이터 @@ -315,231 +246,12 @@ const App = () => { addNotification={addNotification} /> ) : ( - // 쿠폰 관리 탭 -
-
-

쿠폰 관리

-
-
-
- {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 - /> -
-
-
- - -
-
-
- )} -
-
+ )}
) : ( From dbb8b6a0ea81cbabbbd2fb63b417c21a8255ffeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Wed, 6 Aug 2025 21:27:05 +0900 Subject: [PATCH 23/68] =?UTF-8?q?feat:=20features/order/hooks=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useOrder --- src/basic/features/order/hooks/index.ts | 1 + src/basic/features/order/hooks/useOrder.ts | 32 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 src/basic/features/order/hooks/index.ts create mode 100644 src/basic/features/order/hooks/useOrder.ts diff --git a/src/basic/features/order/hooks/index.ts b/src/basic/features/order/hooks/index.ts new file mode 100644 index 00000000..caa7eff1 --- /dev/null +++ b/src/basic/features/order/hooks/index.ts @@ -0,0 +1 @@ +export { useOrder } from './useOrder'; \ No newline at end of file diff --git a/src/basic/features/order/hooks/useOrder.ts b/src/basic/features/order/hooks/useOrder.ts new file mode 100644 index 00000000..e1f8768d --- /dev/null +++ b/src/basic/features/order/hooks/useOrder.ts @@ -0,0 +1,32 @@ +import { useCallback } from 'react'; +import { CartItem, Coupon } from '../../../../types'; + +interface UseOrderProps { + setCart: React.Dispatch>; + setSelectedCoupon: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export function useOrder({ + setCart, + setSelectedCoupon, + addNotification, +}: UseOrderProps) { + // 주문 완료 처리 + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + 'success' + ); + setCart([]); + setSelectedCoupon(null); + }, [addNotification, setCart, setSelectedCoupon]); + + return { + completeOrder, + }; +} \ No newline at end of file From 7732dc2b6659be779baa9a549878a29c1c1da527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Wed, 6 Aug 2025 21:27:19 +0900 Subject: [PATCH 24/68] =?UTF-8?q?refactor:=20features/order=20App=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 65516715..08162d33 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -19,6 +19,8 @@ import { CartSidebar } from './features/cart/ui'; import { useCoupons } from './features/coupons/hooks'; import { CouponManagement } from './features/coupons/ui'; +import { useOrder } from './features/order/hooks'; + const App = () => { // ============================================================================ // 상태 관리 - localStorage와 연동된 데이터 상태들 @@ -98,6 +100,12 @@ const App = () => { calculateCartTotalWithCoupon, }); + const { completeOrder } = useOrder({ + setCart, + setSelectedCoupon, + addNotification, + }); + // ============================================================================ // 파생 상태 - 다른 상태로부터 계산되는 값들 // ============================================================================ @@ -110,20 +118,6 @@ const App = () => { setTotalItemCount(count); }, [cart]); - // ============================================================================ - // 주문 처리 로직 - // ============================================================================ - - // 주문 완료 처리 - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification( - `주문이 완료되었습니다. 주문번호: ${orderNumber}`, - 'success' - ); - setCart([]); - setSelectedCoupon(null); - }, [addNotification, setCart]); // ============================================================================ // 계산된 값들 - 렌더링에 필요한 파생 데이터 From 002d7895f44405fabc3c27d94c3b92ba1ad68f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 12:01:44 +0900 Subject: [PATCH 25/68] =?UTF-8?q?feat:=20pages/ShoppingPage.tsx=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShoppingPage.tsx --- src/basic/__tests__/origin.test.tsx | 301 ++++++++++++++++------------ src/basic/pages/ShoppingPage.tsx | 93 +++++++++ src/basic/pages/index.ts | 1 + 3 files changed, 264 insertions(+), 131 deletions(-) create mode 100644 src/basic/pages/ShoppingPage.tsx create mode 100644 src/basic/pages/index.ts diff --git a/src/basic/__tests__/origin.test.tsx b/src/basic/__tests__/origin.test.tsx index 3f5c3d55..38dfbc80 100644 --- a/src/basic/__tests__/origin.test.tsx +++ b/src/basic/__tests__/origin.test.tsx @@ -1,5 +1,11 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + within, + waitFor, +} from '@testing-library/react'; import { vi } from 'vitest'; import App from '../App'; import '../../setupTests'; @@ -20,25 +26,30 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('고객 쇼핑 플로우', () => { test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { render(); - + // 검색창에 "프리미엄" 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 디바운스 대기 - await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - }, { timeout: 600 }); - + await waitFor( + () => { + expect( + screen.getByText('최고급 품질의 프리미엄 상품입니다.') + ).toBeInTheDocument(); + }, + { timeout: 600 } + ); + // 검색된 상품을 장바구니에 추가 (첫 번째 버튼 선택) const addButtons = screen.getAllByText('장바구니 담기'); fireEvent.click(addButtons[0]); - + // 알림 메시지 확인 await waitFor(() => { expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); }); - + // 장바구니에 추가됨 확인 (장바구니 섹션에서) const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -46,64 +57,66 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { render(); - + // 상품1을 장바구니에 추가 const product1 = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(product1); - + // 수량을 10개로 증가 (10% 할인 적용) const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 9; i++) { fireEvent.click(plusButton); } - + // 10% 할인 적용 확인 - 15% (대량 구매 시 추가 5% 포함) expect(screen.getByText('-15%')).toBeInTheDocument(); }); test('쿠폰을 선택하고 적용할 수 있다', () => { render(); - + // 상품 추가 const addButton = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(addButton); - + // 쿠폰 선택 const couponSelect = screen.getByRole('combobox'); fireEvent.change(couponSelect, { target: { value: 'AMOUNT5000' } }); - + // 결제 정보에서 할인 금액 확인 const paymentSection = screen.getByText('결제 정보').closest('section'); - const discountRow = within(paymentSection).getByText('할인 금액').closest('div'); + const discountRow = within(paymentSection) + .getByText('할인 금액') + .closest('div'); expect(within(discountRow).getByText('-5,000원')).toBeInTheDocument(); }); test('품절 임박 상품에 경고가 표시된다', async () => { render(); - + // 관리자 모드로 전환 fireEvent.click(screen.getByText('관리자 페이지로')); - + // 상품 수정 const editButton = screen.getAllByText('수정')[0]; fireEvent.click(editButton); - + // 재고를 5개로 변경 const stockInputs = screen.getAllByPlaceholderText('숫자만 입력'); const stockInput = stockInputs[1]; // 재고 입력 필드는 두 번째 fireEvent.change(stockInput, { target: { value: '5' } }); fireEvent.blur(stockInput); - + // 수정 완료 버튼 클릭 const editButtons = screen.getAllByText('수정'); const completeEditButton = editButtons[editButtons.length - 1]; // 마지막 수정 버튼 (완료 버튼) fireEvent.click(completeEditButton); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 품절임박 메시지 확인 - 재고가 5개 이하면 품절임박 표시 await waitFor(() => { expect(screen.getByText('품절임박! 5개 남음')).toBeInTheDocument(); @@ -112,39 +125,39 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('주문을 완료할 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 결제하기 버튼 클릭 const orderButton = screen.getByText(/원 결제하기/); fireEvent.click(orderButton); - + // 주문 완료 알림 확인 expect(screen.getByText(/주문이 완료되었습니다/)).toBeInTheDocument(); - + // 장바구니가 비어있는지 확인 expect(screen.getByText('장바구니가 비어있습니다')).toBeInTheDocument(); }); test('장바구니에서 상품을 삭제할 수 있다', () => { render(); - + // 상품 2개 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 장바구니 섹션 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); - + // 첫 번째 상품 삭제 (X 버튼) - const deleteButtons = within(cartSection).getAllByRole('button').filter( - button => button.querySelector('svg') - ); + const deleteButtons = within(cartSection) + .getAllByRole('button') + .filter((button) => button.querySelector('svg')); fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되고 상품2만 남음 expect(within(cartSection).queryByText('상품1')).not.toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); @@ -152,54 +165,56 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('재고를 초과하여 구매할 수 없다', async () => { render(); - + // 상품1 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 재고(20개) 이상으로 증가 시도 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + // 19번 클릭하여 총 20개로 만듦 for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 한 번 더 클릭 시도 (21개가 되려고 함) fireEvent.click(plusButton); - + // 수량이 20개에서 멈춰있어야 함 expect(within(cartSection).getByText('20')).toBeInTheDocument(); - + // 재고 부족 메시지 확인 await waitFor(() => { - expect(screen.getByText(/재고는.*개까지만 있습니다/)).toBeInTheDocument(); + expect( + screen.getByText(/재고는.*개까지만 있습니다/) + ).toBeInTheDocument(); }); }); 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 마이너스 기호 - + // 수량 3개로 증가 fireEvent.click(plusButton); fireEvent.click(plusButton); expect(within(cartSection).getByText('3')).toBeInTheDocument(); - + // 수량 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('2')).toBeInTheDocument(); - + // 1개로 더 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('1')).toBeInTheDocument(); - + // 1개에서 한 번 더 감소하면 장바구니에서 제거될 수도 있음 fireEvent.click(minusButton); // 장바구니가 비었는지 확인 @@ -214,31 +229,31 @@ describe('쇼핑몰 앱 통합 테스트', () => { 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(editButtons[editButtons.length - 1]); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 상품1을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 20개로 증가 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 25% 할인 적용 확인 (또는 대량 구매 시 30%) await waitFor(() => { const discount25 = screen.queryByText('-25%'); @@ -258,27 +273,27 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('새 상품을 추가할 수 있다', () => { // 새 상품 추가 버튼 클릭 fireEvent.click(screen.getByText('새 상품 추가')); - + // 폼 입력 - 상품명 입력 const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + 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 descLabel = descLabels.find((el) => el.tagName === 'LABEL'); const descInput = descLabel.closest('div').querySelector('input'); fireEvent.change(descInput, { target: { value: '테스트 설명' } }); - + // 저장 fireEvent.click(screen.getByText('추가')); - + // 추가된 상품 확인 expect(screen.getByText('테스트 상품')).toBeInTheDocument(); expect(screen.getByText('25,000원')).toBeInTheDocument(); @@ -287,21 +302,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 버튼 클릭 const addCouponButton = screen.getByText('새 쿠폰 추가'); fireEvent.click(addCouponButton); - + // 쿠폰 정보 입력 - fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { target: { value: '테스트 쿠폰' } }); - fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { target: { value: 'TEST2024' } }); - + fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { + target: { value: '테스트 쿠폰' }, + }); + fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { + target: { value: 'TEST2024' }, + }); + const discountInput = screen.getByPlaceholderText('5000'); fireEvent.change(discountInput, { target: { value: '7000' } }); - + // 쿠폰 생성 fireEvent.click(screen.getByText('쿠폰 생성')); - + // 생성된 쿠폰 확인 expect(screen.getByText('테스트 쿠폰')).toBeInTheDocument(); expect(screen.getByText('TEST2024')).toBeInTheDocument(); @@ -311,25 +330,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('상품의 가격 입력 시 숫자만 허용된다', async () => { // 상품 수정 fireEvent.click(screen.getAllByText('수정')[0]); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - + // 문자와 숫자 혼합 입력 시도 - 숫자만 남음 fireEvent.change(priceInput, { target: { value: 'abc123def' } }); expect(priceInput.value).toBe('10000'); // 유효하지 않은 입력은 무시됨 - + // 숫자만 입력 fireEvent.change(priceInput, { target: { value: '123' } }); expect(priceInput.value).toBe('123'); - + // 음수 입력 시도 - regex가 매치되지 않아 값이 변경되지 않음 fireEvent.change(priceInput, { target: { value: '-100' } }); expect(priceInput.value).toBe('123'); // 이전 값 유지 - + // 유효한 음수 입력하기 위해 먼저 1 입력 후 앞에 - 추가는 불가능 // 대신 blur 이벤트를 통해 음수 검증을 테스트 // parseInt()는 실제로 음수를 파싱할 수 있으므로 다른 방법으로 테스트 - + // 공백 입력 시도 fireEvent.change(priceInput, { target: { value: ' ' } }); expect(priceInput.value).toBe('123'); // 유효하지 않은 입력은 무시됨 @@ -338,23 +357,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 할인율 검증이 작동한다', async () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 fireEvent.click(screen.getByText('새 쿠폰 추가')); - + // 퍼센트 타입으로 변경 - 쿠폰 폼 내의 select 찾기 const couponFormSelects = screen.getAllByRole('combobox'); const typeSelect = couponFormSelects[couponFormSelects.length - 1]; // 마지막 select가 타입 선택 fireEvent.change(typeSelect, { target: { value: 'percentage' } }); - + // 100% 초과 할인율 입력 const discountInput = screen.getByPlaceholderText('10'); fireEvent.change(discountInput, { target: { value: '150' } }); fireEvent.blur(discountInput); - + // 에러 메시지 확인 await waitFor(() => { - expect(screen.getByText('할인율은 100%를 초과할 수 없습니다')).toBeInTheDocument(); + expect( + screen.getByText('할인율은 100%를 초과할 수 없습니다') + ).toBeInTheDocument(); }); }); @@ -362,15 +383,15 @@ describe('쇼핑몰 앱 통합 테스트', () => { // 초기 상품명들 확인 (테이블에서) const productTable = screen.getByRole('table'); expect(within(productTable).getByText('상품1')).toBeInTheDocument(); - + // 삭제 버튼들 찾기 - const deleteButtons = within(productTable).getAllByRole('button').filter( - button => button.textContent === '삭제' - ); - + const deleteButtons = within(productTable) + .getAllByRole('button') + .filter((button) => button.textContent === '삭제'); + // 첫 번째 상품 삭제 fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되었는지 확인 expect(within(productTable).queryByText('상품1')).not.toBeInTheDocument(); expect(within(productTable).getByText('상품2')).toBeInTheDocument(); @@ -379,76 +400,79 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰을 삭제할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 초기 쿠폰들 확인 (h3 제목에서) const couponTitles = screen.getAllByRole('heading', { level: 3 }); - const coupon5000 = couponTitles.find(el => el.textContent === '5000원 할인'); - const coupon10 = couponTitles.find(el => el.textContent === '10% 할인'); + 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(); }); - }); describe('로컬스토리지 동기화', () => { test('상품, 장바구니, 쿠폰이 localStorage에 저장된다', () => { render(); - + // 상품을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // localStorage 확인 expect(localStorage.getItem('cart')).toBeTruthy(); expect(JSON.parse(localStorage.getItem('cart'))).toHaveLength(1); - + // 관리자 모드로 전환하여 새 상품 추가 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getByText('새 상품 추가')); - + const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const 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(products.some((p) => p.name === '저장 테스트')).toBe(true); }); test('페이지 새로고침 후에도 데이터가 유지된다', () => { const { unmount } = render(); - + // 장바구니에 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 컴포넌트 unmount unmount(); - + // 다시 mount render(); - + // 장바구니 아이템이 유지되는지 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -459,13 +483,13 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('UI 상태 관리', () => { test('할인이 있을 때 할인율이 표시된다', async () => { render(); - + // 상품을 10개 담아서 할인 발생 const addButton = screen.getAllByText('장바구니 담기')[0]; for (let i = 0; i < 10; i++) { fireEvent.click(addButton); } - + // 할인율 표시 확인 - 대량 구매로 15% 할인 await waitFor(() => { expect(screen.getByText('-15%')).toBeInTheDocument(); @@ -474,12 +498,12 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니 아이템 개수가 헤더에 표시된다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 헤더의 장바구니 아이콘 옆 숫자 확인 const cartCount = screen.getByText('3'); expect(cartCount).toBeInTheDocument(); @@ -487,42 +511,57 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('검색을 초기화할 수 있다', async () => { render(); - + // 검색어 입력 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: '' } }); - + // 모든 상품이 다시 표시됨 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(); - + // 상품 추가하여 알림 발생 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 알림 메시지 확인 expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); - + // 3초 후 알림이 사라짐 - await waitFor(() => { - expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); - }, { timeout: 4000 }); + await waitFor( + () => { + expect( + screen.queryByText('장바구니에 담았습니다') + ).not.toBeInTheDocument(); + }, + { timeout: 4000 } + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/basic/pages/ShoppingPage.tsx b/src/basic/pages/ShoppingPage.tsx new file mode 100644 index 00000000..43456ed9 --- /dev/null +++ b/src/basic/pages/ShoppingPage.tsx @@ -0,0 +1,93 @@ +import { useMemo } from 'react'; +import { CartItem, Coupon } from '../../types'; +import { ProductWithUI } from '../entities/product'; +import { calculateCartTotal } from '../entities/cart'; +import { ProductList } from '../features/products/list/ui'; +import { CartSidebar } from '../features/cart/ui'; + +interface ShoppingPageProps { + // 상태들 + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + searchTerm: string; + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + + // 상품 관련 핸들러 + onAddToCart: (product: ProductWithUI) => void; + getRemainingStock: (product: ProductWithUI) => number; + formatPrice: (price: number, productId?: string) => string; + + // 장바구니 관련 핸들러 + onRemoveFromCart: (productId: string) => void; + onUpdateQuantity: (productId: string, newQuantity: number) => void; + + // 쿠폰 관련 핸들러 + onApplyCoupon: (coupon: Coupon) => void; + onSetSelectedCoupon: (coupon: Coupon | null) => void; + + // 주문 관련 핸들러 + onCompleteOrder: () => void; +} + +export default function ShoppingPage({ + products, + filteredProducts, + searchTerm, + cart, + coupons, + selectedCoupon, + onAddToCart, + getRemainingStock, + formatPrice, + onRemoveFromCart, + onUpdateQuantity, + onApplyCoupon, + onSetSelectedCoupon, + onCompleteOrder, +}: ShoppingPageProps) { + // 장바구니 전체 금액 계산 (쿠폰 할인 포함) + const totals = useMemo(() => { + return calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + return ( +
+
+ {/* 상품 목록 섹션 */} +
+
+

전체 상품

+
+ 총 {products.length}개 상품 +
+
+ + +
+
+ + {/* 장바구니 사이드바 */} +
+ +
+
+ ); +} diff --git a/src/basic/pages/index.ts b/src/basic/pages/index.ts new file mode 100644 index 00000000..e57ae19e --- /dev/null +++ b/src/basic/pages/index.ts @@ -0,0 +1 @@ +export { default as ShoppingPage } from './ShoppingPage'; From 9b68fa3a2f3fec4f0b00a6eb3e27de50ed1a74b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 12:03:01 +0900 Subject: [PATCH 26/68] =?UTF-8?q?refactor:=20pages/ShoppingPage.tsx=20App?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 66 ++++++++++++++--------------------------------- 1 file changed, 20 insertions(+), 46 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 08162d33..d46a6a3f 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,7 +1,7 @@ import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { CartItem, Coupon } from '../types'; -import { ProductWithUI, initialProducts } from './entities/product'; +import { initialProducts } from './entities/product'; import { initialCoupons } from './entities/coupon'; import { calculateCartTotal } from './entities/cart'; @@ -11,15 +11,14 @@ import { NotificationToast, SearchInput, Button } from './shared/ui'; import { useProductSearch } from './features/products/list/hooks'; import { useProducts } from './features/products/management/hooks'; import { ProductManagement } from './features/products/management/ui'; -import { ProductList } from './features/products/list/ui'; import { useCart } from './features/cart/hooks'; -import { CartSidebar } from './features/cart/ui'; import { useCoupons } from './features/coupons/hooks'; import { CouponManagement } from './features/coupons/ui'; import { useOrder } from './features/order/hooks'; +import ShoppingPage from './pages/ShoppingPage'; const App = () => { // ============================================================================ @@ -74,8 +73,7 @@ const App = () => { const { notifications, addNotification, removeNotification } = useNotification(); - const { searchTerm, setSearchTerm, filteredProducts } = - useProductSearch(products); + const { searchTerm, setSearchTerm, filteredProducts } = useProductSearch(products); const { addProduct, updateProduct, deleteProduct } = useProducts({ products, @@ -118,7 +116,6 @@ const App = () => { setTotalItemCount(count); }, [cart]); - // ============================================================================ // 계산된 값들 - 렌더링에 필요한 파생 데이터 // ============================================================================ @@ -252,45 +249,22 @@ const App = () => { // ============================================================================ // 쇼핑몰 메인 페이지 // ============================================================================ -
-
- {/* 상품 목록 섹션 */} -
-
-

- 전체 상품 -

-
- 총 {products.length}개 상품 -
-
- - -
-
- - {/* 장바구니 사이드바 */} -
- -
-
+ )}
From b26cb587ebd596d7ae8f139879d6c9bdf10673b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 12:35:32 +0900 Subject: [PATCH 27/68] =?UTF-8?q?feat:=20pages/AdminPage.tsx=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminPage.tsx --- src/basic/pages/AdminPage.tsx | 97 +++++++++++++++++++++++++++++++++++ src/basic/pages/index.ts | 1 + 2 files changed, 98 insertions(+) create mode 100644 src/basic/pages/AdminPage.tsx diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx new file mode 100644 index 00000000..87eb8dd3 --- /dev/null +++ b/src/basic/pages/AdminPage.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { Coupon } from '../../types'; +import { ProductWithUI } from '../entities/product'; +import { ProductManagement } from '../features/products/management/ui'; +import { CouponManagement } from '../features/coupons/ui'; + +interface AdminPageProps { + // 상품 관련 + products: ProductWithUI[]; + onAddProduct: (product: Omit) => void; + onUpdateProduct: (productId: string, updates: Partial) => void; + onDeleteProduct: (productId: string) => void; + + // 쿠폰 관련 + coupons: Coupon[]; + onAddCoupon: (coupon: Coupon) => void; + onDeleteCoupon: (couponCode: string) => void; + + // 유틸리티 + formatPrice: (price: number, productId?: string) => string; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export default function AdminPage({ + products, + onAddProduct, + onUpdateProduct, + onDeleteProduct, + coupons, + onAddCoupon, + onDeleteCoupon, + formatPrice, + addNotification, +}: AdminPageProps) { + // 관리자 페이지 내부 상태 (탭 관리) + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>( + 'products' + ); + + return ( +
+ {/* 관리자 대시보드 헤더 */} +
+

관리자 대시보드

+

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

+
+ + {/* 탭 네비게이션 */} +
+ +
+ + {/* 탭 컨텐츠 */} + {activeTab === 'products' ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/basic/pages/index.ts b/src/basic/pages/index.ts index e57ae19e..1cc41ff8 100644 --- a/src/basic/pages/index.ts +++ b/src/basic/pages/index.ts @@ -1 +1,2 @@ export { default as ShoppingPage } from './ShoppingPage'; +export { default as AdminPage } from './AdminPage'; From a60e0967e706dc95460a635cb56f0d51941311d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 12:35:45 +0900 Subject: [PATCH 28/68] =?UTF-8?q?refactor:=20pages/AdminPage.tsx=20App=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 90 ++++++++--------------------------------------- 1 file changed, 14 insertions(+), 76 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index d46a6a3f..7dd6e49d 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -10,15 +10,14 @@ import { NotificationToast, SearchInput, Button } from './shared/ui'; import { useProductSearch } from './features/products/list/hooks'; import { useProducts } from './features/products/management/hooks'; -import { ProductManagement } from './features/products/management/ui'; import { useCart } from './features/cart/hooks'; import { useCoupons } from './features/coupons/hooks'; -import { CouponManagement } from './features/coupons/ui'; import { useOrder } from './features/order/hooks'; import ShoppingPage from './pages/ShoppingPage'; +import AdminPage from './pages/AdminPage'; const App = () => { // ============================================================================ @@ -37,9 +36,6 @@ const App = () => { // UI 상태 관리 const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>( - 'products' - ); // ============================================================================ // 유틸리티 함수들 - 데이터 포맷팅 및 계산 로직 @@ -73,7 +69,8 @@ const App = () => { const { notifications, addNotification, removeNotification } = useNotification(); - const { searchTerm, setSearchTerm, filteredProducts } = useProductSearch(products); + const { searchTerm, setSearchTerm, filteredProducts } = + useProductSearch(products); const { addProduct, updateProduct, deleteProduct } = useProducts({ products, @@ -116,16 +113,6 @@ const App = () => { setTotalItemCount(count); }, [cart]); - // ============================================================================ - // 계산된 값들 - 렌더링에 필요한 파생 데이터 - // ============================================================================ - - const totals = calculateCartTotalWithCoupon(); - - // ============================================================================ - // 거대한 JSX 렌더링 - ProductList, ProductManagement 컴포넌트로 교체 - // ============================================================================ - return (
{/* 알림 시스템 */} @@ -188,67 +175,18 @@ const App = () => { {/* 메인 컨텐츠 */}
{isAdmin ? ( - // ============================================================================ - // 관리자 페이지 - // ============================================================================ -
-
-

- 관리자 대시보드 -

-

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

-
- - {/* 탭 네비게이션 */} -
- -
- - {activeTab === 'products' ? ( - - ) : ( - - )} -
+ ) : ( - // ============================================================================ - // 쇼핑몰 메인 페이지 - // ============================================================================ Date: Thu, 7 Aug 2025 12:41:49 +0900 Subject: [PATCH 29/68] =?UTF-8?q?feat:=20shared/ui=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header.tsx --- src/basic/shared/ui/Header.tsx | 71 ++++++++++++++++++++++++++++++++++ src/basic/shared/ui/index.ts | 3 ++ 2 files changed, 74 insertions(+) create mode 100644 src/basic/shared/ui/Header.tsx diff --git a/src/basic/shared/ui/Header.tsx b/src/basic/shared/ui/Header.tsx new file mode 100644 index 00000000..c4a08cb6 --- /dev/null +++ b/src/basic/shared/ui/Header.tsx @@ -0,0 +1,71 @@ +import { SearchInput, Button } from '.'; + +interface HeaderProps { + isAdmin: boolean; + searchTerm: string; + totalItemCount: number; + cartLength: number; + onToggleAdmin: () => void; + onSearchChange: (value: string) => void; +} + +export default function Header({ + isAdmin, + searchTerm, + totalItemCount, + cartLength, + onToggleAdmin, + onSearchChange, +}: HeaderProps) { + return ( +
+
+
+
+

SHOP

+ {/* 검색창 */} + {!isAdmin && ( + + )} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/basic/shared/ui/index.ts b/src/basic/shared/ui/index.ts index eec11c23..20260123 100644 --- a/src/basic/shared/ui/index.ts +++ b/src/basic/shared/ui/index.ts @@ -6,3 +6,6 @@ export { default as SearchInput } from './SearchInput'; // 버튼 관련 export { default as Button } from './Button'; + +// 레이아웃 관련 +export { default as Header } from './Header'; From 4f7317e008ed1f99ebac50948a63507da05688f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 12:42:04 +0900 Subject: [PATCH 30/68] =?UTF-8?q?refactor:=20shared/ui=20App=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 67 +++++++---------------------------------------- 1 file changed, 9 insertions(+), 58 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 7dd6e49d..8b9f5e2b 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,20 +1,14 @@ import { useState, useCallback, useEffect } from 'react'; import { CartItem, Coupon } from '../types'; - import { initialProducts } from './entities/product'; import { initialCoupons } from './entities/coupon'; import { calculateCartTotal } from './entities/cart'; - import { useLocalStorage, useNotification } from './shared/hooks'; -import { NotificationToast, SearchInput, Button } from './shared/ui'; - +import { NotificationToast, Header } from './shared/ui'; import { useProductSearch } from './features/products/list/hooks'; import { useProducts } from './features/products/management/hooks'; - import { useCart } from './features/cart/hooks'; - import { useCoupons } from './features/coupons/hooks'; - import { useOrder } from './features/order/hooks'; import ShoppingPage from './pages/ShoppingPage'; import AdminPage from './pages/AdminPage'; @@ -120,58 +114,15 @@ const App = () => { notifications={notifications} onRemove={removeNotification} /> - {/* 헤더 */} -
-
-
-
-

SHOP

- {/* 검색창 */} - {!isAdmin && ( - - )} -
- -
-
-
- +
setIsAdmin(!isAdmin)} + onSearchChange={setSearchTerm} + /> {/* 메인 컨텐츠 */}
{isAdmin ? ( From 97a05629284b398f0978caf34138e83285589ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 15:19:10 +0900 Subject: [PATCH 31/68] =?UTF-8?q?refactor:=20Header=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EC=97=90=EC=84=9C=20=EC=9E=A5=EB=B0=94?= =?UTF-8?q?=EA=B5=AC=EB=8B=88=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=88=98=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.tsx에 totalItemCount 상태와 useEffect 제거 - Header 컴포넌트가 cart prop을 직접 받아 내부에서 계산 --- src/basic/App.tsx | 28 ++-------------------------- src/basic/shared/ui/Header.tsx | 12 +++++++----- 2 files changed, 9 insertions(+), 31 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 8b9f5e2b..3b1f0889 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback } from 'react'; import { CartItem, Coupon } from '../types'; import { initialProducts } from './entities/product'; import { initialCoupons } from './entities/coupon'; @@ -14,27 +14,15 @@ import ShoppingPage from './pages/ShoppingPage'; import AdminPage from './pages/AdminPage'; const App = () => { - // ============================================================================ - // 상태 관리 - localStorage와 연동된 데이터 상태들 - // ============================================================================ - // localStorage와 연동된 데이터 상태들 const [products, setProducts] = useLocalStorage('products', initialProducts); const [cart, setCart] = useLocalStorage('cart', []); const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); - // ============================================================================ - // UI 상태 관리 - 화면 표시 및 사용자 인터랙션 관련 상태들 - // ============================================================================ - // UI 상태 관리 const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - // ============================================================================ - // 유틸리티 함수들 - 데이터 포맷팅 및 계산 로직 - // ============================================================================ - // 가격 포맷팅 함수 (관리자/일반 사용자 구분, 품절 처리) const formatPrice = (price: number, productId?: string): string => { if (productId) { @@ -95,17 +83,6 @@ const App = () => { addNotification, }); - // ============================================================================ - // 파생 상태 - 다른 상태로부터 계산되는 값들 - // ============================================================================ - - const [totalItemCount, setTotalItemCount] = useState(0); - - // 장바구니 아이템 수 업데이트 - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); return (
@@ -118,8 +95,7 @@ const App = () => {
setIsAdmin(!isAdmin)} onSearchChange={setSearchTerm} /> diff --git a/src/basic/shared/ui/Header.tsx b/src/basic/shared/ui/Header.tsx index c4a08cb6..e72b5e18 100644 --- a/src/basic/shared/ui/Header.tsx +++ b/src/basic/shared/ui/Header.tsx @@ -1,10 +1,10 @@ import { SearchInput, Button } from '.'; +import { CartItem } from '../../../types'; interface HeaderProps { isAdmin: boolean; searchTerm: string; - totalItemCount: number; - cartLength: number; + cart: CartItem[]; onToggleAdmin: () => void; onSearchChange: (value: string) => void; } @@ -12,11 +12,13 @@ interface HeaderProps { export default function Header({ isAdmin, searchTerm, - totalItemCount, - cartLength, + cart, onToggleAdmin, onSearchChange, }: HeaderProps) { + const totalItemCount = cart.reduce((sum, item) => sum + item.quantity, 0); + const cartLength = cart.length; + return (
@@ -68,4 +70,4 @@ export default function Header({
); -} \ No newline at end of file +} From e8663692bcc743e76d3c87667e127364853b270a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 17:19:07 +0900 Subject: [PATCH 32/68] =?UTF-8?q?refactor:=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=B3=84=20=EB=8F=85=EB=A6=BD=EC=A0=81=EC=9D=B8=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminPage에 상품/쿠폰 관리 기능 통합 - ShoppingPage에 검색/장바구니 기능 통합 --- src/basic/App.tsx | 82 ++------------- .../features/coupons/hooks/useCoupons.ts | 4 +- .../products/list/hooks/useProductSearch.tsx | 10 +- src/basic/pages/AdminPage.tsx | 55 ++++++----- src/basic/pages/ShoppingPage.tsx | 99 ++++++++++++------- 5 files changed, 112 insertions(+), 138 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 3b1f0889..849d5dbf 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -5,11 +5,6 @@ import { initialCoupons } from './entities/coupon'; import { calculateCartTotal } from './entities/cart'; import { useLocalStorage, useNotification } from './shared/hooks'; import { NotificationToast, Header } from './shared/ui'; -import { useProductSearch } from './features/products/list/hooks'; -import { useProducts } from './features/products/management/hooks'; -import { useCart } from './features/cart/hooks'; -import { useCoupons } from './features/coupons/hooks'; -import { useOrder } from './features/order/hooks'; import ShoppingPage from './pages/ShoppingPage'; import AdminPage from './pages/AdminPage'; @@ -22,22 +17,11 @@ const App = () => { // UI 상태 관리 const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); - // 가격 포맷팅 함수 (관리자/일반 사용자 구분, 품절 처리) - 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 { notifications, addNotification, removeNotification } = + useNotification(); // 장바구니 전체 금액 계산 (쿠폰 할인 포함) - entities/cart 함수 사용 const calculateCartTotalWithCoupon = useCallback((): { @@ -47,43 +31,6 @@ const App = () => { return calculateCartTotal(cart, selectedCoupon); }, [cart, selectedCoupon]); - // 알림 시스템 - const { notifications, addNotification, removeNotification } = - useNotification(); - - const { searchTerm, setSearchTerm, filteredProducts } = - useProductSearch(products); - - const { addProduct, updateProduct, deleteProduct } = useProducts({ - products, - setProducts, - addNotification, - }); - - const { addToCart, removeFromCart, updateQuantity, getRemainingStock } = - useCart({ - cart, - setCart, - products, - addNotification, - }); - - const { addCoupon, deleteCoupon, applyCoupon } = useCoupons({ - coupons, - setCoupons, - selectedCoupon, - setSelectedCoupon, - addNotification, - calculateCartTotalWithCoupon, - }); - - const { completeOrder } = useOrder({ - setCart, - setSelectedCoupon, - addNotification, - }); - - return (
{/* 알림 시스템 */} @@ -104,31 +51,22 @@ const App = () => { {isAdmin ? ( ) : ( )}
diff --git a/src/basic/features/coupons/hooks/useCoupons.ts b/src/basic/features/coupons/hooks/useCoupons.ts index 1b2630ce..6e4e6fa5 100644 --- a/src/basic/features/coupons/hooks/useCoupons.ts +++ b/src/basic/features/coupons/hooks/useCoupons.ts @@ -24,7 +24,7 @@ export function useCoupons({ addNotification, calculateCartTotalWithCoupon, }: UseCouponsProps) { - // 새 쿠폰 추가 + // 쿠폰 추가 const addCoupon = useCallback( (newCoupon: Coupon) => { const existingCoupon = coupons.find((c) => c.code === newCoupon.code); @@ -77,4 +77,4 @@ export function useCoupons({ applyCoupon, setSelectedCoupon, }; -} \ No newline at end of file +} diff --git a/src/basic/features/products/list/hooks/useProductSearch.tsx b/src/basic/features/products/list/hooks/useProductSearch.tsx index b13fbe0d..b934b635 100644 --- a/src/basic/features/products/list/hooks/useProductSearch.tsx +++ b/src/basic/features/products/list/hooks/useProductSearch.tsx @@ -1,9 +1,11 @@ -import { useState, useMemo } from 'react'; +import { useMemo } from 'react'; import { ProductWithUI } from '../../../../entities/product'; import { useDebounce } from '../../../../shared/hooks'; -export function useProductSearch(products: ProductWithUI[]) { - const [searchTerm, setSearchTerm] = useState(''); +export function useProductSearch( + products: ProductWithUI[], + searchTerm: string +) { const debouncedSearchTerm = useDebounce(searchTerm, 500); const filteredProducts = useMemo(() => { @@ -24,8 +26,6 @@ export function useProductSearch(products: ProductWithUI[]) { }, [products, debouncedSearchTerm]); return { - searchTerm, - setSearchTerm, debouncedSearchTerm, filteredProducts, }; diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx index 87eb8dd3..843509ee 100644 --- a/src/basic/pages/AdminPage.tsx +++ b/src/basic/pages/AdminPage.tsx @@ -3,21 +3,14 @@ import { Coupon } from '../../types'; import { ProductWithUI } from '../entities/product'; import { ProductManagement } from '../features/products/management/ui'; import { CouponManagement } from '../features/coupons/ui'; +import { useProducts } from '../features/products/management/hooks'; +import { useCoupons } from '../features/coupons/hooks'; interface AdminPageProps { - // 상품 관련 products: ProductWithUI[]; - onAddProduct: (product: Omit) => void; - onUpdateProduct: (productId: string, updates: Partial) => void; - onDeleteProduct: (productId: string) => void; - - // 쿠폰 관련 + setProducts: React.Dispatch>; coupons: Coupon[]; - onAddCoupon: (coupon: Coupon) => void; - onDeleteCoupon: (couponCode: string) => void; - - // 유틸리티 - formatPrice: (price: number, productId?: string) => string; + setCoupons: React.Dispatch>; addNotification: ( message: string, type?: 'error' | 'success' | 'warning' @@ -26,13 +19,9 @@ interface AdminPageProps { export default function AdminPage({ products, - onAddProduct, - onUpdateProduct, - onDeleteProduct, + setProducts, coupons, - onAddCoupon, - onDeleteCoupon, - formatPrice, + setCoupons, addNotification, }: AdminPageProps) { // 관리자 페이지 내부 상태 (탭 관리) @@ -40,6 +29,28 @@ export default function AdminPage({ 'products' ); + const { addProduct, updateProduct, deleteProduct } = useProducts({ + products, + setProducts, + addNotification, + }); + + const { addCoupon, deleteCoupon } = useCoupons({ + coupons, + setCoupons, + selectedCoupon: null, // Admin에서는 쿠폰 선택 불필요 + setSelectedCoupon: () => {}, // Admin에서는 쿠폰 선택 불필요 + addNotification, + calculateCartTotalWithCoupon: () => ({ + totalBeforeDiscount: 0, + totalAfterDiscount: 0, + }), + }); + + const formatPrice = (price: number): string => { + return `${price.toLocaleString()}원`; // 관리자는 항상 원화 표시 + }; + return (
{/* 관리자 대시보드 헤더 */} @@ -78,17 +89,17 @@ export default function AdminPage({ {activeTab === 'products' ? ( ) : ( )} diff --git a/src/basic/pages/ShoppingPage.tsx b/src/basic/pages/ShoppingPage.tsx index 43456ed9..78409bb5 100644 --- a/src/basic/pages/ShoppingPage.tsx +++ b/src/basic/pages/ShoppingPage.tsx @@ -1,56 +1,81 @@ import { useMemo } from 'react'; import { CartItem, Coupon } from '../../types'; import { ProductWithUI } from '../entities/product'; -import { calculateCartTotal } from '../entities/cart'; import { ProductList } from '../features/products/list/ui'; import { CartSidebar } from '../features/cart/ui'; +import { useCoupons } from '../features/coupons/hooks'; +import { useCart } from '../features/cart/hooks'; +import { useOrder } from '../features/order/hooks'; +import { useProductSearch } from '../features/products/list/hooks'; interface ShoppingPageProps { - // 상태들 products: ProductWithUI[]; - filteredProducts: ProductWithUI[]; searchTerm: string; cart: CartItem[]; + setCart: React.Dispatch>; coupons: Coupon[]; selectedCoupon: Coupon | null; - - // 상품 관련 핸들러 - onAddToCart: (product: ProductWithUI) => void; - getRemainingStock: (product: ProductWithUI) => number; - formatPrice: (price: number, productId?: string) => string; - - // 장바구니 관련 핸들러 - onRemoveFromCart: (productId: string) => void; - onUpdateQuantity: (productId: string, newQuantity: number) => void; - - // 쿠폰 관련 핸들러 - onApplyCoupon: (coupon: Coupon) => void; - onSetSelectedCoupon: (coupon: Coupon | null) => void; - - // 주문 관련 핸들러 - onCompleteOrder: () => void; + setSelectedCoupon: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; + calculateCartTotalWithCoupon: () => { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; } export default function ShoppingPage({ products, - filteredProducts, searchTerm, cart, + setCart, coupons, selectedCoupon, - onAddToCart, - getRemainingStock, - formatPrice, - onRemoveFromCart, - onUpdateQuantity, - onApplyCoupon, - onSetSelectedCoupon, - onCompleteOrder, + setSelectedCoupon, + addNotification, + calculateCartTotalWithCoupon, }: ShoppingPageProps) { - // 장바구니 전체 금액 계산 (쿠폰 할인 포함) + const { filteredProducts } = useProductSearch(products, searchTerm); + + const { addToCart, removeFromCart, updateQuantity, getRemainingStock } = + useCart({ + cart, + setCart, + products, + addNotification, + }); + + const { applyCoupon } = useCoupons({ + coupons, + setCoupons: () => {}, // Shopping에서는 쿠폰 수정 불필요 + selectedCoupon, + setSelectedCoupon, + addNotification, + calculateCartTotalWithCoupon, + }); + + const { completeOrder } = useOrder({ + setCart, + setSelectedCoupon, + addNotification, + }); + + // ShoppingPage용 가격 포맷팅 (고객 전용) + 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'; + } + } + return `₩${price.toLocaleString()}`; // 고객은 ₩ 표시 + }; + const totals = useMemo(() => { - return calculateCartTotal(cart, selectedCoupon); - }, [cart, selectedCoupon]); + return calculateCartTotalWithCoupon(); + }, [calculateCartTotalWithCoupon]); return (
@@ -67,7 +92,7 @@ export default function ShoppingPage({ @@ -81,11 +106,11 @@ export default function ShoppingPage({ coupons={coupons} selectedCoupon={selectedCoupon} totals={totals} - onRemoveFromCart={onRemoveFromCart} - onUpdateQuantity={onUpdateQuantity} - onApplyCoupon={onApplyCoupon} - onSetSelectedCoupon={onSetSelectedCoupon} - onCompleteOrder={onCompleteOrder} + onRemoveFromCart={removeFromCart} + onUpdateQuantity={updateQuantity} + onApplyCoupon={applyCoupon} + onSetSelectedCoupon={setSelectedCoupon} + onCompleteOrder={completeOrder} />
From 91764a9e7ea6f3cf3ec965aa6f645cd70f811417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 18:45:25 +0900 Subject: [PATCH 33/68] =?UTF-8?q?refactor:=20AdminPage=EC=97=90=20?= =?UTF-8?q?=EB=8F=85=EB=A6=BD=EC=A0=81=EC=9D=B8=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20=EB=A1=9C=EC=A7=81=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/coupons/ui/CouponManagement.tsx | 33 ++++++++++++------- .../management/ui/ProductManagement.tsx | 21 +++++++----- src/basic/pages/AdminPage.tsx | 27 ++------------- 3 files changed, 36 insertions(+), 45 deletions(-) diff --git a/src/basic/features/coupons/ui/CouponManagement.tsx b/src/basic/features/coupons/ui/CouponManagement.tsx index 69866058..8a809197 100644 --- a/src/basic/features/coupons/ui/CouponManagement.tsx +++ b/src/basic/features/coupons/ui/CouponManagement.tsx @@ -1,11 +1,11 @@ import { useState } from 'react'; import { Coupon } from '../../../../types'; import { Button } from '../../../shared/ui'; +import { useCoupons } from '../hooks'; interface CouponManagementProps { coupons: Coupon[]; - onAddCoupon: (coupon: Coupon) => void; - onDeleteCoupon: (couponCode: string) => void; + setCoupons: React.Dispatch>; addNotification: ( message: string, type?: 'error' | 'success' | 'warning' @@ -14,10 +14,21 @@ interface CouponManagementProps { export function CouponManagement({ coupons, - onAddCoupon, - onDeleteCoupon, + setCoupons, addNotification, }: CouponManagementProps) { + const { addCoupon, deleteCoupon } = useCoupons({ + coupons, + setCoupons, + selectedCoupon: null, // Admin에서는 쿠폰 선택 불필요 + setSelectedCoupon: () => {}, // Admin에서는 쿠폰 선택 불필요 + addNotification, + calculateCartTotalWithCoupon: () => ({ + totalBeforeDiscount: 0, + totalAfterDiscount: 0, + }), + }); + const [showCouponForm, setShowCouponForm] = useState(false); const [couponForm, setCouponForm] = useState({ name: '', @@ -29,7 +40,7 @@ export function CouponManagement({ // 쿠폰 폼 제출 const handleCouponSubmit = (e: React.FormEvent) => { e.preventDefault(); - onAddCoupon(couponForm); + addCoupon(couponForm); setCouponForm({ name: '', code: '', @@ -66,7 +77,7 @@ export function CouponManagement({
+
+ ))} + +
+
+ +
+ + +
+ +
+ ); +} diff --git a/src/basic/features/products/admin/ui/ProductManagement.tsx b/src/basic/features/products/admin/ui/ProductManagement.tsx new file mode 100644 index 00000000..69699b91 --- /dev/null +++ b/src/basic/features/products/admin/ui/ProductManagement.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react'; +import { ProductWithUI } from '../../../../entities/product'; +import { useProducts } from '../hooks'; +import { Button } from '../../../../shared/ui'; +import ProductTable from './ProductTable'; +import ProductForm from './ProductForm'; + +interface ProductManagementProps { + products: ProductWithUI[]; + setProducts: React.Dispatch>; + formatPrice: (price: number, productId?: string) => string; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export default function ProductManagement({ + products, + setProducts, + formatPrice, + addNotification, +}: ProductManagementProps) { + const { addProduct, updateProduct, deleteProduct } = useProducts({ + products, + setProducts, + addNotification, + }); + + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] as Array<{ quantity: number; rate: number }>, + }); + + const handleProductSubmit = () => { + if (editingProduct && editingProduct !== 'new') { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProduct(productForm); + } + resetForm(); + }; + + 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 startAddProduct = () => { + setEditingProduct('new'); + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + }); + setShowProductForm(true); + }; + + const resetForm = () => { + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + }); + setEditingProduct(null); + setShowProductForm(false); + }; + + return ( +
+
+
+

상품 목록

+ +
+
+ + + + {showProductForm && ( + + )} +
+ ); +} diff --git a/src/basic/features/products/admin/ui/ProductTable.tsx b/src/basic/features/products/admin/ui/ProductTable.tsx new file mode 100644 index 00000000..4412e555 --- /dev/null +++ b/src/basic/features/products/admin/ui/ProductTable.tsx @@ -0,0 +1,82 @@ +import { ProductWithUI } from '../../../../entities/product'; +import { Button } from '../../../../shared/ui'; + +interface ProductTableProps { + products: ProductWithUI[]; + formatPrice: (price: number, productId?: string) => string; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +} + +export default function ProductTable({ + products, + formatPrice, + onEdit, + onDelete, +}: ProductTableProps) { + return ( +
+ + + + + + + + + + + + {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 || '-'} + + + +
+
+ ); +} diff --git a/src/basic/features/products/admin/ui/index.ts b/src/basic/features/products/admin/ui/index.ts new file mode 100644 index 00000000..83093283 --- /dev/null +++ b/src/basic/features/products/admin/ui/index.ts @@ -0,0 +1,3 @@ +export { default as ProductManagement } from './ProductManagement'; +export { default as ProductTable } from './ProductTable'; +export { default as ProductForm } from './ProductForm'; From e95b007a0b4da04639835c340df39245e579dfcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 19:17:08 +0900 Subject: [PATCH 35/68] =?UTF-8?q?chore:=20=ED=8F=B4=EB=8D=94=EB=AA=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - features/products/management -> features/products/admin - features/products/list -> features/products/shop --- .../{management => admin}/hooks/index.ts | 0 .../hooks/useProducts.ts | 0 .../management/ui/ProductManagement.tsx | 394 ------------------ .../features/products/management/ui/index.ts | 1 - .../products/{list => shop}/hooks/index.ts | 0 .../{list => shop}/hooks/useProductSearch.tsx | 0 .../{list => shop}/ui/ProductCard.tsx | 0 .../{list => shop}/ui/ProductList.tsx | 0 .../products/{list => shop}/ui/index.ts | 0 src/basic/pages/AdminPage.tsx | 2 +- src/basic/pages/ShoppingPage.tsx | 4 +- 11 files changed, 3 insertions(+), 398 deletions(-) rename src/basic/features/products/{management => admin}/hooks/index.ts (100%) rename src/basic/features/products/{management => admin}/hooks/useProducts.ts (100%) delete mode 100644 src/basic/features/products/management/ui/ProductManagement.tsx delete mode 100644 src/basic/features/products/management/ui/index.ts rename src/basic/features/products/{list => shop}/hooks/index.ts (100%) rename src/basic/features/products/{list => shop}/hooks/useProductSearch.tsx (100%) rename src/basic/features/products/{list => shop}/ui/ProductCard.tsx (100%) rename src/basic/features/products/{list => shop}/ui/ProductList.tsx (100%) rename src/basic/features/products/{list => shop}/ui/index.ts (100%) diff --git a/src/basic/features/products/management/hooks/index.ts b/src/basic/features/products/admin/hooks/index.ts similarity index 100% rename from src/basic/features/products/management/hooks/index.ts rename to src/basic/features/products/admin/hooks/index.ts diff --git a/src/basic/features/products/management/hooks/useProducts.ts b/src/basic/features/products/admin/hooks/useProducts.ts similarity index 100% rename from src/basic/features/products/management/hooks/useProducts.ts rename to src/basic/features/products/admin/hooks/useProducts.ts diff --git a/src/basic/features/products/management/ui/ProductManagement.tsx b/src/basic/features/products/management/ui/ProductManagement.tsx deleted file mode 100644 index 9bc3008e..00000000 --- a/src/basic/features/products/management/ui/ProductManagement.tsx +++ /dev/null @@ -1,394 +0,0 @@ -import { FormEvent, useState } from 'react'; -import { ProductWithUI } from '../../../../entities/product'; -import { Button } from '../../../../shared/ui'; -import { useProducts } from '../hooks'; - -interface ProductManagementProps { - products: ProductWithUI[]; - setProducts: React.Dispatch>; - formatPrice: (price: number, productId?: string) => string; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; -} - -export default function ProductManagement({ - products, - setProducts, - formatPrice, - addNotification, -}: ProductManagementProps) { - const { addProduct, updateProduct, deleteProduct } = useProducts({ - products, - setProducts, - addNotification, - }); - - const [showProductForm, setShowProductForm] = useState(false); - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }>, - }); - - const handleProductSubmit = (e: FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct(productForm); - } - setProductForm({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [], - }); - setEditingProduct(null); - setShowProductForm(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 ( -
-
-
-

상품 목록

- -
-
- - {/* 상품 목록 테이블 */} -
- - - - - - - - - - - - {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='%' - /> - % 할인 - -
- ))} - -
-
- -
- - -
-
-
- )} -
- ); -} diff --git a/src/basic/features/products/management/ui/index.ts b/src/basic/features/products/management/ui/index.ts deleted file mode 100644 index 62296d4c..00000000 --- a/src/basic/features/products/management/ui/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as ProductManagement } from './ProductManagement'; diff --git a/src/basic/features/products/list/hooks/index.ts b/src/basic/features/products/shop/hooks/index.ts similarity index 100% rename from src/basic/features/products/list/hooks/index.ts rename to src/basic/features/products/shop/hooks/index.ts diff --git a/src/basic/features/products/list/hooks/useProductSearch.tsx b/src/basic/features/products/shop/hooks/useProductSearch.tsx similarity index 100% rename from src/basic/features/products/list/hooks/useProductSearch.tsx rename to src/basic/features/products/shop/hooks/useProductSearch.tsx diff --git a/src/basic/features/products/list/ui/ProductCard.tsx b/src/basic/features/products/shop/ui/ProductCard.tsx similarity index 100% rename from src/basic/features/products/list/ui/ProductCard.tsx rename to src/basic/features/products/shop/ui/ProductCard.tsx diff --git a/src/basic/features/products/list/ui/ProductList.tsx b/src/basic/features/products/shop/ui/ProductList.tsx similarity index 100% rename from src/basic/features/products/list/ui/ProductList.tsx rename to src/basic/features/products/shop/ui/ProductList.tsx diff --git a/src/basic/features/products/list/ui/index.ts b/src/basic/features/products/shop/ui/index.ts similarity index 100% rename from src/basic/features/products/list/ui/index.ts rename to src/basic/features/products/shop/ui/index.ts diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx index 4d8ab5ac..4d37d9da 100644 --- a/src/basic/pages/AdminPage.tsx +++ b/src/basic/pages/AdminPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Coupon } from '../../types'; import { ProductWithUI } from '../entities/product'; -import { ProductManagement } from '../features/products/management/ui'; +import { ProductManagement } from '../features/products/admin/ui'; import { CouponManagement } from '../features/coupons/ui'; interface AdminPageProps { diff --git a/src/basic/pages/ShoppingPage.tsx b/src/basic/pages/ShoppingPage.tsx index 78409bb5..e1b48fc0 100644 --- a/src/basic/pages/ShoppingPage.tsx +++ b/src/basic/pages/ShoppingPage.tsx @@ -1,12 +1,12 @@ import { useMemo } from 'react'; import { CartItem, Coupon } from '../../types'; import { ProductWithUI } from '../entities/product'; -import { ProductList } from '../features/products/list/ui'; +import { ProductList } from '../features/products/shop/ui'; import { CartSidebar } from '../features/cart/ui'; import { useCoupons } from '../features/coupons/hooks'; import { useCart } from '../features/cart/hooks'; import { useOrder } from '../features/order/hooks'; -import { useProductSearch } from '../features/products/list/hooks'; +import { useProductSearch } from '../features/products/shop/hooks'; interface ShoppingPageProps { products: ProductWithUI[]; From 841d5619fabe6e3992012f34dc5f7e2d56031fc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 19:26:40 +0900 Subject: [PATCH 36/68] =?UTF-8?q?refactor:=20CouponManagement=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CouponManagement.tsx - 컨테이너 역할 (상태 관리, 탭/모달 제어) - CouponTable.tsx - 쿠폰 목록 테이블 - CouponForm.tsx - 쿠폰 추가/수정 폼 --- src/basic/features/coupons/ui/CouponForm.tsx | 169 +++++++++++++ .../features/coupons/ui/CouponManagement.tsx | 235 ++---------------- src/basic/features/coupons/ui/CouponTable.tsx | 79 ++++++ src/basic/features/coupons/ui/index.ts | 4 +- 4 files changed, 275 insertions(+), 212 deletions(-) create mode 100644 src/basic/features/coupons/ui/CouponForm.tsx create mode 100644 src/basic/features/coupons/ui/CouponTable.tsx diff --git a/src/basic/features/coupons/ui/CouponForm.tsx b/src/basic/features/coupons/ui/CouponForm.tsx new file mode 100644 index 00000000..ca7d426f --- /dev/null +++ b/src/basic/features/coupons/ui/CouponForm.tsx @@ -0,0 +1,169 @@ +import { FormEvent } from 'react'; +import { Button } from '../../../shared/ui'; + +interface CouponFormData { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} + +interface CouponFormProps { + couponForm: CouponFormData; + setCouponForm: React.Dispatch>; + onSubmit: () => void; + onCancel: () => void; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export default function CouponForm({ + couponForm, + setCouponForm, + onSubmit, + onCancel, + addNotification, +}: CouponFormProps) { + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + onSubmit(); + }; + + const handleDiscountValueChange = ( + e: React.ChangeEvent + ) => { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setCouponForm({ + ...couponForm, + discountValue: value === '' ? 0 : parseInt(value), + }); + } + }; + + const handleDiscountValueBlur = (e: React.FocusEvent) => { + const value = parseInt(e.target.value) || 0; + if (couponForm.discountType === 'percentage') { + if (value > 100) { + addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); + setCouponForm({ + ...couponForm, + discountValue: 100, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } else { + if (value > 100000) { + addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); + setCouponForm({ + ...couponForm, + discountValue: 100000, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } + }; + + return ( +
+
+

새 쿠폰 생성

+ +
+
+ + + setCouponForm({ + ...couponForm, + name: e.target.value, + }) + } + className='w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm' + placeholder='신규 가입 쿠폰' + required + /> +
+ +
+ + + setCouponForm({ + ...couponForm, + code: e.target.value.toUpperCase(), + }) + } + className='w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono' + placeholder='WELCOME2024' + required + /> +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/basic/features/coupons/ui/CouponManagement.tsx b/src/basic/features/coupons/ui/CouponManagement.tsx index 8a809197..0ffa5948 100644 --- a/src/basic/features/coupons/ui/CouponManagement.tsx +++ b/src/basic/features/coupons/ui/CouponManagement.tsx @@ -1,7 +1,8 @@ import { useState } from 'react'; import { Coupon } from '../../../../types'; -import { Button } from '../../../shared/ui'; import { useCoupons } from '../hooks'; +import CouponForm from './CouponForm'; +import CouponTable from './CouponTable'; interface CouponManagementProps { coupons: Coupon[]; @@ -37,10 +38,12 @@ export function CouponManagement({ discountValue: 0, }); - // 쿠폰 폼 제출 - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); + const handleCouponSubmit = () => { addCoupon(couponForm); + resetForm(); + }; + + const resetForm = () => { setCouponForm({ name: '', code: '', @@ -50,221 +53,31 @@ export function CouponManagement({ setShowCouponForm(false); }; + const startAddCoupon = () => { + setShowCouponForm(true); + }; + return (

쿠폰 관리

-
-
- {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 - /> -
-
-
- - -
-
-
+ )}
diff --git a/src/basic/features/coupons/ui/CouponTable.tsx b/src/basic/features/coupons/ui/CouponTable.tsx new file mode 100644 index 00000000..551b71bd --- /dev/null +++ b/src/basic/features/coupons/ui/CouponTable.tsx @@ -0,0 +1,79 @@ +import { Coupon } from '../../../../types'; +import { Button } from '../../../shared/ui'; + +interface CouponTableProps { + coupons: Coupon[]; + onDelete: (couponCode: string) => void; + onAddNew: () => void; +} + +export default function CouponTable({ + coupons, + onDelete, + onAddNew, +}: CouponTableProps) { + return ( +
+ {coupons.map((coupon) => ( +
+
+
+

{coupon.name}

+

+ {coupon.code} +

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + +
+ +
+
+ ); +} diff --git a/src/basic/features/coupons/ui/index.ts b/src/basic/features/coupons/ui/index.ts index 0f0da3bc..d8dc0a82 100644 --- a/src/basic/features/coupons/ui/index.ts +++ b/src/basic/features/coupons/ui/index.ts @@ -1 +1,3 @@ -export { CouponManagement } from './CouponManagement'; \ No newline at end of file +export { CouponManagement } from './CouponManagement'; +export { default as CouponForm } from './CouponForm'; +export { default as CouponTable } from './CouponTable'; From 2750e9e4e5dfd3da51ebd3759be117b0035d0a42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 20:42:16 +0900 Subject: [PATCH 37/68] =?UTF-8?q?feat:=20widgets/ShoppingSidebar=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShoppingSidebar.tsx --- .../ShoppingSidebar/ui/ShoppingSidebar.tsx | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/basic/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx diff --git a/src/basic/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx b/src/basic/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx new file mode 100644 index 00000000..70810d13 --- /dev/null +++ b/src/basic/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx @@ -0,0 +1,76 @@ +import { useMemo } from 'react'; +import { CartItem, Coupon } from '../../../../types'; +import { ProductWithUI } from '../../../entities/product'; +import { useCart } from '../../../features/cart/hooks'; +import { calculateCartTotal } from '../../../entities/cart'; +import { CartItemsList } from '../../../features/cart/ui'; +import { CouponSelector } from '../../../features/coupons/shop/ui'; +import { OrderSummary } from '../../../features/order/ui'; + +interface ShoppingSidebarProps { + cart: CartItem[]; + setCart: React.Dispatch>; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: React.Dispatch>; + products: ProductWithUI[]; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; + calculateCartTotalWithCoupon: () => { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; +} + +export function ShoppingSidebar({ + cart, + setCart, + coupons, + selectedCoupon, + setSelectedCoupon, + products, + addNotification, + calculateCartTotalWithCoupon, +}: ShoppingSidebarProps) { + const { removeFromCart, updateQuantity } = useCart({ + cart, + setCart, + products, + addNotification, + }); + + const totals = useMemo(() => { + return calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + return ( +
+ {/* 장바구니 아이템 섹션 */} + + {/* 쿠폰 + 주문 섹션 */} + {cart.length > 0 && ( + <> + + + + )} +
+ ); +} From 24ccc0113b8ef0d8a1127d40e1a87fcd4eae1b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 20:46:04 +0900 Subject: [PATCH 38/68] =?UTF-8?q?feat:=20entities/coupon=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EB=A1=9C=20useCoupons=20=ED=9B=85=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/entities/coupon/hooks/index.ts | 1 + src/basic/entities/coupon/hooks/useCoupons.ts | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/basic/entities/coupon/hooks/index.ts create mode 100644 src/basic/entities/coupon/hooks/useCoupons.ts diff --git a/src/basic/entities/coupon/hooks/index.ts b/src/basic/entities/coupon/hooks/index.ts new file mode 100644 index 00000000..9ef23f5e --- /dev/null +++ b/src/basic/entities/coupon/hooks/index.ts @@ -0,0 +1 @@ +export { useCoupons } from './useCoupons'; diff --git a/src/basic/entities/coupon/hooks/useCoupons.ts b/src/basic/entities/coupon/hooks/useCoupons.ts new file mode 100644 index 00000000..6e4e6fa5 --- /dev/null +++ b/src/basic/entities/coupon/hooks/useCoupons.ts @@ -0,0 +1,80 @@ +import { useCallback } from 'react'; +import { Coupon } from '../../../../types'; + +interface UseCouponsProps { + coupons: Coupon[]; + setCoupons: React.Dispatch>; + selectedCoupon: Coupon | null; + setSelectedCoupon: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; + calculateCartTotalWithCoupon: () => { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; +} + +export function useCoupons({ + coupons, + setCoupons, + selectedCoupon, + setSelectedCoupon, + addNotification, + calculateCartTotalWithCoupon, +}: UseCouponsProps) { + // 쿠폰 추가 + 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, setCoupons] + ); + + // 쿠폰 삭제 + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }, + [selectedCoupon, addNotification, setCoupons, setSelectedCoupon] + ); + + // 쿠폰 적용 + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotalWithCoupon().totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + addNotification( + 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + 'error' + ); + return; + } + + setSelectedCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, + [addNotification, calculateCartTotalWithCoupon, setSelectedCoupon] + ); + + return { + coupons, + selectedCoupon, + addCoupon, + deleteCoupon, + applyCoupon, + setSelectedCoupon, + }; +} From baa1fce192ad391afb20977d337b63e32afce054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 20:48:59 +0900 Subject: [PATCH 39/68] =?UTF-8?q?refactor:=20ShoppingPage=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/pages/ShoppingPage.tsx | 54 ++++++------------- .../ShoppingSidebar/ui/ShoppingSidebar.tsx | 2 +- src/basic/widgets/ShoppingSidebar/ui/index.ts | 1 + 3 files changed, 17 insertions(+), 40 deletions(-) create mode 100644 src/basic/widgets/ShoppingSidebar/ui/index.ts diff --git a/src/basic/pages/ShoppingPage.tsx b/src/basic/pages/ShoppingPage.tsx index e1b48fc0..05bb6e78 100644 --- a/src/basic/pages/ShoppingPage.tsx +++ b/src/basic/pages/ShoppingPage.tsx @@ -1,12 +1,9 @@ -import { useMemo } from 'react'; import { CartItem, Coupon } from '../../types'; import { ProductWithUI } from '../entities/product'; -import { ProductList } from '../features/products/shop/ui'; -import { CartSidebar } from '../features/cart/ui'; -import { useCoupons } from '../features/coupons/hooks'; +import { ProductList } from '../features/product/shop/ui'; import { useCart } from '../features/cart/hooks'; -import { useOrder } from '../features/order/hooks'; -import { useProductSearch } from '../features/products/shop/hooks'; +import { useProductSearch } from '../features/product/shop/hooks'; +import { ShoppingSidebar } from '../widgets/ShoppingSidebar/ui'; interface ShoppingPageProps { products: ProductWithUI[]; @@ -39,30 +36,14 @@ export default function ShoppingPage({ }: ShoppingPageProps) { const { filteredProducts } = useProductSearch(products, searchTerm); - const { addToCart, removeFromCart, updateQuantity, getRemainingStock } = - useCart({ - cart, - setCart, - products, - addNotification, - }); - - const { applyCoupon } = useCoupons({ - coupons, - setCoupons: () => {}, // Shopping에서는 쿠폰 수정 불필요 - selectedCoupon, - setSelectedCoupon, - addNotification, - calculateCartTotalWithCoupon, - }); - - const { completeOrder } = useOrder({ + // ProductList용 로직만 (addToCart, getRemainingStock) + const { addToCart, getRemainingStock } = useCart({ + cart, setCart, - setSelectedCoupon, + products, addNotification, }); - // ShoppingPage용 가격 포맷팅 (고객 전용) const formatPrice = (price: number, productId?: string): string => { if (productId) { const product = products.find((p) => p.id === productId); @@ -70,13 +51,9 @@ export default function ShoppingPage({ return 'SOLD OUT'; } } - return `₩${price.toLocaleString()}`; // 고객은 ₩ 표시 + return `₩${price.toLocaleString()}`; }; - const totals = useMemo(() => { - return calculateCartTotalWithCoupon(); - }, [calculateCartTotalWithCoupon]); - return (
@@ -99,18 +76,17 @@ export default function ShoppingPage({
- {/* 장바구니 사이드바 */} + {/* ShoppingSidebar (widgets) */}
-
diff --git a/src/basic/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx b/src/basic/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx index 70810d13..7443861d 100644 --- a/src/basic/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx +++ b/src/basic/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx @@ -4,7 +4,7 @@ import { ProductWithUI } from '../../../entities/product'; import { useCart } from '../../../features/cart/hooks'; import { calculateCartTotal } from '../../../entities/cart'; import { CartItemsList } from '../../../features/cart/ui'; -import { CouponSelector } from '../../../features/coupons/shop/ui'; +import { CouponSelector } from '../../../features/coupon/shop/ui'; import { OrderSummary } from '../../../features/order/ui'; interface ShoppingSidebarProps { diff --git a/src/basic/widgets/ShoppingSidebar/ui/index.ts b/src/basic/widgets/ShoppingSidebar/ui/index.ts new file mode 100644 index 00000000..4f959865 --- /dev/null +++ b/src/basic/widgets/ShoppingSidebar/ui/index.ts @@ -0,0 +1 @@ +export { ShoppingSidebar } from './ShoppingSidebar'; From 938abd4b2c6e44c33b84e53c7b96bd627b3bdb7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 20:50:35 +0900 Subject: [PATCH 40/68] =?UTF-8?q?feat:=20features/cart=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EB=A1=9C=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EC=95=84=EC=9D=B4=ED=85=9C=20UI=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/features/cart/ui/CartItemsList.tsx | 131 +++++++++++ src/basic/features/cart/ui/CartSidebar.tsx | 228 ------------------- src/basic/features/cart/ui/index.ts | 2 +- 3 files changed, 132 insertions(+), 229 deletions(-) create mode 100644 src/basic/features/cart/ui/CartItemsList.tsx delete mode 100644 src/basic/features/cart/ui/CartSidebar.tsx diff --git a/src/basic/features/cart/ui/CartItemsList.tsx b/src/basic/features/cart/ui/CartItemsList.tsx new file mode 100644 index 00000000..db7a3b0e --- /dev/null +++ b/src/basic/features/cart/ui/CartItemsList.tsx @@ -0,0 +1,131 @@ +import { CartItem } from '../../../../types'; +import { Button } from '../../../shared/ui'; +import { calculateItemTotal } from '../../../entities/cart'; + +interface CartItemsListProps { + cart: CartItem[]; + onRemove: (productId: string) => void; + onUpdateQuantity: (productId: string, newQuantity: number) => void; +} + +export function CartItemsList({ + cart, + onRemove, + onUpdateQuantity, +}: CartItemsListProps) { + return ( +
+

+ + + + 장바구니 +

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

장바구니가 비어있습니다

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

+ {item.product.name} +

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

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

+
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/basic/features/cart/ui/CartSidebar.tsx b/src/basic/features/cart/ui/CartSidebar.tsx deleted file mode 100644 index cbc64c01..00000000 --- a/src/basic/features/cart/ui/CartSidebar.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { CartItem, Coupon } from '../../../../types'; -import { calculateItemTotal } from '../../../entities/cart'; -import { Button } from '../../../shared/ui'; - -interface CartSidebarProps { - cart: CartItem[]; - coupons: Coupon[]; - selectedCoupon: Coupon | null; - totals: { - totalBeforeDiscount: number; - totalAfterDiscount: number; - }; - onRemoveFromCart: (productId: string) => void; - onUpdateQuantity: (productId: string, newQuantity: number) => void; - onApplyCoupon: (coupon: Coupon) => void; - onSetSelectedCoupon: (coupon: Coupon | null) => void; - onCompleteOrder: () => void; -} - -export function CartSidebar({ - cart, - coupons, - selectedCoupon, - totals, - onRemoveFromCart, - onUpdateQuantity, - onApplyCoupon, - onSetSelectedCoupon, - onCompleteOrder, -}: CartSidebarProps) { - return ( -
- {/* 장바구니 섹션 */} -
-

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

- {item.product.name} -

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

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

-
-
-
- ); - })} -
- )} -
- - {/* 쿠폰 할인 섹션 */} - {cart.length > 0 && ( - <> -
-
-

쿠폰 할인

- -
- {coupons.length > 0 && ( - - )} -
- - {/* 결제 정보 섹션 */} -
-

결제 정보

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

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

-
-
- - )} -
- ); -} \ No newline at end of file diff --git a/src/basic/features/cart/ui/index.ts b/src/basic/features/cart/ui/index.ts index 239c5b3a..7408d52f 100644 --- a/src/basic/features/cart/ui/index.ts +++ b/src/basic/features/cart/ui/index.ts @@ -1 +1 @@ -export { CartSidebar } from './CartSidebar'; \ No newline at end of file +export { CartItemsList } from './CartItemsList'; From d5bc482ae33047acf79f8200d3e5afb669122066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 20:51:11 +0900 Subject: [PATCH 41/68] =?UTF-8?q?feat:=20features/coupon=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EB=A1=9C=20=EC=BF=A0=ED=8F=B0=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20UI=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/shop/ui/CouponSelector.tsx | 67 +++++++++++++++++++ src/basic/features/coupon/shop/ui/index.ts | 1 + 2 files changed, 68 insertions(+) create mode 100644 src/basic/features/coupon/shop/ui/CouponSelector.tsx create mode 100644 src/basic/features/coupon/shop/ui/index.ts diff --git a/src/basic/features/coupon/shop/ui/CouponSelector.tsx b/src/basic/features/coupon/shop/ui/CouponSelector.tsx new file mode 100644 index 00000000..c5f8a111 --- /dev/null +++ b/src/basic/features/coupon/shop/ui/CouponSelector.tsx @@ -0,0 +1,67 @@ +import { Coupon } from '../../../../../types'; +import { Button } from '../../../../shared/ui'; +import { useCoupons } from '../../../../entities/coupon/hooks'; + +interface CouponSelectorProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; + calculateCartTotalWithCoupon: () => { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; +} + +export function CouponSelector({ + coupons, + selectedCoupon, + setSelectedCoupon, + addNotification, + calculateCartTotalWithCoupon, +}: CouponSelectorProps) { + const { applyCoupon } = useCoupons({ + coupons, + setCoupons: () => {}, + selectedCoupon, + setSelectedCoupon, + addNotification, + calculateCartTotalWithCoupon, + }); + + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/basic/features/coupon/shop/ui/index.ts b/src/basic/features/coupon/shop/ui/index.ts new file mode 100644 index 00000000..b4bb0ed2 --- /dev/null +++ b/src/basic/features/coupon/shop/ui/index.ts @@ -0,0 +1 @@ +export { CouponSelector } from './CouponSelector'; From 874431c951e408e24fe25dba864012af489c990a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 20:51:41 +0900 Subject: [PATCH 42/68] =?UTF-8?q?feat:=20features/order=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EB=A1=9C=20=EC=A3=BC=EB=AC=B8=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20UI=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/features/order/ui/OrderSummary.tsx | 74 ++++++++++++++++++++ src/basic/features/order/ui/index.ts | 1 + 2 files changed, 75 insertions(+) create mode 100644 src/basic/features/order/ui/OrderSummary.tsx create mode 100644 src/basic/features/order/ui/index.ts diff --git a/src/basic/features/order/ui/OrderSummary.tsx b/src/basic/features/order/ui/OrderSummary.tsx new file mode 100644 index 00000000..da519b8b --- /dev/null +++ b/src/basic/features/order/ui/OrderSummary.tsx @@ -0,0 +1,74 @@ +import { CartItem, Coupon } from '../../../../types'; +import { Button } from '../../../shared/ui'; +import { useOrder } from '../hooks'; + +interface OrderSummaryProps { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + setCart: React.Dispatch>; + setSelectedCoupon: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export function OrderSummary({ + totals, + setCart, + setSelectedCoupon, + addNotification, +}: OrderSummaryProps) { + const { completeOrder } = useOrder({ + setCart, + setSelectedCoupon, + addNotification, + }); + + return ( +
+

결제 정보

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

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

+
+
+ ); +} diff --git a/src/basic/features/order/ui/index.ts b/src/basic/features/order/ui/index.ts new file mode 100644 index 00000000..dd49db3a --- /dev/null +++ b/src/basic/features/order/ui/index.ts @@ -0,0 +1 @@ +export { OrderSummary } from './OrderSummary'; From 0b809325d9ea3a009b8645ec2e4736cb8521789d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 20:52:27 +0900 Subject: [PATCH 43/68] =?UTF-8?q?chore:=20=ED=8F=B4=EB=8D=94=EB=AA=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin}/ui/CouponForm.tsx | 2 +- .../admin}/ui/CouponManagement.tsx | 6 +- .../admin}/ui/CouponTable.tsx | 4 +- .../{coupons => coupon/admin}/ui/index.ts | 0 src/basic/features/coupons/hooks/index.ts | 1 - .../features/coupons/hooks/useCoupons.ts | 80 ------------------- .../admin/hooks/index.ts | 0 .../admin/hooks/useProducts.ts | 0 .../admin/ui/ProductForm.tsx | 0 .../admin/ui/ProductManagement.tsx | 0 .../admin/ui/ProductTable.tsx | 0 .../{products => product}/admin/ui/index.ts | 0 .../{products => product}/shop/hooks/index.ts | 0 .../shop/hooks/useProductSearch.tsx | 0 .../shop/ui/ProductCard.tsx | 0 .../shop/ui/ProductList.tsx | 0 .../{products => product}/shop/ui/index.ts | 0 src/basic/pages/AdminPage.tsx | 4 +- 18 files changed, 8 insertions(+), 89 deletions(-) rename src/basic/features/{coupons => coupon/admin}/ui/CouponForm.tsx (99%) rename src/basic/features/{coupons => coupon/admin}/ui/CouponManagement.tsx (94%) rename src/basic/features/{coupons => coupon/admin}/ui/CouponTable.tsx (96%) rename src/basic/features/{coupons => coupon/admin}/ui/index.ts (100%) delete mode 100644 src/basic/features/coupons/hooks/index.ts delete mode 100644 src/basic/features/coupons/hooks/useCoupons.ts rename src/basic/features/{products => product}/admin/hooks/index.ts (100%) rename src/basic/features/{products => product}/admin/hooks/useProducts.ts (100%) rename src/basic/features/{products => product}/admin/ui/ProductForm.tsx (100%) rename src/basic/features/{products => product}/admin/ui/ProductManagement.tsx (100%) rename src/basic/features/{products => product}/admin/ui/ProductTable.tsx (100%) rename src/basic/features/{products => product}/admin/ui/index.ts (100%) rename src/basic/features/{products => product}/shop/hooks/index.ts (100%) rename src/basic/features/{products => product}/shop/hooks/useProductSearch.tsx (100%) rename src/basic/features/{products => product}/shop/ui/ProductCard.tsx (100%) rename src/basic/features/{products => product}/shop/ui/ProductList.tsx (100%) rename src/basic/features/{products => product}/shop/ui/index.ts (100%) diff --git a/src/basic/features/coupons/ui/CouponForm.tsx b/src/basic/features/coupon/admin/ui/CouponForm.tsx similarity index 99% rename from src/basic/features/coupons/ui/CouponForm.tsx rename to src/basic/features/coupon/admin/ui/CouponForm.tsx index ca7d426f..7ccdd871 100644 --- a/src/basic/features/coupons/ui/CouponForm.tsx +++ b/src/basic/features/coupon/admin/ui/CouponForm.tsx @@ -1,5 +1,5 @@ import { FormEvent } from 'react'; -import { Button } from '../../../shared/ui'; +import { Button } from '../../../../shared/ui'; interface CouponFormData { name: string; diff --git a/src/basic/features/coupons/ui/CouponManagement.tsx b/src/basic/features/coupon/admin/ui/CouponManagement.tsx similarity index 94% rename from src/basic/features/coupons/ui/CouponManagement.tsx rename to src/basic/features/coupon/admin/ui/CouponManagement.tsx index 0ffa5948..de831b52 100644 --- a/src/basic/features/coupons/ui/CouponManagement.tsx +++ b/src/basic/features/coupon/admin/ui/CouponManagement.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; -import { Coupon } from '../../../../types'; -import { useCoupons } from '../hooks'; -import CouponForm from './CouponForm'; +import { Coupon } from '../../../../../types'; +import { useCoupons } from '../../../../entities/coupon/hooks'; import CouponTable from './CouponTable'; +import CouponForm from './CouponForm'; interface CouponManagementProps { coupons: Coupon[]; diff --git a/src/basic/features/coupons/ui/CouponTable.tsx b/src/basic/features/coupon/admin/ui/CouponTable.tsx similarity index 96% rename from src/basic/features/coupons/ui/CouponTable.tsx rename to src/basic/features/coupon/admin/ui/CouponTable.tsx index 551b71bd..a89f3c8e 100644 --- a/src/basic/features/coupons/ui/CouponTable.tsx +++ b/src/basic/features/coupon/admin/ui/CouponTable.tsx @@ -1,5 +1,5 @@ -import { Coupon } from '../../../../types'; -import { Button } from '../../../shared/ui'; +import { Coupon } from '../../../../../types'; +import { Button } from '../../../../shared/ui'; interface CouponTableProps { coupons: Coupon[]; diff --git a/src/basic/features/coupons/ui/index.ts b/src/basic/features/coupon/admin/ui/index.ts similarity index 100% rename from src/basic/features/coupons/ui/index.ts rename to src/basic/features/coupon/admin/ui/index.ts diff --git a/src/basic/features/coupons/hooks/index.ts b/src/basic/features/coupons/hooks/index.ts deleted file mode 100644 index c12e168c..00000000 --- a/src/basic/features/coupons/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useCoupons } from './useCoupons'; \ No newline at end of file diff --git a/src/basic/features/coupons/hooks/useCoupons.ts b/src/basic/features/coupons/hooks/useCoupons.ts deleted file mode 100644 index 6e4e6fa5..00000000 --- a/src/basic/features/coupons/hooks/useCoupons.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { useCallback } from 'react'; -import { Coupon } from '../../../../types'; - -interface UseCouponsProps { - coupons: Coupon[]; - setCoupons: React.Dispatch>; - selectedCoupon: Coupon | null; - setSelectedCoupon: React.Dispatch>; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; - calculateCartTotalWithCoupon: () => { - totalBeforeDiscount: number; - totalAfterDiscount: number; - }; -} - -export function useCoupons({ - coupons, - setCoupons, - selectedCoupon, - setSelectedCoupon, - addNotification, - calculateCartTotalWithCoupon, -}: UseCouponsProps) { - // 쿠폰 추가 - 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, setCoupons] - ); - - // 쿠폰 삭제 - const deleteCoupon = useCallback( - (couponCode: string) => { - setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, - [selectedCoupon, addNotification, setCoupons, setSelectedCoupon] - ); - - // 쿠폰 적용 - const applyCoupon = useCallback( - (coupon: Coupon) => { - const currentTotal = calculateCartTotalWithCoupon().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification( - 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', - 'error' - ); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, - [addNotification, calculateCartTotalWithCoupon, setSelectedCoupon] - ); - - return { - coupons, - selectedCoupon, - addCoupon, - deleteCoupon, - applyCoupon, - setSelectedCoupon, - }; -} diff --git a/src/basic/features/products/admin/hooks/index.ts b/src/basic/features/product/admin/hooks/index.ts similarity index 100% rename from src/basic/features/products/admin/hooks/index.ts rename to src/basic/features/product/admin/hooks/index.ts diff --git a/src/basic/features/products/admin/hooks/useProducts.ts b/src/basic/features/product/admin/hooks/useProducts.ts similarity index 100% rename from src/basic/features/products/admin/hooks/useProducts.ts rename to src/basic/features/product/admin/hooks/useProducts.ts diff --git a/src/basic/features/products/admin/ui/ProductForm.tsx b/src/basic/features/product/admin/ui/ProductForm.tsx similarity index 100% rename from src/basic/features/products/admin/ui/ProductForm.tsx rename to src/basic/features/product/admin/ui/ProductForm.tsx diff --git a/src/basic/features/products/admin/ui/ProductManagement.tsx b/src/basic/features/product/admin/ui/ProductManagement.tsx similarity index 100% rename from src/basic/features/products/admin/ui/ProductManagement.tsx rename to src/basic/features/product/admin/ui/ProductManagement.tsx diff --git a/src/basic/features/products/admin/ui/ProductTable.tsx b/src/basic/features/product/admin/ui/ProductTable.tsx similarity index 100% rename from src/basic/features/products/admin/ui/ProductTable.tsx rename to src/basic/features/product/admin/ui/ProductTable.tsx diff --git a/src/basic/features/products/admin/ui/index.ts b/src/basic/features/product/admin/ui/index.ts similarity index 100% rename from src/basic/features/products/admin/ui/index.ts rename to src/basic/features/product/admin/ui/index.ts diff --git a/src/basic/features/products/shop/hooks/index.ts b/src/basic/features/product/shop/hooks/index.ts similarity index 100% rename from src/basic/features/products/shop/hooks/index.ts rename to src/basic/features/product/shop/hooks/index.ts diff --git a/src/basic/features/products/shop/hooks/useProductSearch.tsx b/src/basic/features/product/shop/hooks/useProductSearch.tsx similarity index 100% rename from src/basic/features/products/shop/hooks/useProductSearch.tsx rename to src/basic/features/product/shop/hooks/useProductSearch.tsx diff --git a/src/basic/features/products/shop/ui/ProductCard.tsx b/src/basic/features/product/shop/ui/ProductCard.tsx similarity index 100% rename from src/basic/features/products/shop/ui/ProductCard.tsx rename to src/basic/features/product/shop/ui/ProductCard.tsx diff --git a/src/basic/features/products/shop/ui/ProductList.tsx b/src/basic/features/product/shop/ui/ProductList.tsx similarity index 100% rename from src/basic/features/products/shop/ui/ProductList.tsx rename to src/basic/features/product/shop/ui/ProductList.tsx diff --git a/src/basic/features/products/shop/ui/index.ts b/src/basic/features/product/shop/ui/index.ts similarity index 100% rename from src/basic/features/products/shop/ui/index.ts rename to src/basic/features/product/shop/ui/index.ts diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx index 4d37d9da..d021fbf0 100644 --- a/src/basic/pages/AdminPage.tsx +++ b/src/basic/pages/AdminPage.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { Coupon } from '../../types'; import { ProductWithUI } from '../entities/product'; -import { ProductManagement } from '../features/products/admin/ui'; -import { CouponManagement } from '../features/coupons/ui'; +import { ProductManagement } from '../features/product/admin/ui'; +import { CouponManagement } from '../features/coupon/admin/ui'; interface AdminPageProps { products: ProductWithUI[]; From ec25eaad3c07b8833c1995a09f68055b8e192498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 23:13:32 +0900 Subject: [PATCH 44/68] =?UTF-8?q?feat:=20shared/utils=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EB=A1=9C=20=EC=9C=A0=ED=8B=B8=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/shared/utils/index.ts | 2 ++ src/basic/shared/utils/priceUtils.ts | 8 +++++++ src/basic/shared/utils/stockUtils.ts | 31 ++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 src/basic/shared/utils/index.ts create mode 100644 src/basic/shared/utils/priceUtils.ts create mode 100644 src/basic/shared/utils/stockUtils.ts diff --git a/src/basic/shared/utils/index.ts b/src/basic/shared/utils/index.ts new file mode 100644 index 00000000..1adf3e41 --- /dev/null +++ b/src/basic/shared/utils/index.ts @@ -0,0 +1,2 @@ +export * from './priceUtils'; +export * from './stockUtils'; \ No newline at end of file diff --git a/src/basic/shared/utils/priceUtils.ts b/src/basic/shared/utils/priceUtils.ts new file mode 100644 index 00000000..9011aea8 --- /dev/null +++ b/src/basic/shared/utils/priceUtils.ts @@ -0,0 +1,8 @@ +/** + * 숫자를 천 단위 구분자가 있는 문자열로 포맷팅 + * @param price 포맷팅할 가격 + * @returns 천 단위 구분자가 포함된 문자열 (예: "10,000") + */ +export const formatPrice = (price: number): string => { + return price.toLocaleString(); +}; \ No newline at end of file diff --git a/src/basic/shared/utils/stockUtils.ts b/src/basic/shared/utils/stockUtils.ts new file mode 100644 index 00000000..b20d84f2 --- /dev/null +++ b/src/basic/shared/utils/stockUtils.ts @@ -0,0 +1,31 @@ +/** + * 전체 재고에서 장바구니 수량을 뺀 남은 재고 계산 + * @param stock 전체 재고 수량 + * @param cartQuantity 장바구니에 담긴 수량 + * @returns 남은 재고 수량 + */ +export const getRemainingStock = ({ + stock, + cartQuantity, +}: { + stock: number; + cartQuantity: number; +}) => { + return stock - cartQuantity; +}; + +/** + * 재고 상태를 확인해서 품절 여부를 문자열로 반환 + * @param stock 전체 재고 수량 + * @param cartQuantity 장바구니에 담긴 수량 + * @returns 품절이면 "SOLD OUT", 아니면 빈 문자열 + */ +export const getProductStockStatus = ({ + stock, + cartQuantity, +}: { + stock: number; + cartQuantity: number; +}) => { + return getRemainingStock({ stock, cartQuantity }) <= 0 ? 'SOLD OUT' : ''; +}; \ No newline at end of file From bca84429e6af2620e473426508c2972de8b10145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 23:13:52 +0900 Subject: [PATCH 45/68] =?UTF-8?q?refactor:=20shared/utils=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=ED=95=A8=EC=88=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/features/cart/hooks/useCart.ts | 25 +++++++++------ .../product/admin/ui/ProductManagement.tsx | 3 -- .../product/admin/ui/ProductTable.tsx | 10 ++++-- .../features/product/shop/ui/ProductCard.tsx | 31 ++++++++++++++++--- .../features/product/shop/ui/ProductList.tsx | 15 +++------ src/basic/pages/AdminPage.tsx | 4 --- src/basic/pages/ShoppingPage.tsx | 17 ++-------- 7 files changed, 55 insertions(+), 50 deletions(-) diff --git a/src/basic/features/cart/hooks/useCart.ts b/src/basic/features/cart/hooks/useCart.ts index 4f512484..49bc4fb3 100644 --- a/src/basic/features/cart/hooks/useCart.ts +++ b/src/basic/features/cart/hooks/useCart.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; -import { CartItem, Product } from '../../../../types'; +import { CartItem } from '../../../../types'; import { ProductWithUI } from '../../../entities/product'; +import { getRemainingStock } from '../../../shared/utils'; interface UseCartProps { cart: CartItem[]; @@ -18,12 +19,11 @@ export function useCart({ products, addNotification, }: UseCartProps) { - // 남은 재고 계산 (전체 재고 - 장바구니 수량) - const getRemainingStock = useCallback( - (product: Product): number => { - const cartItem = cart.find((item) => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - return remaining; + // 특정 상품의 장바구니 수량 찾기 + const getCartQuantity = useCallback( + (productId: string): number => { + const cartItem = cart.find((item) => item.product.id === productId); + return cartItem?.quantity || 0; }, [cart] ); @@ -31,7 +31,12 @@ export function useCart({ // 장바구니에 상품 추가 const addToCart = useCallback( (product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); + const cartQuantity = getCartQuantity(product.id); + const remainingStock = getRemainingStock({ + stock: product.stock, + cartQuantity + }); + if (remainingStock <= 0) { addNotification('재고가 부족합니다!', 'error'); return; @@ -65,7 +70,7 @@ export function useCart({ addNotification('장바구니에 담았습니다', 'success'); }, - [addNotification, getRemainingStock, setCart] + [addNotification, getCartQuantity, setCart] ); // 장바구니에서 상품 제거 @@ -111,6 +116,6 @@ export function useCart({ addToCart, removeFromCart, updateQuantity, - getRemainingStock, + getCartQuantity, }; } \ No newline at end of file diff --git a/src/basic/features/product/admin/ui/ProductManagement.tsx b/src/basic/features/product/admin/ui/ProductManagement.tsx index 69699b91..94a08563 100644 --- a/src/basic/features/product/admin/ui/ProductManagement.tsx +++ b/src/basic/features/product/admin/ui/ProductManagement.tsx @@ -8,7 +8,6 @@ import ProductForm from './ProductForm'; interface ProductManagementProps { products: ProductWithUI[]; setProducts: React.Dispatch>; - formatPrice: (price: number, productId?: string) => string; addNotification: ( message: string, type?: 'error' | 'success' | 'warning' @@ -18,7 +17,6 @@ interface ProductManagementProps { export default function ProductManagement({ products, setProducts, - formatPrice, addNotification, }: ProductManagementProps) { const { addProduct, updateProduct, deleteProduct } = useProducts({ @@ -96,7 +94,6 @@ export default function ProductManagement({ diff --git a/src/basic/features/product/admin/ui/ProductTable.tsx b/src/basic/features/product/admin/ui/ProductTable.tsx index 4412e555..9783bfe9 100644 --- a/src/basic/features/product/admin/ui/ProductTable.tsx +++ b/src/basic/features/product/admin/ui/ProductTable.tsx @@ -1,19 +1,23 @@ import { ProductWithUI } from '../../../../entities/product'; import { Button } from '../../../../shared/ui'; +import { formatPrice } from '../../../../shared/utils'; interface ProductTableProps { products: ProductWithUI[]; - formatPrice: (price: number, productId?: string) => string; onEdit: (product: ProductWithUI) => void; onDelete: (productId: string) => void; } export default function ProductTable({ products, - formatPrice, onEdit, onDelete, }: ProductTableProps) { + // 관리자용 가격 표시 함수 + const displayAdminPrice = (price: number) => { + const formatted = formatPrice(price); + return `${formatted}원`; + }; return (
@@ -43,7 +47,7 @@ export default function ProductTable({ {product.name}
- {formatPrice(product.price, product.id)} + {displayAdminPrice(product.price)} void; - formatPrice: (price: number, productId?: string) => string; } export default function ProductCard({ product, - remainingStock, + cart, onAddToCart, - formatPrice, }: ProductCardProps) { + // 장바구니에서 현재 상품 수량 찾기 + const cartQuantity = cart.find(item => item.product.id === product.id)?.quantity || 0; + + // 남은 재고 계산 + const remainingStock = getRemainingStock({ + stock: product.stock, + cartQuantity + }); + + // 품절 상태 체크 + const stockStatus = getProductStockStatus({ + stock: product.stock, + cartQuantity + }); + + // 가격 표시 함수 + const displayPrice = () => { + if (stockStatus) return stockStatus; + const formatted = formatPrice(product.price); + return `₩${formatted}`; + }; return (
{/* 상품 이미지 영역 (placeholder) */} @@ -57,7 +78,7 @@ export default function ProductCard({ {/* 가격 정보 */}

- {formatPrice(product.price, product.id)} + {displayPrice()}

{product.discounts.length > 0 && (

diff --git a/src/basic/features/product/shop/ui/ProductList.tsx b/src/basic/features/product/shop/ui/ProductList.tsx index e571f142..0764678a 100644 --- a/src/basic/features/product/shop/ui/ProductList.tsx +++ b/src/basic/features/product/shop/ui/ProductList.tsx @@ -1,23 +1,19 @@ -// import { CartItem } from '../../../../../types'; +import { CartItem } from '../../../../../types'; import { ProductWithUI } from '../../../../entities/product'; import ProductCard from './ProductCard'; interface ProductListProps { products: ProductWithUI[]; searchTerm: string; - // cart: CartItem[]; + cart: CartItem[]; onAddToCart: (product: ProductWithUI) => void; - formatPrice: (price: number, productId?: string) => string; - getRemainingStock: (product: ProductWithUI) => number; } export default function ProductList({ products, searchTerm, - // cart, + cart, onAddToCart, - formatPrice, - getRemainingStock, }: ProductListProps) { if (products.length === 0) { return ( @@ -32,15 +28,12 @@ export default function ProductList({ return (

{products.map((product) => { - const remainingStock = getRemainingStock(product); - return ( ); })} diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx index d021fbf0..51398196 100644 --- a/src/basic/pages/AdminPage.tsx +++ b/src/basic/pages/AdminPage.tsx @@ -27,9 +27,6 @@ export default function AdminPage({ 'products' ); - const formatPrice = (price: number): string => { - return `${price.toLocaleString()}원`; // 관리자는 항상 원화 표시 - }; return (
@@ -70,7 +67,6 @@ export default function AdminPage({ ) : ( diff --git a/src/basic/pages/ShoppingPage.tsx b/src/basic/pages/ShoppingPage.tsx index 05bb6e78..1aeb82d7 100644 --- a/src/basic/pages/ShoppingPage.tsx +++ b/src/basic/pages/ShoppingPage.tsx @@ -36,24 +36,14 @@ export default function ShoppingPage({ }: ShoppingPageProps) { const { filteredProducts } = useProductSearch(products, searchTerm); - // ProductList용 로직만 (addToCart, getRemainingStock) - const { addToCart, getRemainingStock } = useCart({ + // ProductList용 로직만 (addToCart, getCartQuantity) + const { addToCart, getCartQuantity } = useCart({ cart, setCart, products, addNotification, }); - 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'; - } - } - return `₩${price.toLocaleString()}`; - }; - return (
@@ -69,9 +59,8 @@ export default function ShoppingPage({
From cc9e54de0ae34e3179eeb6e2f5d51c26a7da8fc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 23:16:24 +0900 Subject: [PATCH 46/68] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/pages/AdminPage.tsx | 2 -- src/basic/pages/ShoppingPage.tsx | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx index 51398196..b16a7814 100644 --- a/src/basic/pages/AdminPage.tsx +++ b/src/basic/pages/AdminPage.tsx @@ -22,12 +22,10 @@ export default function AdminPage({ setCoupons, addNotification, }: AdminPageProps) { - // 관리자 페이지 내부 상태 (탭 관리) const [activeTab, setActiveTab] = useState<'products' | 'coupons'>( 'products' ); - return (
{/* 관리자 대시보드 헤더 */} diff --git a/src/basic/pages/ShoppingPage.tsx b/src/basic/pages/ShoppingPage.tsx index 1aeb82d7..c19bce6a 100644 --- a/src/basic/pages/ShoppingPage.tsx +++ b/src/basic/pages/ShoppingPage.tsx @@ -36,8 +36,7 @@ export default function ShoppingPage({ }: ShoppingPageProps) { const { filteredProducts } = useProductSearch(products, searchTerm); - // ProductList용 로직만 (addToCart, getCartQuantity) - const { addToCart, getCartQuantity } = useCart({ + const { addToCart } = useCart({ cart, setCart, products, From ab0e64788721d9522afa86a9c885c205ac5b65a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Thu, 7 Aug 2025 23:21:26 +0900 Subject: [PATCH 47/68] =?UTF-8?q?chore:=20advanced=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/App.tsx | 1159 +---------------- src/advanced/entities/cart/index.ts | 11 + src/advanced/entities/cart/types.ts | 13 + src/advanced/entities/cart/utils.ts | 103 ++ src/advanced/entities/coupon/data.ts | 19 + src/advanced/entities/coupon/hooks/index.ts | 1 + .../entities/coupon/hooks/useCoupons.ts | 80 ++ src/advanced/entities/coupon/index.ts | 2 + src/advanced/entities/product/data.ts | 38 + src/advanced/entities/product/index.ts | 5 + src/advanced/entities/product/types.ts | 6 + src/advanced/features/cart/hooks/index.ts | 1 + src/advanced/features/cart/hooks/useCart.ts | 121 ++ .../features/cart/ui/CartItemsList.tsx | 131 ++ src/advanced/features/cart/ui/index.ts | 1 + .../features/coupon/admin/ui/CouponForm.tsx | 169 +++ .../coupon/admin/ui/CouponManagement.tsx | 85 ++ .../features/coupon/admin/ui/CouponTable.tsx | 79 ++ .../features/coupon/admin/ui/index.ts | 3 + .../coupon/shop/ui/CouponSelector.tsx | 67 + src/advanced/features/coupon/shop/ui/index.ts | 1 + src/advanced/features/order/hooks/index.ts | 1 + src/advanced/features/order/hooks/useOrder.ts | 32 + .../features/order/ui/OrderSummary.tsx | 74 ++ src/advanced/features/order/ui/index.ts | 1 + .../features/product/admin/hooks/index.ts | 1 + .../product/admin/hooks/useProducts.ts | 59 + .../features/product/admin/ui/ProductForm.tsx | 263 ++++ .../product/admin/ui/ProductManagement.tsx | 113 ++ .../product/admin/ui/ProductTable.tsx | 86 ++ .../features/product/admin/ui/index.ts | 3 + .../features/product/shop/hooks/index.ts | 1 + .../product/shop/hooks/useProductSearch.tsx | 32 + .../features/product/shop/ui/ProductCard.tsx | 116 ++ .../features/product/shop/ui/ProductList.tsx | 42 + .../features/product/shop/ui/index.ts | 2 + src/advanced/pages/AdminPage.tsx | 79 ++ src/advanced/pages/ShoppingPage.tsx | 82 ++ src/advanced/pages/index.ts | 2 + src/advanced/shared/hooks/index.ts | 9 + src/advanced/shared/hooks/useDebounce.ts | 23 + src/advanced/shared/hooks/useLocalStorage.ts | 41 + src/advanced/shared/hooks/useNotification.ts | 55 + src/advanced/shared/ui/Button.tsx | 49 + src/advanced/shared/ui/Header.tsx | 73 ++ src/advanced/shared/ui/NotificationToast.tsx | 51 + src/advanced/shared/ui/SearchInput.tsx | 40 + src/advanced/shared/ui/index.ts | 11 + src/advanced/shared/utils/index.ts | 2 + src/advanced/shared/utils/priceUtils.ts | 8 + src/advanced/shared/utils/stockUtils.ts | 31 + .../ShoppingSidebar/ui/ShoppingSidebar.tsx | 76 ++ .../widgets/ShoppingSidebar/ui/index.ts | 1 + 53 files changed, 2451 insertions(+), 1103 deletions(-) create mode 100644 src/advanced/entities/cart/index.ts create mode 100644 src/advanced/entities/cart/types.ts create mode 100644 src/advanced/entities/cart/utils.ts create mode 100644 src/advanced/entities/coupon/data.ts create mode 100644 src/advanced/entities/coupon/hooks/index.ts create mode 100644 src/advanced/entities/coupon/hooks/useCoupons.ts create mode 100644 src/advanced/entities/coupon/index.ts create mode 100644 src/advanced/entities/product/data.ts create mode 100644 src/advanced/entities/product/index.ts create mode 100644 src/advanced/entities/product/types.ts create mode 100644 src/advanced/features/cart/hooks/index.ts create mode 100644 src/advanced/features/cart/hooks/useCart.ts create mode 100644 src/advanced/features/cart/ui/CartItemsList.tsx create mode 100644 src/advanced/features/cart/ui/index.ts create mode 100644 src/advanced/features/coupon/admin/ui/CouponForm.tsx create mode 100644 src/advanced/features/coupon/admin/ui/CouponManagement.tsx create mode 100644 src/advanced/features/coupon/admin/ui/CouponTable.tsx create mode 100644 src/advanced/features/coupon/admin/ui/index.ts create mode 100644 src/advanced/features/coupon/shop/ui/CouponSelector.tsx create mode 100644 src/advanced/features/coupon/shop/ui/index.ts create mode 100644 src/advanced/features/order/hooks/index.ts create mode 100644 src/advanced/features/order/hooks/useOrder.ts create mode 100644 src/advanced/features/order/ui/OrderSummary.tsx create mode 100644 src/advanced/features/order/ui/index.ts create mode 100644 src/advanced/features/product/admin/hooks/index.ts create mode 100644 src/advanced/features/product/admin/hooks/useProducts.ts create mode 100644 src/advanced/features/product/admin/ui/ProductForm.tsx create mode 100644 src/advanced/features/product/admin/ui/ProductManagement.tsx create mode 100644 src/advanced/features/product/admin/ui/ProductTable.tsx create mode 100644 src/advanced/features/product/admin/ui/index.ts create mode 100644 src/advanced/features/product/shop/hooks/index.ts create mode 100644 src/advanced/features/product/shop/hooks/useProductSearch.tsx create mode 100644 src/advanced/features/product/shop/ui/ProductCard.tsx create mode 100644 src/advanced/features/product/shop/ui/ProductList.tsx create mode 100644 src/advanced/features/product/shop/ui/index.ts create mode 100644 src/advanced/pages/AdminPage.tsx create mode 100644 src/advanced/pages/ShoppingPage.tsx create mode 100644 src/advanced/pages/index.ts create mode 100644 src/advanced/shared/hooks/index.ts create mode 100644 src/advanced/shared/hooks/useDebounce.ts create mode 100644 src/advanced/shared/hooks/useLocalStorage.ts create mode 100644 src/advanced/shared/hooks/useNotification.ts create mode 100644 src/advanced/shared/ui/Button.tsx create mode 100644 src/advanced/shared/ui/Header.tsx create mode 100644 src/advanced/shared/ui/NotificationToast.tsx create mode 100644 src/advanced/shared/ui/SearchInput.tsx create mode 100644 src/advanced/shared/ui/index.ts create mode 100644 src/advanced/shared/utils/index.ts create mode 100644 src/advanced/shared/utils/priceUtils.ts create mode 100644 src/advanced/shared/utils/stockUtils.ts create mode 100644 src/advanced/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx create mode 100644 src/advanced/widgets/ShoppingSidebar/ui/index.ts diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1..849d5dbf 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1124 +1,77 @@ -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 { CartItem, Coupon } from '../types'; +import { initialProducts } from './entities/product'; +import { initialCoupons } from './entities/coupon'; +import { calculateCartTotal } from './entities/cart'; +import { useLocalStorage, useNotification } from './shared/hooks'; +import { NotificationToast, Header } from './shared/ui'; +import ShoppingPage from './pages/ShoppingPage'; +import AdminPage from './pages/AdminPage'; const App = () => { + // localStorage와 연동된 데이터 상태들 + const [products, setProducts] = useLocalStorage('products', initialProducts); + const [cart, setCart] = useLocalStorage('cart', []); + const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - + // UI 상태 관리 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 { notifications, addNotification, removeNotification } = + useNotification(); - const calculateCartTotal = (): { + // 장바구니 전체 금액 계산 (쿠폰 할인 포함) - entities/cart 함수 사용 + const calculateCartTotalWithCoupon = useCallback((): { 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 calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); 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" - /> -
- )} -
- -
-
-
- -
+
+ {/* 알림 시스템 */} + + {/* 헤더 */} +
setIsAdmin(!isAdmin)} + onSearchChange={setSearchTerm} + /> + {/* 메인 컨텐츠 */} +
{isAdmin ? ( -
-
-

관리자 대시보드

-

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

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

상품 목록

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

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

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

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

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

새 쿠폰 생성

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

전체 상품

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

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

-
-
- - )} -
-
-
+ )}
); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/advanced/entities/cart/index.ts b/src/advanced/entities/cart/index.ts new file mode 100644 index 00000000..05920651 --- /dev/null +++ b/src/advanced/entities/cart/index.ts @@ -0,0 +1,11 @@ +// 타입 관련 +export type { CartTotal, CartCalculationOptions } from './types'; + +// 유틸리티 함수 관련 +export { + getProductDiscount, + getBulkPurchaseDiscount, + getMaxApplicableDiscount, + calculateItemTotal, + calculateCartTotal, +} from './utils'; diff --git a/src/advanced/entities/cart/types.ts b/src/advanced/entities/cart/types.ts new file mode 100644 index 00000000..7b9e8f54 --- /dev/null +++ b/src/advanced/entities/cart/types.ts @@ -0,0 +1,13 @@ +import { CartItem, Coupon } from '../../../types'; + +// 장바구니 총액 계산 결과 +export interface CartTotal { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} + +// 장바구니 계산에 필요한 옵션들 +export interface CartCalculationOptions { + cart: CartItem[]; + selectedCoupon?: Coupon | null; +} diff --git a/src/advanced/entities/cart/utils.ts b/src/advanced/entities/cart/utils.ts new file mode 100644 index 00000000..b0dca1a1 --- /dev/null +++ b/src/advanced/entities/cart/utils.ts @@ -0,0 +1,103 @@ +import { CartItem, Coupon } from '../../../types'; +import { CartTotal } from './types'; + +/** + * 개별 상품의 할인율 계산 + * 상품별로 설정된 수량 할인 정책에 따라 할인율을 결정 + */ +export const getProductDiscount = (item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + return discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); +}; + +/** + * 대량구매 할인 체크 + * 장바구니에 10개 이상 구매한 상품이 하나라도 있으면 모든 상품에 5% 추가 할인 + */ +export const getBulkPurchaseDiscount = (cart: CartItem[]): number => { + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + return hasBulkPurchase ? 0.05 : 0; +}; + +/** + * 개별 아이템의 최대 적용 가능한 할인율 계산 + * @param item 계산할 장바구니 아이템 + * @param cart 전체 장바구니 + */ +export const getMaxApplicableDiscount = ( + item: CartItem, + cart: CartItem[] +): number => { + const baseDiscount = getProductDiscount(item); + const bulkDiscount = getBulkPurchaseDiscount(cart); + return Math.min(baseDiscount + bulkDiscount, 0.5); +}; + +/** + * 개별 상품의 할인 적용된 총액 계산 + * @param item 계산할 장바구니 아이템 + * @param cart 전체 장바구니 + */ +export const calculateItemTotal = ( + item: CartItem, + cart: CartItem[] +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +/** + * 장바구니 전체 금액 계산 (쿠폰 할인 포함) + * 대량구매 할인을 한 번만 계산하여 모든 아이템에 적용 + */ +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null = null +): CartTotal => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + // 대량구매 할인은 한 번만 계산 + const bulkDiscount = getBulkPurchaseDiscount(cart); + + // 각 상품별 금액 계산 + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + + // 개별 상품 할인 + 대량구매 할인 조합 + const productDiscount = getProductDiscount(item); + const totalDiscount = Math.min(productDiscount + bulkDiscount, 0.5); + const itemTotal = Math.round(itemPrice * (1 - totalDiscount)); + + totalAfterDiscount += itemTotal; + }); + + // 쿠폰 할인 적용 + 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/entities/coupon/data.ts b/src/advanced/entities/coupon/data.ts new file mode 100644 index 00000000..cbc4813d --- /dev/null +++ b/src/advanced/entities/coupon/data.ts @@ -0,0 +1,19 @@ +import { Coupon } from '../../../types'; + +/** + * 초기 쿠폰 데이터 + */ +export const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; diff --git a/src/advanced/entities/coupon/hooks/index.ts b/src/advanced/entities/coupon/hooks/index.ts new file mode 100644 index 00000000..9ef23f5e --- /dev/null +++ b/src/advanced/entities/coupon/hooks/index.ts @@ -0,0 +1 @@ +export { useCoupons } from './useCoupons'; diff --git a/src/advanced/entities/coupon/hooks/useCoupons.ts b/src/advanced/entities/coupon/hooks/useCoupons.ts new file mode 100644 index 00000000..6e4e6fa5 --- /dev/null +++ b/src/advanced/entities/coupon/hooks/useCoupons.ts @@ -0,0 +1,80 @@ +import { useCallback } from 'react'; +import { Coupon } from '../../../../types'; + +interface UseCouponsProps { + coupons: Coupon[]; + setCoupons: React.Dispatch>; + selectedCoupon: Coupon | null; + setSelectedCoupon: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; + calculateCartTotalWithCoupon: () => { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; +} + +export function useCoupons({ + coupons, + setCoupons, + selectedCoupon, + setSelectedCoupon, + addNotification, + calculateCartTotalWithCoupon, +}: UseCouponsProps) { + // 쿠폰 추가 + 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, setCoupons] + ); + + // 쿠폰 삭제 + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }, + [selectedCoupon, addNotification, setCoupons, setSelectedCoupon] + ); + + // 쿠폰 적용 + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotalWithCoupon().totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + addNotification( + 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + 'error' + ); + return; + } + + setSelectedCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, + [addNotification, calculateCartTotalWithCoupon, setSelectedCoupon] + ); + + return { + coupons, + selectedCoupon, + addCoupon, + deleteCoupon, + applyCoupon, + setSelectedCoupon, + }; +} diff --git a/src/advanced/entities/coupon/index.ts b/src/advanced/entities/coupon/index.ts new file mode 100644 index 00000000..5e90635a --- /dev/null +++ b/src/advanced/entities/coupon/index.ts @@ -0,0 +1,2 @@ +// 데이터 관련 +export { initialCoupons } from './data'; diff --git a/src/advanced/entities/product/data.ts b/src/advanced/entities/product/data.ts new file mode 100644 index 00000000..cf0bfc59 --- /dev/null +++ b/src/advanced/entities/product/data.ts @@ -0,0 +1,38 @@ +import { 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: '대용량과 고성능을 자랑하는 상품입니다.', + }, +]; diff --git a/src/advanced/entities/product/index.ts b/src/advanced/entities/product/index.ts new file mode 100644 index 00000000..0d4ee263 --- /dev/null +++ b/src/advanced/entities/product/index.ts @@ -0,0 +1,5 @@ +// 타입 관련 +export type { ProductWithUI } from './types'; + +// 데이터 관련 +export { initialProducts } from './data'; diff --git a/src/advanced/entities/product/types.ts b/src/advanced/entities/product/types.ts new file mode 100644 index 00000000..dfa1402c --- /dev/null +++ b/src/advanced/entities/product/types.ts @@ -0,0 +1,6 @@ +import { Product } from '../../../types'; + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} diff --git a/src/advanced/features/cart/hooks/index.ts b/src/advanced/features/cart/hooks/index.ts new file mode 100644 index 00000000..bbe99fcd --- /dev/null +++ b/src/advanced/features/cart/hooks/index.ts @@ -0,0 +1 @@ +export { useCart } from './useCart'; \ No newline at end of file diff --git a/src/advanced/features/cart/hooks/useCart.ts b/src/advanced/features/cart/hooks/useCart.ts new file mode 100644 index 00000000..49bc4fb3 --- /dev/null +++ b/src/advanced/features/cart/hooks/useCart.ts @@ -0,0 +1,121 @@ +import { useCallback } from 'react'; +import { CartItem } from '../../../../types'; +import { ProductWithUI } from '../../../entities/product'; +import { getRemainingStock } from '../../../shared/utils'; + +interface UseCartProps { + cart: CartItem[]; + setCart: React.Dispatch>; + products: ProductWithUI[]; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export function useCart({ + cart, + setCart, + products, + addNotification, +}: UseCartProps) { + // 특정 상품의 장바구니 수량 찾기 + const getCartQuantity = useCallback( + (productId: string): number => { + const cartItem = cart.find((item) => item.product.id === productId); + return cartItem?.quantity || 0; + }, + [cart] + ); + + // 장바구니에 상품 추가 + const addToCart = useCallback( + (product: ProductWithUI) => { + const cartQuantity = getCartQuantity(product.id); + const remainingStock = getRemainingStock({ + stock: product.stock, + cartQuantity + }); + + 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'); + }, + [addNotification, getCartQuantity, setCart] + ); + + // 장바구니에서 상품 제거 + const removeFromCart = useCallback( + (productId: string) => { + setCart((prevCart) => + prevCart.filter((item) => item.product.id !== productId) + ); + }, + [setCart] + ); + + // 장바구니 상품 수량 업데이트 + 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, setCart] + ); + + return { + cart, + addToCart, + removeFromCart, + updateQuantity, + getCartQuantity, + }; +} \ No newline at end of file diff --git a/src/advanced/features/cart/ui/CartItemsList.tsx b/src/advanced/features/cart/ui/CartItemsList.tsx new file mode 100644 index 00000000..db7a3b0e --- /dev/null +++ b/src/advanced/features/cart/ui/CartItemsList.tsx @@ -0,0 +1,131 @@ +import { CartItem } from '../../../../types'; +import { Button } from '../../../shared/ui'; +import { calculateItemTotal } from '../../../entities/cart'; + +interface CartItemsListProps { + cart: CartItem[]; + onRemove: (productId: string) => void; + onUpdateQuantity: (productId: string, newQuantity: number) => void; +} + +export function CartItemsList({ + cart, + onRemove, + onUpdateQuantity, +}: CartItemsListProps) { + return ( +
+

+ + + + 장바구니 +

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

장바구니가 비어있습니다

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

+ {item.product.name} +

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

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

+
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/advanced/features/cart/ui/index.ts b/src/advanced/features/cart/ui/index.ts new file mode 100644 index 00000000..7408d52f --- /dev/null +++ b/src/advanced/features/cart/ui/index.ts @@ -0,0 +1 @@ +export { CartItemsList } from './CartItemsList'; diff --git a/src/advanced/features/coupon/admin/ui/CouponForm.tsx b/src/advanced/features/coupon/admin/ui/CouponForm.tsx new file mode 100644 index 00000000..7ccdd871 --- /dev/null +++ b/src/advanced/features/coupon/admin/ui/CouponForm.tsx @@ -0,0 +1,169 @@ +import { FormEvent } from 'react'; +import { Button } from '../../../../shared/ui'; + +interface CouponFormData { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} + +interface CouponFormProps { + couponForm: CouponFormData; + setCouponForm: React.Dispatch>; + onSubmit: () => void; + onCancel: () => void; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export default function CouponForm({ + couponForm, + setCouponForm, + onSubmit, + onCancel, + addNotification, +}: CouponFormProps) { + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + onSubmit(); + }; + + const handleDiscountValueChange = ( + e: React.ChangeEvent + ) => { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setCouponForm({ + ...couponForm, + discountValue: value === '' ? 0 : parseInt(value), + }); + } + }; + + const handleDiscountValueBlur = (e: React.FocusEvent) => { + const value = parseInt(e.target.value) || 0; + if (couponForm.discountType === 'percentage') { + if (value > 100) { + addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); + setCouponForm({ + ...couponForm, + discountValue: 100, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } else { + if (value > 100000) { + addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); + setCouponForm({ + ...couponForm, + discountValue: 100000, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } + }; + + return ( +
+
+

새 쿠폰 생성

+ +
+
+ + + setCouponForm({ + ...couponForm, + name: e.target.value, + }) + } + className='w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm' + placeholder='신규 가입 쿠폰' + required + /> +
+ +
+ + + setCouponForm({ + ...couponForm, + code: e.target.value.toUpperCase(), + }) + } + className='w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono' + placeholder='WELCOME2024' + required + /> +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/advanced/features/coupon/admin/ui/CouponManagement.tsx b/src/advanced/features/coupon/admin/ui/CouponManagement.tsx new file mode 100644 index 00000000..de831b52 --- /dev/null +++ b/src/advanced/features/coupon/admin/ui/CouponManagement.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { Coupon } from '../../../../../types'; +import { useCoupons } from '../../../../entities/coupon/hooks'; +import CouponTable from './CouponTable'; +import CouponForm from './CouponForm'; + +interface CouponManagementProps { + coupons: Coupon[]; + setCoupons: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export function CouponManagement({ + coupons, + setCoupons, + addNotification, +}: CouponManagementProps) { + const { addCoupon, deleteCoupon } = useCoupons({ + coupons, + setCoupons, + selectedCoupon: null, // Admin에서는 쿠폰 선택 불필요 + setSelectedCoupon: () => {}, // Admin에서는 쿠폰 선택 불필요 + addNotification, + calculateCartTotalWithCoupon: () => ({ + totalBeforeDiscount: 0, + totalAfterDiscount: 0, + }), + }); + + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0, + }); + + const handleCouponSubmit = () => { + addCoupon(couponForm); + resetForm(); + }; + + const resetForm = () => { + setCouponForm({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + setShowCouponForm(false); + }; + + const startAddCoupon = () => { + setShowCouponForm(true); + }; + + return ( +
+
+

쿠폰 관리

+
+ +
+ + + {showCouponForm && ( + + )} +
+
+ ); +} diff --git a/src/advanced/features/coupon/admin/ui/CouponTable.tsx b/src/advanced/features/coupon/admin/ui/CouponTable.tsx new file mode 100644 index 00000000..a89f3c8e --- /dev/null +++ b/src/advanced/features/coupon/admin/ui/CouponTable.tsx @@ -0,0 +1,79 @@ +import { Coupon } from '../../../../../types'; +import { Button } from '../../../../shared/ui'; + +interface CouponTableProps { + coupons: Coupon[]; + onDelete: (couponCode: string) => void; + onAddNew: () => void; +} + +export default function CouponTable({ + coupons, + onDelete, + onAddNew, +}: CouponTableProps) { + return ( +
+ {coupons.map((coupon) => ( +
+
+
+

{coupon.name}

+

+ {coupon.code} +

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + +
+ +
+
+ ); +} diff --git a/src/advanced/features/coupon/admin/ui/index.ts b/src/advanced/features/coupon/admin/ui/index.ts new file mode 100644 index 00000000..d8dc0a82 --- /dev/null +++ b/src/advanced/features/coupon/admin/ui/index.ts @@ -0,0 +1,3 @@ +export { CouponManagement } from './CouponManagement'; +export { default as CouponForm } from './CouponForm'; +export { default as CouponTable } from './CouponTable'; diff --git a/src/advanced/features/coupon/shop/ui/CouponSelector.tsx b/src/advanced/features/coupon/shop/ui/CouponSelector.tsx new file mode 100644 index 00000000..c5f8a111 --- /dev/null +++ b/src/advanced/features/coupon/shop/ui/CouponSelector.tsx @@ -0,0 +1,67 @@ +import { Coupon } from '../../../../../types'; +import { Button } from '../../../../shared/ui'; +import { useCoupons } from '../../../../entities/coupon/hooks'; + +interface CouponSelectorProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; + calculateCartTotalWithCoupon: () => { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; +} + +export function CouponSelector({ + coupons, + selectedCoupon, + setSelectedCoupon, + addNotification, + calculateCartTotalWithCoupon, +}: CouponSelectorProps) { + const { applyCoupon } = useCoupons({ + coupons, + setCoupons: () => {}, + selectedCoupon, + setSelectedCoupon, + addNotification, + calculateCartTotalWithCoupon, + }); + + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/advanced/features/coupon/shop/ui/index.ts b/src/advanced/features/coupon/shop/ui/index.ts new file mode 100644 index 00000000..b4bb0ed2 --- /dev/null +++ b/src/advanced/features/coupon/shop/ui/index.ts @@ -0,0 +1 @@ +export { CouponSelector } from './CouponSelector'; diff --git a/src/advanced/features/order/hooks/index.ts b/src/advanced/features/order/hooks/index.ts new file mode 100644 index 00000000..caa7eff1 --- /dev/null +++ b/src/advanced/features/order/hooks/index.ts @@ -0,0 +1 @@ +export { useOrder } from './useOrder'; \ No newline at end of file diff --git a/src/advanced/features/order/hooks/useOrder.ts b/src/advanced/features/order/hooks/useOrder.ts new file mode 100644 index 00000000..e1f8768d --- /dev/null +++ b/src/advanced/features/order/hooks/useOrder.ts @@ -0,0 +1,32 @@ +import { useCallback } from 'react'; +import { CartItem, Coupon } from '../../../../types'; + +interface UseOrderProps { + setCart: React.Dispatch>; + setSelectedCoupon: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export function useOrder({ + setCart, + setSelectedCoupon, + addNotification, +}: UseOrderProps) { + // 주문 완료 처리 + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + 'success' + ); + setCart([]); + setSelectedCoupon(null); + }, [addNotification, setCart, setSelectedCoupon]); + + return { + completeOrder, + }; +} \ No newline at end of file diff --git a/src/advanced/features/order/ui/OrderSummary.tsx b/src/advanced/features/order/ui/OrderSummary.tsx new file mode 100644 index 00000000..da519b8b --- /dev/null +++ b/src/advanced/features/order/ui/OrderSummary.tsx @@ -0,0 +1,74 @@ +import { CartItem, Coupon } from '../../../../types'; +import { Button } from '../../../shared/ui'; +import { useOrder } from '../hooks'; + +interface OrderSummaryProps { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + setCart: React.Dispatch>; + setSelectedCoupon: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export function OrderSummary({ + totals, + setCart, + setSelectedCoupon, + addNotification, +}: OrderSummaryProps) { + const { completeOrder } = useOrder({ + setCart, + setSelectedCoupon, + addNotification, + }); + + return ( +
+

결제 정보

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

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

+
+
+ ); +} diff --git a/src/advanced/features/order/ui/index.ts b/src/advanced/features/order/ui/index.ts new file mode 100644 index 00000000..dd49db3a --- /dev/null +++ b/src/advanced/features/order/ui/index.ts @@ -0,0 +1 @@ +export { OrderSummary } from './OrderSummary'; diff --git a/src/advanced/features/product/admin/hooks/index.ts b/src/advanced/features/product/admin/hooks/index.ts new file mode 100644 index 00000000..5977d886 --- /dev/null +++ b/src/advanced/features/product/admin/hooks/index.ts @@ -0,0 +1 @@ +export { useProducts } from './useProducts'; diff --git a/src/advanced/features/product/admin/hooks/useProducts.ts b/src/advanced/features/product/admin/hooks/useProducts.ts new file mode 100644 index 00000000..2ae395e3 --- /dev/null +++ b/src/advanced/features/product/admin/hooks/useProducts.ts @@ -0,0 +1,59 @@ +import { useCallback } from 'react'; +import { ProductWithUI } from '../../../../entities/product'; + +interface UseProductsProps { + products: ProductWithUI[]; + setProducts: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export function useProducts({ + products, + setProducts, + addNotification, +}: UseProductsProps) { + // 상품 추가 + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [setProducts, addNotification] + ); + + // 상품 업데이트 + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => + product.id === productId ? { ...product, ...updates } : product + ) + ); + addNotification('상품이 수정되었습니다.', 'success'); + }, + [setProducts, addNotification] + ); + + // 상품 삭제 + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification('상품이 삭제되었습니다.', 'success'); + }, + [setProducts, addNotification] + ); + + return { + products, + addProduct, + updateProduct, + deleteProduct, + }; +} diff --git a/src/advanced/features/product/admin/ui/ProductForm.tsx b/src/advanced/features/product/admin/ui/ProductForm.tsx new file mode 100644 index 00000000..9358c8e2 --- /dev/null +++ b/src/advanced/features/product/admin/ui/ProductForm.tsx @@ -0,0 +1,263 @@ +import { FormEvent } from 'react'; +import { Button } from '../../../../shared/ui'; + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Array<{ quantity: number; rate: number }>; +} + +interface ProductFormProps { + productForm: ProductFormData; + setProductForm: React.Dispatch>; + onSubmit: () => void; + onCancel: () => void; + isEditing: boolean; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export default function ProductForm({ + productForm, + setProductForm, + onSubmit, + onCancel, + isEditing, + addNotification, +}: ProductFormProps) { + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + onSubmit(); + }; + + const addDiscount = () => { + setProductForm({ + ...productForm, + discounts: [...productForm.discounts, { quantity: 10, rate: 0.1 }], + }); + }; + + const removeDiscount = (index: number) => { + const newDiscounts = productForm.discounts.filter((_, i) => i !== index); + setProductForm({ + ...productForm, + discounts: newDiscounts, + }); + }; + + const updateDiscount = ( + index: number, + field: 'quantity' | 'rate', + value: number + ) => { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index][field] = value; + setProductForm({ + ...productForm, + discounts: newDiscounts, + }); + }; + + return ( +
+
+

+ {isEditing ? '상품 수정' : '새 상품 추가'} +

+ +
+
+ + + 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) => ( +
+ { + updateDiscount( + index, + 'quantity', + parseInt(e.target.value) || 0 + ); + }} + className='w-20 px-2 py-1 border rounded' + min='1' + placeholder='수량' + /> + 개 이상 구매 시 + { + updateDiscount( + index, + 'rate', + (parseInt(e.target.value) || 0) / 100 + ); + }} + className='w-16 px-2 py-1 border rounded' + min='0' + max='100' + placeholder='%' + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/advanced/features/product/admin/ui/ProductManagement.tsx b/src/advanced/features/product/admin/ui/ProductManagement.tsx new file mode 100644 index 00000000..94a08563 --- /dev/null +++ b/src/advanced/features/product/admin/ui/ProductManagement.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react'; +import { ProductWithUI } from '../../../../entities/product'; +import { useProducts } from '../hooks'; +import { Button } from '../../../../shared/ui'; +import ProductTable from './ProductTable'; +import ProductForm from './ProductForm'; + +interface ProductManagementProps { + products: ProductWithUI[]; + setProducts: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export default function ProductManagement({ + products, + setProducts, + addNotification, +}: ProductManagementProps) { + const { addProduct, updateProduct, deleteProduct } = useProducts({ + products, + setProducts, + addNotification, + }); + + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] as Array<{ quantity: number; rate: number }>, + }); + + const handleProductSubmit = () => { + if (editingProduct && editingProduct !== 'new') { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProduct(productForm); + } + resetForm(); + }; + + 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 startAddProduct = () => { + setEditingProduct('new'); + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + }); + setShowProductForm(true); + }; + + const resetForm = () => { + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + }); + setEditingProduct(null); + setShowProductForm(false); + }; + + return ( +
+
+
+

상품 목록

+ +
+
+ + + + {showProductForm && ( + + )} +
+ ); +} diff --git a/src/advanced/features/product/admin/ui/ProductTable.tsx b/src/advanced/features/product/admin/ui/ProductTable.tsx new file mode 100644 index 00000000..9783bfe9 --- /dev/null +++ b/src/advanced/features/product/admin/ui/ProductTable.tsx @@ -0,0 +1,86 @@ +import { ProductWithUI } from '../../../../entities/product'; +import { Button } from '../../../../shared/ui'; +import { formatPrice } from '../../../../shared/utils'; + +interface ProductTableProps { + products: ProductWithUI[]; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +} + +export default function ProductTable({ + products, + onEdit, + onDelete, +}: ProductTableProps) { + // 관리자용 가격 표시 함수 + const displayAdminPrice = (price: number) => { + const formatted = formatPrice(price); + return `${formatted}원`; + }; + return ( +
+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {displayAdminPrice(product.price)} + + 10 + ? 'bg-green-100 text-green-800' + : product.stock > 0 + ? 'bg-yellow-100 text-yellow-800' + : 'bg-red-100 text-red-800' + }`} + > + {product.stock}개 + + + {product.description || '-'} + + + +
+
+ ); +} diff --git a/src/advanced/features/product/admin/ui/index.ts b/src/advanced/features/product/admin/ui/index.ts new file mode 100644 index 00000000..83093283 --- /dev/null +++ b/src/advanced/features/product/admin/ui/index.ts @@ -0,0 +1,3 @@ +export { default as ProductManagement } from './ProductManagement'; +export { default as ProductTable } from './ProductTable'; +export { default as ProductForm } from './ProductForm'; diff --git a/src/advanced/features/product/shop/hooks/index.ts b/src/advanced/features/product/shop/hooks/index.ts new file mode 100644 index 00000000..57636cb8 --- /dev/null +++ b/src/advanced/features/product/shop/hooks/index.ts @@ -0,0 +1 @@ +export { useProductSearch } from './useProductSearch'; diff --git a/src/advanced/features/product/shop/hooks/useProductSearch.tsx b/src/advanced/features/product/shop/hooks/useProductSearch.tsx new file mode 100644 index 00000000..b934b635 --- /dev/null +++ b/src/advanced/features/product/shop/hooks/useProductSearch.tsx @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +import { ProductWithUI } from '../../../../entities/product'; +import { useDebounce } from '../../../../shared/hooks'; + +export function useProductSearch( + products: ProductWithUI[], + searchTerm: string +) { + const debouncedSearchTerm = useDebounce(searchTerm, 500); + + 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]); + + return { + debouncedSearchTerm, + filteredProducts, + }; +} diff --git a/src/advanced/features/product/shop/ui/ProductCard.tsx b/src/advanced/features/product/shop/ui/ProductCard.tsx new file mode 100644 index 00000000..281167c8 --- /dev/null +++ b/src/advanced/features/product/shop/ui/ProductCard.tsx @@ -0,0 +1,116 @@ +import { CartItem } from '../../../../../types'; +import { ProductWithUI } from '../../../../entities/product'; +import { Button } from '../../../../shared/ui'; +import { formatPrice, getRemainingStock, getProductStockStatus } from '../../../../shared/utils'; + +interface ProductCardProps { + product: ProductWithUI; + cart: CartItem[]; + onAddToCart: (product: ProductWithUI) => void; +} + +export default function ProductCard({ + product, + cart, + onAddToCart, +}: ProductCardProps) { + // 장바구니에서 현재 상품 수량 찾기 + const cartQuantity = cart.find(item => item.product.id === product.id)?.quantity || 0; + + // 남은 재고 계산 + const remainingStock = getRemainingStock({ + stock: product.stock, + cartQuantity + }); + + // 품절 상태 체크 + const stockStatus = getProductStockStatus({ + stock: product.stock, + cartQuantity + }); + + // 가격 표시 함수 + const displayPrice = () => { + if (stockStatus) return stockStatus; + const formatted = formatPrice(product.price); + return `₩${formatted}`; + }; + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

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

+ {displayPrice()} +

+ {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/features/product/shop/ui/ProductList.tsx b/src/advanced/features/product/shop/ui/ProductList.tsx new file mode 100644 index 00000000..0764678a --- /dev/null +++ b/src/advanced/features/product/shop/ui/ProductList.tsx @@ -0,0 +1,42 @@ +import { CartItem } from '../../../../../types'; +import { ProductWithUI } from '../../../../entities/product'; +import ProductCard from './ProductCard'; + +interface ProductListProps { + products: ProductWithUI[]; + searchTerm: string; + cart: CartItem[]; + onAddToCart: (product: ProductWithUI) => void; +} + +export default function ProductList({ + products, + searchTerm, + cart, + onAddToCart, +}: ProductListProps) { + if (products.length === 0) { + return ( +
+

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

+
+ ); + } + + return ( +
+ {products.map((product) => { + return ( + + ); + })} +
+ ); +} diff --git a/src/advanced/features/product/shop/ui/index.ts b/src/advanced/features/product/shop/ui/index.ts new file mode 100644 index 00000000..ff14cf93 --- /dev/null +++ b/src/advanced/features/product/shop/ui/index.ts @@ -0,0 +1,2 @@ +export { default as ProductList } from './ProductList'; +export { default as ProductCard } from './ProductCard'; diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx new file mode 100644 index 00000000..b16a7814 --- /dev/null +++ b/src/advanced/pages/AdminPage.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; +import { Coupon } from '../../types'; +import { ProductWithUI } from '../entities/product'; +import { ProductManagement } from '../features/product/admin/ui'; +import { CouponManagement } from '../features/coupon/admin/ui'; + +interface AdminPageProps { + products: ProductWithUI[]; + setProducts: React.Dispatch>; + coupons: Coupon[]; + setCoupons: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; +} + +export default function AdminPage({ + products, + setProducts, + coupons, + setCoupons, + addNotification, +}: AdminPageProps) { + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>( + 'products' + ); + + return ( +
+ {/* 관리자 대시보드 헤더 */} +
+

관리자 대시보드

+

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

+
+ + {/* 탭 네비게이션 */} +
+ +
+ + {/* 탭 컨텐츠 */} + {activeTab === 'products' ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/advanced/pages/ShoppingPage.tsx b/src/advanced/pages/ShoppingPage.tsx new file mode 100644 index 00000000..c19bce6a --- /dev/null +++ b/src/advanced/pages/ShoppingPage.tsx @@ -0,0 +1,82 @@ +import { CartItem, Coupon } from '../../types'; +import { ProductWithUI } from '../entities/product'; +import { ProductList } from '../features/product/shop/ui'; +import { useCart } from '../features/cart/hooks'; +import { useProductSearch } from '../features/product/shop/hooks'; +import { ShoppingSidebar } from '../widgets/ShoppingSidebar/ui'; + +interface ShoppingPageProps { + products: ProductWithUI[]; + searchTerm: string; + cart: CartItem[]; + setCart: React.Dispatch>; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: React.Dispatch>; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; + calculateCartTotalWithCoupon: () => { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; +} + +export default function ShoppingPage({ + products, + searchTerm, + cart, + setCart, + coupons, + selectedCoupon, + setSelectedCoupon, + addNotification, + calculateCartTotalWithCoupon, +}: ShoppingPageProps) { + const { filteredProducts } = useProductSearch(products, searchTerm); + + const { addToCart } = useCart({ + cart, + setCart, + products, + addNotification, + }); + + return ( +
+
+ {/* 상품 목록 섹션 */} +
+
+

전체 상품

+
+ 총 {products.length}개 상품 +
+
+ + +
+
+ + {/* ShoppingSidebar (widgets) */} +
+ +
+
+ ); +} diff --git a/src/advanced/pages/index.ts b/src/advanced/pages/index.ts new file mode 100644 index 00000000..1cc41ff8 --- /dev/null +++ b/src/advanced/pages/index.ts @@ -0,0 +1,2 @@ +export { default as ShoppingPage } from './ShoppingPage'; +export { default as AdminPage } from './AdminPage'; diff --git a/src/advanced/shared/hooks/index.ts b/src/advanced/shared/hooks/index.ts new file mode 100644 index 00000000..403a7e87 --- /dev/null +++ b/src/advanced/shared/hooks/index.ts @@ -0,0 +1,9 @@ +// localStorage 훅 +export { useLocalStorage } from './useLocalStorage'; + +// 알림 시스템 훅 +export { useNotification } from './useNotification'; +export type { Notification, UseNotificationReturn } from './useNotification'; + +// 디바운스 훅 +export { useDebounce } from './useDebounce'; diff --git a/src/advanced/shared/hooks/useDebounce.ts b/src/advanced/shared/hooks/useDebounce.ts new file mode 100644 index 00000000..cf979960 --- /dev/null +++ b/src/advanced/shared/hooks/useDebounce.ts @@ -0,0 +1,23 @@ +import { useState, useEffect } from 'react'; + +/** + * 값의 변경을 지연시키는 디바운스 훅 + * @param value 디바운스할 값 + * @param delay 지연 시간 (밀리초) + * @returns 디바운스된 값 + */ +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; +} diff --git a/src/advanced/shared/hooks/useLocalStorage.ts b/src/advanced/shared/hooks/useLocalStorage.ts new file mode 100644 index 00000000..5488dc7a --- /dev/null +++ b/src/advanced/shared/hooks/useLocalStorage.ts @@ -0,0 +1,41 @@ +import { useState, useEffect } from 'react'; + +/** + * localStorage와 연동된 상태를 관리하는 커스텀 훅 + * @param key localStorage 키 + * @param defaultValue 기본값 + * @returns [상태값, 상태변경함수] + */ +export function useLocalStorage( + key: string, + defaultValue: T +): [T, React.Dispatch>] { + // 초기값 설정 - localStorage에서 복원 + const [state, setState] = useState(() => { + try { + const saved = localStorage.getItem(key); + if (saved) { + return JSON.parse(saved); + } + } catch (error) { + console.warn(`localStorage에서 ${key} 읽기 실패:`, error); + } + return defaultValue; + }); + + // 상태가 변경될 때마다 localStorage에 저장 + useEffect(() => { + try { + if (key === 'cart' && Array.isArray(state) && state.length === 0) { + // 장바구니가 비어있으면 localStorage에서 제거 + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(state)); + } + } catch (error) { + console.warn(`localStorage에 ${key} 저장 실패:`, error); + } + }, [key, state]); + + return [state, setState]; +} diff --git a/src/advanced/shared/hooks/useNotification.ts b/src/advanced/shared/hooks/useNotification.ts new file mode 100644 index 00000000..7b9aa844 --- /dev/null +++ b/src/advanced/shared/hooks/useNotification.ts @@ -0,0 +1,55 @@ +import { useState, useCallback } from 'react'; + +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export interface UseNotificationReturn { + notifications: Notification[]; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; + removeNotification: (id: string) => void; +} + +/** + * 알림 시스템을 관리하는 커스텀 훅 + * 알림 추가, 자동 제거, 수동 제거 기능 제공 + */ +export function useNotification(): UseNotificationReturn { + const [notifications, setNotifications] = useState([]); + + /** + * 새 알림 추가 (3초 후 자동 제거) + */ + const addNotification = useCallback( + (message: string, type: 'error' | 'success' | 'warning' = 'success') => { + const id = Date.now().toString(); + const newNotification: Notification = { id, message, type }; + + setNotifications((prev) => [...prev, newNotification]); + + // 3초 후 자동 제거 + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); + + /** + * 특정 알림 수동 제거 + */ + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return { + notifications, + addNotification, + removeNotification, + }; +} diff --git a/src/advanced/shared/ui/Button.tsx b/src/advanced/shared/ui/Button.tsx new file mode 100644 index 00000000..7eae07d2 --- /dev/null +++ b/src/advanced/shared/ui/Button.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +export interface ButtonProps + extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'icon' | 'link'; + size?: 'sm' | 'md' | 'lg'; + children: React.ReactNode; +} + +const Button: React.FC = ({ + variant = 'primary', + size, + className = '', + children, + disabled, + ...props +}) => { + const baseClasses = 'transition-colors focus:outline-none'; + + const variantClasses = { + primary: + 'inline-flex items-center justify-center font-medium bg-gray-900 text-white hover:bg-gray-800 focus:ring-gray-500 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed', + secondary: + 'inline-flex items-center justify-center font-medium bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed', + danger: 'font-medium text-red-600 hover:text-red-800 focus:ring-red-500', + ghost: + 'inline-flex items-center justify-center font-medium border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:ring-gray-500 disabled:opacity-50 disabled:cursor-not-allowed', + icon: 'inline-flex items-center justify-center text-gray-400 hover:text-gray-600', + link: 'text-indigo-600 hover:text-indigo-800', + }; + + const sizeClasses = { + sm: 'px-2 py-1 text-xs rounded', + md: 'px-4 py-2 text-sm rounded-md', + lg: 'px-6 py-3 text-base rounded-lg', + }; + + const classes = `${baseClasses} ${variantClasses[variant]} ${ + size ? sizeClasses[size] : '' + } ${className}`.trim(); + + return ( + + ); +}; + +export default Button; diff --git a/src/advanced/shared/ui/Header.tsx b/src/advanced/shared/ui/Header.tsx new file mode 100644 index 00000000..e72b5e18 --- /dev/null +++ b/src/advanced/shared/ui/Header.tsx @@ -0,0 +1,73 @@ +import { SearchInput, Button } from '.'; +import { CartItem } from '../../../types'; + +interface HeaderProps { + isAdmin: boolean; + searchTerm: string; + cart: CartItem[]; + onToggleAdmin: () => void; + onSearchChange: (value: string) => void; +} + +export default function Header({ + isAdmin, + searchTerm, + cart, + onToggleAdmin, + onSearchChange, +}: HeaderProps) { + const totalItemCount = cart.reduce((sum, item) => sum + item.quantity, 0); + const cartLength = cart.length; + + return ( +
+
+
+
+

SHOP

+ {/* 검색창 */} + {!isAdmin && ( + + )} +
+ +
+
+
+ ); +} diff --git a/src/advanced/shared/ui/NotificationToast.tsx b/src/advanced/shared/ui/NotificationToast.tsx new file mode 100644 index 00000000..ec858603 --- /dev/null +++ b/src/advanced/shared/ui/NotificationToast.tsx @@ -0,0 +1,51 @@ +import { Notification } from '../hooks/useNotification'; + +interface NotificationToastProps { + notifications: Notification[]; + onRemove: (id: string) => void; +} + +export default function NotificationToast({ + notifications, + onRemove, +}: NotificationToastProps) { + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ); +} diff --git a/src/advanced/shared/ui/SearchInput.tsx b/src/advanced/shared/ui/SearchInput.tsx new file mode 100644 index 00000000..9e9ee832 --- /dev/null +++ b/src/advanced/shared/ui/SearchInput.tsx @@ -0,0 +1,40 @@ +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; +} + +export default function SearchInput({ + value, + onChange, + placeholder = '상품 검색...', + className, +}: SearchInputProps) { + return ( +
+ onChange(e.target.value)} + placeholder={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/shared/ui/index.ts b/src/advanced/shared/ui/index.ts new file mode 100644 index 00000000..20260123 --- /dev/null +++ b/src/advanced/shared/ui/index.ts @@ -0,0 +1,11 @@ +// 알림 관련 +export { default as NotificationToast } from './NotificationToast'; + +// 입력 관련 +export { default as SearchInput } from './SearchInput'; + +// 버튼 관련 +export { default as Button } from './Button'; + +// 레이아웃 관련 +export { default as Header } from './Header'; diff --git a/src/advanced/shared/utils/index.ts b/src/advanced/shared/utils/index.ts new file mode 100644 index 00000000..1adf3e41 --- /dev/null +++ b/src/advanced/shared/utils/index.ts @@ -0,0 +1,2 @@ +export * from './priceUtils'; +export * from './stockUtils'; \ No newline at end of file diff --git a/src/advanced/shared/utils/priceUtils.ts b/src/advanced/shared/utils/priceUtils.ts new file mode 100644 index 00000000..9011aea8 --- /dev/null +++ b/src/advanced/shared/utils/priceUtils.ts @@ -0,0 +1,8 @@ +/** + * 숫자를 천 단위 구분자가 있는 문자열로 포맷팅 + * @param price 포맷팅할 가격 + * @returns 천 단위 구분자가 포함된 문자열 (예: "10,000") + */ +export const formatPrice = (price: number): string => { + return price.toLocaleString(); +}; \ No newline at end of file diff --git a/src/advanced/shared/utils/stockUtils.ts b/src/advanced/shared/utils/stockUtils.ts new file mode 100644 index 00000000..b20d84f2 --- /dev/null +++ b/src/advanced/shared/utils/stockUtils.ts @@ -0,0 +1,31 @@ +/** + * 전체 재고에서 장바구니 수량을 뺀 남은 재고 계산 + * @param stock 전체 재고 수량 + * @param cartQuantity 장바구니에 담긴 수량 + * @returns 남은 재고 수량 + */ +export const getRemainingStock = ({ + stock, + cartQuantity, +}: { + stock: number; + cartQuantity: number; +}) => { + return stock - cartQuantity; +}; + +/** + * 재고 상태를 확인해서 품절 여부를 문자열로 반환 + * @param stock 전체 재고 수량 + * @param cartQuantity 장바구니에 담긴 수량 + * @returns 품절이면 "SOLD OUT", 아니면 빈 문자열 + */ +export const getProductStockStatus = ({ + stock, + cartQuantity, +}: { + stock: number; + cartQuantity: number; +}) => { + return getRemainingStock({ stock, cartQuantity }) <= 0 ? 'SOLD OUT' : ''; +}; \ No newline at end of file diff --git a/src/advanced/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx b/src/advanced/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx new file mode 100644 index 00000000..7443861d --- /dev/null +++ b/src/advanced/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx @@ -0,0 +1,76 @@ +import { useMemo } from 'react'; +import { CartItem, Coupon } from '../../../../types'; +import { ProductWithUI } from '../../../entities/product'; +import { useCart } from '../../../features/cart/hooks'; +import { calculateCartTotal } from '../../../entities/cart'; +import { CartItemsList } from '../../../features/cart/ui'; +import { CouponSelector } from '../../../features/coupon/shop/ui'; +import { OrderSummary } from '../../../features/order/ui'; + +interface ShoppingSidebarProps { + cart: CartItem[]; + setCart: React.Dispatch>; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: React.Dispatch>; + products: ProductWithUI[]; + addNotification: ( + message: string, + type?: 'error' | 'success' | 'warning' + ) => void; + calculateCartTotalWithCoupon: () => { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; +} + +export function ShoppingSidebar({ + cart, + setCart, + coupons, + selectedCoupon, + setSelectedCoupon, + products, + addNotification, + calculateCartTotalWithCoupon, +}: ShoppingSidebarProps) { + const { removeFromCart, updateQuantity } = useCart({ + cart, + setCart, + products, + addNotification, + }); + + const totals = useMemo(() => { + return calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + return ( +
+ {/* 장바구니 아이템 섹션 */} + + {/* 쿠폰 + 주문 섹션 */} + {cart.length > 0 && ( + <> + + + + )} +
+ ); +} diff --git a/src/advanced/widgets/ShoppingSidebar/ui/index.ts b/src/advanced/widgets/ShoppingSidebar/ui/index.ts new file mode 100644 index 00000000..4f959865 --- /dev/null +++ b/src/advanced/widgets/ShoppingSidebar/ui/index.ts @@ -0,0 +1 @@ +export { ShoppingSidebar } from './ShoppingSidebar'; From cc685736e6e9364bbc8c53d6da5a8217061ef16b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 8 Aug 2025 00:09:55 +0900 Subject: [PATCH 48/68] =?UTF-8?q?chore:=20jotai=20=EC=84=A4=EC=B9=98=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B4=88=EA=B8=B0=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 26 ++++++++++++++++++++++++++ src/advanced/main.tsx | 15 +++++++++------ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 79034acb..6d1d68b4 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.13.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85..51539e27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + jotai: + specifier: ^2.13.0 + version: 2.13.0(@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.13.0: + resolution: {integrity: sha512-H43zXdanNTdpfOEJ4NVbm4hgmrctpXLZagjJNcqAywhUv+sTE7esvFjwm5oBg/ywT9Qw63lIkM6fjrhFuW8UDg==} + 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.13.0(@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/main.tsx b/src/advanced/main.tsx index e63eef4a..f88d2765 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 { Provider } from 'jotai'; ReactDOM.createRoot(document.getElementById('root')!).render( - - , -) + + + + +); From 14cc4642c9b010e85aee3bcd0dc25a9932c39ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 8 Aug 2025 00:44:03 +0900 Subject: [PATCH 49/68] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B0=B0=EC=97=B4=20notificationsAtom=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/shared/store/index.ts | 1 + src/advanced/shared/store/notificationAtom.ts | 9 +++++++++ 2 files changed, 10 insertions(+) create mode 100644 src/advanced/shared/store/index.ts create mode 100644 src/advanced/shared/store/notificationAtom.ts diff --git a/src/advanced/shared/store/index.ts b/src/advanced/shared/store/index.ts new file mode 100644 index 00000000..9befcd1e --- /dev/null +++ b/src/advanced/shared/store/index.ts @@ -0,0 +1 @@ +export * from './notificationAtom'; \ No newline at end of file diff --git a/src/advanced/shared/store/notificationAtom.ts b/src/advanced/shared/store/notificationAtom.ts new file mode 100644 index 00000000..78c1844d --- /dev/null +++ b/src/advanced/shared/store/notificationAtom.ts @@ -0,0 +1,9 @@ +import { atom } from 'jotai'; + +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export const notificationsAtom = atom([]); \ No newline at end of file From 28815ec14fef64c8c3107b7334b02f739d6128e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 8 Aug 2025 00:44:32 +0900 Subject: [PATCH 50/68] =?UTF-8?q?feat:=20=EA=B3=A0=EC=9C=A0=20ID=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=ED=95=98=EB=8A=94=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=ED=9B=85=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/utils/notificationUtils.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/advanced/shared/utils/notificationUtils.ts diff --git a/src/advanced/shared/utils/notificationUtils.ts b/src/advanced/shared/utils/notificationUtils.ts new file mode 100644 index 00000000..4dd61770 --- /dev/null +++ b/src/advanced/shared/utils/notificationUtils.ts @@ -0,0 +1,24 @@ +import { useCallback } from 'react'; +import { useSetAtom } from 'jotai'; +import { notificationsAtom } from '../store'; + +let idCounter = 0; + +const generateUniqueId = () => { + return `notification-${Date.now()}-${++idCounter}`; +}; + +export const useNotification = () => { + const setNotifications = useSetAtom(notificationsAtom); + + const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { + const id = generateUniqueId(); + setNotifications(prev => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, 3000); + }, [setNotifications]); + + return { addNotification }; +}; \ No newline at end of file From 6495ddc727478d12bad31fb340736e6910b4e470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 8 Aug 2025 00:45:10 +0900 Subject: [PATCH 51/68] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Provider로 테스트 격리 --- src/advanced/__tests__/origin.test.tsx | 43 ++++++++++++++++---------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/advanced/__tests__/origin.test.tsx b/src/advanced/__tests__/origin.test.tsx index 3f5c3d55..77f24a4e 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -1,9 +1,18 @@ // @ts-nocheck import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; import { vi } from 'vitest'; +import { Provider } from 'jotai'; import App from '../App'; import '../../setupTests'; +const renderWithProvider = () => { + return render( + + + + ); +}; + describe('쇼핑몰 앱 통합 테스트', () => { beforeEach(() => { // localStorage 초기화 @@ -19,7 +28,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('고객 쇼핑 플로우', () => { test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { - render(); + renderWithProvider(); // 검색창에 "프리미엄" 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); @@ -45,7 +54,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { - render(); + renderWithProvider(); // 상품1을 장바구니에 추가 const product1 = screen.getAllByText('장바구니 담기')[0]; @@ -64,7 +73,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('쿠폰을 선택하고 적용할 수 있다', () => { - render(); + renderWithProvider(); // 상품 추가 const addButton = screen.getAllByText('장바구니 담기')[0]; @@ -81,7 +90,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('품절 임박 상품에 경고가 표시된다', async () => { - render(); + renderWithProvider(); // 관리자 모드로 전환 fireEvent.click(screen.getByText('관리자 페이지로')); @@ -111,7 +120,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('주문을 완료할 수 있다', () => { - render(); + renderWithProvider(); // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); @@ -128,7 +137,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('장바구니에서 상품을 삭제할 수 있다', () => { - render(); + renderWithProvider(); // 상품 2개 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); @@ -151,7 +160,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('재고를 초과하여 구매할 수 없다', async () => { - render(); + renderWithProvider(); // 상품1 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); @@ -178,7 +187,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('장바구니에서 수량을 감소시킬 수 있다', () => { - render(); + renderWithProvider(); // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); @@ -213,7 +222,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('20개 이상 구매 시 최대 할인이 적용된다', async () => { - render(); + renderWithProvider(); // 관리자 모드로 전환하여 상품1의 재고를 늘림 fireEvent.click(screen.getByText('관리자 페이지로')); @@ -250,7 +259,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('관리자 기능', () => { beforeEach(() => { - render(); + renderWithProvider(); // 관리자 모드로 전환 fireEvent.click(screen.getByText('관리자 페이지로')); }); @@ -404,7 +413,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('로컬스토리지 동기화', () => { test('상품, 장바구니, 쿠폰이 localStorage에 저장된다', () => { - render(); + renderWithProvider(); // 상품을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); @@ -437,7 +446,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('페이지 새로고침 후에도 데이터가 유지된다', () => { - const { unmount } = render(); + const { unmount } = renderWithProvider(); // 장바구니에 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); @@ -447,7 +456,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { unmount(); // 다시 mount - render(); + renderWithProvider(); // 장바구니 아이템이 유지되는지 확인 const cartSection = screen.getByText('장바구니').closest('section'); @@ -458,7 +467,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('UI 상태 관리', () => { test('할인이 있을 때 할인율이 표시된다', async () => { - render(); + renderWithProvider(); // 상품을 10개 담아서 할인 발생 const addButton = screen.getAllByText('장바구니 담기')[0]; @@ -473,7 +482,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('장바구니 아이템 개수가 헤더에 표시된다', () => { - render(); + renderWithProvider(); // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); @@ -486,7 +495,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('검색을 초기화할 수 있다', async () => { - render(); + renderWithProvider(); // 검색어 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); @@ -511,7 +520,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('알림 메시지가 자동으로 사라진다', async () => { - render(); + renderWithProvider(); // 상품 추가하여 알림 발생 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); From d33e2f0db3c9e4ecf8ff2c67ece80384c93b97eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 8 Aug 2025 00:45:43 +0900 Subject: [PATCH 52/68] =?UTF-8?q?refactor:=20addNotification=20prop=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/App.tsx | 12 +--- src/advanced/features/cart/hooks/useCart.ts | 25 +++------ .../features/coupon/admin/ui/CouponForm.tsx | 8 +-- .../coupon/admin/ui/CouponManagement.tsx | 43 ++++++++------- .../coupon/shop/ui/CouponSelector.tsx | 35 +++++++----- src/advanced/features/order/hooks/useOrder.ts | 15 ++--- .../features/order/ui/OrderSummary.tsx | 6 -- .../product/admin/hooks/useProducts.ts | 7 +-- .../features/product/admin/ui/ProductForm.tsx | 8 +-- .../product/admin/ui/ProductManagement.tsx | 7 --- src/advanced/pages/AdminPage.tsx | 7 --- src/advanced/pages/ShoppingPage.tsx | 7 --- src/advanced/shared/hooks/index.ts | 4 -- src/advanced/shared/hooks/useNotification.ts | 55 ------------------- src/advanced/shared/ui/NotificationToast.tsx | 20 +++---- src/advanced/shared/utils/index.ts | 3 +- .../ShoppingSidebar/ui/ShoppingSidebar.tsx | 8 --- 17 files changed, 80 insertions(+), 190 deletions(-) delete mode 100644 src/advanced/shared/hooks/useNotification.ts diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index 849d5dbf..4ca6156b 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -3,7 +3,7 @@ import { CartItem, Coupon } from '../types'; import { initialProducts } from './entities/product'; import { initialCoupons } from './entities/coupon'; import { calculateCartTotal } from './entities/cart'; -import { useLocalStorage, useNotification } from './shared/hooks'; +import { useLocalStorage } from './shared/hooks'; import { NotificationToast, Header } from './shared/ui'; import ShoppingPage from './pages/ShoppingPage'; import AdminPage from './pages/AdminPage'; @@ -19,9 +19,6 @@ const App = () => { const [isAdmin, setIsAdmin] = useState(false); const [searchTerm, setSearchTerm] = useState(''); - // 알림 시스템 - const { notifications, addNotification, removeNotification } = - useNotification(); // 장바구니 전체 금액 계산 (쿠폰 할인 포함) - entities/cart 함수 사용 const calculateCartTotalWithCoupon = useCallback((): { @@ -34,10 +31,7 @@ const App = () => { return (
{/* 알림 시스템 */} - + {/* 헤더 */}
{ setProducts={setProducts} coupons={coupons} setCoupons={setCoupons} - addNotification={addNotification} /> ) : ( { coupons={coupons} selectedCoupon={selectedCoupon} setSelectedCoupon={setSelectedCoupon} - addNotification={addNotification} calculateCartTotalWithCoupon={calculateCartTotalWithCoupon} /> )} diff --git a/src/advanced/features/cart/hooks/useCart.ts b/src/advanced/features/cart/hooks/useCart.ts index 49bc4fb3..3577b248 100644 --- a/src/advanced/features/cart/hooks/useCart.ts +++ b/src/advanced/features/cart/hooks/useCart.ts @@ -1,24 +1,17 @@ import { useCallback } from 'react'; import { CartItem } from '../../../../types'; import { ProductWithUI } from '../../../entities/product'; -import { getRemainingStock } from '../../../shared/utils'; +import { getRemainingStock, useNotification } from '../../../shared/utils'; interface UseCartProps { cart: CartItem[]; setCart: React.Dispatch>; products: ProductWithUI[]; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; } -export function useCart({ - cart, - setCart, - products, - addNotification, -}: UseCartProps) { +export function useCart({ cart, setCart, products }: UseCartProps) { + const { addNotification } = useNotification(); + // 특정 상품의 장바구니 수량 찾기 const getCartQuantity = useCallback( (productId: string): number => { @@ -32,11 +25,11 @@ export function useCart({ const addToCart = useCallback( (product: ProductWithUI) => { const cartQuantity = getCartQuantity(product.id); - const remainingStock = getRemainingStock({ - stock: product.stock, - cartQuantity + const remainingStock = getRemainingStock({ + stock: product.stock, + cartQuantity, }); - + if (remainingStock <= 0) { addNotification('재고가 부족합니다!', 'error'); return; @@ -118,4 +111,4 @@ export function useCart({ updateQuantity, getCartQuantity, }; -} \ No newline at end of file +} diff --git a/src/advanced/features/coupon/admin/ui/CouponForm.tsx b/src/advanced/features/coupon/admin/ui/CouponForm.tsx index 7ccdd871..39e8a8e0 100644 --- a/src/advanced/features/coupon/admin/ui/CouponForm.tsx +++ b/src/advanced/features/coupon/admin/ui/CouponForm.tsx @@ -1,5 +1,6 @@ import { FormEvent } from 'react'; import { Button } from '../../../../shared/ui'; +import { useNotification } from '../../../../shared/utils'; interface CouponFormData { name: string; @@ -13,10 +14,6 @@ interface CouponFormProps { setCouponForm: React.Dispatch>; onSubmit: () => void; onCancel: () => void; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; } export default function CouponForm({ @@ -24,8 +21,9 @@ export default function CouponForm({ setCouponForm, onSubmit, onCancel, - addNotification, }: CouponFormProps) { + const { addNotification } = useNotification(); + const handleSubmit = (e: FormEvent) => { e.preventDefault(); onSubmit(); diff --git a/src/advanced/features/coupon/admin/ui/CouponManagement.tsx b/src/advanced/features/coupon/admin/ui/CouponManagement.tsx index de831b52..ab41e0a8 100644 --- a/src/advanced/features/coupon/admin/ui/CouponManagement.tsx +++ b/src/advanced/features/coupon/admin/ui/CouponManagement.tsx @@ -1,34 +1,40 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { Coupon } from '../../../../../types'; -import { useCoupons } from '../../../../entities/coupon/hooks'; import CouponTable from './CouponTable'; import CouponForm from './CouponForm'; +import { useNotification } from '../../../../shared/utils'; interface CouponManagementProps { coupons: Coupon[]; setCoupons: React.Dispatch>; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; } export function CouponManagement({ coupons, setCoupons, - addNotification, }: CouponManagementProps) { - const { addCoupon, deleteCoupon } = useCoupons({ - coupons, - setCoupons, - selectedCoupon: null, // Admin에서는 쿠폰 선택 불필요 - setSelectedCoupon: () => {}, // Admin에서는 쿠폰 선택 불필요 - addNotification, - calculateCartTotalWithCoupon: () => ({ - totalBeforeDiscount: 0, - totalAfterDiscount: 0, - }), - }); + const { addNotification } = useNotification(); + + 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, setCoupons] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }, + [addNotification, setCoupons] + ); const [showCouponForm, setShowCouponForm] = useState(false); const [couponForm, setCouponForm] = useState({ @@ -76,7 +82,6 @@ export function CouponManagement({ setCouponForm={setCouponForm} onSubmit={handleCouponSubmit} onCancel={resetForm} - addNotification={addNotification} /> )}
diff --git a/src/advanced/features/coupon/shop/ui/CouponSelector.tsx b/src/advanced/features/coupon/shop/ui/CouponSelector.tsx index c5f8a111..0b35a015 100644 --- a/src/advanced/features/coupon/shop/ui/CouponSelector.tsx +++ b/src/advanced/features/coupon/shop/ui/CouponSelector.tsx @@ -1,15 +1,12 @@ +import { useCallback } from 'react'; import { Coupon } from '../../../../../types'; import { Button } from '../../../../shared/ui'; -import { useCoupons } from '../../../../entities/coupon/hooks'; +import { useNotification } from '../../../../shared/utils'; interface CouponSelectorProps { coupons: Coupon[]; selectedCoupon: Coupon | null; setSelectedCoupon: React.Dispatch>; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; calculateCartTotalWithCoupon: () => { totalBeforeDiscount: number; totalAfterDiscount: number; @@ -20,17 +17,27 @@ export function CouponSelector({ coupons, selectedCoupon, setSelectedCoupon, - addNotification, calculateCartTotalWithCoupon, }: CouponSelectorProps) { - const { applyCoupon } = useCoupons({ - coupons, - setCoupons: () => {}, - selectedCoupon, - setSelectedCoupon, - addNotification, - calculateCartTotalWithCoupon, - }); + const { addNotification } = useNotification(); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotalWithCoupon().totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + addNotification( + 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + 'error' + ); + return; + } + + setSelectedCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, + [addNotification, calculateCartTotalWithCoupon, setSelectedCoupon] + ); return (
diff --git a/src/advanced/features/order/hooks/useOrder.ts b/src/advanced/features/order/hooks/useOrder.ts index e1f8768d..45dbcd86 100644 --- a/src/advanced/features/order/hooks/useOrder.ts +++ b/src/advanced/features/order/hooks/useOrder.ts @@ -1,20 +1,15 @@ import { useCallback } from 'react'; import { CartItem, Coupon } from '../../../../types'; +import { useNotification } from '../../../shared/utils'; interface UseOrderProps { setCart: React.Dispatch>; setSelectedCoupon: React.Dispatch>; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; } -export function useOrder({ - setCart, - setSelectedCoupon, - addNotification, -}: UseOrderProps) { +export function useOrder({ setCart, setSelectedCoupon }: UseOrderProps) { + const { addNotification } = useNotification(); + // 주문 완료 처리 const completeOrder = useCallback(() => { const orderNumber = `ORD-${Date.now()}`; @@ -29,4 +24,4 @@ export function useOrder({ return { completeOrder, }; -} \ No newline at end of file +} diff --git a/src/advanced/features/order/ui/OrderSummary.tsx b/src/advanced/features/order/ui/OrderSummary.tsx index da519b8b..07fb4206 100644 --- a/src/advanced/features/order/ui/OrderSummary.tsx +++ b/src/advanced/features/order/ui/OrderSummary.tsx @@ -9,22 +9,16 @@ interface OrderSummaryProps { }; setCart: React.Dispatch>; setSelectedCoupon: React.Dispatch>; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; } export function OrderSummary({ totals, setCart, setSelectedCoupon, - addNotification, }: OrderSummaryProps) { const { completeOrder } = useOrder({ setCart, setSelectedCoupon, - addNotification, }); return ( diff --git a/src/advanced/features/product/admin/hooks/useProducts.ts b/src/advanced/features/product/admin/hooks/useProducts.ts index 2ae395e3..819eef7e 100644 --- a/src/advanced/features/product/admin/hooks/useProducts.ts +++ b/src/advanced/features/product/admin/hooks/useProducts.ts @@ -1,20 +1,17 @@ import { useCallback } from 'react'; +import { useNotification } from '../../../../shared/utils'; import { ProductWithUI } from '../../../../entities/product'; interface UseProductsProps { products: ProductWithUI[]; setProducts: React.Dispatch>; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; } export function useProducts({ products, setProducts, - addNotification, }: UseProductsProps) { + const { addNotification } = useNotification(); // 상품 추가 const addProduct = useCallback( (newProduct: Omit) => { diff --git a/src/advanced/features/product/admin/ui/ProductForm.tsx b/src/advanced/features/product/admin/ui/ProductForm.tsx index 9358c8e2..85b8c5b6 100644 --- a/src/advanced/features/product/admin/ui/ProductForm.tsx +++ b/src/advanced/features/product/admin/ui/ProductForm.tsx @@ -1,5 +1,6 @@ import { FormEvent } from 'react'; import { Button } from '../../../../shared/ui'; +import { useNotification } from '../../../../shared/utils'; interface ProductFormData { name: string; @@ -15,10 +16,6 @@ interface ProductFormProps { onSubmit: () => void; onCancel: () => void; isEditing: boolean; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; } export default function ProductForm({ @@ -27,8 +24,9 @@ export default function ProductForm({ onSubmit, onCancel, isEditing, - addNotification, }: ProductFormProps) { + const { addNotification } = useNotification(); + const handleSubmit = (e: FormEvent) => { e.preventDefault(); onSubmit(); diff --git a/src/advanced/features/product/admin/ui/ProductManagement.tsx b/src/advanced/features/product/admin/ui/ProductManagement.tsx index 94a08563..a2f32e23 100644 --- a/src/advanced/features/product/admin/ui/ProductManagement.tsx +++ b/src/advanced/features/product/admin/ui/ProductManagement.tsx @@ -8,21 +8,15 @@ import ProductForm from './ProductForm'; interface ProductManagementProps { products: ProductWithUI[]; setProducts: React.Dispatch>; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; } export default function ProductManagement({ products, setProducts, - addNotification, }: ProductManagementProps) { const { addProduct, updateProduct, deleteProduct } = useProducts({ products, setProducts, - addNotification, }); const [showProductForm, setShowProductForm] = useState(false); @@ -105,7 +99,6 @@ export default function ProductManagement({ onSubmit={handleProductSubmit} onCancel={resetForm} isEditing={editingProduct !== 'new'} - addNotification={addNotification} /> )}
diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx index b16a7814..1568f249 100644 --- a/src/advanced/pages/AdminPage.tsx +++ b/src/advanced/pages/AdminPage.tsx @@ -9,10 +9,6 @@ interface AdminPageProps { setProducts: React.Dispatch>; coupons: Coupon[]; setCoupons: React.Dispatch>; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; } export default function AdminPage({ @@ -20,7 +16,6 @@ export default function AdminPage({ setProducts, coupons, setCoupons, - addNotification, }: AdminPageProps) { const [activeTab, setActiveTab] = useState<'products' | 'coupons'>( 'products' @@ -65,13 +60,11 @@ export default function AdminPage({ ) : ( )}
diff --git a/src/advanced/pages/ShoppingPage.tsx b/src/advanced/pages/ShoppingPage.tsx index c19bce6a..aad2bec7 100644 --- a/src/advanced/pages/ShoppingPage.tsx +++ b/src/advanced/pages/ShoppingPage.tsx @@ -13,10 +13,6 @@ interface ShoppingPageProps { coupons: Coupon[]; selectedCoupon: Coupon | null; setSelectedCoupon: React.Dispatch>; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; calculateCartTotalWithCoupon: () => { totalBeforeDiscount: number; totalAfterDiscount: number; @@ -31,7 +27,6 @@ export default function ShoppingPage({ coupons, selectedCoupon, setSelectedCoupon, - addNotification, calculateCartTotalWithCoupon, }: ShoppingPageProps) { const { filteredProducts } = useProductSearch(products, searchTerm); @@ -40,7 +35,6 @@ export default function ShoppingPage({ cart, setCart, products, - addNotification, }); return ( @@ -73,7 +67,6 @@ export default function ShoppingPage({ selectedCoupon={selectedCoupon} setSelectedCoupon={setSelectedCoupon} products={products} - addNotification={addNotification} calculateCartTotalWithCoupon={calculateCartTotalWithCoupon} />
diff --git a/src/advanced/shared/hooks/index.ts b/src/advanced/shared/hooks/index.ts index 403a7e87..0af9fea2 100644 --- a/src/advanced/shared/hooks/index.ts +++ b/src/advanced/shared/hooks/index.ts @@ -1,9 +1,5 @@ // localStorage 훅 export { useLocalStorage } from './useLocalStorage'; -// 알림 시스템 훅 -export { useNotification } from './useNotification'; -export type { Notification, UseNotificationReturn } from './useNotification'; - // 디바운스 훅 export { useDebounce } from './useDebounce'; diff --git a/src/advanced/shared/hooks/useNotification.ts b/src/advanced/shared/hooks/useNotification.ts deleted file mode 100644 index 7b9aa844..00000000 --- a/src/advanced/shared/hooks/useNotification.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useState, useCallback } from 'react'; - -export interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -export interface UseNotificationReturn { - notifications: Notification[]; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; - removeNotification: (id: string) => void; -} - -/** - * 알림 시스템을 관리하는 커스텀 훅 - * 알림 추가, 자동 제거, 수동 제거 기능 제공 - */ -export function useNotification(): UseNotificationReturn { - const [notifications, setNotifications] = useState([]); - - /** - * 새 알림 추가 (3초 후 자동 제거) - */ - const addNotification = useCallback( - (message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - const newNotification: Notification = { id, message, type }; - - setNotifications((prev) => [...prev, newNotification]); - - // 3초 후 자동 제거 - setTimeout(() => { - setNotifications((prev) => prev.filter((n) => n.id !== id)); - }, 3000); - }, - [] - ); - - /** - * 특정 알림 수동 제거 - */ - const removeNotification = useCallback((id: string) => { - setNotifications((prev) => prev.filter((n) => n.id !== id)); - }, []); - - return { - notifications, - addNotification, - removeNotification, - }; -} diff --git a/src/advanced/shared/ui/NotificationToast.tsx b/src/advanced/shared/ui/NotificationToast.tsx index ec858603..eb441252 100644 --- a/src/advanced/shared/ui/NotificationToast.tsx +++ b/src/advanced/shared/ui/NotificationToast.tsx @@ -1,14 +1,12 @@ -import { Notification } from '../hooks/useNotification'; +import { useAtom } from 'jotai'; +import { notificationsAtom } from '../store'; -interface NotificationToastProps { - notifications: Notification[]; - onRemove: (id: string) => void; -} - -export default function NotificationToast({ - notifications, - onRemove, -}: NotificationToastProps) { +export default function NotificationToast() { + const [notifications, setNotifications] = useAtom(notificationsAtom); + + const removeNotification = (id: string) => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }; if (notifications.length === 0) return null; return ( @@ -26,7 +24,7 @@ export default function NotificationToast({ > {notif.message}
); diff --git a/src/advanced/pages/ShoppingPage.tsx b/src/advanced/pages/ShoppingPage.tsx index 2345947a..9f829555 100644 --- a/src/advanced/pages/ShoppingPage.tsx +++ b/src/advanced/pages/ShoppingPage.tsx @@ -1,5 +1,4 @@ import { useAtomValue } from 'jotai'; -import { Coupon } from '../../types'; import { ProductList } from '../features/product/shop/ui'; import { useCart } from '../features/cart/hooks'; import { useProductSearch } from '../features/product/shop/hooks'; @@ -8,9 +7,6 @@ import { productsAtom } from '../shared/store'; interface ShoppingPageProps { searchTerm: string; - coupons: Coupon[]; - selectedCoupon: Coupon | null; - setSelectedCoupon: React.Dispatch>; calculateCartTotalWithCoupon: () => { totalBeforeDiscount: number; totalAfterDiscount: number; @@ -19,9 +15,6 @@ interface ShoppingPageProps { export default function ShoppingPage({ searchTerm, - coupons, - selectedCoupon, - setSelectedCoupon, calculateCartTotalWithCoupon, }: ShoppingPageProps) { const products = useAtomValue(productsAtom); @@ -52,9 +45,6 @@ export default function ShoppingPage({ {/* ShoppingSidebar (widgets) */}
diff --git a/src/advanced/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx b/src/advanced/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx index 90cb05f2..1e58353b 100644 --- a/src/advanced/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx +++ b/src/advanced/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx @@ -1,17 +1,13 @@ import { useMemo } from 'react'; import { useAtomValue } from 'jotai'; -import { Coupon } from '../../../../types'; import { useCart } from '../../../features/cart/hooks'; import { calculateCartTotal } from '../../../entities/cart'; import { CartItemsList } from '../../../features/cart/ui'; import { CouponSelector } from '../../../features/coupon/shop/ui'; import { OrderSummary } from '../../../features/order/ui'; -import { cartAtom } from '../../../shared/store'; +import { cartAtom, selectedCouponAtom } from '../../../shared/store'; interface ShoppingSidebarProps { - coupons: Coupon[]; - selectedCoupon: Coupon | null; - setSelectedCoupon: React.Dispatch>; calculateCartTotalWithCoupon: () => { totalBeforeDiscount: number; totalAfterDiscount: number; @@ -19,12 +15,10 @@ interface ShoppingSidebarProps { } export function ShoppingSidebar({ - coupons, - selectedCoupon, - setSelectedCoupon, calculateCartTotalWithCoupon, }: ShoppingSidebarProps) { const cart = useAtomValue(cartAtom); + const selectedCoupon = useAtomValue(selectedCouponAtom); const { removeFromCart, updateQuantity } = useCart(); const totals = useMemo(() => { @@ -43,14 +37,10 @@ export function ShoppingSidebar({ {cart.length > 0 && ( <> )} From 82cd1ce3c7b20f46cb604ab2f62f6c0c0f1dfd08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 8 Aug 2025 01:56:37 +0900 Subject: [PATCH 61/68] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=EC=96=B4=20sea?= =?UTF-8?q?rchAtom=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/shared/store/searchAtom.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/advanced/shared/store/searchAtom.ts diff --git a/src/advanced/shared/store/searchAtom.ts b/src/advanced/shared/store/searchAtom.ts new file mode 100644 index 00000000..2b44f603 --- /dev/null +++ b/src/advanced/shared/store/searchAtom.ts @@ -0,0 +1,4 @@ +import { atom } from 'jotai'; + +// 검색어 atom +export const searchTermAtom = atom(''); From 6d4856f37e2a7a2d5117e1b16b93905c8d91fbd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 8 Aug 2025 01:57:40 +0900 Subject: [PATCH 62/68] =?UTF-8?q?refactor:=20searchTerm=20props=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/pages/ShoppingPage.tsx | 20 ++++---------------- src/advanced/shared/ui/Header.tsx | 11 ++++------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/src/advanced/pages/ShoppingPage.tsx b/src/advanced/pages/ShoppingPage.tsx index 9f829555..b3d54860 100644 --- a/src/advanced/pages/ShoppingPage.tsx +++ b/src/advanced/pages/ShoppingPage.tsx @@ -3,21 +3,11 @@ import { ProductList } from '../features/product/shop/ui'; import { useCart } from '../features/cart/hooks'; import { useProductSearch } from '../features/product/shop/hooks'; import { ShoppingSidebar } from '../widgets/ShoppingSidebar/ui'; -import { productsAtom } from '../shared/store'; +import { productsAtom, searchTermAtom } from '../shared/store'; -interface ShoppingPageProps { - searchTerm: string; - calculateCartTotalWithCoupon: () => { - totalBeforeDiscount: number; - totalAfterDiscount: number; - }; -} - -export default function ShoppingPage({ - searchTerm, - calculateCartTotalWithCoupon, -}: ShoppingPageProps) { +export default function ShoppingPage() { const products = useAtomValue(productsAtom); + const searchTerm = useAtomValue(searchTermAtom); const { filteredProducts } = useProductSearch(products, searchTerm); const { addToCart } = useCart(); @@ -44,9 +34,7 @@ export default function ShoppingPage({ {/* ShoppingSidebar (widgets) */}
- +
); diff --git a/src/advanced/shared/ui/Header.tsx b/src/advanced/shared/ui/Header.tsx index c093acbe..da0ad68b 100644 --- a/src/advanced/shared/ui/Header.tsx +++ b/src/advanced/shared/ui/Header.tsx @@ -1,21 +1,18 @@ -import { useAtomValue } from 'jotai'; +import { useAtomValue, useAtom } from 'jotai'; import { SearchInput, Button } from '.'; -import { cartAtom } from '../store'; +import { cartAtom, searchTermAtom } from '../store'; interface HeaderProps { isAdmin: boolean; - searchTerm: string; onToggleAdmin: () => void; - onSearchChange: (value: string) => void; } export default function Header({ isAdmin, - searchTerm, onToggleAdmin, - onSearchChange, }: HeaderProps) { const cart = useAtomValue(cartAtom); + const [searchTerm, setSearchTerm] = useAtom(searchTermAtom); const totalItemCount = cart.reduce((sum, item) => sum + item.quantity, 0); const cartLength = cart.length; @@ -29,7 +26,7 @@ export default function Header({ {!isAdmin && ( From daa7e745ece082c7e69003230bde3bac975f16f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 8 Aug 2025 01:58:08 +0900 Subject: [PATCH 63/68] =?UTF-8?q?feat:=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EC=B4=9D=EC=95=A1=20=EA=B3=84=EC=82=B0=20cartTotal?= =?UTF-8?q?sAtom=20atom=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/shared/store/cartTotalsAtom.ts | 11 +++++++++++ src/advanced/shared/store/index.ts | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 src/advanced/shared/store/cartTotalsAtom.ts diff --git a/src/advanced/shared/store/cartTotalsAtom.ts b/src/advanced/shared/store/cartTotalsAtom.ts new file mode 100644 index 00000000..572af675 --- /dev/null +++ b/src/advanced/shared/store/cartTotalsAtom.ts @@ -0,0 +1,11 @@ +import { atom } from 'jotai'; +import { calculateCartTotal } from '../../entities/cart'; +import { cartAtom } from './cartAtom'; +import { selectedCouponAtom } from './couponsAtom'; + +// 장바구니 총액 계산 derived atom +export const cartTotalsAtom = atom((get) => { + const cart = get(cartAtom); + const selectedCoupon = get(selectedCouponAtom); + return calculateCartTotal(cart, selectedCoupon); +}); \ No newline at end of file diff --git a/src/advanced/shared/store/index.ts b/src/advanced/shared/store/index.ts index 2d94b3b3..45250057 100644 --- a/src/advanced/shared/store/index.ts +++ b/src/advanced/shared/store/index.ts @@ -1,4 +1,6 @@ export * from './notificationAtom'; export * from './cartAtom'; export * from './productsAtom'; -export * from './couponsAtom'; \ No newline at end of file +export * from './couponsAtom'; +export * from './searchAtom'; +export * from './cartTotalsAtom'; \ No newline at end of file From 4f2d66887bb8978c401a2359f03ac817883bc211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 8 Aug 2025 01:58:28 +0900 Subject: [PATCH 64/68] =?UTF-8?q?refactor:=20calculateCartTotalWithCoupon?= =?UTF-8?q?=20props=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/App.tsx | 25 ++----------------- .../coupon/shop/ui/CouponSelector.tsx | 18 ++++--------- .../ShoppingSidebar/ui/ShoppingSidebar.tsx | 25 +++---------------- 3 files changed, 11 insertions(+), 57 deletions(-) diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index 5b95cb0b..0e80d0cb 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,27 +1,11 @@ -import { useState, useCallback } from 'react'; -import { useAtomValue } from 'jotai'; -import { calculateCartTotal } from './entities/cart'; +import { useState } from 'react'; import { NotificationToast, Header } from './shared/ui'; -import { cartAtom, selectedCouponAtom } from './shared/store'; import ShoppingPage from './pages/ShoppingPage'; import AdminPage from './pages/AdminPage'; const App = () => { - // 전역 상태 관리 - const cart = useAtomValue(cartAtom); - const selectedCoupon = useAtomValue(selectedCouponAtom); - // 로컬 UI 상태 관리 const [isAdmin, setIsAdmin] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - - // 장바구니 전체 금액 계산 (쿠폰 할인 포함) - entities/cart 함수 사용 - const calculateCartTotalWithCoupon = useCallback((): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - return calculateCartTotal(cart, selectedCoupon); - }, [cart, selectedCoupon]); return (
@@ -30,19 +14,14 @@ const App = () => { {/* 헤더 */}
setIsAdmin(!isAdmin)} - onSearchChange={setSearchTerm} /> {/* 메인 컨텐츠 */}
{isAdmin ? ( ) : ( - + )}
diff --git a/src/advanced/features/coupon/shop/ui/CouponSelector.tsx b/src/advanced/features/coupon/shop/ui/CouponSelector.tsx index 4002041c..76c2a330 100644 --- a/src/advanced/features/coupon/shop/ui/CouponSelector.tsx +++ b/src/advanced/features/coupon/shop/ui/CouponSelector.tsx @@ -3,25 +3,17 @@ import { useAtomValue, useAtom } from 'jotai'; import { Coupon } from '../../../../../types'; import { Button } from '../../../../shared/ui'; import { useNotification } from '../../../../shared/utils'; -import { couponsAtom, selectedCouponAtom } from '../../../../shared/store'; +import { couponsAtom, selectedCouponAtom, cartTotalsAtom } from '../../../../shared/store'; -interface CouponSelectorProps { - calculateCartTotalWithCoupon: () => { - totalBeforeDiscount: number; - totalAfterDiscount: number; - }; -} - -export function CouponSelector({ - calculateCartTotalWithCoupon, -}: CouponSelectorProps) { +export function CouponSelector() { const coupons = useAtomValue(couponsAtom); const [selectedCoupon, setSelectedCoupon] = useAtom(selectedCouponAtom); + const cartTotals = useAtomValue(cartTotalsAtom); const { addNotification } = useNotification(); const applyCoupon = useCallback( (coupon: Coupon) => { - const currentTotal = calculateCartTotalWithCoupon().totalAfterDiscount; + const currentTotal = cartTotals.totalAfterDiscount; if (currentTotal < 10000 && coupon.discountType === 'percentage') { addNotification( @@ -34,7 +26,7 @@ export function CouponSelector({ setSelectedCoupon(coupon); addNotification('쿠폰이 적용되었습니다.', 'success'); }, - [addNotification, calculateCartTotalWithCoupon, setSelectedCoupon] + [addNotification, cartTotals.totalAfterDiscount, setSelectedCoupon] ); return ( diff --git a/src/advanced/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx b/src/advanced/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx index 1e58353b..6a4692b3 100644 --- a/src/advanced/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx +++ b/src/advanced/widgets/ShoppingSidebar/ui/ShoppingSidebar.tsx @@ -1,30 +1,15 @@ -import { useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { useCart } from '../../../features/cart/hooks'; -import { calculateCartTotal } from '../../../entities/cart'; import { CartItemsList } from '../../../features/cart/ui'; import { CouponSelector } from '../../../features/coupon/shop/ui'; import { OrderSummary } from '../../../features/order/ui'; -import { cartAtom, selectedCouponAtom } from '../../../shared/store'; +import { cartAtom, cartTotalsAtom } from '../../../shared/store'; -interface ShoppingSidebarProps { - calculateCartTotalWithCoupon: () => { - totalBeforeDiscount: number; - totalAfterDiscount: number; - }; -} - -export function ShoppingSidebar({ - calculateCartTotalWithCoupon, -}: ShoppingSidebarProps) { +export function ShoppingSidebar() { const cart = useAtomValue(cartAtom); - const selectedCoupon = useAtomValue(selectedCouponAtom); + const totals = useAtomValue(cartTotalsAtom); const { removeFromCart, updateQuantity } = useCart(); - const totals = useMemo(() => { - return calculateCartTotal(cart, selectedCoupon); - }, [cart, selectedCoupon]); - return (
{/* 장바구니 아이템 섹션 */} @@ -36,9 +21,7 @@ export function ShoppingSidebar({ {/* 쿠폰 + 주문 섹션 */} {cart.length > 0 && ( <> - + From 5e82d6311c11daffbc54db1430c078862e5a9584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 8 Aug 2025 02:09:08 +0900 Subject: [PATCH 65/68] =?UTF-8?q?refactor:=20useCoupons=20=ED=9B=85=20prop?= =?UTF-8?q?s=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/coupon/hooks/useCoupons.ts | 38 +++++++------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/src/advanced/entities/coupon/hooks/useCoupons.ts b/src/advanced/entities/coupon/hooks/useCoupons.ts index 6e4e6fa5..b850554b 100644 --- a/src/advanced/entities/coupon/hooks/useCoupons.ts +++ b/src/advanced/entities/coupon/hooks/useCoupons.ts @@ -1,29 +1,19 @@ import { useCallback } from 'react'; +import { useAtom, useAtomValue } from 'jotai'; import { Coupon } from '../../../../types'; +import { + couponsAtom, + selectedCouponAtom, +} from '../../../shared/store/couponsAtom'; +import { cartTotalsAtom } from '../../../shared/store/cartTotalsAtom'; +import { useNotification } from '../../../shared/utils'; -interface UseCouponsProps { - coupons: Coupon[]; - setCoupons: React.Dispatch>; - selectedCoupon: Coupon | null; - setSelectedCoupon: React.Dispatch>; - addNotification: ( - message: string, - type?: 'error' | 'success' | 'warning' - ) => void; - calculateCartTotalWithCoupon: () => { - totalBeforeDiscount: number; - totalAfterDiscount: number; - }; -} +export function useCoupons() { + const { addNotification } = useNotification(); + const [coupons, setCoupons] = useAtom(couponsAtom); + const [selectedCoupon, setSelectedCoupon] = useAtom(selectedCouponAtom); + const cartTotals = useAtomValue(cartTotalsAtom); -export function useCoupons({ - coupons, - setCoupons, - selectedCoupon, - setSelectedCoupon, - addNotification, - calculateCartTotalWithCoupon, -}: UseCouponsProps) { // 쿠폰 추가 const addCoupon = useCallback( (newCoupon: Coupon) => { @@ -53,7 +43,7 @@ export function useCoupons({ // 쿠폰 적용 const applyCoupon = useCallback( (coupon: Coupon) => { - const currentTotal = calculateCartTotalWithCoupon().totalAfterDiscount; + const currentTotal = cartTotals.totalAfterDiscount; if (currentTotal < 10000 && coupon.discountType === 'percentage') { addNotification( @@ -66,7 +56,7 @@ export function useCoupons({ setSelectedCoupon(coupon); addNotification('쿠폰이 적용되었습니다.', 'success'); }, - [addNotification, calculateCartTotalWithCoupon, setSelectedCoupon] + [addNotification, cartTotals.totalAfterDiscount, setSelectedCoupon] ); return { From 8fe55d409ea536fc8425aec7f94e0dbaea89535d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 8 Aug 2025 02:09:31 +0900 Subject: [PATCH 66/68] =?UTF-8?q?chore:=20eslint=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/App.tsx | 12 ++----- src/advanced/features/cart/hooks/useCart.ts | 12 ++----- .../coupon/admin/ui/CouponManagement.tsx | 31 ++----------------- .../coupon/shop/ui/CouponSelector.tsx | 6 +++- .../features/order/ui/OrderSummary.tsx | 4 +-- 5 files changed, 14 insertions(+), 51 deletions(-) diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index 0e80d0cb..1e7e234b 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -4,7 +4,6 @@ import ShoppingPage from './pages/ShoppingPage'; import AdminPage from './pages/AdminPage'; const App = () => { - // 로컬 UI 상태 관리 const [isAdmin, setIsAdmin] = useState(false); return ( @@ -12,17 +11,10 @@ const App = () => { {/* 알림 시스템 */} {/* 헤더 */} -
setIsAdmin(!isAdmin)} - /> +
setIsAdmin(!isAdmin)} /> {/* 메인 컨텐츠 */}
- {isAdmin ? ( - - ) : ( - - )} + {isAdmin ? : }
); diff --git a/src/advanced/features/cart/hooks/useCart.ts b/src/advanced/features/cart/hooks/useCart.ts index 5bb2c578..fea59154 100644 --- a/src/advanced/features/cart/hooks/useCart.ts +++ b/src/advanced/features/cart/hooks/useCart.ts @@ -1,6 +1,5 @@ import { useCallback } from 'react'; import { useAtom, useAtomValue } from 'jotai'; -import { CartItem } from '../../../../types'; import { ProductWithUI } from '../../../entities/product'; import { getRemainingStock, useNotification } from '../../../shared/utils'; import { cartAtom, productsAtom } from '../../../shared/store'; @@ -33,18 +32,13 @@ export function useCart() { return; } - const existingItem = cart.find( - (item) => item.product.id === product.id - ); + const existingItem = cart.find((item) => item.product.id === product.id); if (existingItem) { const newQuantity = existingItem.quantity + 1; if (newQuantity > product.stock) { - addNotification( - `재고는 ${product.stock}개까지만 있습니다.`, - 'error' - ); + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); return; } @@ -60,7 +54,7 @@ export function useCart() { addNotification('장바구니에 담았습니다', 'success'); }, - [addNotification, cart, setCart] + [addNotification, cart, setCart, getCartQuantity] ); // 장바구니에서 상품 제거 diff --git a/src/advanced/features/coupon/admin/ui/CouponManagement.tsx b/src/advanced/features/coupon/admin/ui/CouponManagement.tsx index 29ce45c2..dc8b973e 100644 --- a/src/advanced/features/coupon/admin/ui/CouponManagement.tsx +++ b/src/advanced/features/coupon/admin/ui/CouponManagement.tsx @@ -1,35 +1,10 @@ -import { useState, useCallback } from 'react'; -import { useAtom } from 'jotai'; -import { Coupon } from '../../../../../types'; +import { useState } from 'react'; import CouponTable from './CouponTable'; import CouponForm from './CouponForm'; -import { useNotification } from '../../../../shared/utils'; -import { couponsAtom } from '../../../../shared/store'; +import { useCoupons } from '../../../../entities/coupon/hooks/useCoupons'; export default function CouponManagement() { - const [coupons, setCoupons] = useAtom(couponsAtom); - const { addNotification } = useNotification(); - - 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, setCoupons] - ); - - const deleteCoupon = useCallback( - (couponCode: string) => { - setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, - [addNotification, setCoupons] - ); + const { coupons, addCoupon, deleteCoupon } = useCoupons(); const [showCouponForm, setShowCouponForm] = useState(false); const [couponForm, setCouponForm] = useState({ diff --git a/src/advanced/features/coupon/shop/ui/CouponSelector.tsx b/src/advanced/features/coupon/shop/ui/CouponSelector.tsx index 76c2a330..4e1ff8cf 100644 --- a/src/advanced/features/coupon/shop/ui/CouponSelector.tsx +++ b/src/advanced/features/coupon/shop/ui/CouponSelector.tsx @@ -3,7 +3,11 @@ import { useAtomValue, useAtom } from 'jotai'; import { Coupon } from '../../../../../types'; import { Button } from '../../../../shared/ui'; import { useNotification } from '../../../../shared/utils'; -import { couponsAtom, selectedCouponAtom, cartTotalsAtom } from '../../../../shared/store'; +import { + couponsAtom, + selectedCouponAtom, + cartTotalsAtom, +} from '../../../../shared/store'; export function CouponSelector() { const coupons = useAtomValue(couponsAtom); diff --git a/src/advanced/features/order/ui/OrderSummary.tsx b/src/advanced/features/order/ui/OrderSummary.tsx index ed2c439f..308f942d 100644 --- a/src/advanced/features/order/ui/OrderSummary.tsx +++ b/src/advanced/features/order/ui/OrderSummary.tsx @@ -8,9 +8,7 @@ interface OrderSummaryProps { }; } -export function OrderSummary({ - totals, -}: OrderSummaryProps) { +export function OrderSummary({ totals }: OrderSummaryProps) { const { completeOrder } = useOrder(); return ( From 473ea60badaf1cd130f3506e8931692bbc939e36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 8 Aug 2025 02:35:12 +0900 Subject: [PATCH 67/68] =?UTF-8?q?feat:=20404=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 404.html | 13 +++++++++++++ index.advanced.html => index.html | 0 package.json | 3 ++- vite.config.ts | 13 +++++++++++-- 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 404.html rename index.advanced.html => index.html (100%) diff --git a/404.html b/404.html new file mode 100644 index 00000000..5e239920 --- /dev/null +++ b/404.html @@ -0,0 +1,13 @@ + + + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + diff --git a/index.advanced.html b/index.html similarity index 100% rename from index.advanced.html rename to index.html diff --git a/package.json b/package.json index 6d1d68b4..6eb4d6c2 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", "build": "tsc -b && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "deploy": "npx gh-pages -d dist" }, "dependencies": { "jotai": "^2.13.0", diff --git a/vite.config.ts b/vite.config.ts index e6c4016b..51e08dc5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,12 +5,21 @@ import react from '@vitejs/plugin-react-swc'; export default mergeConfig( defineConfig({ plugins: [react()], + base: '/front_6th_chapter2-1/', // 프로덕션용 고정값 + build: { + outDir: 'dist', + rollupOptions: { + input: { + main: 'index.html', + }, + }, + }, }), defineTestConfig({ test: { globals: true, environment: 'jsdom', - setupFiles: './src/setupTests.ts' + setupFiles: './src/setupTests.ts', }, }) -) +); From c9c378d4423b48e39fa962ca791d1cf2a7ba914e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=A7=80=EC=88=98?= Date: Fri, 8 Aug 2025 02:41:31 +0900 Subject: [PATCH 68/68] =?UTF-8?q?chore:=20=EB=B0=B0=ED=8F=AC=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tsconfig.app.json | 3 ++- vite.config.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tsconfig.app.json b/tsconfig.app.json index d739292a..2ef6aebb 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,5 +23,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["node_modules", "src/refactoring(hint)/**/*"] } diff --git a/vite.config.ts b/vite.config.ts index 51e08dc5..d03b4afd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,13 +5,14 @@ import react from '@vitejs/plugin-react-swc'; export default mergeConfig( defineConfig({ plugins: [react()], - base: '/front_6th_chapter2-1/', // 프로덕션용 고정값 + base: '/front_6th_chapter2-2/', // 프로덕션용 고정값 build: { outDir: 'dist', rollupOptions: { input: { main: 'index.html', }, + external: [/src\/refactoring\(hint\)/], }, }, }),