From 850c093d0583852ccf12c38e5fc68407d5f30afa Mon Sep 17 00:00:00 2001 From: bebusl Date: Mon, 4 Aug 2025 23:33:21 +0900 Subject: [PATCH 01/18] Empty-Commit From 3dee1ff7a2a0210d7e5000092b97e7431756e2a8 Mon Sep 17 00:00:00 2001 From: bebusl Date: Thu, 7 Aug 2025 01:08:14 +0900 Subject: [PATCH 02/18] =?UTF-8?q?style:=20=ED=8F=AC=EB=A7=A4=ED=8C=85=20?= =?UTF-8?q?=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 | 1673 +++++++++++++++++++++++++++------------------ 1 file changed, 1025 insertions(+), 648 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1..4ef0350e 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,5 +1,5 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState, useCallback, useEffect } from "react"; +import { CartItem, Coupon, Product } from "../types"; interface ProductWithUI extends Product { description?: string; @@ -9,65 +9,62 @@ interface ProductWithUI extends Product { interface Notification { id: string; message: string; - type: 'error' | 'success' | 'warning'; + type: "error" | "success" | "warning"; } // 초기 데이터 const initialProducts: ProductWithUI[] = [ { - id: 'p1', - name: '상품1', + id: "p1", + name: "상품1", price: 10000, stock: 20, discounts: [ { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } + { quantity: 20, rate: 0.2 }, ], - description: '최고급 품질의 프리미엄 상품입니다.' + description: "최고급 품질의 프리미엄 상품입니다.", }, { - id: 'p2', - name: '상품2', + id: "p2", + name: "상품2", price: 20000, stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, }, { - id: 'p3', - name: '상품3', + id: "p3", + name: "상품3", price: 30000, stock: 20, discounts: [ { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } + { quantity: 30, rate: 0.25 }, ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, ]; const initialCoupons: Coupon[] = [ { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, }, { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, ]; const App = () => { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); + const saved = localStorage.getItem("products"); if (saved) { try { return JSON.parse(saved); @@ -79,7 +76,7 @@ const App = () => { }); const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); + const saved = localStorage.getItem("cart"); if (saved) { try { return JSON.parse(saved); @@ -91,7 +88,7 @@ const App = () => { }); const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); + const saved = localStorage.getItem("coupons"); if (saved) { try { return JSON.parse(saved); @@ -106,59 +103,60 @@ const App = () => { const [isAdmin, setIsAdmin] = useState(false); const [notifications, setNotifications] = useState([]); const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [activeTab, setActiveTab] = useState<"products" | "coupons">( + "products" + ); const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); // Admin const [editingProduct, setEditingProduct] = useState(null); const [productForm, setProductForm] = useState({ - name: '', + name: "", price: 0, stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> + description: "", + discounts: [] as Array<{ quantity: number; rate: number }>, }); const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 + 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); + const product = products.find((p) => p.id === productId); if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; + 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 + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate : maxDiscount; }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); if (hasBulkPurchase) { return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 } - + return baseDiscount; }; @@ -166,7 +164,7 @@ const App = () => { const { price } = item.product; const { quantity } = item; const discount = getMaxApplicableDiscount(item); - + return Math.round(price * quantity * (1 - discount)); }; @@ -177,44 +175,51 @@ 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); + if (selectedCoupon.discountType === "amount") { + 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); - }, []); + 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); @@ -222,18 +227,18 @@ const App = () => { }, [cart]); useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); + localStorage.setItem("products", JSON.stringify(products)); }, [products]); useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); + localStorage.setItem("coupons", JSON.stringify(coupons)); }, [coupons]); useEffect(() => { if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); + localStorage.setItem("cart", JSON.stringify(cart)); } else { - localStorage.removeItem('cart'); + localStorage.removeItem("cart"); } }, [cart]); @@ -244,139 +249,180 @@ const App = () => { return () => clearTimeout(timer); }, [searchTerm]); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } + 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; - 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; + 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.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); + 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 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 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 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 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') { + if (editingProduct && editingProduct !== "new") { updateProduct(editingProduct, productForm); setEditingProduct(null); } 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); }; @@ -385,10 +431,10 @@ const App = () => { e.preventDefault(); addCoupon(couponForm); setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 + name: "", + code: "", + discountType: "amount", + discountValue: 0, }); setShowCouponForm(false); }; @@ -399,8 +445,8 @@ const App = () => { name: product.name, price: product.price, stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] + description: product.description || "", + discounts: product.discounts || [], }); setShowProductForm(true); }; @@ -408,9 +454,15 @@ const App = () => { 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; @@ -418,22 +470,38 @@ const App = () => {
{notifications.length > 0 && (
- {notifications.map(notif => ( + {notifications.map((notif) => (
{notif.message} -
@@ -462,17 +530,27 @@ const App = () => { {!isAdmin && (
- - + + {cart.length > 0 && ( @@ -490,27 +568,31 @@ const App = () => { {isAdmin ? (
-

관리자 대시보드

-

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

+

+ 관리자 대시보드 +

+

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

- {activeTab === 'products' ? ( + {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}% 할인`} - +
+

쿠폰 관리

+
+
+
+ {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 - /> -
-
-
+
-
-
- )} -
+ + {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 + /> +
+
+
+ + +
+
+
+ )} +
)}
@@ -897,137 +1169,221 @@ const App = () => { {/* 상품 목록 */}
-

전체 상품

+

+ 전체 상품 +

총 {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()}원 @@ -1053,27 +1411,33 @@ const App = () => { <>

-

쿠폰 할인

+

+ 쿠폰 할인 +

{coupons.length > 0 && ( - @@ -1085,27 +1449,40 @@ const App = () => {
상품 금액 - {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 +1498,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; From 7a51acffc96fa463d1c1ea871d73383b91a0ee36 Mon Sep 17 00:00:00 2001 From: bebusl Date: Thu, 7 Aug 2025 01:09:50 +0900 Subject: [PATCH 03/18] =?UTF-8?q?chore:=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=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 fixup --- src/basic/App.tsx | 56 +---------------------------------------------- src/basic/data.ts | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 55 deletions(-) create mode 100644 src/basic/data.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 4ef0350e..4d6dea8d 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,10 +1,6 @@ import { useState, useCallback, useEffect } from "react"; import { CartItem, Coupon, Product } from "../types"; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} +import { initialCoupons, initialProducts, ProductWithUI } from "./data"; interface Notification { id: string; @@ -12,56 +8,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 = () => { const [products, setProducts] = useState(() => { const saved = localStorage.getItem("products"); diff --git a/src/basic/data.ts b/src/basic/data.ts new file mode 100644 index 00000000..deae87bd --- /dev/null +++ b/src/basic/data.ts @@ -0,0 +1,56 @@ +import { Coupon, Product } from "../types"; + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +// 초기 데이터 +export const initialProducts: ProductWithUI[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: "최고급 품질의 프리미엄 상품입니다.", + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +export const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; From e89492e941a629840cc2dc7bce56a113eb2e5da7 Mon Sep 17 00:00:00 2001 From: bebusl Date: Thu, 7 Aug 2025 01:14:01 +0900 Subject: [PATCH 04/18] fixup --- src/basic/data.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/basic/data.ts b/src/basic/data.ts index deae87bd..8fa0234a 100644 --- a/src/basic/data.ts +++ b/src/basic/data.ts @@ -1,12 +1,7 @@ -import { Coupon, Product } from "../types"; - -export interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} +import { Coupon } from "../types"; // 초기 데이터 -export const initialProducts: ProductWithUI[] = [ +export const initialProducts = [ { id: "p1", name: "상품1", From 945a1eebc28b76b96828fc366ee2dabeb7a14d16 Mon Sep 17 00:00:00 2001 From: bebusl Date: Thu, 7 Aug 2025 01:24:00 +0900 Subject: [PATCH 05/18] =?UTF-8?q?feat:=20=ED=95=A8=EC=88=98=EB=93=A4?= =?UTF-8?q?=EC=9D=84=20=EC=97=B0=EA=B2=B0=EC=8B=9C=EC=BC=9C=EC=A3=BC?= =?UTF-8?q?=EB=8A=94=20pipeTask=20=EC=9C=A0=ED=8B=B8=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/utils/pipeTask.ts | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/basic/utils/pipeTask.ts diff --git a/src/basic/utils/pipeTask.ts b/src/basic/utils/pipeTask.ts new file mode 100644 index 00000000..cc4a640b --- /dev/null +++ b/src/basic/utils/pipeTask.ts @@ -0,0 +1,42 @@ +type Task = () => Promise | T; + +/** this가 본인의 Context 참조되도록 해야 하므로 화살표함수가 아닌 함수 표현형으로 정의*/ +export function pipeTask(run: Task) { + let successHandler: ((result: T) => void) | undefined; + let errorHandler: ((error: unknown) => void) | undefined; + let finallyHandler: (() => void) | undefined; + + const obj = { + onSuccess(cb: (result: T) => void) { + successHandler = cb; + return obj; + }, + onError(cb: (err: unknown) => void) { + errorHandler = cb; + return obj; + }, + finally(cb: () => void) { + finallyHandler = cb; + return obj; + }, + run() { + try { + const result = run(); + if (result instanceof Promise) { + result + .then((res) => successHandler?.(res)) + .catch((err) => errorHandler?.(err)) + .finally(() => finallyHandler?.()); + } else { + successHandler?.(result); + finallyHandler?.(); + } + } catch (err) { + errorHandler?.(err); + finallyHandler?.(); + } + }, + }; + + return obj; +} From 8c35f00fa1c0f68da8007a4c50eae159066b65c4 Mon Sep 17 00:00:00 2001 From: bebusl Date: Thu, 7 Aug 2025 01:44:49 +0900 Subject: [PATCH 06/18] =?UTF-8?q?refactor:=20useTask=20=ED=9B=85=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/hooks/useNotifyingTask.ts | 31 +++++++++++++++++++++ src/basic/utils/pipeTask.ts | 42 ----------------------------- 2 files changed, 31 insertions(+), 42 deletions(-) create mode 100644 src/basic/hooks/useNotifyingTask.ts delete mode 100644 src/basic/utils/pipeTask.ts diff --git a/src/basic/hooks/useNotifyingTask.ts b/src/basic/hooks/useNotifyingTask.ts new file mode 100644 index 00000000..0657f6bd --- /dev/null +++ b/src/basic/hooks/useNotifyingTask.ts @@ -0,0 +1,31 @@ +import { useCallback } from "react"; + +type Task = (...args: Args) => Promise | T; + +interface UseTaskOptions { + onSuccess?: (result: T) => void; + onError?: (error: unknown) => void; + deps?: any[]; +} + +export function useTask( + taskFn: Task, + options: UseTaskOptions = {} +) { + const { onSuccess, onError, deps = [] } = options; + + const run = useCallback( + async (...args: Args): Promise => { + try { + const result = await taskFn(...args); + onSuccess?.(result); + return result; + } catch (error) { + onError?.(error); + } + }, + [taskFn, onSuccess, onError, ...deps] + ); + + return run; +} diff --git a/src/basic/utils/pipeTask.ts b/src/basic/utils/pipeTask.ts deleted file mode 100644 index cc4a640b..00000000 --- a/src/basic/utils/pipeTask.ts +++ /dev/null @@ -1,42 +0,0 @@ -type Task = () => Promise | T; - -/** this가 본인의 Context 참조되도록 해야 하므로 화살표함수가 아닌 함수 표현형으로 정의*/ -export function pipeTask(run: Task) { - let successHandler: ((result: T) => void) | undefined; - let errorHandler: ((error: unknown) => void) | undefined; - let finallyHandler: (() => void) | undefined; - - const obj = { - onSuccess(cb: (result: T) => void) { - successHandler = cb; - return obj; - }, - onError(cb: (err: unknown) => void) { - errorHandler = cb; - return obj; - }, - finally(cb: () => void) { - finallyHandler = cb; - return obj; - }, - run() { - try { - const result = run(); - if (result instanceof Promise) { - result - .then((res) => successHandler?.(res)) - .catch((err) => errorHandler?.(err)) - .finally(() => finallyHandler?.()); - } else { - successHandler?.(result); - finallyHandler?.(); - } - } catch (err) { - errorHandler?.(err); - finallyHandler?.(); - } - }, - }; - - return obj; -} From 61fff73a620b327454ba636e2695cf659bec7ce7 Mon Sep 17 00:00:00 2001 From: bebusl Date: Thu, 7 Aug 2025 01:52:45 +0900 Subject: [PATCH 07/18] =?UTF-8?q?feat:=20useProducts=20=ED=9B=85=EC=9D=84?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=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 - `useProducts` 훅을 새로 만들어 상품 관련 상태 및 CRUD 로직을 `App.tsx`에서 분리했습니다. - 상품 추가, 수정, 삭제와 같은 비동기 작업을 처리하고 사용자에게 알림을 표시하기 위해 `useTask` 훅을 도입했습니다. - 이를 통해 `App.tsx`의 복잡도를 낮추고 코드 재사용성을 높였습니다. --- src/basic/App.tsx | 58 ++++++++-------------------- src/basic/hooks/useProducts.ts | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 42 deletions(-) create mode 100644 src/basic/hooks/useProducts.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 4d6dea8d..18e5449b 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,6 +1,8 @@ import { useState, useCallback, useEffect } from "react"; import { CartItem, Coupon, Product } from "../types"; -import { initialCoupons, initialProducts, ProductWithUI } from "./data"; +import { initialCoupons } from "./data"; +import { type ProductWithUI, useProducts } from "./hooks/useProducts"; +import { useTask } from "./hooks/useNotifyingTask"; interface Notification { id: string; @@ -9,17 +11,7 @@ interface Notification { } const App = () => { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem("products"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); + const { products, addProduct, updateProduct, deleteProduct } = useProducts(); const [cart, setCart] = useState(() => { const saved = localStorage.getItem("cart"); @@ -172,10 +164,6 @@ const App = () => { setTotalItemCount(count); }, [cart]); - useEffect(() => { - localStorage.setItem("products", JSON.stringify(products)); - }, [products]); - useEffect(() => { localStorage.setItem("coupons", JSON.stringify(coupons)); }, [coupons]); @@ -295,37 +283,23 @@ const App = () => { setSelectedCoupon(null); }, [addNotification]); - const addProduct = useCallback( - (newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}`, - }; - setProducts((prev) => [...prev, product]); + const addProductTask = useTask(addProduct, { + onSuccess: () => { addNotification("상품이 추가되었습니다.", "success"); }, - [addNotification] - ); + }); - const updateProduct = useCallback( - (productId: string, updates: Partial) => { - setProducts((prev) => - prev.map((product) => - product.id === productId ? { ...product, ...updates } : product - ) - ); + const updateProductTask = useTask(updateProduct, { + onSuccess: () => { addNotification("상품이 수정되었습니다.", "success"); }, - [addNotification] - ); + }); - const deleteProduct = useCallback( - (productId: string) => { - setProducts((prev) => prev.filter((p) => p.id !== productId)); + const deleteProductTask = useTask(deleteProduct, { + onSuccess: () => { addNotification("상품이 삭제되었습니다.", "success"); }, - [addNotification] - ); + }); const addCoupon = useCallback( (newCoupon: Coupon) => { @@ -354,10 +328,10 @@ const App = () => { const handleProductSubmit = (e: React.FormEvent) => { e.preventDefault(); if (editingProduct && editingProduct !== "new") { - updateProduct(editingProduct, productForm); + updateProductTask(editingProduct, productForm); setEditingProduct(null); } else { - addProduct({ + addProductTask({ ...productForm, discounts: productForm.discounts, }); @@ -625,7 +599,7 @@ const App = () => { 수정
-
- ))} -
- )} +
diff --git a/src/basic/components/Notification.tsx b/src/basic/components/Notification.tsx new file mode 100644 index 00000000..db7f112f --- /dev/null +++ b/src/basic/components/Notification.tsx @@ -0,0 +1,51 @@ +interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +type Props = { + notifications: Notification[]; + removeNotification: (notificationId: string) => void; +}; +const Notification = ({ notifications = [], removeNotification }: Props) => { + return ( + notifications.length > 0 && ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ) + ); +}; +export default Notification; diff --git a/src/basic/hooks/useNotification.ts b/src/basic/hooks/useNotification.ts new file mode 100644 index 00000000..e425afc7 --- /dev/null +++ b/src/basic/hooks/useNotification.ts @@ -0,0 +1,24 @@ +import { useCallback, useState } from "react"; +import Notification from "../components/Notification"; + +export const useNotification = () => { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); + + const removeNotification = useCallback((notificationId: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== notificationId)); + }, []); + + return { notifications, addNotification, removeNotification }; +}; From 0d12bdb916ba94beb760460b35746834cfed6529 Mon Sep 17 00:00:00 2001 From: bebusl Date: Fri, 8 Aug 2025 00:30:37 +0900 Subject: [PATCH 17/18] =?UTF-8?q?refactor:=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 1150 +-------------------------------- src/basic/pages/AdminPage.tsx | 725 +++++++++++++++++++++ src/basic/pages/CartPage.tsx | 434 +++++++++++++ 3 files changed, 1190 insertions(+), 1119 deletions(-) create mode 100644 src/basic/pages/AdminPage.tsx create mode 100644 src/basic/pages/CartPage.tsx diff --git a/src/basic/App.tsx b/src/basic/App.tsx index d71145a2..7ab69e5b 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,30 +1,28 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useEffect } from "react"; import { Coupon } from "../types"; -import { type ProductWithUI, useProducts } from "./hooks/useProducts"; -import { useTask } from "./utils/hooks/useTask"; +import { useProducts } from "./hooks/useProducts"; import { useCart } from "./hooks/useCart"; import { useCoupons } from "./hooks/useCoupons"; -import { - calculateCartTotal, - calculateItemTotal, - getMaxApplicableDiscount, - getRemainingStock, -} from "./models/cart"; -import { useSearch } from "./hooks/useSearch"; +import { getRemainingStock } from "./models/cart"; + import Notification from "./components/Notification"; import { useNotification } from "./hooks/useNotification"; +import AdminPage from "./pages/AdminPage"; +import CartPage from "./pages/CartPage"; const App = () => { - const { products, addProduct, updateProduct, deleteProduct } = useProducts(); + const registerProduct = useProducts(); + const products = registerProduct.products; + + const registerCart = useCart(); + const cart = registerCart.cart; - const { cart, addToCart, removeFromCart, updateQuantity, clearCart } = - useCart(); + const registerCoupons = useCoupons(); + const coupons = registerCoupons.coupons; - const { coupons, addCoupon, deleteCoupon } = useCoupons(); const [selectedCoupon, setSelectedCoupon] = useState(null); const [searchTerm, setSearchTerm] = useState(""); - const { debouncedSearchTerm, searchResult } = useSearch(searchTerm, products); const { notifications, addNotification, removeNotification } = useNotification(); @@ -32,28 +30,6 @@ const App = () => { /** 뷰 데이터 */ const [isAdmin, setIsAdmin] = useState(false); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<"products" | "coupons">( - "products" - ); - 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 [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); @@ -76,160 +52,6 @@ const App = () => { setTotalItemCount(count); }, [cart]); - const addToCartTask = useCallback( - (product: ProductWithUI) => { - const remainingStock = getRemainingStock(product, cart); - if (remainingStock <= 0) { - addNotification("재고가 부족합니다!", "error"); - return; - } - addToCart(product); - addNotification("장바구니에 담았습니다", "success"); - }, - [addToCart, addNotification] - ); - - const updateQuantityTask = 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; - } - updateQuantity(productId, newQuantity); - }, - [products, updateQuantity, addNotification] - ); - - const applyCoupon = useCallback( - (coupon: Coupon) => { - const currentTotal = calculateCartTotal( - cart, - selectedCoupon - ).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" - ); - clearCart(); - setSelectedCoupon(null); - }, [addNotification, clearCart]); - - const addProductTask = useTask(addProduct, { - onSuccess: () => { - addNotification("상품이 추가되었습니다.", "success"); - }, - }); - - const updateProductTask = useTask(updateProduct, { - onSuccess: () => { - addNotification("상품이 수정되었습니다.", "success"); - }, - }); - - const deleteProductTask = useTask(deleteProduct, { - onSuccess: () => { - addNotification("상품이 삭제되었습니다.", "success"); - }, - }); - - const addCouponTask = useTask(addCoupon, { - onSuccess: () => { - addNotification("쿠폰이 추가되었습니다.", "success"); - }, - onError: (e) => { - addNotification((e as Error).message, "error"); - }, - deps: [coupons, addNotification], - }); - - const deleteCouponTask = useTask( - (couponCode: string) => { - deleteCoupon(couponCode); - - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - }, - { - onSuccess: () => { - addNotification("쿠폰이 삭제되었습니다.", "success"); - }, - } - ); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== "new") { - updateProductTask(editingProduct, productForm); - setEditingProduct(null); - } else { - addProductTask({ - ...productForm, - discounts: productForm.discounts, - }); - } - setProductForm({ - name: "", - price: 0, - stock: 0, - description: "", - discounts: [], - }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCouponTask(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(cart, selectedCoupon); - return (
{
{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}개 상품 -
-
- {searchResult.length === 0 ? ( -
-

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

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

- {product.name} -

- {product.description && ( -

- {product.description} -

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

- {formatPrice(product.price, product.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, - getMaxApplicableDiscount(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()}원 - -
-
- - - -
-

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

-
-
- - )} -
-
-
+ )}
diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx new file mode 100644 index 00000000..a6562b77 --- /dev/null +++ b/src/basic/pages/AdminPage.tsx @@ -0,0 +1,725 @@ +import React, { useState } from "react"; +import { ProductWithUI, useProducts } from "../hooks/useProducts"; +import { useTask } from "../utils/hooks/useTask"; +import { useCoupons } from "../hooks/useCoupons"; +import { Coupon } from "../../types"; + +type Props = { + selectedCoupon: Coupon | null; + setSelectedCoupon: React.Dispatch>; + registerProduct: ReturnType; + registerCoupons: ReturnType; + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void; + formatPrice: (price: number, productId?: string) => string; +}; + +const AdminPage = ({ + registerProduct, + registerCoupons, + addNotification, + formatPrice, + selectedCoupon, + setSelectedCoupon, +}: Props) => { + const { products, addProduct, deleteProduct, updateProduct } = + registerProduct; + + const { coupons, addCoupon, deleteCoupon } = registerCoupons; + + const [activeTab, setActiveTab] = useState<"products" | "coupons">( + "products" + ); + + const [showCouponForm, setShowCouponForm] = useState(false); + + 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 [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, + }); + + const addProductTask = useTask(addProduct, { + onSuccess: () => { + addNotification("상품이 추가되었습니다.", "success"); + }, + }); + + const updateProductTask = useTask(updateProduct, { + onSuccess: () => { + addNotification("상품이 수정되었습니다.", "success"); + }, + }); + + const deleteProductTask = useTask(deleteProduct, { + onSuccess: () => { + addNotification("상품이 삭제되었습니다.", "success"); + }, + }); + + const addCouponTask = useTask(addCoupon, { + onSuccess: () => { + addNotification("쿠폰이 추가되었습니다.", "success"); + }, + onError: (e) => { + addNotification((e as Error).message, "error"); + }, + deps: [coupons, addNotification], + }); + + const deleteCouponTask = useTask( + (couponCode: string) => { + deleteCoupon(couponCode); + + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + }, + { + onSuccess: () => { + addNotification("쿠폰이 삭제되었습니다.", "success"); + }, + } + ); + + /** handler */ + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addCouponTask(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 handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== "new") { + updateProductTask(editingProduct, productForm); + setEditingProduct(null); + } else { + addProductTask({ + ...productForm, + discounts: productForm.discounts, + }); + } + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + setEditingProduct(null); + setShowProductForm(false); + }; + + return ( +
+
+

관리자 대시보드

+

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

+
+
+ +
+ + {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 + /> +
+
+
+ + +
+
+
+ )} +
+
+ )} +
+ ); +}; + +export default AdminPage; diff --git a/src/basic/pages/CartPage.tsx b/src/basic/pages/CartPage.tsx new file mode 100644 index 00000000..326e39cf --- /dev/null +++ b/src/basic/pages/CartPage.tsx @@ -0,0 +1,434 @@ +import React, { useCallback } from "react"; + +import { Coupon, Product } from "../../types"; +import { useSearch } from "../hooks/useSearch"; +import { ProductWithUI } from "../hooks/useProducts"; +import { + calculateCartTotal, + calculateItemTotal, + getMaxApplicableDiscount, + getRemainingStock, +} from "../models/cart"; +import { useCart } from "../hooks/useCart"; + +type Props = { + products: Product[]; + searchTerm: string; + coupons: Coupon[]; + registerCart: ReturnType; + selectedCoupon: Coupon | null; + setSelectedCoupon: React.Dispatch>; + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void; + formatPrice: (price: number, productId?: string) => string; +}; + +const CartPage = ({ + registerCart, + products, + coupons, + searchTerm, + selectedCoupon, + setSelectedCoupon, + addNotification, + formatPrice, +}: Props) => { + const { debouncedSearchTerm, searchResult } = useSearch(searchTerm, products); + const { cart, addToCart, clearCart, removeFromCart, updateQuantity } = + registerCart; + + const addToCartTask = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product, cart); + if (remainingStock <= 0) { + addNotification("재고가 부족합니다!", "error"); + return; + } + addToCart(product); + addNotification("장바구니에 담았습니다", "success"); + }, + [addToCart, addNotification] + ); + + const updateQuantityTask = 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; + } + updateQuantity(productId, newQuantity); + }, + [products, updateQuantity, addNotification] + ); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal( + cart, + selectedCoupon + ).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" + ); + clearCart(); + setSelectedCoupon(null); + }, [addNotification, clearCart]); + + const totals = calculateCartTotal(cart, selectedCoupon); + + return ( +
+
+ {/* 상품 목록 */} +
+
+

전체 상품

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

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

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

+ {product.name} +

+ {product.description && ( +

+ {product.description} +

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

+ {formatPrice(product.price, product.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, + getMaxApplicableDiscount(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()}원 + +
+
+ + + +
+

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

+
+
+ + )} +
+
+
+ ); +}; + +export default CartPage; From 2ffb0f99b951e5c7b3cd5f09b3098267eaac01e8 Mon Sep 17 00:00:00 2001 From: bebusl Date: Fri, 8 Aug 2025 00:38:12 +0900 Subject: [PATCH 18/18] =?UTF-8?q?refactor:=20Header=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixup --- src/basic/App.tsx | 79 +++++++-------------------------- src/basic/components/Header.tsx | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 62 deletions(-) create mode 100644 src/basic/components/Header.tsx diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 7ab69e5b..0801515f 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Coupon } from "../types"; import { useProducts } from "./hooks/useProducts"; import { useCart } from "./hooks/useCart"; @@ -9,6 +9,7 @@ import Notification from "./components/Notification"; import { useNotification } from "./hooks/useNotification"; import AdminPage from "./pages/AdminPage"; import CartPage from "./pages/CartPage"; +import Header from "./components/Header"; const App = () => { const registerProduct = useProducts(); @@ -30,6 +31,10 @@ const App = () => { /** 뷰 데이터 */ const [isAdmin, setIsAdmin] = useState(false); + const togglePage = () => { + setIsAdmin((isAdmin) => !isAdmin); + }; + const formatPrice = (price: number, productId?: string): string => { if (productId) { const product = products.find((p) => p.id === productId); @@ -45,12 +50,10 @@ const App = () => { return `₩${price.toLocaleString()}`; }; - const [totalItemCount, setTotalItemCount] = useState(0); - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); + const totalItemCount = useMemo( + () => cart.reduce((sum, item) => sum + item.quantity, 0), + [cart] + ); return (
@@ -58,61 +61,13 @@ const App = () => { notifications={notifications} removeNotification={removeNotification} /> -
-
-
-
-

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && ( -
- setSearchTerm(e.target.value)} - placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" - /> -
- )} -
- -
-
-
+
{isAdmin ? ( diff --git a/src/basic/components/Header.tsx b/src/basic/components/Header.tsx new file mode 100644 index 00000000..c54748ce --- /dev/null +++ b/src/basic/components/Header.tsx @@ -0,0 +1,77 @@ +import React from "react"; + +type Props = { + isAdmin: boolean; + searchTerm: string; + totalItemCount: number; + togglePage: () => void; + setSearchTerm: React.Dispatch>; +}; + +const Header = ({ + isAdmin, + totalItemCount, + searchTerm, + togglePage, + setSearchTerm, +}: Props) => { + return ( +
+
+
+
+

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" + /> +
+ )} +
+ +
+
+
+ ); +}; + +export default Header;