From 0f033fa778348b3cd64df9f6c4dfc5c4f9420a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Sat, 2 Aug 2025 14:59:08 +0900 Subject: [PATCH 01/52] =?UTF-8?q?feat(docs):=20pr.md=20=ED=8C=8C=EC=9D=BC?= =?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 --- pr.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 pr.md diff --git a/pr.md b/pr.md new file mode 100644 index 00000000..e69de29b From 2d7bb1ddfc543d98335faf5799bf876c2293d373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Sat, 2 Aug 2025 14:59:55 +0900 Subject: [PATCH 02/52] =?UTF-8?q?feat(docs):=20pr.md=20=ED=8C=8C=EC=9D=BC?= =?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 - 과제의 핵심취지 및 필수 사항 추가 - 기본과 심화 과제 내용 정리 - 과제 셀프회고 및 질문 섹션 추가 --- pr.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/pr.md b/pr.md index e69de29b..d0a8458c 100644 --- a/pr.md +++ b/pr.md @@ -0,0 +1,52 @@ +## 과제의 핵심취지 + +- React의 hook 이해하기 +- 함수형 프로그래밍에 대한 이해 +- 액션과 순수함수의 분리 + +## 과제에서 꼭 알아가길 바라는 점 + +- 엔티티를 다루는 상태와 그렇지 않은 상태 - cart, isCartFull vs isShowPopup +- 엔티티를 다루는 컴포넌트와 훅 - CartItemView, useCart(), useProduct() +- 엔티티를 다루지 않는 컴포넌트와 훅 - Button, useRoute, useEvent 등 +- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) + +### 기본과제 + +- Component에서 비즈니스 로직을 분리하기 +- 비즈니스 로직에서 특정 엔티티만 다루는 계산을 분리하기 +- 뷰데이터와 엔티티데이터의 분리에 대한 이해 +- entities -> features -> UI 계층에 대한 이해 + +- [ ] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? +- [ ] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? +- [ ] 계산함수는 순수함수로 작성이 되었나요? +- [ ] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? +- [ ] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? +- [ ] 계산함수는 순수함수로 작성이 되었나요? +- [ ] 특정 Entitiy만 다루는 함수는 분리되어 있나요? +- [ ] 특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? +- [ ] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? + +### 심화과제 + +- 재사용 가능한 Custom UI 컴포넌트를 만들어 보기 +- 재사용 가능한 Custom 라이브러리 Hook을 만들어 보기 +- 재사용 가능한 Custom 유틸 함수를 만들어 보기 +- 그래서 엔티티와는 어떤 다른 계층적 특징을 가지는지 이해하기 + +- [ ] UI 컴포넌트 계층과 엔티티 컴포넌트의 계층의 성격이 다르다는 것을 이해하고 적용했는가? +- [ ] 엔티티 Hook과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가? +- [ ] 엔티티 순수함수와 유틸리티 함수의 계층의 성격이 다르다는 것을 이해하고 적용했는가? + +## 과제 셀프회고 + + + +### 과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요? + +### 과제를 다시 해보면 더 잘 할 수 있었겠다 아쉬운 점이 있다면 무엇인가요? + +### 리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :) + +## 리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 From 73b23654a4d4ce3f4523e043e5b06d38afb9b785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Sun, 3 Aug 2025 11:57:37 +0900 Subject: [PATCH 03/52] =?UTF-8?q?packages=20scripts=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a0fb5177..79034acb 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "start:advanced": "vite serve --open ./index.advanced.html", "test": "vitest", "test:origin": "vitest src/origin", - "test:basic": "vitest basic.test.ts", - "test:advanced": "vitest advanced.test.js", + "test:basic": "vitest src/basic", + "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" From a2dc34cee7ba5808d448a24051c01d7b5aaf9250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Sun, 3 Aug 2025 20:58:19 +0900 Subject: [PATCH 04/52] =?UTF-8?q?fix:=20=EB=A6=B0=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= 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 0eb4ef66e02dd67c9ac6dae44fc9d435c81c4e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Sun, 3 Aug 2025 21:55:34 +0900 Subject: [PATCH 05/52] =?UTF-8?q?feat:=201=EC=9D=BC=EC=B0=A8=EB=B6=80?= =?UTF-8?q?=ED=84=B0=205=EC=9D=BC=EC=B0=A8=EA=B9=8C=EC=A7=80=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Product, Cart, Coupon, Notification 도메인 완전 분리 - 각 도메인 관련 컴포넌트, 상수, 순수함수, 상태관리 분리 - 최종 아키텍처 문서화 및 통합 테스트 계획 수립 --- 1days-refactoring-prd.md | 91 ++++++++++++++++++++++++ 2days-refactoring-prd.md | 103 +++++++++++++++++++++++++++ 3days-refactoring-prd.md | 89 +++++++++++++++++++++++ 4days-refactoring-prd.md | 103 +++++++++++++++++++++++++++ 5days-refactoring-prd.md | 148 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 534 insertions(+) create mode 100644 1days-refactoring-prd.md create mode 100644 2days-refactoring-prd.md create mode 100644 3days-refactoring-prd.md create mode 100644 4days-refactoring-prd.md create mode 100644 5days-refactoring-prd.md diff --git a/1days-refactoring-prd.md b/1days-refactoring-prd.md new file mode 100644 index 00000000..72d8aec6 --- /dev/null +++ b/1days-refactoring-prd.md @@ -0,0 +1,91 @@ +# Day 1: Product 도메인 완전 분리 (컴포넌트 → 상수 → 순수함수 → 상태관리) + +## 목표 + +- Product 도메인의 모든 요소를 단계별로 완전 분리 +- 컴포넌트 → 상수 → 순수함수 → 상태관리 순서로 진행 +- 테스트를 계속 통과시키며 점진적 리팩토링 + +## 작업 범위 + +### 1단계: Jotai 설치 및 기본 설정 + +```bash +npm install jotai +``` + +### 2단계: Product 관련 컴포넌트 분리 + +```typescript +// src/advanced/components/ProductList.tsx +export const ProductList: React.FC<{ + products: ProductWithUI[]; + onAddToCart: (product: ProductWithUI) => void; + isAdmin: boolean; + onEditProduct?: (product: ProductWithUI) => void; +}>; + +// src/advanced/components/ProductCard.tsx +export const ProductCard: React.FC; + +// src/advanced/components/ProductForm.tsx (관리자용) +export const ProductForm: React.FC; +``` + +### 3단계: Product 상수 분리 + +```typescript +// src/advanced/constants/product.ts +export const initialProducts: ProductWithUI[] = [...] +export const PRODUCT_FORM_INITIAL_STATE = {...} +``` + +### 4단계: Product 순수함수 분리 + +```typescript +// src/advanced/utils/productUtils.ts +export const filterProducts = (products: ProductWithUI[], searchTerm: string): ProductWithUI[] +export const generateProductId = (): string +export const validateProductData = (product: Partial): boolean + +// src/advanced/utils/formatters.ts +export const formatPrice = (price: number, isAdmin: boolean = false): string +``` + +### 5단계: Product 상태관리 (Atom + Hook) + +```typescript +// src/advanced/models/productAtoms.ts +export const productsAtom = atom(initialProducts); +export const searchTermAtom = atom(""); +export const debouncedSearchTermAtom = atom(""); + +// src/advanced/hooks/useProducts.ts +export const useProducts = () => { + // atom 기반 상태 관리 + // CRUD 함수들 +}; +``` + +### 6단계: App.tsx에서 Product 관련 코드 제거 및 연결 + +- useState들 제거 +- 순수함수들 제거 +- 컴포넌트 import로 교체 + +## 테스트 전략 + +- [ ] 각 단계 완료 후 즉시 테스트 실행 +- [ ] 기존 Product 관련 테스트 통과 확인 +- [ ] 새로운 유틸리티 함수 단위 테스트 + +## 성공 기준 + +1. Product 도메인이 완전히 분리됨 +2. App 컴포넌트에서 Product 관련 코드가 모두 제거됨 +3. 모든 기존 테스트가 통과함 +4. 검색, 상품 관리 기능이 정상 동작함 + +## 다음 날 준비사항 + +- Cart 도메인 분석 준비 diff --git a/2days-refactoring-prd.md b/2days-refactoring-prd.md new file mode 100644 index 00000000..a91712e2 --- /dev/null +++ b/2days-refactoring-prd.md @@ -0,0 +1,103 @@ +# Day 2: Cart 도메인 완전 분리 (컴포넌트 → 상수 → 순수함수 → 상태관리) + +## 목표 + +- Cart 도메인의 모든 요소를 단계별로 완전 분리 +- 장바구니 관련 컴포넌트, 상수, 순수함수, 상태관리 모두 분리 + +## 작업 범위 + +### 1단계: Cart 관련 컴포넌트 분리 + +```typescript +// src/advanced/components/CartSummary.tsx +export const CartSummary: React.FC<{ + cart: CartItem[]; + selectedCoupon: Coupon | null; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemoveItem: (productId: string) => void; + onApplyCoupon: (coupon: Coupon) => void; + onCompleteOrder: () => void; +}>; + +// src/advanced/components/CartItem.tsx +export const CartItem: React.FC; + +// src/advanced/components/CouponSelector.tsx +export const CouponSelector: React.FC; +``` + +### 2단계: Cart 상수 분리 + +```typescript +// src/advanced/constants/cart.ts +export const BULK_PURCHASE_THRESHOLD = 10; +export const BULK_PURCHASE_ADDITIONAL_DISCOUNT = 0.05; +export const MAX_DISCOUNT_RATE = 0.5; +export const MIN_PERCENTAGE_COUPON_AMOUNT = 10000; +``` + +### 3단계: Cart 순수함수 분리 + +```typescript +// src/advanced/utils/cartUtils.ts +export const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number +export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number +export const calculateCartTotal = (cart: CartItem[], selectedCoupon: Coupon | null): CartTotal +export const getRemainingStock = (product: Product, cart: CartItem[]): number +export const getCartItemCount = (cart: CartItem[]): number + +// src/advanced/utils/discountUtils.ts +export const calculateDiscountRate = (discounts: Discount[], quantity: number): number +export const hasBulkPurchase = (cart: CartItem[]): boolean +export const applyCouponDiscount = (total: number, coupon: Coupon): number +``` + +### 4단계: Cart 상태관리 (Atom + Hook) + +```typescript +// src/advanced/models/cartAtoms.ts +export const cartAtom = atom([]) +export const selectedCouponAtom = atom(null) +export const cartTotalAtom = atom((get) => ...) +export const cartItemCountAtom = atom((get) => ...) + +// src/advanced/hooks/useCart.ts +export const useCart = () => { + // localStorage 연동 + // CRUD 함수들 + // 재고 검증 +} +``` + +### 5단계: LocalStorage 연동 + +```typescript +// src/advanced/utils/hooks/useLocalStorage.ts +export const useLocalStorage = (key: string, initialValue: T) +``` + +### 6단계: App.tsx에서 Cart 관련 코드 제거 및 연결 + +## 이전 작업과의 연결점 + +- Day 1에서 분리한 formatPrice 유틸리티 활용 +- Product 도메인과의 연동 (재고 계산) + +## 테스트 전략 + +- [ ] 각 단계 완료 후 즉시 테스트 실행 +- [ ] 장바구니 CRUD 기능 테스트 +- [ ] 할인 계산 로직 테스트 +- [ ] localStorage 연동 테스트 + +## 성공 기준 + +1. Cart 도메인이 완전히 분리됨 +2. App 컴포넌트에서 Cart 관련 코드가 모두 제거됨 +3. 장바구니 기능이 정상 동작함 +4. 모든 기존 테스트가 통과함 + +## 다음 날 준비사항 + +- Coupon 도메인 분석 준비 diff --git a/3days-refactoring-prd.md b/3days-refactoring-prd.md new file mode 100644 index 00000000..d5026bb9 --- /dev/null +++ b/3days-refactoring-prd.md @@ -0,0 +1,89 @@ +# Day 3: Coupon 도메인 완전 분리 (컴포넌트 → 상수 → 순수함수 → 상태관리) + +## 목표 + +- Coupon 도메인의 모든 요소를 단계별로 완전 분리 +- 쿠폰 관련 컴포넌트, 상수, 순수함수, 상태관리 모두 분리 + +## 작업 범위 + +### 1단계: Coupon 관련 컴포넌트 분리 + +```typescript +// src/advanced/components/CouponList.tsx +export const CouponList: React.FC<{ + coupons: Coupon[]; + onApplyCoupon: (coupon: Coupon) => void; + selectedCoupon: Coupon | null; +}>; + +// src/advanced/components/CouponCard.tsx +export const CouponCard: React.FC; + +// src/advanced/components/CouponForm.tsx (관리자용) +export const CouponForm: React.FC; + +// src/advanced/components/CouponManagement.tsx (관리자용) +export const CouponManagement: React.FC; +``` + +### 2단계: Coupon 상수 분리 + +```typescript +// src/advanced/constants/coupon.ts +export const initialCoupons: Coupon[] = [...] +export const COUPON_FORM_INITIAL_STATE = {...} +export const COUPON_VALIDATION_RULES = {...} +``` + +### 3단계: Coupon 순수함수 분리 + +```typescript +// src/advanced/utils/couponUtils.ts +export const validateCouponCondition = (coupon: Coupon, cartTotal: number): boolean +export const applyCouponDiscount = (total: number, coupon: Coupon): number +export const generateCouponCode = (): string +export const validateCouponData = (coupon: Partial): boolean +export const isCouponDuplicate = (coupons: Coupon[], code: string): boolean +export const getCouponDisplayText = (coupon: Coupon): string +``` + +### 4단계: Coupon 상태관리 (Atom + Hook) + +```typescript +// src/advanced/models/couponAtoms.ts +export const couponsAtom = atom(initialCoupons) +export const availableCouponsAtom = atom((get) => ...) + +// src/advanced/hooks/useCoupons.ts +export const useCoupons = () => { + // localStorage 연동 + // CRUD 함수들 + // 검증 로직 +} +``` + +### 5단계: App.tsx에서 Coupon 관련 코드 제거 및 연결 + +## 이전 작업과의 연결점 + +- Day 2의 Cart total과 연동하여 쿠폰 적용 검증 +- 기존 localStorage 유틸리티 재사용 + +## 테스트 전략 + +- [ ] 각 단계 완료 후 즉시 테스트 실행 +- [ ] 쿠폰 적용 조건 검증 테스트 +- [ ] 쿠폰 할인 계산 테스트 +- [ ] 쿠폰 CRUD 기능 테스트 + +## 성공 기준 + +1. Coupon 도메인이 완전히 분리됨 +2. App 컴포넌트에서 Coupon 관련 코드가 모두 제거됨 +3. 쿠폰 적용 및 검증이 정상 동작함 +4. 모든 기존 테스트가 통과함 + +## 다음 날 준비사항 + +- Notification 시스템 분석 준비 diff --git a/4days-refactoring-prd.md b/4days-refactoring-prd.md new file mode 100644 index 00000000..d165ce32 --- /dev/null +++ b/4days-refactoring-prd.md @@ -0,0 +1,103 @@ +# Day 4: Notification 시스템 + UI 컴포넌트 분리 + +## 목표 + +- Notification 시스템 완전 분리 +- 재사용 가능한 UI 컴포넌트 생성 +- 검색 및 디바운스 기능 모듈화 + +## 작업 범위 + +### 1단계: Notification 관련 컴포넌트 분리 + +```typescript +// src/advanced/components/ui/NotificationList.tsx +export const NotificationList: React.FC; + +// src/advanced/components/ui/Toast.tsx +export const Toast: React.FC; +``` + +### 2단계: Notification 상수 분리 + +```typescript +// src/advanced/constants/notification.ts +export const NOTIFICATION_DURATION = 3000; +export const NOTIFICATION_TYPES = { + SUCCESS: "success", + ERROR: "error", + WARNING: "warning", +} as const; +``` + +### 3단계: Notification 순수함수 분리 + +```typescript +// src/advanced/utils/notificationUtils.ts +export const createNotification = (message: string, type: NotificationType): Notification +export const generateNotificationId = (): string +``` + +### 4단계: Notification 상태관리 (Atom + Hook) + +```typescript +// src/advanced/models/notificationAtoms.ts +export const notificationsAtom = atom([]); + +// src/advanced/hooks/useNotifications.ts +export const useNotifications = () => { + // 알림 추가/제거/자동제거 +}; +``` + +### 5단계: 재사용 가능한 UI 컴포넌트 생성 + +```typescript +// src/advanced/components/ui/Button.tsx +export const Button: React.FC; + +// src/advanced/components/ui/Input.tsx +export const Input: React.FC; + +// src/advanced/components/ui/SearchInput.tsx +export const SearchInput: React.FC; + +// src/advanced/components/ui/TabNavigation.tsx +export const TabNavigation: React.FC; +``` + +### 6단계: 검색 및 디바운스 기능 모듈화 + +```typescript +// src/advanced/utils/hooks/useDebounce.ts +export const useDebounce = (value: T, delay: number): T + +// src/advanced/utils/searchUtils.ts +export const searchProducts = (products: ProductWithUI[], term: string): ProductWithUI[] +``` + +### 7단계: App.tsx에서 해당 코드 제거 및 연결 + +## 이전 작업과의 연결점 + +- Day 1-3에서 분리한 모든 도메인과 연동 +- 모든 도메인에서 notification 사용 + +## 테스트 전략 + +- [ ] 알림 시스템 동작 테스트 +- [ ] UI 컴포넌트 렌더링 테스트 +- [ ] 디바운스 기능 테스트 +- [ ] 검색 기능 테스트 + +## 성공 기준 + +1. Notification 시스템이 완전히 분리됨 +2. 재사용 가능한 UI 컴포넌트들이 생성됨 +3. App 컴포넌트가 대폭 간소화됨 +4. 모든 기존 테스트가 통과함 + +## 다음 날 준비사항 + +- 최종 App 컴포넌트 정리 준비 +- 관리자 페이지 컴포넌트 완성 준비 diff --git a/5days-refactoring-prd.md b/5days-refactoring-prd.md new file mode 100644 index 00000000..93480ebd --- /dev/null +++ b/5days-refactoring-prd.md @@ -0,0 +1,148 @@ +# Day 5: 최종 정리 및 레이아웃 컴포넌트 분리 + +## 목표 + +- 레이아웃 관련 컴포넌트 분리 완료 +- AdminPage, ProductListPage 최종 정리 +- App 컴포넌트를 최소한의 라우팅 로직만 포함하도록 정리 +- 전체 아키텍처 검증 및 문서화 + +## 작업 범위 + +### 1단계: 레이아웃 컴포넌트 분리 + +```typescript +// src/advanced/components/layout/Header.tsx +export const Header: React.FC<{ + isAdmin: boolean; + onToggleAdmin: () => void; + cartItemCount: number; +}>; + +// src/advanced/components/layout/Navigation.tsx +export const Navigation: React.FC; + +// src/advanced/components/layout/MainLayout.tsx +export const MainLayout: React.FC; +``` + +### 2단계: 페이지 컴포넌트 최종 정리 + +```typescript +// src/advanced/components/ProductListPage.tsx +export const ProductListPage: React.FC - 완전한 상품 목록 페이지 + +// src/advanced/components/AdminPage.tsx +export const AdminPage: React.FC - 완전한 관리자 페이지 +``` + +### 3단계: App 컴포넌트 최종 정리 + +```typescript +// src/advanced/App.tsx - 최종 모습 (50줄 이하) +const App = () => { + const [isAdmin, setIsAdmin] = useState(false); + + return ( + +
setIsAdmin(!isAdmin)} + cartItemCount={...} + /> + +
+ {isAdmin ? : } +
+ + ); +}; +``` + +### 4단계: 최종 폴더 구조 정리 + +``` +src/advanced/ +├── App.tsx (50줄 이하) +├── components/ +│ ├── layout/ - 레이아웃 컴포넌트 +│ ├── ui/ - 재사용 UI 컴포넌트 +│ ├── ProductListPage.tsx +│ ├── AdminPage.tsx +│ ├── CartSummary.tsx +│ ├── ProductList.tsx +│ ├── ProductCard.tsx +│ ├── ProductForm.tsx +│ ├── CouponList.tsx +│ ├── CouponCard.tsx +│ └── CouponForm.tsx +├── constants/ +│ ├── product.ts +│ ├── cart.ts +│ ├── coupon.ts +│ └── notification.ts +├── utils/ +│ ├── hooks/ +│ ├── productUtils.ts +│ ├── cartUtils.ts +│ ├── couponUtils.ts +│ ├── discountUtils.ts +│ ├── formatters.ts +│ └── searchUtils.ts +├── models/ +│ ├── productAtoms.ts +│ ├── cartAtoms.ts +│ ├── couponAtoms.ts +│ └── notificationAtoms.ts +└── hooks/ + ├── useProducts.ts + ├── useCart.ts + ├── useCoupons.ts + └── useNotifications.ts +``` + +### 5단계: 최종 통합 테스트 + +- [ ] 모든 도메인 기능 통합 테스트 +- [ ] E2E 시나리오 테스트 +- [ ] 성능 테스트 (렌더링 최적화 확인) + +### 6단계: 아키텍처 문서화 + +```markdown +// docs/refactoring-summary.md + +- 리팩토링 전후 비교 +- 아키텍처 다이어그램 +- 각 계층의 책임 범위 +- 데이터 흐름 설명 +``` + +## 최종 검증 항목 + +### PR 체크리스트 기준 + +- [x] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? +- [x] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? +- [x] 계산함수는 순수함수로 작성이 되었나요? +- [x] 특정 Entity만 다루는 함수는 분리되어 있나요? +- [x] 특정 Entity만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? +- [x] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? +- [x] UI 컴포넌트 계층과 엔티티 컴포넌트의 계층의 성격이 다르다는 것을 이해하고 적용했는가? +- [x] 엔티티 Hook과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가? +- [x] 엔티티 순수함수와 유틸리티 함수의 계층의 성격이 다르다는 것을 이해하고 적용했는가? + +## 최종 성공 기준 + +1. App 컴포넌트가 50줄 이하로 단순화됨 +2. 모든 기존 테스트가 통과함 +3. 각 도메인이 완전히 독립적으로 분리됨 +4. 재사용 가능한 컴포넌트들이 확보됨 +5. 코드 가독성과 유지보수성이 대폭 향상됨 + +## 최종 deliverable + +- 완전히 리팩토링된 쇼핑몰 애플리케이션 +- 계층별 아키텍처 문서 +- 리팩토링 전후 비교 분석 +- 각 도메인별 사용 가이드 From ab0e39fb684fe7e0ff4a1100de7e6a4c925fa86b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Sun, 3 Aug 2025 21:56:25 +0900 Subject: [PATCH 06/52] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EA=B5=AC=EC=A1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/components/AdminPage.tsx | 20 ++++++++++++++ src/advanced/components/CartPage.tsx | 21 +++++++++++++++ src/advanced/components/icons/index.tsx | 12 +++++++++ src/advanced/components/ui/UIToast.ts | 0 src/advanced/constants/index.ts | 8 ++++++ src/advanced/hooks/useCart.ts | 29 +++++++++++++++++++++ src/advanced/hooks/useCoupons.ts | 13 +++++++++ src/advanced/hooks/useProducts.ts | 18 +++++++++++++ src/advanced/models/cart.ts | 18 +++++++++++++ src/advanced/models/coupon.ts | 0 src/advanced/models/discount.ts | 0 src/advanced/models/product.ts | 0 src/advanced/utils/formatters.ts | 7 +++++ src/advanced/utils/hooks/useDebounce.ts | 11 ++++++++ src/advanced/utils/hooks/useLocalStorage.ts | 15 +++++++++++ src/advanced/utils/hooks/useValidate.ts | 0 src/advanced/utils/validators.ts | 8 ++++++ 17 files changed, 180 insertions(+) create mode 100644 src/advanced/components/AdminPage.tsx create mode 100644 src/advanced/components/CartPage.tsx create mode 100644 src/advanced/components/icons/index.tsx create mode 100644 src/advanced/components/ui/UIToast.ts create mode 100644 src/advanced/constants/index.ts create mode 100644 src/advanced/hooks/useCart.ts create mode 100644 src/advanced/hooks/useCoupons.ts create mode 100644 src/advanced/hooks/useProducts.ts create mode 100644 src/advanced/models/cart.ts create mode 100644 src/advanced/models/coupon.ts create mode 100644 src/advanced/models/discount.ts create mode 100644 src/advanced/models/product.ts create mode 100644 src/advanced/utils/formatters.ts create mode 100644 src/advanced/utils/hooks/useDebounce.ts create mode 100644 src/advanced/utils/hooks/useLocalStorage.ts create mode 100644 src/advanced/utils/hooks/useValidate.ts create mode 100644 src/advanced/utils/validators.ts diff --git a/src/advanced/components/AdminPage.tsx b/src/advanced/components/AdminPage.tsx new file mode 100644 index 00000000..afb5b1ae --- /dev/null +++ b/src/advanced/components/AdminPage.tsx @@ -0,0 +1,20 @@ +// TODO: 관리자 페이지 컴포넌트 +// 힌트: +// 1. 탭 UI로 상품 관리와 쿠폰 관리 분리 +// 2. 상품 추가/수정/삭제 기능 +// 3. 쿠폰 생성 기능 +// 4. 할인 규칙 설정 +// +// 필요한 hooks: +// - useProducts: 상품 CRUD +// - useCoupons: 쿠폰 CRUD +// +// 하위 컴포넌트: +// - ProductForm: 새 상품 추가 폼 +// - ProductAccordion: 상품 정보 표시 및 수정 +// - CouponForm: 새 쿠폰 추가 폼 +// - CouponList: 쿠폰 목록 표시 + +export function AdminPage() { + // TODO: 구현 +} \ No newline at end of file diff --git a/src/advanced/components/CartPage.tsx b/src/advanced/components/CartPage.tsx new file mode 100644 index 00000000..069edafc --- /dev/null +++ b/src/advanced/components/CartPage.tsx @@ -0,0 +1,21 @@ +// TODO: 장바구니 페이지 컴포넌트 +// 힌트: +// 1. 상품 목록 표시 (검색 기능 포함) +// 2. 장바구니 관리 +// 3. 쿠폰 적용 +// 4. 주문 처리 +// +// 필요한 hooks: +// - useProducts: 상품 목록 관리 +// - useCart: 장바구니 상태 관리 +// - useCoupons: 쿠폰 목록 관리 +// - useDebounce: 검색어 디바운싱 +// +// 하위 컴포넌트: +// - SearchBar: 검색 입력 +// - ProductList: 상품 목록 표시 +// - Cart: 장바구니 표시 및 결제 + +export function CartPage() { + // TODO: 구현 +} \ No newline at end of file diff --git a/src/advanced/components/icons/index.tsx b/src/advanced/components/icons/index.tsx new file mode 100644 index 00000000..1609d774 --- /dev/null +++ b/src/advanced/components/icons/index.tsx @@ -0,0 +1,12 @@ +// TODO: SVG 아이콘 컴포넌트들 +// 구현할 아이콘: +// - CartIcon: 장바구니 아이콘 +// - AdminIcon: 관리자 아이콘 +// - PlusIcon: 플러스 아이콘 +// - MinusIcon: 마이너스 아이콘 +// - TrashIcon: 삭제 아이콘 +// - ChevronDownIcon: 아래 화살표 +// - ChevronUpIcon: 위 화살표 +// - CheckIcon: 체크 아이콘 + +// TODO: 구현 \ No newline at end of file diff --git a/src/advanced/components/ui/UIToast.ts b/src/advanced/components/ui/UIToast.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/advanced/constants/index.ts b/src/advanced/constants/index.ts new file mode 100644 index 00000000..bef3834f --- /dev/null +++ b/src/advanced/constants/index.ts @@ -0,0 +1,8 @@ +// TODO: 초기 데이터 상수 +// 정의할 상수들: +// - initialProducts: 초기 상품 목록 (상품1, 상품2, 상품3 + 설명 필드 포함) +// - initialCoupons: 초기 쿠폰 목록 (5000원 할인, 10% 할인) +// +// 참고: origin/App.tsx의 초기 데이터 구조를 참조 + +// TODO: 구현 \ No newline at end of file diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts new file mode 100644 index 00000000..6db309aa --- /dev/null +++ b/src/advanced/hooks/useCart.ts @@ -0,0 +1,29 @@ +// TODO: 장바구니 관리 Hook +// 힌트: +// 1. 장바구니 상태 관리 (localStorage 연동) +// 2. 상품 추가/삭제/수량 변경 +// 3. 쿠폰 적용 +// 4. 총액 계산 +// 5. 재고 확인 +// +// 사용할 모델 함수: +// - cartModel.addItemToCart +// - cartModel.removeItemFromCart +// - cartModel.updateCartItemQuantity +// - cartModel.calculateCartTotal +// - cartModel.getRemainingStock +// +// 반환할 값: +// - cart: 장바구니 아이템 배열 +// - selectedCoupon: 선택된 쿠폰 +// - addToCart: 상품 추가 함수 +// - removeFromCart: 상품 제거 함수 +// - updateQuantity: 수량 변경 함수 +// - applyCoupon: 쿠폰 적용 함수 +// - calculateTotal: 총액 계산 함수 +// - getRemainingStock: 재고 확인 함수 +// - clearCart: 장바구니 비우기 함수 + +export function useCart() { + // TODO: 구현 +} \ No newline at end of file diff --git a/src/advanced/hooks/useCoupons.ts b/src/advanced/hooks/useCoupons.ts new file mode 100644 index 00000000..d2ad82ab --- /dev/null +++ b/src/advanced/hooks/useCoupons.ts @@ -0,0 +1,13 @@ +// TODO: 쿠폰 관리 Hook +// 힌트: +// 1. 쿠폰 목록 상태 관리 (localStorage 연동 고려) +// 2. 쿠폰 추가/삭제 +// +// 반환할 값: +// - coupons: 쿠폰 배열 +// - addCoupon: 새 쿠폰 추가 +// - removeCoupon: 쿠폰 삭제 + +export function useCoupons() { + // TODO: 구현 +} \ No newline at end of file diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts new file mode 100644 index 00000000..f4bef103 --- /dev/null +++ b/src/advanced/hooks/useProducts.ts @@ -0,0 +1,18 @@ +// TODO: 상품 관리 Hook +// 힌트: +// 1. 상품 목록 상태 관리 (localStorage 연동 고려) +// 2. 상품 CRUD 작업 +// 3. 재고 업데이트 +// 4. 할인 규칙 추가/삭제 +// +// 반환할 값: +// - products: 상품 배열 +// - updateProduct: 상품 정보 수정 +// - addProduct: 새 상품 추가 +// - updateProductStock: 재고 수정 +// - addProductDiscount: 할인 규칙 추가 +// - removeProductDiscount: 할인 규칙 삭제 + +export function useProducts() { + // TODO: 구현 +} \ No newline at end of file diff --git a/src/advanced/models/cart.ts b/src/advanced/models/cart.ts new file mode 100644 index 00000000..5c681048 --- /dev/null +++ b/src/advanced/models/cart.ts @@ -0,0 +1,18 @@ +// TODO: 장바구니 비즈니스 로직 (순수 함수) +// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) +// +// 구현할 함수들: +// 1. calculateItemTotal(item): 개별 아이템의 할인 적용 후 총액 계산 +// 2. getMaxApplicableDiscount(item): 적용 가능한 최대 할인율 계산 +// 3. calculateCartTotal(cart, coupon): 장바구니 총액 계산 (할인 전/후, 할인액) +// 4. updateCartItemQuantity(cart, productId, quantity): 수량 변경 +// 5. addItemToCart(cart, product): 상품 추가 +// 6. removeItemFromCart(cart, productId): 상품 제거 +// 7. getRemainingStock(product, cart): 남은 재고 계산 +// +// 원칙: +// - UI와 관련된 로직 없음 +// - 외부 상태에 의존하지 않음 +// - 모든 필요한 데이터는 파라미터로 전달받음 + +// TODO: 구현 \ No newline at end of file diff --git a/src/advanced/models/coupon.ts b/src/advanced/models/coupon.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/advanced/models/discount.ts b/src/advanced/models/discount.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/advanced/models/product.ts b/src/advanced/models/product.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts new file mode 100644 index 00000000..ff157f5c --- /dev/null +++ b/src/advanced/utils/formatters.ts @@ -0,0 +1,7 @@ +// TODO: 포맷팅 유틸리티 함수들 +// 구현할 함수: +// - formatPrice(price: number): string - 가격을 한국 원화 형식으로 포맷 +// - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷 +// - formatPercentage(rate: number): string - 소수를 퍼센트로 변환 (0.1 → 10%) + +// TODO: 구현 \ No newline at end of file diff --git a/src/advanced/utils/hooks/useDebounce.ts b/src/advanced/utils/hooks/useDebounce.ts new file mode 100644 index 00000000..53c8a374 --- /dev/null +++ b/src/advanced/utils/hooks/useDebounce.ts @@ -0,0 +1,11 @@ +// TODO: 디바운스 Hook +// 힌트: +// 1. 값이 변경되어도 지정된 시간 동안 대기 +// 2. 대기 시간 동안 값이 다시 변경되면 타이머 리셋 +// 3. 최종적으로 안정된 값만 반환 +// +// 사용 예시: 검색어 입력 디바운싱 + +export function useDebounce(value: T, delay: number): T { + // TODO: 구현 +} \ No newline at end of file diff --git a/src/advanced/utils/hooks/useLocalStorage.ts b/src/advanced/utils/hooks/useLocalStorage.ts new file mode 100644 index 00000000..5dc72c50 --- /dev/null +++ b/src/advanced/utils/hooks/useLocalStorage.ts @@ -0,0 +1,15 @@ +// TODO: LocalStorage Hook +// 힌트: +// 1. localStorage와 React state 동기화 +// 2. 초기값 로드 시 에러 처리 +// 3. 저장 시 JSON 직렬화/역직렬화 +// 4. 빈 배열이나 undefined는 삭제 +// +// 반환값: [저장된 값, 값 설정 함수] + +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + // TODO: 구현 +} \ No newline at end of file diff --git a/src/advanced/utils/hooks/useValidate.ts b/src/advanced/utils/hooks/useValidate.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/advanced/utils/validators.ts b/src/advanced/utils/validators.ts new file mode 100644 index 00000000..7d2dda44 --- /dev/null +++ b/src/advanced/utils/validators.ts @@ -0,0 +1,8 @@ +// TODO: 검증 유틸리티 함수들 +// 구현할 함수: +// - isValidCouponCode(code: string): boolean - 쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자) +// - isValidStock(stock: number): boolean - 재고 수량 검증 (0 이상) +// - isValidPrice(price: number): boolean - 가격 검증 (양수) +// - extractNumbers(value: string): string - 문자열에서 숫자만 추출 + +// TODO: 구현 \ No newline at end of file From 904bab2844cbcee3f7467fd074a1945b1aebff27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Sun, 3 Aug 2025 22:11:02 +0900 Subject: [PATCH 07/52] =?UTF-8?q?feat(deps):=20jotai=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jotai 패키지를 dependencies에 추가 - pnpm-lock.yaml 파일 업데이트 --- package.json | 1 + pnpm-lock.yaml | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/package.json b/package.json index 79034acb..b078e68b 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.12.5", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85..393c86d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + jotai: + specifier: ^2.12.5 + version: 2.12.5(@types/react@19.1.9)(react@19.1.1) react: specifier: ^19.1.1 version: 19.1.1 @@ -1056,6 +1059,18 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jotai@2.12.5: + resolution: {integrity: sha512-G8m32HW3lSmcz/4mbqx0hgJIQ0ekndKWiYP7kWVKi0p6saLXdSoye+FZiOFyonnd7Q482LCzm8sMDl7Ar1NWDw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2413,6 +2428,11 @@ snapshots: isexe@2.0.0: {} + jotai@2.12.5(@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: {} From aebf99818eac96f39ca107f3aacde1dabdbada22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Mon, 4 Aug 2025 00:00:30 +0900 Subject: [PATCH 08/52] =?UTF-8?q?feat:=20product=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/App.tsx | 1685 ++++++++++++++--------- src/advanced/components/ProductCard.tsx | 97 ++ src/advanced/components/ProductForm.tsx | 224 +++ src/advanced/components/ProductList.tsx | 50 + src/advanced/constants/product.ts | 43 + src/advanced/hooks/useProducts.ts | 72 +- src/advanced/models/productAtoms.ts | 12 + src/advanced/utils/formatters.ts | 45 +- src/advanced/utils/productUtils.ts | 50 + src/types.ts | 4 +- 10 files changed, 1598 insertions(+), 684 deletions(-) create mode 100644 src/advanced/components/ProductCard.tsx create mode 100644 src/advanced/components/ProductForm.tsx create mode 100644 src/advanced/components/ProductList.tsx create mode 100644 src/advanced/constants/product.ts create mode 100644 src/advanced/models/productAtoms.ts create mode 100644 src/advanced/utils/productUtils.ts diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1..90438c5c 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,5 +1,7 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState, useCallback, useEffect } from "react"; +import { CartItem, Coupon, Product } from "../types"; +import { useAtom } from "jotai"; +import { useProducts } from "./hooks/useProducts"; interface ProductWithUI extends Product { description?: string; @@ -9,77 +11,70 @@ 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, - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); + addProduct, + updateProduct, + deleteProduct, + } = useProducts(); const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); + const saved = localStorage.getItem("cart"); if (saved) { try { return JSON.parse(saved); @@ -91,7 +86,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 +101,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 +162,7 @@ const App = () => { const { price } = item.product; const { quantity } = item; const discount = getMaxApplicableDiscount(item); - + return Math.round(price * quantity * (1 - discount)); }; @@ -177,44 +173,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 +225,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 +247,172 @@ 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; + + if (newQuantity > product.stock) { + addNotification( + `재고는 ${product.stock}개까지만 있습니다.`, + "error" + ); + return prevCart; + } - 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.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 handleAddProduct = useCallback( + (newProduct: Omit) => { + addProduct(newProduct); + 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 handleUpdateProduct = useCallback( + (productId: string, updates: Partial) => { + updateProduct(productId, updates); + addNotification("상품이 수정되었습니다.", "success"); + }, + [addNotification] + ); - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); + const handleDeleteProduct = useCallback( + (productId: string) => { + deleteProduct(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') { - updateProduct(editingProduct, productForm); + if (editingProduct && editingProduct !== "new") { + handleUpdateProduct(editingProduct, productForm); setEditingProduct(null); } else { - addProduct({ + handleAddProduct({ ...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 +421,10 @@ const App = () => { e.preventDefault(); addCoupon(couponForm); setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 + name: "", + code: "", + discountType: "amount", + discountValue: 0, }); setShowCouponForm(false); }; @@ -399,8 +435,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 +444,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 +460,38 @@ const App = () => {
{notifications.length > 0 && (
- {notifications.map(notif => ( + {notifications.map((notif) => (
{notif.message} -
@@ -462,17 +520,27 @@ const App = () => { {!isAdmin && (
- - + + {cart.length > 0 && ( @@ -490,27 +558,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 +1159,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 +1401,33 @@ const App = () => { <>

-

쿠폰 할인

+

+ 쿠폰 할인 +

{coupons.length > 0 && ( - @@ -1085,27 +1439,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 +1488,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/advanced/components/ProductCard.tsx b/src/advanced/components/ProductCard.tsx new file mode 100644 index 00000000..547c3b67 --- /dev/null +++ b/src/advanced/components/ProductCard.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { Product } from "../../types"; + +interface ProductCardProps { + product: Product; + remainingStock: number; + onAddToCart: (product: Product) => void; + formatPrice: (price: number, productId?: string) => string; +} + +export const ProductCard: React.FC = ({ + product, + remainingStock, + onAddToCart, + formatPrice, +}) => { + 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}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/advanced/components/ProductForm.tsx b/src/advanced/components/ProductForm.tsx new file mode 100644 index 00000000..59074b48 --- /dev/null +++ b/src/advanced/components/ProductForm.tsx @@ -0,0 +1,224 @@ +import React from "react"; +import { Discount } from "../../types"; + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} + +interface ProductFormProps { + productForm: ProductFormData; + editingProduct: string | null; + onSubmit: (e: React.FormEvent) => void; + onCancel: () => void; + onFormChange: (form: ProductFormData) => void; + onNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const ProductForm: React.FC = ({ + productForm, + editingProduct, + onSubmit, + onCancel, + onFormChange, + onNotification, +}) => { + const updateForm = (updates: Partial) => { + onFormChange({ ...productForm, ...updates }); + }; + + return ( +
+
+

+ {editingProduct === "new" ? "새 상품 추가" : "상품 수정"} +

+
+
+ + updateForm({ 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 + /> +
+
+ + updateForm({ 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)) { + updateForm({ price: value === "" ? 0 : parseInt(value) }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + updateForm({ price: 0 }); + } else if (parseInt(value) < 0) { + onNotification("가격은 0보다 커야 합니다", "error"); + updateForm({ 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)) { + updateForm({ stock: value === "" ? 0 : parseInt(value) }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + updateForm({ stock: 0 }); + } else if (parseInt(value) < 0) { + onNotification("재고는 0보다 커야 합니다", "error"); + updateForm({ stock: 0 }); + } else if (parseInt(value) > 9999) { + onNotification("재고는 9999개를 초과할 수 없습니다", "error"); + updateForm({ 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; + updateForm({ 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; + updateForm({ discounts: newDiscounts }); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/advanced/components/ProductList.tsx b/src/advanced/components/ProductList.tsx new file mode 100644 index 00000000..086d2ccf --- /dev/null +++ b/src/advanced/components/ProductList.tsx @@ -0,0 +1,50 @@ +import React from "react"; + +import { ProductCard } from "./ProductCard"; +import { Product } from "../../types"; + +interface ProductListProps { + products: Product[]; + filteredProducts: Product[]; + debouncedSearchTerm: string; + onAddToCart: (product: Product) => void; + formatPrice: (price: number, productId?: string) => string; + getRemainingStock: (product: Product) => number; +} + +export const ProductList: React.FC = ({ + products, + filteredProducts, + debouncedSearchTerm, + onAddToCart, + formatPrice, + getRemainingStock, +}) => { + return ( +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/advanced/constants/product.ts b/src/advanced/constants/product.ts new file mode 100644 index 00000000..c9700aa3 --- /dev/null +++ b/src/advanced/constants/product.ts @@ -0,0 +1,43 @@ +import { Product } from "../../types"; + +export const initialProducts: Product[] = [ + { + 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 PRODUCT_FORM_INITIAL_STATE = { + name: "", + price: 0, + stock: 0, + description: "", + isRecommended: false, +}; diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts index f4bef103..66e670e0 100644 --- a/src/advanced/hooks/useProducts.ts +++ b/src/advanced/hooks/useProducts.ts @@ -1,18 +1,54 @@ -// TODO: 상품 관리 Hook -// 힌트: -// 1. 상품 목록 상태 관리 (localStorage 연동 고려) -// 2. 상품 CRUD 작업 -// 3. 재고 업데이트 -// 4. 할인 규칙 추가/삭제 -// -// 반환할 값: -// - products: 상품 배열 -// - updateProduct: 상품 정보 수정 -// - addProduct: 새 상품 추가 -// - updateProductStock: 재고 수정 -// - addProductDiscount: 할인 규칙 추가 -// - removeProductDiscount: 할인 규칙 삭제 - -export function useProducts() { - // TODO: 구현 -} \ No newline at end of file +import { useAtom } from "jotai"; +import { useCallback } from "react"; +import { productsAtom } from "../models/productAtoms"; + +import { Product } from "../../types"; +import { generateProductId, validateProductData } from "../utils/productUtils"; + +export const useProducts = () => { + const [products, setProducts] = useAtom(productsAtom); + + // 상품 추가 + const addProduct = useCallback((productData: Omit) => { + if (!validateProductData(productData)) { + throw new Error("Invalid product data"); + } + + const newProduct: Product = { + ...productData, + id: generateProductId(), + }; + + setProducts((prev: Product[]) => [...prev, newProduct]); + return newProduct; + }, []); + + // 상품 수정 + const updateProduct = useCallback((id: string, updates: Partial) => { + setProducts((prev: Product[]) => + prev.map((product: Product) => + product.id === id ? { ...product, ...updates } : product + ) + ); + }, []); + + const deleteProduct = useCallback((productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + }, []); + + // 상품 삭제 + const removeProduct = useCallback((id: string) => { + setProducts((prev: Product[]) => + prev.filter((product: Product) => product.id !== id) + ); + }, []); + + return { + products, + + addProduct, + updateProduct, + deleteProduct, + removeProduct, + }; +}; diff --git a/src/advanced/models/productAtoms.ts b/src/advanced/models/productAtoms.ts new file mode 100644 index 00000000..98b4f8c0 --- /dev/null +++ b/src/advanced/models/productAtoms.ts @@ -0,0 +1,12 @@ +import { atom } from "jotai"; +import { initialProducts } from "../constants/product"; +import { Product } from "../../types"; +import { atomWithStorage, createJSONStorage } from "jotai/utils"; + +// 기본 atoms +export const productsAtom = atomWithStorage( + "products", + initialProducts, + createJSONStorage(), + { getOnInit: true } +); diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts index ff157f5c..3b3342d6 100644 --- a/src/advanced/utils/formatters.ts +++ b/src/advanced/utils/formatters.ts @@ -1,7 +1,40 @@ -// TODO: 포맷팅 유틸리티 함수들 -// 구현할 함수: -// - formatPrice(price: number): string - 가격을 한국 원화 형식으로 포맷 -// - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷 -// - formatPercentage(rate: number): string - 소수를 퍼센트로 변환 (0.1 → 10%) +import { CartItem, Product } from "../../types"; -// TODO: 구현 \ No newline at end of file +/** + * 가격 포맷팅 (관리자 모드 지원) + */ +export const formatPrice = ( + price: number, + isAdmin: boolean = false, + product?: Product, + cart?: CartItem[] +): string => { + if (product && cart) { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + if (remaining <= 0) { + return "품절"; + } + } + + if (isAdmin) { + return `${price.toLocaleString()}원`; + } + + return `${price.toLocaleString()}원`; +}; + +/** + * 날짜를 YYYY-MM-DD 형식으로 포맷 + */ +export const formatDate = (date: Date): string => { + return date.toISOString().split("T")[0]; +}; + +/** + * 소수를 퍼센트로 변환 (0.1 → 10%) + */ +export const formatPercentage = (rate: number): string => { + return `${(rate * 100).toFixed(0)}%`; +}; diff --git a/src/advanced/utils/productUtils.ts b/src/advanced/utils/productUtils.ts new file mode 100644 index 00000000..65be485a --- /dev/null +++ b/src/advanced/utils/productUtils.ts @@ -0,0 +1,50 @@ +import { CartItem, Product } from "../../types"; + +/** + * 상품 검색 필터링 + */ +export const filterProducts = ( + products: Product[], + searchTerm: string +): Product[] => { + if (!searchTerm) return products; + + return products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(searchTerm.toLowerCase())) + ); +}; + +/** + * 상품 ID 생성 + */ +export const generateProductId = (): string => { + return "p" + Date.now(); +}; + +/** + * 상품 데이터 유효성 검증 + */ +export const validateProductData = (product: Partial): boolean => { + return !!( + product.name && + product.name.trim() && + product.price && + product.price > 0 && + product.stock !== undefined && + product.stock >= 0 + ); +}; + +/** + * 남은 재고 계산 + */ +export const getRemainingStock = ( + product: Product, + cart: CartItem[] +): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); +}; diff --git a/src/types.ts b/src/types.ts index 5489e296..c4071b5f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,8 @@ export interface Product { price: number; stock: number; discounts: Discount[]; + description?: string; + isRecommended?: boolean; } export interface Discount { @@ -19,6 +21,6 @@ export interface CartItem { export interface Coupon { name: string; code: string; - discountType: 'amount' | 'percentage'; + discountType: "amount" | "percentage"; discountValue: number; } From 3cab7c1bfb53b5f2ee8594e9119ec15b8c78e0b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Mon, 4 Aug 2025 00:08:08 +0900 Subject: [PATCH 09/52] =?UTF-8?q?refactor(App):=20=EC=A0=9C=ED=92=88=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=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 - ProductWithUI 인터페이스 제거 및 Product로 변경 - 초기 제품 데이터 삭제 - addToCart, handleAddProduct, handleUpdateProduct, handleDeleteProduct 함수의 매개변수 타입 수정 --- src/advanced/App.tsx | 58 ++++++-------------------------------------- 1 file changed, 7 insertions(+), 51 deletions(-) diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index 90438c5c..c2c3bf35 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -3,52 +3,12 @@ import { CartItem, Coupon, Product } from "../types"; import { useAtom } from "jotai"; import { useProducts } from "./hooks/useProducts"; -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원 할인", @@ -224,10 +184,6 @@ const App = () => { setTotalItemCount(count); }, [cart]); - useEffect(() => { - localStorage.setItem("products", JSON.stringify(products)); - }, [products]); - useEffect(() => { localStorage.setItem("coupons", JSON.stringify(coupons)); }, [coupons]); @@ -248,7 +204,7 @@ const App = () => { }, [searchTerm]); const addToCart = useCallback( - (product: ProductWithUI) => { + (product: Product) => { const remainingStock = getRemainingStock(product); if (remainingStock <= 0) { addNotification("재고가 부족합니다!", "error"); @@ -348,19 +304,19 @@ const App = () => { }, [addNotification]); const handleAddProduct = useCallback( - (newProduct: Omit) => { + (newProduct: Omit) => { addProduct(newProduct); addNotification("상품이 추가되었습니다.", "success"); }, - [addNotification] + [addProduct, addNotification] ); const handleUpdateProduct = useCallback( - (productId: string, updates: Partial) => { + (productId: string, updates: Partial) => { updateProduct(productId, updates); addNotification("상품이 수정되었습니다.", "success"); }, - [addNotification] + [updateProduct, addNotification] ); const handleDeleteProduct = useCallback( @@ -368,7 +324,7 @@ const App = () => { deleteProduct(productId); addNotification("상품이 삭제되었습니다.", "success"); }, - [addNotification] + [deleteProduct, addNotification] ); const addCoupon = useCallback( @@ -429,7 +385,7 @@ const App = () => { setShowCouponForm(false); }; - const startEditProduct = (product: ProductWithUI) => { + const startEditProduct = (product: Product) => { setEditingProduct(product.id); setProductForm({ name: product.name, From 581282bb6869e88b0ead4bab47da4a4ff133b417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Mon, 4 Aug 2025 00:35:52 +0900 Subject: [PATCH 10/52] =?UTF-8?q?refactor(App):=20=EA=B0=80=EA=B2=A9=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EB=B0=8F=20=EC=9E=AC=EA=B3=A0=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EB=A1=9C=EC=A7=81=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 - formatPrice 함수의 매개변수 변경 및 불필요한 코드 제거 - getRemainingStock 함수의 인자 추가로 재고 확인 로직 개선 --- src/advanced/App.tsx | 38 ++++++++++---------------------- src/advanced/utils/formatters.ts | 25 ++++----------------- 2 files changed, 16 insertions(+), 47 deletions(-) diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index c2c3bf35..8e6af94f 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -2,6 +2,8 @@ import { useState, useCallback, useEffect } from "react"; import { CartItem, Coupon, Product } from "../types"; import { useAtom } from "jotai"; import { useProducts } from "./hooks/useProducts"; +import { formatPrice } from "./utils/formatters"; +import { getRemainingStock } from "./utils/productUtils"; interface Notification { id: string; @@ -85,21 +87,6 @@ const App = () => { 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; @@ -158,13 +145,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; - }; - const addNotification = useCallback( (message: string, type: "error" | "success" | "warning" = "success") => { const id = Date.now().toString(); @@ -205,7 +185,7 @@ const App = () => { const addToCart = useCallback( (product: Product) => { - const remainingStock = getRemainingStock(product); + const remainingStock = getRemainingStock(product, cart); if (remainingStock <= 0) { addNotification("재고가 부족합니다!", "error"); return; @@ -599,7 +579,10 @@ const App = () => { {product.name} - {formatPrice(product.price, product.id)} + {`${formatPrice( + product.price, + !getRemainingStock(product, cart) + )}원`} { ) : (
{filteredProducts.map((product) => { - const remainingStock = getRemainingStock(product); + const remainingStock = getRemainingStock(product, cart); return (
{ {/* 가격 정보 */}

- {formatPrice(product.price, product.id)} + {`₩${formatPrice( + product.price, + !getRemainingStock(product, cart) + )}`}

{product.discounts.length > 0 && (

diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts index 3b3342d6..bfd5d676 100644 --- a/src/advanced/utils/formatters.ts +++ b/src/advanced/utils/formatters.ts @@ -1,28 +1,11 @@ import { CartItem, Product } from "../../types"; -/** - * 가격 포맷팅 (관리자 모드 지원) - */ -export const formatPrice = ( - price: number, - isAdmin: boolean = false, - product?: Product, - cart?: CartItem[] -): string => { - if (product && cart) { - const cartItem = cart.find((item) => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - if (remaining <= 0) { - return "품절"; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; +export const formatPrice = (price: number, isSoldOut: boolean): string => { + if (isSoldOut) { + return "SOLD OUT"; } - return `${price.toLocaleString()}원`; + return price.toLocaleString(); }; /** From c26d17fed32f9b309895b3a9b39f5ea999f0e4bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Mon, 4 Aug 2025 22:46:14 +0900 Subject: [PATCH 11/52] =?UTF-8?q?feat(hooks):=20=EB=94=94=EB=B0=94?= =?UTF-8?q?=EC=9A=B4=EC=8A=A4=20=EB=B0=8F=20=EA=B2=80=EC=83=89=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useDebouncedCallback 훅 구현: 지정된 시간만큼 콜백 호출 지연 - useSearchParams 훅 추가: URL의 search parameters를 관리 - useFilterSearchParams 훅 추가: searchParams를 필터링하여 반환하는 기능 구현 --- src/advanced/utils/hooks/useDebounce.ts | 47 ++++++++++++++++----- src/hooks/useFilterSearchParams.ts | 52 ++++++++++++++++++++++++ src/hooks/useSearchParams.ts | 54 +++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 src/hooks/useFilterSearchParams.ts create mode 100644 src/hooks/useSearchParams.ts diff --git a/src/advanced/utils/hooks/useDebounce.ts b/src/advanced/utils/hooks/useDebounce.ts index 53c8a374..14e641cc 100644 --- a/src/advanced/utils/hooks/useDebounce.ts +++ b/src/advanced/utils/hooks/useDebounce.ts @@ -1,11 +1,36 @@ -// TODO: 디바운스 Hook -// 힌트: -// 1. 값이 변경되어도 지정된 시간 동안 대기 -// 2. 대기 시간 동안 값이 다시 변경되면 타이머 리셋 -// 3. 최종적으로 안정된 값만 반환 -// -// 사용 예시: 검색어 입력 디바운싱 - -export function useDebounce(value: T, delay: number): T { - // TODO: 구현 -} \ No newline at end of file +import { useState, useEffect, useCallback, useRef } from "react"; + +/** + * 디바운스된 콜백 Hook + * 함수 호출을 지정된 시간만큼 지연시킵니다 + */ +export function useDebouncedCallback any>( + callback: T, + delay: number +): T { + const timeoutRef = useRef(null); + + const debouncedCallback = useCallback( + (...args: Parameters) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + callback(...args); + }, delay); + }, + [callback, delay] + ); + + // 컴포넌트 언마운트 시 타이머 정리 + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return debouncedCallback as T; +} diff --git a/src/hooks/useFilterSearchParams.ts b/src/hooks/useFilterSearchParams.ts new file mode 100644 index 00000000..11fae1b6 --- /dev/null +++ b/src/hooks/useFilterSearchParams.ts @@ -0,0 +1,52 @@ +import React from "react"; +import { z } from "zod"; +import { useSearchParams } from "./useSearchParams"; + +const filterSearchParamsSchema = z.object({ + searchTerm: z.string().optional(), +}); + +type filterSearchParamsSchemaType = z.infer; + +/** + * searchParams를 Order requestType에 맞는 타입으로 변환해서 return + */ +export default function useFilterSearchParams() { + const [searchParams, setSearchParams] = useSearchParams(); + + const filterSearchParams: filterSearchParamsSchemaType = React.useMemo(() => { + // URLSearchParams를 일반 객체로 변환 + const paramsObject = Object.fromEntries(searchParams.entries()); + const validSearchParams = filterSearchParamsSchema.safeParse(paramsObject); + + if (!validSearchParams.success) { + return { + searchTerm: "", + }; + } + + return { + ...validSearchParams.data, + }; + }, [searchParams]); + + const setFilterSearchParams = ( + value: Partial + ) => { + const newSearchParams = { + ...filterSearchParams, + ...value, + }; + + // 빈 값들은 제거하고 URL에 설정 + const filteredParams = Object.fromEntries( + Object.entries(newSearchParams).filter( + ([_, v]) => v !== "" && v !== undefined + ) + ); + + setSearchParams(filteredParams); + }; + + return { filterSearchParams, setFilterSearchParams }; +} diff --git a/src/hooks/useSearchParams.ts b/src/hooks/useSearchParams.ts new file mode 100644 index 00000000..7544656e --- /dev/null +++ b/src/hooks/useSearchParams.ts @@ -0,0 +1,54 @@ +import { useState, useEffect, useCallback } from "react"; + +/** + * useState와 useEffect를 활용한 간단한 useSearchParams 훅 + * URL의 search parameters를 구독하고 업데이트할 수 있습니다. + */ +export function useSearchParams(): [ + URLSearchParams, + (params: URLSearchParams | Record) => void +] { + const [searchParams, setSearchParamsState] = useState(() => { + if (typeof window === "undefined") { + return new URLSearchParams(); + } + return new URLSearchParams(window.location.search); + }); + + // URL 변경 감지 + useEffect(() => { + if (typeof window === "undefined") return; + + const handlePopState = () => { + setSearchParamsState(new URLSearchParams(window.location.search)); + }; + + window.addEventListener("popstate", handlePopState); + + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, []); + + // searchParams 업데이트 함수 + const setSearchParams = useCallback( + (params: URLSearchParams | Record) => { + if (typeof window === "undefined") return; + + const newSearchParams = + params instanceof URLSearchParams + ? params + : new URLSearchParams(params); + + const newUrl = `${window.location.pathname}${ + newSearchParams.toString() ? `?${newSearchParams.toString()}` : "" + }${window.location.hash}`; + + history.replaceState(null, "", newUrl); + setSearchParamsState(newSearchParams); + }, + [] + ); + + return [searchParams, setSearchParams]; +} From 2918ed1a9682b51da4578925dbf0e399bb233cbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Mon, 4 Aug 2025 23:00:25 +0900 Subject: [PATCH 12/52] =?UTF-8?q?refactor:=20basic=20=EA=B3=BC=EC=A0=9C=20?= =?UTF-8?q?advanced=20=EA=B3=BC=EC=A0=9C=20=EB=82=B4=EC=9A=A9=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 132 ++++++++++--- package.json | 3 +- pnpm-lock.yaml | 8 + src/basic/App.tsx | 182 ++++++------------ src/basic/components/AdminPage.tsx | 20 ++ src/basic/components/CartPage.tsx | 21 +++ src/basic/components/ProductCard.tsx | 97 ++++++++++ src/basic/components/ProductForm.tsx | 224 +++++++++++++++++++++++ src/basic/components/ProductList.tsx | 50 +++++ src/basic/components/icons/index.tsx | 12 ++ src/basic/components/ui/UIToast.ts | 0 src/basic/constants/index.ts | 8 + src/basic/constants/product.ts | 43 +++++ src/basic/hooks/useCart.ts | 29 +++ src/basic/hooks/useCoupons.ts | 13 ++ src/basic/hooks/useProducts.ts | 79 ++++++++ src/basic/main.tsx | 12 +- src/basic/models/cart.ts | 18 ++ src/basic/models/coupon.ts | 0 src/basic/models/discount.ts | 0 src/basic/models/product.ts | 0 src/basic/utils/formatters.ts | 23 +++ src/basic/utils/hooks/useDebounce.ts | 36 ++++ src/basic/utils/hooks/useLocalStorage.ts | 15 ++ src/basic/utils/hooks/useValidate.ts | 0 src/basic/utils/productUtils.ts | 50 +++++ src/basic/utils/validators.ts | 8 + 27 files changed, 927 insertions(+), 156 deletions(-) create mode 100644 src/basic/components/AdminPage.tsx create mode 100644 src/basic/components/CartPage.tsx create mode 100644 src/basic/components/ProductCard.tsx create mode 100644 src/basic/components/ProductForm.tsx create mode 100644 src/basic/components/ProductList.tsx create mode 100644 src/basic/components/icons/index.tsx create mode 100644 src/basic/components/ui/UIToast.ts create mode 100644 src/basic/constants/index.ts create mode 100644 src/basic/constants/product.ts create mode 100644 src/basic/hooks/useCart.ts create mode 100644 src/basic/hooks/useCoupons.ts create mode 100644 src/basic/hooks/useProducts.ts create mode 100644 src/basic/models/cart.ts create mode 100644 src/basic/models/coupon.ts create mode 100644 src/basic/models/discount.ts create mode 100644 src/basic/models/product.ts create mode 100644 src/basic/utils/formatters.ts create mode 100644 src/basic/utils/hooks/useDebounce.ts create mode 100644 src/basic/utils/hooks/useLocalStorage.ts create mode 100644 src/basic/utils/hooks/useValidate.ts create mode 100644 src/basic/utils/productUtils.ts create mode 100644 src/basic/utils/validators.ts diff --git a/README.md b/README.md index 3198c545..8c8d22e2 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,15 @@ 이번 과제는 단일책임원칙을 위반한 거대한 컴포넌트를 리팩토링 하는 것입니다. React의 컴포넌트는 단일 책임 원칙(Single Responsibility Principle, SRP)을 따르는 것이 좋습니다. 즉, 각 컴포넌트는 하나의 책임만을 가져야 합니다. 하지만 실제로는 여러 가지 기능을 가진 거대한 컴포넌트를 작성하는 경우가 많습니다. [목표] + ## 1. 취지 + - React의 추구미(!)를 이해해보아요! - 단일 책임 원칙(SRP)을 위반한 거대한 컴포넌트가 얼마나 안 좋은지 경험해보아요! - 단일 책임이라는 개념을 이해하기 상태, 순수함수, 컴포넌트, 훅 등 다양한 계층을 이해해합니다. - 엔티티와 UI를 구분하고 데이터, 상태, 비즈니스 로직 등의 특징이 다르다는 것을 이해해보세요. - 이를 통해 적절한 Custom Hook과 유틸리티 함수를 분리하고, 컴포넌트 계층 구조를 정리하는 능력을 갖춥니다! - ## 2. 목표 모든 소프트웨어에는 적절한 책임과 계층이 존재합니다. 하나의 계층(Component)만으로 소프트웨어를 구성하게 되면 나중에는 정리정돈이 되지 않은 코드를 만나게 됩니다. 예전에는 이러한 BestPractice에 대해서 혼돈의 시대였지만 FE가 진화를 거듭하는 과정에서 적절한 계측에 대한 합의가 이루어지고 있는 상태입니다. @@ -22,7 +23,7 @@ React의 주요 책임 계층은 Component, hook, function 등이 있습니다. - 엔티티를 다루는 상태와 그렇지 않은 상태 - cart, isCartFull vs isShowPopup - 엔티티를 다루는 컴포넌트와 훅 - CartItemView, useCart(), useProduct() - 엔티티를 다루지 않는 컴포넌트와 훅 - Button, useRoute, useEvent 등 -- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) +- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) 이번 과제의 목표는 이러한 계층을 이해하고 분리하여 정리정돈을 하는 기준이나 방법등을 습득하는데 있습니다. @@ -35,34 +36,34 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. #### 1) 장바구니 페이지 요구사항 - 상품 목록 - - 상품명, 가격, 재고 수량 등을 표시 - - 각 상품의 할인 정보 표시 - - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 + - 상품명, 가격, 재고 수량 등을 표시 + - 각 상품의 할인 정보 표시 + - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 - 장바구니 - - 장바구니 내 상품 수량 조절 가능 - - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 - - 적용된 할인율 표시 (예: "10% 할인 적용") - - 장바구니 내 모든 상품의 총액을 계산해야 + - 장바구니 내 상품 수량 조절 가능 + - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 + - 적용된 할인율 표시 (예: "10% 할인 적용") + - 장바구니 내 모든 상품의 총액을 계산해야 - 쿠폰 할인 - - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 + - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 - 주문요약 - - 할인 전 총 금액 - - 총 할인 금액 - - 최종 결제 금액 + - 할인 전 총 금액 + - 총 할인 금액 + - 최종 결제 금액 #### 2) 관리자 페이지 요구사항 - 상품 관리 - - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 - - 새로운 상품 추가 가능 - - 상품 제거 가능 + - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 + - 새로운 상품 추가 가능 + - 상품 제거 가능 - 할인 관리 - - 상품별 할인 정보 추가/수정/삭제 가능 - - 할인 조건 설정 (구매 수량에 따른 할인율) + - 상품별 할인 정보 추가/수정/삭제 가능 + - 할인 조건 설정 (구매 수량에 따른 할인율) - 쿠폰 관리 - - 전체 상품에 적용 가능한 쿠폰 생성 - - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) - - 할인 유형은 금액 또는 비율로 설정 가능 + - 전체 상품에 적용 가능한 쿠폰 생성 + - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) + - 할인 유형은 금액 또는 비율로 설정 가능 ### (2) 코드 개선 요구사항 @@ -88,9 +89,8 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. ### (3) 테스트 코드 통과하기 - - ## 심화과제: Props drilling + - 이번 심화과제는 Context나 Jotai를 사용해서 Props drilling을 없애는 것입니다. - 어떤 props는 남겨야 하는지, 어떤 props는 제거해야 하는지에 대한 기준을 세워보세요. - Context나 Jotai를 사용하여 상태를 관리하는 방법을 익히고, 이를 통해 컴포넌트 간의 데이터 전달을 효율적으로 처리할 수 있습니다. @@ -102,9 +102,8 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. - basic에서 열심히 컴포넌트를 분리해주었겠죠? - 아마 그 과정에서 container - presenter 패턴으로 만들어졌기에 props drilling이 상당히 불편했을거에요. - 그래서 심화과제에서는 props drilling을 제거하는 작업을 할거에요. - - 전역상태관리가 아직 낯설다 - jotai를 선택해주세요 (참고자료 참고) - - 나는 깊이를 공부해보고 싶아. - context를 선택해서 상태관리를 해보세요. - + - 전역상태관리가 아직 낯설다 - jotai를 선택해주세요 (참고자료 참고) + - 나는 깊이를 공부해보고 싶아. - context를 선택해서 상태관리를 해보세요. ### (1) 요구사항 @@ -112,15 +111,92 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. - Context나 Jotai를 사용하여 상태를 관리합니다. - 테스트 코드를 통과합니다. - ### (2) 힌트 - UI 컴포넌트와 엔티티 컴포넌트는 각각 props를 다르게 받는게 좋습니다. - - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, + - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, - 엔티티 컴포넌트는 가급적 엔티티를 중심으로 전달받는 것이 좋습니다. - 특히 콜백의 경우, - UI 컴포넌트는 이벤트 핸들러를 props로 받아서 처리하도록 해서 재사용성을 높이지만, - 엔티티 컴포넌트는 props가 아닌 컴포넌트 내부에서 상태를 관리하는 것이 좋습니다. +### (3) 추가된 Custom Hook + +#### useSearchParamsStore + +Jotai atom을 사용하여 URL search parameters를 관리하는 훅입니다. + +**주요 기능:** + +- `getParam(key)`: 특정 파라미터 값 가져오기 +- `setParam(key, value)`: 단일 파라미터 설정 +- `setParams(object)`: 여러 파라미터 한번에 설정 +- `clearParams()`: 모든 파라미터 제거 +- `searchParams`: 현재 모든 파라미터 객체 + +**사용 예시:** + +```tsx +const { searchParams, getParam, setParam, setParams, clearParams } = + useSearchParamsStore(); + +// 단일 파라미터 설정 +setParam("category", "electronics"); + +// 여러 파라미터 설정 +setParams({ + category: "clothing", + brand: "nike", + price: "100-500", +}); + +// 파라미터 값 가져오기 +const category = getParam("category"); +``` + +#### useSearchParams (직접 구현) + +React Router의 `useSearchParams`를 직접 구현한 커스텀 훅입니다. + +**주요 기능:** + +- `get(key)`: 특정 파라미터 값 가져오기 +- `set(key, value)`: 단일 파라미터 설정 +- `delete(key)`: 특정 파라미터 삭제 +- `clear()`: 모든 파라미터 제거 +- `has(key)`: 파라미터 존재 여부 확인 +- `keys()`: 모든 파라미터 키 배열 +- `values()`: 모든 파라미터 값 배열 +- `size()`: 파라미터 개수 +- `toString()`: URL 쿼리 문자열로 변환 +- `forEach(callback)`: 모든 파라미터 순회 + +**사용 예시:** + +```tsx +const [searchParams, setSearchParams] = useSearchParams(); + +// 단일 파라미터 설정 +searchParams.set("category", "electronics"); + +// 여러 파라미터 설정 +setSearchParams({ + category: "clothing", + brand: "nike", + price: "100-500", +}); + +// 파라미터 값 가져오기 +const category = searchParams.get("category"); +// 파라미터 삭제 +searchParams.delete("category"); +// 히스토리 교체 (replace: true) +setSearchParams( + { + newParam: "value", + }, + { replace: true } +); +``` diff --git a/package.json b/package.json index b078e68b..a9fc8e2c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "dependencies": { "jotai": "^2.12.5", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "zod": "^4.0.14" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 393c86d0..b4f584d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: react-dom: specifier: ^19.1.1 version: 19.1.1(react@19.1.1) + zod: + specifier: ^4.0.14 + version: 4.0.14 devDependencies: '@testing-library/jest-dom': specifier: ^6.6.4 @@ -1530,6 +1533,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.0.14: + resolution: {integrity: sha512-nGFJTnJN6cM2v9kXL+SOBq3AtjQby3Mv5ySGFof5UGRHrRioSJ5iG680cYNjE/yWk671nROcpPj4hAS8nyLhSw==} + snapshots: '@adobe/css-tools@4.4.0': {} @@ -2849,3 +2855,5 @@ snapshots: xmlchars@2.2.0: {} yocto-queue@0.1.0: {} + + zod@4.0.14: {} diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 4ef0350e..c84db5df 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,10 +1,11 @@ import { useState, useCallback, useEffect } from "react"; import { CartItem, Coupon, Product } from "../types"; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} +import { useProducts } from "./hooks/useProducts"; +import { formatPrice } from "./utils/formatters"; +import { getRemainingStock } from "./utils/productUtils"; +import { useDebouncedCallback } from "./utils/hooks/useDebounce"; +import useFilterSearchParams from "../hooks/useFilterSearchParams"; interface Notification { id: string; @@ -12,41 +13,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원 할인", @@ -63,17 +29,13 @@ const initialCoupons: Coupon[] = [ ]; 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"); @@ -107,8 +69,22 @@ const App = () => { "products" ); const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + const { filterSearchParams, setFilterSearchParams } = useFilterSearchParams(); + + const handleSearchChange = (e: React.ChangeEvent) => { + const value = e.target.value; + + if (!value) { + setFilterSearchParams({ searchTerm: "" }); + return; + } + + debouncedSetSearchTerm(value); + }; + + const debouncedSetSearchTerm = useDebouncedCallback((value: string) => { + setFilterSearchParams({ searchTerm: value }); + }, 500); // Admin const [editingProduct, setEditingProduct] = useState(null); @@ -127,21 +103,6 @@ const App = () => { 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; @@ -200,13 +161,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; - }; - const addNotification = useCallback( (message: string, type: "error" | "success" | "warning" = "success") => { const id = Date.now().toString(); @@ -226,10 +180,6 @@ const App = () => { setTotalItemCount(count); }, [cart]); - useEffect(() => { - localStorage.setItem("products", JSON.stringify(products)); - }, [products]); - useEffect(() => { localStorage.setItem("coupons", JSON.stringify(coupons)); }, [coupons]); @@ -242,16 +192,9 @@ const App = () => { } }, [cart]); - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - const addToCart = useCallback( - (product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); + (product: Product) => { + const remainingStock = getRemainingStock(product, cart); if (remainingStock <= 0) { addNotification("재고가 부족합니다!", "error"); return; @@ -349,36 +292,28 @@ const App = () => { setSelectedCoupon(null); }, [addNotification]); - const addProduct = useCallback( - (newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}`, - }; - setProducts((prev) => [...prev, product]); + const handleAddProduct = useCallback( + (newProduct: Omit) => { + addProduct(newProduct); addNotification("상품이 추가되었습니다.", "success"); }, - [addNotification] + [addProduct, addNotification] ); - const updateProduct = useCallback( - (productId: string, updates: Partial) => { - setProducts((prev) => - prev.map((product) => - product.id === productId ? { ...product, ...updates } : product - ) - ); + const handleUpdateProduct = useCallback( + (productId: string, updates: Partial) => { + updateProduct(productId, updates); addNotification("상품이 수정되었습니다.", "success"); }, - [addNotification] + [updateProduct, addNotification] ); - const deleteProduct = useCallback( + const handleDeleteProduct = useCallback( (productId: string) => { - setProducts((prev) => prev.filter((p) => p.id !== productId)); + deleteProduct(productId); addNotification("상품이 삭제되었습니다.", "success"); }, - [addNotification] + [deleteProduct, addNotification] ); const addCoupon = useCallback( @@ -408,10 +343,10 @@ const App = () => { const handleProductSubmit = (e: React.FormEvent) => { e.preventDefault(); if (editingProduct && editingProduct !== "new") { - updateProduct(editingProduct, productForm); + handleUpdateProduct(editingProduct, productForm); setEditingProduct(null); } else { - addProduct({ + handleAddProduct({ ...productForm, discounts: productForm.discounts, }); @@ -439,7 +374,7 @@ const App = () => { setShowCouponForm(false); }; - const startEditProduct = (product: ProductWithUI) => { + const startEditProduct = (product: Product) => { setEditingProduct(product.id); setProductForm({ name: product.name, @@ -453,16 +388,15 @@ const App = () => { const totals = calculateCartTotal(); - const filteredProducts = debouncedSearchTerm + const searchTerm = filterSearchParams.searchTerm ?? ""; + const filteredProducts = searchTerm ? products.filter( (product) => - product.name - .toLowerCase() - .includes(debouncedSearchTerm.toLowerCase()) || + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || (product.description && product.description .toLowerCase() - .includes(debouncedSearchTerm.toLowerCase())) + .includes(searchTerm.toLowerCase())) ) : products; @@ -518,8 +452,8 @@ const App = () => {

setSearchTerm(e.target.value)} + defaultValue={searchTerm} + onChange={handleSearchChange} placeholder="상품 검색..." className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" /> @@ -653,7 +587,10 @@ const App = () => { {product.name} - {formatPrice(product.price, product.id)} + {`${formatPrice( + product.price, + !getRemainingStock(product, cart) + )}원`} { 수정 +
+
+ ); +}; diff --git a/src/basic/components/ProductForm.tsx b/src/basic/components/ProductForm.tsx new file mode 100644 index 00000000..59074b48 --- /dev/null +++ b/src/basic/components/ProductForm.tsx @@ -0,0 +1,224 @@ +import React from "react"; +import { Discount } from "../../types"; + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} + +interface ProductFormProps { + productForm: ProductFormData; + editingProduct: string | null; + onSubmit: (e: React.FormEvent) => void; + onCancel: () => void; + onFormChange: (form: ProductFormData) => void; + onNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const ProductForm: React.FC = ({ + productForm, + editingProduct, + onSubmit, + onCancel, + onFormChange, + onNotification, +}) => { + const updateForm = (updates: Partial) => { + onFormChange({ ...productForm, ...updates }); + }; + + return ( +
+
+

+ {editingProduct === "new" ? "새 상품 추가" : "상품 수정"} +

+
+
+ + updateForm({ 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 + /> +
+
+ + updateForm({ 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)) { + updateForm({ price: value === "" ? 0 : parseInt(value) }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + updateForm({ price: 0 }); + } else if (parseInt(value) < 0) { + onNotification("가격은 0보다 커야 합니다", "error"); + updateForm({ 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)) { + updateForm({ stock: value === "" ? 0 : parseInt(value) }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + updateForm({ stock: 0 }); + } else if (parseInt(value) < 0) { + onNotification("재고는 0보다 커야 합니다", "error"); + updateForm({ stock: 0 }); + } else if (parseInt(value) > 9999) { + onNotification("재고는 9999개를 초과할 수 없습니다", "error"); + updateForm({ 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; + updateForm({ 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; + updateForm({ discounts: newDiscounts }); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/basic/components/ProductList.tsx b/src/basic/components/ProductList.tsx new file mode 100644 index 00000000..086d2ccf --- /dev/null +++ b/src/basic/components/ProductList.tsx @@ -0,0 +1,50 @@ +import React from "react"; + +import { ProductCard } from "./ProductCard"; +import { Product } from "../../types"; + +interface ProductListProps { + products: Product[]; + filteredProducts: Product[]; + debouncedSearchTerm: string; + onAddToCart: (product: Product) => void; + formatPrice: (price: number, productId?: string) => string; + getRemainingStock: (product: Product) => number; +} + +export const ProductList: React.FC = ({ + products, + filteredProducts, + debouncedSearchTerm, + onAddToCart, + formatPrice, + getRemainingStock, +}) => { + return ( +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/basic/components/icons/index.tsx b/src/basic/components/icons/index.tsx new file mode 100644 index 00000000..1609d774 --- /dev/null +++ b/src/basic/components/icons/index.tsx @@ -0,0 +1,12 @@ +// TODO: SVG 아이콘 컴포넌트들 +// 구현할 아이콘: +// - CartIcon: 장바구니 아이콘 +// - AdminIcon: 관리자 아이콘 +// - PlusIcon: 플러스 아이콘 +// - MinusIcon: 마이너스 아이콘 +// - TrashIcon: 삭제 아이콘 +// - ChevronDownIcon: 아래 화살표 +// - ChevronUpIcon: 위 화살표 +// - CheckIcon: 체크 아이콘 + +// TODO: 구현 \ No newline at end of file diff --git a/src/basic/components/ui/UIToast.ts b/src/basic/components/ui/UIToast.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/basic/constants/index.ts b/src/basic/constants/index.ts new file mode 100644 index 00000000..bef3834f --- /dev/null +++ b/src/basic/constants/index.ts @@ -0,0 +1,8 @@ +// TODO: 초기 데이터 상수 +// 정의할 상수들: +// - initialProducts: 초기 상품 목록 (상품1, 상품2, 상품3 + 설명 필드 포함) +// - initialCoupons: 초기 쿠폰 목록 (5000원 할인, 10% 할인) +// +// 참고: origin/App.tsx의 초기 데이터 구조를 참조 + +// TODO: 구현 \ No newline at end of file diff --git a/src/basic/constants/product.ts b/src/basic/constants/product.ts new file mode 100644 index 00000000..c9700aa3 --- /dev/null +++ b/src/basic/constants/product.ts @@ -0,0 +1,43 @@ +import { Product } from "../../types"; + +export const initialProducts: Product[] = [ + { + 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 PRODUCT_FORM_INITIAL_STATE = { + name: "", + price: 0, + stock: 0, + description: "", + isRecommended: false, +}; diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 00000000..6db309aa --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,29 @@ +// TODO: 장바구니 관리 Hook +// 힌트: +// 1. 장바구니 상태 관리 (localStorage 연동) +// 2. 상품 추가/삭제/수량 변경 +// 3. 쿠폰 적용 +// 4. 총액 계산 +// 5. 재고 확인 +// +// 사용할 모델 함수: +// - cartModel.addItemToCart +// - cartModel.removeItemFromCart +// - cartModel.updateCartItemQuantity +// - cartModel.calculateCartTotal +// - cartModel.getRemainingStock +// +// 반환할 값: +// - cart: 장바구니 아이템 배열 +// - selectedCoupon: 선택된 쿠폰 +// - addToCart: 상품 추가 함수 +// - removeFromCart: 상품 제거 함수 +// - updateQuantity: 수량 변경 함수 +// - applyCoupon: 쿠폰 적용 함수 +// - calculateTotal: 총액 계산 함수 +// - getRemainingStock: 재고 확인 함수 +// - clearCart: 장바구니 비우기 함수 + +export function useCart() { + // TODO: 구현 +} \ No newline at end of file diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 00000000..d2ad82ab --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,13 @@ +// TODO: 쿠폰 관리 Hook +// 힌트: +// 1. 쿠폰 목록 상태 관리 (localStorage 연동 고려) +// 2. 쿠폰 추가/삭제 +// +// 반환할 값: +// - coupons: 쿠폰 배열 +// - addCoupon: 새 쿠폰 추가 +// - removeCoupon: 쿠폰 삭제 + +export function useCoupons() { + // TODO: 구현 +} \ No newline at end of file diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 00000000..8be7c1f5 --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,79 @@ +import { useCallback, useState } from "react"; + +import { Product } from "../../types"; +import { generateProductId, validateProductData } from "../utils/productUtils"; +import { initialProducts } from "../constants/product"; +import useFilterSearchParams from "../../hooks/useFilterSearchParams"; + +export const useProducts = () => { + const [products, setProducts] = useState(() => { + const saved = localStorage.getItem("products"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialProducts; + } + } + return initialProducts; + }); + + const { filterSearchParams } = useFilterSearchParams(); + + // 상품 추가 + const addProduct = useCallback((productData: Omit) => { + if (!validateProductData(productData)) { + throw new Error("Invalid product data"); + } + + const newProduct: Product = { + ...productData, + id: generateProductId(), + }; + + setProducts((prev: Product[]) => [...prev, newProduct]); + return newProduct; + }, []); + + // 상품 수정 + const updateProduct = useCallback((id: string, updates: Partial) => { + setProducts((prev: Product[]) => + prev.map((product: Product) => + product.id === id ? { ...product, ...updates } : product + ) + ); + }, []); + + const deleteProduct = useCallback((productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + }, []); + + // 상품 삭제 + const removeProduct = useCallback((id: string) => { + setProducts((prev: Product[]) => + prev.filter((product: Product) => product.id !== id) + ); + }, []); + + const filteredProducts = filterSearchParams.searchTerm + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(filterSearchParams.searchTerm?.toLowerCase() ?? "") || + (product.description && + product.description + .toLowerCase() + .includes(filterSearchParams.searchTerm?.toLowerCase() ?? "")) + ) + : products; + + return { + products, + filteredProducts, + addProduct, + updateProduct, + deleteProduct, + removeProduct, + }; +}; diff --git a/src/basic/main.tsx b/src/basic/main.tsx index e63eef4a..95e2bdc2 100644 --- a/src/basic/main.tsx +++ b/src/basic/main.tsx @@ -1,9 +1,9 @@ -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"; -ReactDOM.createRoot(document.getElementById('root')!).render( +ReactDOM.createRoot(document.getElementById("root")!).render( - , -) + +); diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts new file mode 100644 index 00000000..5c681048 --- /dev/null +++ b/src/basic/models/cart.ts @@ -0,0 +1,18 @@ +// TODO: 장바구니 비즈니스 로직 (순수 함수) +// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) +// +// 구현할 함수들: +// 1. calculateItemTotal(item): 개별 아이템의 할인 적용 후 총액 계산 +// 2. getMaxApplicableDiscount(item): 적용 가능한 최대 할인율 계산 +// 3. calculateCartTotal(cart, coupon): 장바구니 총액 계산 (할인 전/후, 할인액) +// 4. updateCartItemQuantity(cart, productId, quantity): 수량 변경 +// 5. addItemToCart(cart, product): 상품 추가 +// 6. removeItemFromCart(cart, productId): 상품 제거 +// 7. getRemainingStock(product, cart): 남은 재고 계산 +// +// 원칙: +// - UI와 관련된 로직 없음 +// - 외부 상태에 의존하지 않음 +// - 모든 필요한 데이터는 파라미터로 전달받음 + +// TODO: 구현 \ No newline at end of file diff --git a/src/basic/models/coupon.ts b/src/basic/models/coupon.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/basic/models/discount.ts b/src/basic/models/discount.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/basic/models/product.ts b/src/basic/models/product.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts new file mode 100644 index 00000000..bfd5d676 --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,23 @@ +import { CartItem, Product } from "../../types"; + +export const formatPrice = (price: number, isSoldOut: boolean): string => { + if (isSoldOut) { + return "SOLD OUT"; + } + + return price.toLocaleString(); +}; + +/** + * 날짜를 YYYY-MM-DD 형식으로 포맷 + */ +export const formatDate = (date: Date): string => { + return date.toISOString().split("T")[0]; +}; + +/** + * 소수를 퍼센트로 변환 (0.1 → 10%) + */ +export const formatPercentage = (rate: number): string => { + return `${(rate * 100).toFixed(0)}%`; +}; diff --git a/src/basic/utils/hooks/useDebounce.ts b/src/basic/utils/hooks/useDebounce.ts new file mode 100644 index 00000000..14e641cc --- /dev/null +++ b/src/basic/utils/hooks/useDebounce.ts @@ -0,0 +1,36 @@ +import { useState, useEffect, useCallback, useRef } from "react"; + +/** + * 디바운스된 콜백 Hook + * 함수 호출을 지정된 시간만큼 지연시킵니다 + */ +export function useDebouncedCallback any>( + callback: T, + delay: number +): T { + const timeoutRef = useRef(null); + + const debouncedCallback = useCallback( + (...args: Parameters) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + callback(...args); + }, delay); + }, + [callback, delay] + ); + + // 컴포넌트 언마운트 시 타이머 정리 + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return debouncedCallback as T; +} diff --git a/src/basic/utils/hooks/useLocalStorage.ts b/src/basic/utils/hooks/useLocalStorage.ts new file mode 100644 index 00000000..5dc72c50 --- /dev/null +++ b/src/basic/utils/hooks/useLocalStorage.ts @@ -0,0 +1,15 @@ +// TODO: LocalStorage Hook +// 힌트: +// 1. localStorage와 React state 동기화 +// 2. 초기값 로드 시 에러 처리 +// 3. 저장 시 JSON 직렬화/역직렬화 +// 4. 빈 배열이나 undefined는 삭제 +// +// 반환값: [저장된 값, 값 설정 함수] + +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + // TODO: 구현 +} \ No newline at end of file diff --git a/src/basic/utils/hooks/useValidate.ts b/src/basic/utils/hooks/useValidate.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/basic/utils/productUtils.ts b/src/basic/utils/productUtils.ts new file mode 100644 index 00000000..65be485a --- /dev/null +++ b/src/basic/utils/productUtils.ts @@ -0,0 +1,50 @@ +import { CartItem, Product } from "../../types"; + +/** + * 상품 검색 필터링 + */ +export const filterProducts = ( + products: Product[], + searchTerm: string +): Product[] => { + if (!searchTerm) return products; + + return products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(searchTerm.toLowerCase())) + ); +}; + +/** + * 상품 ID 생성 + */ +export const generateProductId = (): string => { + return "p" + Date.now(); +}; + +/** + * 상품 데이터 유효성 검증 + */ +export const validateProductData = (product: Partial): boolean => { + return !!( + product.name && + product.name.trim() && + product.price && + product.price > 0 && + product.stock !== undefined && + product.stock >= 0 + ); +}; + +/** + * 남은 재고 계산 + */ +export const getRemainingStock = ( + product: Product, + cart: CartItem[] +): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); +}; diff --git a/src/basic/utils/validators.ts b/src/basic/utils/validators.ts new file mode 100644 index 00000000..7d2dda44 --- /dev/null +++ b/src/basic/utils/validators.ts @@ -0,0 +1,8 @@ +// TODO: 검증 유틸리티 함수들 +// 구현할 함수: +// - isValidCouponCode(code: string): boolean - 쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자) +// - isValidStock(stock: number): boolean - 재고 수량 검증 (0 이상) +// - isValidPrice(price: number): boolean - 가격 검증 (양수) +// - extractNumbers(value: string): string - 문자열에서 숫자만 추출 + +// TODO: 구현 \ No newline at end of file From 981d14030c1dbce236db0d47ec8bf16b3557c5b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Mon, 4 Aug 2025 23:56:28 +0900 Subject: [PATCH 13/52] =?UTF-8?q?refactor:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=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 | 699 +---------------------------- src/basic/components/AdminPage.tsx | 698 +++++++++++++++++++++++++++- src/basic/main.tsx | 2 +- 3 files changed, 712 insertions(+), 687 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index c84db5df..5987c9c8 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -6,6 +6,7 @@ import { formatPrice } from "./utils/formatters"; import { getRemainingStock } from "./utils/productUtils"; import { useDebouncedCallback } from "./utils/hooks/useDebounce"; import useFilterSearchParams from "../hooks/useFilterSearchParams"; +import { AdminPage } from "./components/AdminPage"; interface Notification { id: string; @@ -31,7 +32,7 @@ const initialCoupons: Coupon[] = [ const App = () => { const { products, - + filteredProducts, addProduct, updateProduct, deleteProduct, @@ -64,11 +65,6 @@ 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 { filterSearchParams, setFilterSearchParams } = useFilterSearchParams(); const handleSearchChange = (e: React.ChangeEvent) => { @@ -86,23 +82,6 @@ const App = () => { setFilterSearchParams({ searchTerm: value }); }, 500); - // 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 getMaxApplicableDiscount = (item: CartItem): number => { const { discounts } = item.product; const { quantity } = item; @@ -340,66 +319,8 @@ const App = () => { [selectedCoupon, addNotification] ); - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== "new") { - handleUpdateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - handleAddProduct({ - ...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: Product) => { - 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 searchTerm = filterSearchParams.searchTerm ?? ""; - const filteredProducts = searchTerm - ? products.filter( - (product) => - product.name.toLowerCase().includes(searchTerm.toLowerCase()) || - (product.description && - product.description - .toLowerCase() - .includes(searchTerm.toLowerCase())) - ) - : products; - return (
{notifications.length > 0 && ( @@ -452,7 +373,7 @@ const App = () => {
{
{isAdmin ? ( -
-
-

- 관리자 대시보드 -

-

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

-
-
- -
- - {activeTab === "products" ? ( -
-
-
-

상품 목록

- -
-
- -
- - - - - - - - - - - - {(activeTab === "products" ? products : products).map( - (product) => ( - - - - - - - - ) - )} - -
- 상품명 - - 가격 - - 재고 - - 설명 - - 작업 -
- {product.name} - - {`${formatPrice( - product.price, - !getRemainingStock(product, cart) - )}원`} - - 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 - /> -
-
-
- - -
-
-
- )} -
-
- )} -
+ ) : (
@@ -1116,7 +448,8 @@ const App = () => { {filteredProducts.length === 0 ? (

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

) : ( diff --git a/src/basic/components/AdminPage.tsx b/src/basic/components/AdminPage.tsx index afb5b1ae..e6af9d0d 100644 --- a/src/basic/components/AdminPage.tsx +++ b/src/basic/components/AdminPage.tsx @@ -15,6 +15,698 @@ // - CouponForm: 새 쿠폰 추가 폼 // - CouponList: 쿠폰 목록 표시 -export function AdminPage() { - // TODO: 구현 -} \ No newline at end of file +import { useState } from "react"; +import { CartItem, Coupon, Product } from "../../types"; +import { formatPrice } from "../utils/formatters"; +import { getRemainingStock } from "../utils/productUtils"; + +interface AdminPageProps { + products: Product[]; + coupons: Coupon[]; + cart: CartItem[]; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; + handleDeleteProduct: (id: string) => void; + deleteCoupon: (code: string) => void; + addProduct: (productData: Omit) => Product; + updateProduct: (id: string, updates: Partial) => void; + addCoupon: (coupon: Coupon) => void; +} + +export function AdminPage({ + products, + coupons, + cart, + addNotification, + handleDeleteProduct, + deleteCoupon, + addProduct, + updateProduct, + addCoupon, +}: AdminPageProps) { + // 내부 상태 관리 + const [activeTab, setActiveTab] = useState<"products" | "coupons">( + "products" + ); + const [showProductForm, setShowProductForm] = useState(false); + const [showCouponForm, setShowCouponForm] = 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 handleStartEditProduct = (product: Product) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [], + }); + setShowProductForm(true); + }; + + // 새 상품 추가 시작 + const handleAddNewProduct = () => { + setEditingProduct("new"); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + setShowProductForm(true); + }; + + // 상품 폼 취소 + const handleCancelProductForm = () => { + setEditingProduct(null); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + setShowProductForm(false); + }; + + // 쿠폰 폼 취소 + const handleCancelCouponForm = () => { + setCouponForm({ + name: "", + code: "", + discountType: "amount", + discountValue: 0, + }); + setShowCouponForm(false); + }; + + // 상품 폼 제출 처리 + 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); + }; + + return ( +
+
+

관리자 대시보드

+

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

+
+
+ +
+ + {activeTab === "products" ? ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {`${formatPrice( + product.price, + !getRemainingStock(product, cart) + )}원`} + + 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 + /> +
+
+
+ + +
+
+
+ )} +
+
+ )} +
+ ); +} diff --git a/src/basic/main.tsx b/src/basic/main.tsx index 95e2bdc2..f46c379c 100644 --- a/src/basic/main.tsx +++ b/src/basic/main.tsx @@ -1,6 +1,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import App from "./App.tsx"; +import App from "./App"; ReactDOM.createRoot(document.getElementById("root")!).render( From e6b4dd28cdba1be85e19b4693a4fde890789ee38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Tue, 5 Aug 2025 00:17:47 +0900 Subject: [PATCH 14/52] =?UTF-8?q?refactor:=20MainPage=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 | 351 ++--------------------------- src/basic/components/MainPage.tsx | 361 ++++++++++++++++++++++++++++++ 2 files changed, 380 insertions(+), 332 deletions(-) create mode 100644 src/basic/components/MainPage.tsx diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 5987c9c8..2899c1ee 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -7,6 +7,7 @@ import { getRemainingStock } from "./utils/productUtils"; import { useDebouncedCallback } from "./utils/hooks/useDebounce"; import useFilterSearchParams from "../hooks/useFilterSearchParams"; import { AdminPage } from "./components/AdminPage"; +import { MainPage } from "./components/MainPage"; interface Notification { id: string; @@ -433,338 +434,24 @@ const App = () => { addCoupon={addCoupon} /> ) : ( -
-
- {/* 상품 목록 */} -
-
-

- 전체 상품 -

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

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

-
- ) : ( -
- {filteredProducts.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, - !getRemainingStock(product, cart) - )}`} -

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

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

-
-
- - )} -
-
-
+ )}
diff --git a/src/basic/components/MainPage.tsx b/src/basic/components/MainPage.tsx new file mode 100644 index 00000000..8c7a6ef2 --- /dev/null +++ b/src/basic/components/MainPage.tsx @@ -0,0 +1,361 @@ +import { CartItem, Coupon, Product } from "../../types"; +import { formatPrice } from "../utils/formatters"; +import { getRemainingStock } from "../utils/productUtils"; + +interface MainPageProps { + products: Product[]; + filteredProducts: Product[]; + filterSearchParams: { searchTerm?: string }; + cart: CartItem[]; + addToCart: (product: Product) => void; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + applyCoupon: (coupon: Coupon) => void; + completeOrder: () => void; + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + calculateItemTotal: (item: CartItem) => number; +} + +export function MainPage({ + products, + filteredProducts, + filterSearchParams, + cart, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + completeOrder, + totals, + coupons, + selectedCoupon, + setSelectedCoupon, + calculateItemTotal, +}: MainPageProps) { + return ( +
+
+ {/* 상품 목록 */} +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.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, + !getRemainingStock(product, cart) + )}`} +

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

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

+
+
+ + )} +
+
+
+ ); +} From 6948fb9cd91760ea13dd8e3e3bcba0020b2e9ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Tue, 5 Aug 2025 01:05:10 +0900 Subject: [PATCH 15/52] =?UTF-8?q?feat(hooks):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색 파라미터 변경 시 커스텀 이벤트 발생 기능 추가 - popstate 이벤트와 함께 searchParamsChanged 이벤트 리스너 등록 및 해제 로직 구현 --- src/hooks/useSearchParams.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/hooks/useSearchParams.ts b/src/hooks/useSearchParams.ts index 7544656e..26fd0c8c 100644 --- a/src/hooks/useSearchParams.ts +++ b/src/hooks/useSearchParams.ts @@ -23,10 +23,22 @@ export function useSearchParams(): [ setSearchParamsState(new URLSearchParams(window.location.search)); }; + const handleSearchParamsChanged = (event: CustomEvent) => { + setSearchParamsState(event.detail.searchParams); + }; + window.addEventListener("popstate", handlePopState); + window.addEventListener( + "searchParamsChanged", + handleSearchParamsChanged as EventListener + ); return () => { window.removeEventListener("popstate", handlePopState); + window.removeEventListener( + "searchParamsChanged", + handleSearchParamsChanged as EventListener + ); }; }, []); @@ -45,7 +57,13 @@ export function useSearchParams(): [ }${window.location.hash}`; history.replaceState(null, "", newUrl); - setSearchParamsState(newSearchParams); + + // replaceState 후 상태 동기화를 위한 커스텀 이벤트 발생 + window.dispatchEvent( + new CustomEvent("searchParamsChanged", { + detail: { searchParams: newSearchParams }, + }) + ); }, [] ); From 9219159ed6d59495e78d62a5cfb5559b6be7de3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Tue, 5 Aug 2025 01:05:48 +0900 Subject: [PATCH 16/52] =?UTF-8?q?refactor:=20price=20display=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=ED=95=A8=EC=88=98=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/utils/formatters.ts | 11 +++++++---- src/basic/utils/productUtils.ts | 13 +++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts index bfd5d676..b33d888b 100644 --- a/src/basic/utils/formatters.ts +++ b/src/basic/utils/formatters.ts @@ -1,11 +1,14 @@ -import { CartItem, Product } from "../../types"; +import { Product } from "../../types"; -export const formatPrice = (price: number, isSoldOut: boolean): string => { - if (isSoldOut) { +export const displayPrice = ( + product: Product, + { suffix = "", prefix = "" }: { suffix?: string; prefix?: string } = {} +): string => { + if (product.stock <= 0) { return "SOLD OUT"; } - return price.toLocaleString(); + return `${prefix}${product.price.toLocaleString()}${suffix}`; }; /** diff --git a/src/basic/utils/productUtils.ts b/src/basic/utils/productUtils.ts index 65be485a..747300dc 100644 --- a/src/basic/utils/productUtils.ts +++ b/src/basic/utils/productUtils.ts @@ -4,6 +4,7 @@ import { CartItem, Product } from "../../types"; * 상품 검색 필터링 */ export const filterProducts = ( + //TODO: 진석 리팩토링 필요 products: Product[], searchTerm: string ): Product[] => { @@ -41,10 +42,14 @@ export const validateProductData = (product: Partial): boolean => { /** * 남은 재고 계산 */ -export const getRemainingStock = ( - product: Product, - cart: CartItem[] -): number => { +const getRemainingStock = (product: Product, cart: CartItem[]): number => { const cartItem = cart.find((item) => item.product.id === product.id); return product.stock - (cartItem?.quantity || 0); }; + +export const getRefinedProduct = (product: Product, cart: CartItem[]) => { + return { + ...product, + stock: getRemainingStock(product, cart), + } satisfies Product; +}; From eb5638336ab5971a2149968bf36bca05614c08e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Tue, 5 Aug 2025 01:11:09 +0900 Subject: [PATCH 17/52] =?UTF-8?q?feat(hooks):=20useProducts=20localStorage?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/hooks/useProducts.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts index 8be7c1f5..d59df4e3 100644 --- a/src/basic/hooks/useProducts.ts +++ b/src/basic/hooks/useProducts.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useState, useEffect } from "react"; import { Product } from "../../types"; import { generateProductId, validateProductData } from "../utils/productUtils"; @@ -6,7 +6,7 @@ import { initialProducts } from "../constants/product"; import useFilterSearchParams from "../../hooks/useFilterSearchParams"; export const useProducts = () => { - const [products, setProducts] = useState(() => { + const [products, _setProducts] = useState(() => { const saved = localStorage.getItem("products"); if (saved) { try { @@ -18,6 +18,19 @@ export const useProducts = () => { return initialProducts; }); + const setProducts: React.Dispatch> = + useCallback( + (products) => { + _setProducts((prev) => { + const newValue = + typeof products === "function" ? products(prev) : products; + localStorage.setItem("products", JSON.stringify(newValue)); + return newValue; + }); + }, + [_setProducts] + ); + const { filterSearchParams } = useFilterSearchParams(); // 상품 추가 From 28b1098f16395341a94655dd0779f92238bca09c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Tue, 5 Aug 2025 01:11:38 +0900 Subject: [PATCH 18/52] =?UTF-8?q?refactor:=20AdminPage,=20MainPage=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=20=ED=95=A8=EC=88=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 178 ++--------------------------- src/basic/components/AdminPage.tsx | 81 ++++++++++--- src/basic/components/MainPage.tsx | 174 ++++++++++++++++++++++------ 3 files changed, 221 insertions(+), 212 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 2899c1ee..6d079693 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,13 +1,11 @@ -import { useState, useCallback, useEffect } from "react"; -import { CartItem, Coupon, Product } from "../types"; +import { useCallback, useEffect, useState } from "react"; +import { CartItem, Coupon } from "../types"; -import { useProducts } from "./hooks/useProducts"; -import { formatPrice } from "./utils/formatters"; -import { getRemainingStock } from "./utils/productUtils"; -import { useDebouncedCallback } from "./utils/hooks/useDebounce"; import useFilterSearchParams from "../hooks/useFilterSearchParams"; import { AdminPage } from "./components/AdminPage"; import { MainPage } from "./components/MainPage"; +import { useProducts } from "./hooks/useProducts"; +import { useDebouncedCallback } from "./utils/hooks/useDebounce"; interface Notification { id: string; @@ -172,156 +170,6 @@ const App = () => { } }, [cart]); - const addToCart = useCallback( - (product: Product) => { - const remainingStock = getRemainingStock(product, cart); - 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 handleAddProduct = useCallback( - (newProduct: Omit) => { - addProduct(newProduct); - addNotification("상품이 추가되었습니다.", "success"); - }, - [addProduct, addNotification] - ); - - const handleUpdateProduct = useCallback( - (productId: string, updates: Partial) => { - updateProduct(productId, updates); - addNotification("상품이 수정되었습니다.", "success"); - }, - [updateProduct, addNotification] - ); - - const handleDeleteProduct = useCallback( - (productId: string) => { - deleteProduct(productId); - addNotification("상품이 삭제되었습니다.", "success"); - }, - [deleteProduct, 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 totals = calculateCartTotal(); - return (
{notifications.length > 0 && ( @@ -427,11 +275,12 @@ const App = () => { coupons={coupons} cart={cart} addNotification={addNotification} - handleDeleteProduct={handleDeleteProduct} - deleteCoupon={deleteCoupon} addProduct={addProduct} updateProduct={updateProduct} - addCoupon={addCoupon} + deleteProduct={deleteProduct} + setCoupons={setCoupons} + selectedCoupon={selectedCoupon} + setSelectedCoupon={setSelectedCoupon} /> ) : ( { searchTerm: filterSearchParams.searchTerm ?? "", }} cart={cart} - addToCart={addToCart} - removeFromCart={removeFromCart} - updateQuantity={updateQuantity} - applyCoupon={applyCoupon} - completeOrder={completeOrder} - totals={totals} - coupons={coupons} + setCart={setCart} selectedCoupon={selectedCoupon} setSelectedCoupon={setSelectedCoupon} + addNotification={addNotification} + calculateCartTotal={calculateCartTotal} calculateItemTotal={calculateItemTotal} + coupons={coupons} /> )} diff --git a/src/basic/components/AdminPage.tsx b/src/basic/components/AdminPage.tsx index e6af9d0d..3b0dd55d 100644 --- a/src/basic/components/AdminPage.tsx +++ b/src/basic/components/AdminPage.tsx @@ -15,10 +15,9 @@ // - CouponForm: 새 쿠폰 추가 폼 // - CouponList: 쿠폰 목록 표시 -import { useState } from "react"; +import { useState, useCallback } from "react"; import { CartItem, Coupon, Product } from "../../types"; -import { formatPrice } from "../utils/formatters"; -import { getRemainingStock } from "../utils/productUtils"; +import { displayPrice } from "../utils/formatters"; interface AdminPageProps { products: Product[]; @@ -28,11 +27,12 @@ interface AdminPageProps { message: string, type: "error" | "success" | "warning" ) => void; - handleDeleteProduct: (id: string) => void; - deleteCoupon: (code: string) => void; addProduct: (productData: Omit) => Product; updateProduct: (id: string, updates: Partial) => void; - addCoupon: (coupon: Coupon) => void; + deleteProduct: (id: string) => void; + setCoupons: (coupons: Coupon[] | ((prev: Coupon[]) => Coupon[])) => void; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; } export function AdminPage({ @@ -40,11 +40,12 @@ export function AdminPage({ coupons, cart, addNotification, - handleDeleteProduct, - deleteCoupon, addProduct, updateProduct, - addCoupon, + deleteProduct, + setCoupons, + selectedCoupon, + setSelectedCoupon, }: AdminPageProps) { // 내부 상태 관리 const [activeTab, setActiveTab] = useState<"products" | "coupons">( @@ -122,12 +123,14 @@ export function AdminPage({ e.preventDefault(); if (editingProduct && editingProduct !== "new") { updateProduct(editingProduct, productForm); + addNotification("상품이 수정되었습니다.", "success"); setEditingProduct(null); } else { addProduct({ ...productForm, discounts: productForm.discounts, }); + addNotification("상품이 추가되었습니다.", "success"); } setProductForm({ name: "", @@ -153,6 +156,59 @@ export function AdminPage({ setShowCouponForm(false); }; + // 상품 추가 + const handleAddProduct = useCallback( + (newProduct: Omit) => { + addProduct(newProduct); + addNotification("상품이 추가되었습니다.", "success"); + }, + [addProduct, addNotification] + ); + + // 상품 수정 + const handleUpdateProduct = useCallback( + (productId: string, updates: Partial) => { + updateProduct(productId, updates); + addNotification("상품이 수정되었습니다.", "success"); + }, + [updateProduct, addNotification] + ); + + // 상품 삭제 + const handleDeleteProduct = useCallback( + (productId: string) => { + deleteProduct(productId); + addNotification("상품이 삭제되었습니다.", "success"); + }, + [deleteProduct, 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, 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] + ); + return (
@@ -226,10 +282,9 @@ export function AdminPage({ {product.name} - {`${formatPrice( - product.price, - !getRemainingStock(product, cart) - )}원`} + {displayPrice(product, { + suffix: "원", + })} void; - removeFromCart: (productId: string) => void; - updateQuantity: (productId: string, newQuantity: number) => void; - applyCoupon: (coupon: Coupon) => void; - completeOrder: () => void; - totals: { + setCart: (cart: CartItem[] | ((prev: CartItem[]) => CartItem[])) => void; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; + calculateCartTotal: () => { totalBeforeDiscount: number; totalAfterDiscount: number; }; - coupons: Coupon[]; - selectedCoupon: Coupon | null; - setSelectedCoupon: (coupon: Coupon | null) => void; calculateItemTotal: (item: CartItem) => number; + coupons: Coupon[]; } export function MainPage({ @@ -27,17 +28,124 @@ export function MainPage({ filteredProducts, filterSearchParams, cart, - addToCart, - removeFromCart, - updateQuantity, - applyCoupon, - completeOrder, - totals, - coupons, + setCart, selectedCoupon, setSelectedCoupon, + addNotification, + calculateCartTotal, calculateItemTotal, + coupons, }: MainPageProps) { + // 장바구니에 상품 추가 + const addToCart = useCallback( + (product: Product) => { + const refinedProduct = getRefinedProduct(product, cart); + if (refinedProduct.stock <= 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, 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] + ); + + // 쿠폰 적용 + 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, setSelectedCoupon] + ); + + // 주문 완료 + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" + ); + setCart([]); + setSelectedCoupon(null); + }, [addNotification, setCart, setSelectedCoupon]); + + const totals = calculateCartTotal(); + return (
@@ -59,7 +167,7 @@ export function MainPage({ ) : (
{filteredProducts.map((product) => { - const remainingStock = getRemainingStock(product, cart); + const refinedProduct = getRefinedProduct(product, cart); return (

- {`₩${formatPrice( - product.price, - !getRemainingStock(product, cart) - )}`} + {displayPrice(refinedProduct, { + prefix: "₩", + })}

{product.discounts.length > 0 && (

@@ -127,14 +234,15 @@ export function MainPage({ {/* 재고 상태 */}

- {remainingStock <= 5 && remainingStock > 0 && ( -

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

- )} - {remainingStock > 5 && ( + {refinedProduct.stock <= 5 && + refinedProduct.stock > 0 && ( +

+ 품절임박! {refinedProduct.stock}개 남음 +

+ )} + {refinedProduct.stock > 5 && (

- 재고 {remainingStock}개 + 재고 {refinedProduct.stock}개

)}
@@ -142,14 +250,14 @@ export function MainPage({ {/* 장바구니 버튼 */}
From 64d1f34580da7e5db6db9b9829ad1dd7d7c15bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Tue, 5 Aug 2025 01:41:25 +0900 Subject: [PATCH 19/52] =?UTF-8?q?refactor:=20cart=20domain=20=EB=8D=B0?= =?UTF-8?q?=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/App.tsx | 112 +++++-------------- src/basic/components/MainPage.tsx | 105 +++++------------- src/basic/constants/cart.ts | 4 + src/basic/hooks/useCart.ts | 172 +++++++++++++++++++++++++----- src/basic/utils/cartUtils.ts | 93 ++++++++++++++++ 5 files changed, 294 insertions(+), 192 deletions(-) create mode 100644 src/basic/constants/cart.ts create mode 100644 src/basic/utils/cartUtils.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 6d079693..19e257c0 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -5,6 +5,12 @@ import useFilterSearchParams from "../hooks/useFilterSearchParams"; import { AdminPage } from "./components/AdminPage"; import { MainPage } from "./components/MainPage"; import { useProducts } from "./hooks/useProducts"; +import { useCart } from "./hooks/useCart"; +import { + calculateCartTotal, + calculateItemTotal, + getCartItemCount, +} from "./utils/cartUtils"; import { useDebouncedCallback } from "./utils/hooks/useDebounce"; interface Notification { @@ -37,18 +43,6 @@ const App = () => { deleteProduct, } = useProducts(); - 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) { @@ -81,64 +75,6 @@ const App = () => { setFilterSearchParams({ searchTerm: value }); }, 500); - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach((item) => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === "amount") { - totalAfterDiscount = Math.max( - 0, - totalAfterDiscount - selectedCoupon.discountValue - ); - } else { - totalAfterDiscount = Math.round( - totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) - ); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount), - }; - }; - const addNotification = useCallback( (message: string, type: "error" | "success" | "warning" = "success") => { const id = Date.now().toString(); @@ -151,25 +87,23 @@ const App = () => { [] ); - const [totalItemCount, setTotalItemCount] = useState(0); - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); + const { + cart, + addToCart, + updateCartItemQuantity, + removeFromCart, + clearCart, + getProductRemainingStock, + isInCart, + getCartItem, + } = useCart(addNotification); + + const totalItemCount = getCartItemCount(cart); useEffect(() => { localStorage.setItem("coupons", JSON.stringify(coupons)); }, [coupons]); - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem("cart", JSON.stringify(cart)); - } else { - localStorage.removeItem("cart"); - } - }, [cart]); - return (
{notifications.length > 0 && ( @@ -290,13 +224,17 @@ const App = () => { searchTerm: filterSearchParams.searchTerm ?? "", }} cart={cart} - setCart={setCart} + addToCart={addToCart} + updateCartItemQuantity={updateCartItemQuantity} + removeFromCart={removeFromCart} + clearCart={clearCart} selectedCoupon={selectedCoupon} setSelectedCoupon={setSelectedCoupon} addNotification={addNotification} - calculateCartTotal={calculateCartTotal} - calculateItemTotal={calculateItemTotal} + calculateCartTotal={() => calculateCartTotal(cart, selectedCoupon)} + calculateItemTotal={(item) => calculateItemTotal(item, cart)} coupons={coupons} + getProductRemainingStock={getProductRemainingStock} /> )} diff --git a/src/basic/components/MainPage.tsx b/src/basic/components/MainPage.tsx index d7910cc8..3f1a755d 100644 --- a/src/basic/components/MainPage.tsx +++ b/src/basic/components/MainPage.tsx @@ -8,7 +8,10 @@ interface MainPageProps { filteredProducts: Product[]; filterSearchParams: { searchTerm?: string }; cart: CartItem[]; - setCart: (cart: CartItem[] | ((prev: CartItem[]) => CartItem[])) => void; + addToCart: (product: Product, quantity?: number) => void; + updateCartItemQuantity: (productId: string, quantity: number) => void; + removeFromCart: (productId: string) => void; + clearCart: () => void; selectedCoupon: Coupon | null; setSelectedCoupon: (coupon: Coupon | null) => void; addNotification: ( @@ -21,6 +24,7 @@ interface MainPageProps { }; calculateItemTotal: (item: CartItem) => number; coupons: Coupon[]; + getProductRemainingStock: (product: Product) => number; } export function MainPage({ @@ -28,90 +32,33 @@ export function MainPage({ filteredProducts, filterSearchParams, cart, - setCart, + addToCart, + updateCartItemQuantity, + removeFromCart, + clearCart, selectedCoupon, setSelectedCoupon, addNotification, calculateCartTotal, calculateItemTotal, coupons, + getProductRemainingStock, }: MainPageProps) { - // 장바구니에 상품 추가 - const addToCart = useCallback( + // 장바구니에 상품 추가 (props로 받은 함수 사용) + const handleAddToCart = useCallback( (product: Product) => { - const refinedProduct = getRefinedProduct(product, cart); - if (refinedProduct.stock <= 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 }]; - }); - + addToCart(product, 1); addNotification("장바구니에 담았습니다", "success"); }, - [cart, addNotification, setCart] + [addToCart, addNotification] ); - // 장바구니에서 상품 제거 - const removeFromCart = useCallback( - (productId: string) => { - setCart((prevCart) => - prevCart.filter((item) => item.product.id !== productId) - ); - }, - [setCart] - ); - - // 장바구니 상품 수량 변경 - const updateQuantity = useCallback( + // 장바구니 상품 수량 변경 (props로 받은 함수 사용) + const handleUpdateQuantity = 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 - ) - ); + updateCartItemQuantity(productId, newQuantity); }, - [products, removeFromCart, addNotification, setCart] + [updateCartItemQuantity] ); // 쿠폰 적용 @@ -140,9 +87,9 @@ export function MainPage({ `주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success" ); - setCart([]); + clearCart(); setSelectedCoupon(null); - }, [addNotification, setCart, setSelectedCoupon]); + }, [addNotification, clearCart, setSelectedCoupon]); const totals = calculateCartTotal(); @@ -249,7 +196,7 @@ export function MainPage({ {/* 장바구니 버튼 */}
) : ( -
-
-

쿠폰 관리

-
-
-
- {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/components/CouponForm.tsx b/src/basic/components/CouponForm.tsx new file mode 100644 index 00000000..3c9314a4 --- /dev/null +++ b/src/basic/components/CouponForm.tsx @@ -0,0 +1,168 @@ +import { useState } from "react"; +import { Coupon } from "../../types"; +import { COUPON_FORM_INITIAL_STATE, COUPON_LIMITS } from "../constants/coupon"; + +interface CouponFormProps { + onSubmit: (coupon: Coupon) => void; + onCancel: () => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const CouponForm: React.FC = ({ + onSubmit, + onCancel, + addNotification, +}) => { + const [formData, setFormData] = useState(COUPON_FORM_INITIAL_STATE); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(formData as Coupon); + setFormData(COUPON_FORM_INITIAL_STATE); + }; + + const handleInputChange = ( + field: keyof typeof formData, + value: string | number + ) => { + setFormData((prev) => ({ + ...prev, + [field]: value, + })); + }; + + const getDiscountValuePlaceholder = () => { + return formData.discountType === "amount" ? "5000" : "10"; + }; + + const getDiscountValueMax = () => { + return COUPON_LIMITS[formData.discountType].max; + }; + + const getDiscountValueMin = () => { + return COUPON_LIMITS[formData.discountType].min; + }; + + return ( +
+

새 쿠폰 생성

+ +
+
+ + handleInputChange("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 + /> +
+ +
+ + + handleInputChange("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)) { + handleInputChange( + "discountValue", + value === "" ? 0 : parseInt(value) + ); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + const max = getDiscountValueMax(); + const min = getDiscountValueMin(); + + if (value > max) { + if (formData.discountType === "percentage") { + addNotification( + `할인율은 ${max}%를 초과할 수 없습니다`, + "error" + ); + } else { + addNotification( + `할인 금액은 ${max}원을 초과할 수 없습니다`, + "error" + ); + } + + handleInputChange("discountValue", max); + } else if (value < min) { + handleInputChange("discountValue", min); + } + }} + 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={getDiscountValuePlaceholder()} + min={getDiscountValueMin()} + max={getDiscountValueMax()} + required + /> +
+
+ +
+ + +
+
+ ); +}; diff --git a/src/basic/components/CouponList.tsx b/src/basic/components/CouponList.tsx new file mode 100644 index 00000000..b1239edc --- /dev/null +++ b/src/basic/components/CouponList.tsx @@ -0,0 +1,50 @@ +import { Coupon } from "../../types"; +import { getCouponDisplayText } from "../utils/couponUtils"; + +interface CouponListProps { + coupons: Coupon[]; + onApplyCoupon: (coupon: Coupon) => void; + selectedCoupon: Coupon | null; +} + +export const CouponList: React.FC = ({ + coupons, + onApplyCoupon, + selectedCoupon, +}) => { + if (coupons.length === 0) { + return ( +
+

사용 가능한 쿠폰이 없습니다

+
+ ); + } + + return ( +
+ {coupons.map((coupon) => ( +
onApplyCoupon(coupon)} + > +
+
+

{coupon.name}

+

{coupon.code}

+
+
+ + {getCouponDisplayText(coupon)} + +
+
+
+ ))} +
+ ); +}; diff --git a/src/basic/components/CouponManagement.tsx b/src/basic/components/CouponManagement.tsx new file mode 100644 index 00000000..0f3fac95 --- /dev/null +++ b/src/basic/components/CouponManagement.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { Coupon } from "../../types"; +import { CouponForm } from "./CouponForm"; +import { + getCouponDisplayText, + validateCouponData, + isCouponDuplicate, +} from "../utils/couponUtils"; + +interface CouponManagementProps { + coupons: Coupon[]; + onAddCoupon: (coupon: Coupon) => void; + onRemoveCoupon: (couponCode: string) => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const CouponManagement: React.FC = ({ + coupons, + onAddCoupon, + onRemoveCoupon, + addNotification, +}) => { + const [showCouponForm, setShowCouponForm] = useState(false); + + const handleAddCoupon = (coupon: Coupon) => { + // 검증 로직을 먼저 수행 + if (!validateCouponData(coupon)) { + return; + } + + // 중복 검사 + if (isCouponDuplicate(coupons, coupon.code)) { + addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + + // 검증 통과 후 쿠폰 추가 + onAddCoupon(coupon); + addNotification("쿠폰이 추가되었습니다.", "success"); + setShowCouponForm(false); + }; + + const handleRemoveCoupon = (couponCode: string) => { + onRemoveCoupon(couponCode); + addNotification("쿠폰이 삭제되었습니다.", "success"); + }; + + const handleCancelForm = () => { + setShowCouponForm(false); + }; + + return ( +
+
+

쿠폰 관리

+
+ +
+
+ {coupons.map((coupon) => ( +
+
+
+

{coupon.name}

+

+ {coupon.code} +

+
+ + {getCouponDisplayText(coupon)} + +
+
+ +
+
+ ))} + +
+ +
+
+ + {showCouponForm && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/basic/components/MainPage.tsx b/src/basic/components/MainPage.tsx index 41d31969..5a5c2f21 100644 --- a/src/basic/components/MainPage.tsx +++ b/src/basic/components/MainPage.tsx @@ -85,7 +85,7 @@ export function MainPage({ // 쿠폰 적용 const applyCoupon = useCallback( (coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; + const currentTotal = calculateCartTotal().totalBeforeDiscount; if (currentTotal < 10000 && coupon.discountType === "percentage") { addNotification( diff --git a/src/basic/constants/coupon.ts b/src/basic/constants/coupon.ts new file mode 100644 index 00000000..2a4ca77c --- /dev/null +++ b/src/basic/constants/coupon.ts @@ -0,0 +1,46 @@ +import { Coupon } from "../../types"; + +// 초기 쿠폰 데이터 +export const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; + +// 쿠폰 폼 초기 상태 +export const COUPON_FORM_INITIAL_STATE = { + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, +}; + +// 쿠폰 검증 규칙 +export const COUPON_VALIDATION_RULES = { + MIN_AMOUNT_DISCOUNT: 1000, + MAX_AMOUNT_DISCOUNT: 100000, + MIN_PERCENTAGE_DISCOUNT: 1, + MAX_PERCENTAGE_DISCOUNT: 100, + MIN_CART_TOTAL_FOR_PERCENTAGE: 10000, +}; + +// 쿠폰 타입별 최대값 +export const COUPON_LIMITS = { + amount: { + min: 1000, + max: 100000, + }, + percentage: { + min: 1, + max: 100, + }, +}; diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts index d2ad82ab..4bf99564 100644 --- a/src/basic/hooks/useCoupons.ts +++ b/src/basic/hooks/useCoupons.ts @@ -1,13 +1,89 @@ -// TODO: 쿠폰 관리 Hook -// 힌트: -// 1. 쿠폰 목록 상태 관리 (localStorage 연동 고려) -// 2. 쿠폰 추가/삭제 -// -// 반환할 값: -// - coupons: 쿠폰 배열 -// - addCoupon: 새 쿠폰 추가 -// - removeCoupon: 쿠폰 삭제 - -export function useCoupons() { - // TODO: 구현 -} \ No newline at end of file +import { useCallback, useState } from "react"; +import { Coupon } from "../../types"; +import { initialCoupons } from "../constants/coupon"; +import { isCouponDuplicate, generateCouponCode } from "../utils/couponUtils"; + +export const useCoupons = () => { + const [coupons, _setCoupons] = useState(() => { + const saved = localStorage.getItem("coupons"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialCoupons; + } + } + return initialCoupons; + }); + + const setCoupons: React.Dispatch> = + useCallback( + (coupons) => { + _setCoupons((prev) => { + const newValue = + typeof coupons === "function" ? coupons(prev) : coupons; + localStorage.setItem("coupons", JSON.stringify(newValue)); + return newValue; + }); + }, + [_setCoupons] + ); + + // 쿠폰 추가 + const addCoupon = useCallback( + (couponData: Coupon) => { + setCoupons((prev) => [...prev, couponData]); + return couponData; + }, + [setCoupons] + ); + + // 쿠폰 삭제 + const removeCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((coupon) => coupon.code !== couponCode)); + }, + [setCoupons] + ); + + // 쿠폰 코드로 쿠폰 찾기 + const getCouponByCode = useCallback( + (code: string) => { + return coupons.find((coupon) => coupon.code === code); + }, + [coupons] + ); + + // 쿠폰 존재 여부 확인 + const isCouponExists = useCallback( + (code: string) => { + return coupons.some((coupon) => coupon.code === code); + }, + [coupons] + ); + + // 쿠폰 목록 가져오기 + const getAllCoupons = useCallback(() => { + return coupons; + }, [coupons]); + + // 쿠폰 코드 자동 생성 + const generateUniqueCouponCode = useCallback(() => { + let code: string; + do { + code = generateCouponCode(); + } while (isCouponExists(code)); + return code; + }, [isCouponExists]); + + return { + coupons, + addCoupon, + removeCoupon, + + getCouponByCode, + isCouponExists, + getAllCoupons, + generateUniqueCouponCode, + }; +}; diff --git a/src/basic/utils/couponUtils.ts b/src/basic/utils/couponUtils.ts new file mode 100644 index 00000000..6d293fe0 --- /dev/null +++ b/src/basic/utils/couponUtils.ts @@ -0,0 +1,66 @@ +import { Coupon } from "../../types"; +import { COUPON_VALIDATION_RULES } from "../constants/coupon"; + +// 쿠폰 할인 적용 +export const applyCouponDiscount = (total: number, coupon: Coupon): number => { + if (!coupon) return total; + + if (coupon.discountType === "amount") { + return Math.max(0, total - coupon.discountValue); + } else if (coupon.discountType === "percentage") { + const discountAmount = (total * coupon.discountValue) / 100; + return Math.max(0, total - discountAmount); + } + + return total; +}; + +// 쿠폰 코드 생성 +export const generateCouponCode = (): string => { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let result = ""; + for (let i = 0; i < 8; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +}; + +// 쿠폰 데이터 검증 +export const validateCouponData = (coupon: Partial): boolean => { + if ( + !coupon.name || + !coupon.code || + !coupon.discountType || + !coupon.discountValue + ) { + return false; + } + + if (coupon.discountType === "amount") { + return ( + coupon.discountValue >= COUPON_VALIDATION_RULES.MIN_AMOUNT_DISCOUNT && + coupon.discountValue <= COUPON_VALIDATION_RULES.MAX_AMOUNT_DISCOUNT + ); + } else if (coupon.discountType === "percentage") { + return ( + coupon.discountValue >= COUPON_VALIDATION_RULES.MIN_PERCENTAGE_DISCOUNT && + coupon.discountValue <= COUPON_VALIDATION_RULES.MAX_PERCENTAGE_DISCOUNT + ); + } + + return false; +}; + +// 쿠폰 중복 검사 +export const isCouponDuplicate = (coupons: Coupon[], code: string): boolean => { + return coupons.some((coupon) => coupon.code === code); +}; + +// 쿠폰 표시 텍스트 생성 +export const getCouponDisplayText = (coupon: Coupon): string => { + if (coupon.discountType === "amount") { + return `${coupon.discountValue.toLocaleString()}원 할인`; + } else { + return `${coupon.discountValue}% 할인`; + } +}; From f36deeefaeb5e884805b19164f043c6be551d59c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Tue, 5 Aug 2025 23:43:16 +0900 Subject: [PATCH 25/52] =?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 --- src/basic/App.tsx | 80 ++++---------------------------------- src/basic/Header.tsx | 91 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 72 deletions(-) create mode 100644 src/basic/Header.tsx diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 6fc00800..becc1737 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -4,6 +4,7 @@ import { Coupon } from "../types"; import useFilterSearchParams from "../hooks/useFilterSearchParams"; import { AdminPage } from "./components/AdminPage"; import { MainPage } from "./components/MainPage"; +import { Header } from "./Header"; import { useCart } from "./hooks/useCart"; import { useCoupons } from "./hooks/useCoupons"; import { useProducts } from "./hooks/useProducts"; @@ -12,7 +13,6 @@ import { calculateItemTotal, getCartItemCount, } from "./utils/cartUtils"; -import { useDebouncedCallback } from "./utils/hooks/useDebounce"; interface Notification { id: string; @@ -34,22 +34,7 @@ const App = () => { const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [notifications, setNotifications] = useState([]); - const { filterSearchParams, setFilterSearchParams } = useFilterSearchParams(); - - const handleSearchChange = (e: React.ChangeEvent) => { - const value = e.target.value; - - if (!value) { - setFilterSearchParams({ searchTerm: "" }); - return; - } - - debouncedSetSearchTerm(value); - }; - - const debouncedSetSearchTerm = useDebouncedCallback((value: string) => { - setFilterSearchParams({ searchTerm: value }); - }, 500); + const { filterSearchParams } = useFilterSearchParams(); const addNotification = useCallback( (message: string, type: "error" | "success" | "warning" = "success") => { @@ -116,61 +101,12 @@ const App = () => { ))}
)} -
-
-
-
-

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && ( -
- -
- )} -
- -
-
-
+
{isAdmin ? ( diff --git a/src/basic/Header.tsx b/src/basic/Header.tsx new file mode 100644 index 00000000..dbb2e0b5 --- /dev/null +++ b/src/basic/Header.tsx @@ -0,0 +1,91 @@ +import { CartItem } from "../types"; + +import useFilterSearchParams from "../hooks/useFilterSearchParams"; +import { useDebouncedCallback } from "./utils/hooks/useDebounce"; + +export const Header = ({ + isAdmin, + setIsAdmin, + cart, + totalItemCount, +}: { + isAdmin: boolean; + setIsAdmin: (isAdmin: boolean) => void; + cart: CartItem[]; + totalItemCount: number; +}) => { + const { filterSearchParams, setFilterSearchParams } = useFilterSearchParams(); + + const handleSearchChange = (e: React.ChangeEvent) => { + const value = e.target.value; + + if (!value) { + setFilterSearchParams({ searchTerm: "" }); + return; + } + + debouncedSetSearchTerm(value); + }; + + const debouncedSetSearchTerm = useDebouncedCallback((value: string) => { + setFilterSearchParams({ searchTerm: value }); + }, 500); + + return ( +
+
+
+
+

SHOP

+ {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} + {!isAdmin && ( +
+ +
+ )} +
+ +
+
+
+ ); +}; From f3545e4e8cfb2b8ddc0e36530d47c968567033bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Wed, 6 Aug 2025 00:30:27 +0900 Subject: [PATCH 26/52] =?UTF-8?q?refactor:=20App.tsx=EC=97=90=EC=84=9C=20N?= =?UTF-8?q?otificationList=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=ED=91=9C=EC=8B=9C=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 --- src/basic/App.tsx | 47 +++++---------------------------- src/basic/NotificationList.tsx | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 41 deletions(-) create mode 100644 src/basic/NotificationList.tsx diff --git a/src/basic/App.tsx b/src/basic/App.tsx index becc1737..d2977c09 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -13,8 +13,9 @@ import { calculateItemTotal, getCartItemCount, } from "./utils/cartUtils"; +import { NotificationList } from "./NotificationList"; -interface Notification { +export interface Notification { id: string; message: string; type: "error" | "success" | "warning"; @@ -61,46 +62,10 @@ const App = () => { return (
- {notifications.length > 0 && ( -
- {notifications.map((notif) => ( -
- {notif.message} - -
- ))} -
- )} +
>; +}) => { + return ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ); +}; From 8202dbe7564636224b9d5be55708f2f60a16b763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Wed, 6 Aug 2025 01:12:13 +0900 Subject: [PATCH 27/52] =?UTF-8?q?refactor:=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=A6=AC=ED=8C=A9=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 | 9 +++++---- src/basic/components/{ => coupon}/CouponForm.tsx | 0 src/basic/components/{ => coupon}/CouponList.tsx | 0 src/basic/components/{ => coupon}/CouponManagement.tsx | 4 ++-- src/basic/components/{ => product}/ProductCard.tsx | 0 src/basic/components/{ => product}/ProductForm.tsx | 0 src/basic/components/{ => product}/ProductList.tsx | 2 +- src/basic/{ => components/ui}/Header.tsx | 0 src/basic/{ => components/ui}/NotificationList.tsx | 2 +- src/basic/components/ui/UIToast.ts | 0 src/basic/{components => pages}/AdminPage.tsx | 2 +- src/basic/{components => pages}/CartPage.tsx | 0 src/basic/{components => pages}/MainPage.tsx | 0 src/basic/utils/validators.ts | 8 -------- 14 files changed, 10 insertions(+), 17 deletions(-) rename src/basic/components/{ => coupon}/CouponForm.tsx (100%) rename src/basic/components/{ => coupon}/CouponList.tsx (100%) rename src/basic/components/{ => coupon}/CouponManagement.tsx (98%) rename src/basic/components/{ => product}/ProductCard.tsx (100%) rename src/basic/components/{ => product}/ProductForm.tsx (100%) rename src/basic/components/{ => product}/ProductList.tsx (97%) rename src/basic/{ => components/ui}/Header.tsx (100%) rename src/basic/{ => components/ui}/NotificationList.tsx (96%) delete mode 100644 src/basic/components/ui/UIToast.ts rename src/basic/{components => pages}/AdminPage.tsx (99%) rename src/basic/{components => pages}/CartPage.tsx (100%) rename src/basic/{components => pages}/MainPage.tsx (100%) delete mode 100644 src/basic/utils/validators.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index d2977c09..f0228f8b 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -2,9 +2,9 @@ import { useCallback, useState } from "react"; import { Coupon } from "../types"; import useFilterSearchParams from "../hooks/useFilterSearchParams"; -import { AdminPage } from "./components/AdminPage"; -import { MainPage } from "./components/MainPage"; -import { Header } from "./Header"; +import { AdminPage } from "./pages/AdminPage"; +import { MainPage } from "./pages/MainPage"; + import { useCart } from "./hooks/useCart"; import { useCoupons } from "./hooks/useCoupons"; import { useProducts } from "./hooks/useProducts"; @@ -13,7 +13,8 @@ import { calculateItemTotal, getCartItemCount, } from "./utils/cartUtils"; -import { NotificationList } from "./NotificationList"; +import { NotificationList } from "./components/ui/NotificationList"; +import { Header } from "./components/ui/Header"; export interface Notification { id: string; diff --git a/src/basic/components/CouponForm.tsx b/src/basic/components/coupon/CouponForm.tsx similarity index 100% rename from src/basic/components/CouponForm.tsx rename to src/basic/components/coupon/CouponForm.tsx diff --git a/src/basic/components/CouponList.tsx b/src/basic/components/coupon/CouponList.tsx similarity index 100% rename from src/basic/components/CouponList.tsx rename to src/basic/components/coupon/CouponList.tsx diff --git a/src/basic/components/CouponManagement.tsx b/src/basic/components/coupon/CouponManagement.tsx similarity index 98% rename from src/basic/components/CouponManagement.tsx rename to src/basic/components/coupon/CouponManagement.tsx index 0f3fac95..94649bff 100644 --- a/src/basic/components/CouponManagement.tsx +++ b/src/basic/components/coupon/CouponManagement.tsx @@ -1,11 +1,11 @@ import { useState } from "react"; -import { Coupon } from "../../types"; +import { Coupon } from "../../../types"; import { CouponForm } from "./CouponForm"; import { getCouponDisplayText, validateCouponData, isCouponDuplicate, -} from "../utils/couponUtils"; +} from "../../utils/couponUtils"; interface CouponManagementProps { coupons: Coupon[]; diff --git a/src/basic/components/ProductCard.tsx b/src/basic/components/product/ProductCard.tsx similarity index 100% rename from src/basic/components/ProductCard.tsx rename to src/basic/components/product/ProductCard.tsx diff --git a/src/basic/components/ProductForm.tsx b/src/basic/components/product/ProductForm.tsx similarity index 100% rename from src/basic/components/ProductForm.tsx rename to src/basic/components/product/ProductForm.tsx diff --git a/src/basic/components/ProductList.tsx b/src/basic/components/product/ProductList.tsx similarity index 97% rename from src/basic/components/ProductList.tsx rename to src/basic/components/product/ProductList.tsx index 086d2ccf..a2887ae0 100644 --- a/src/basic/components/ProductList.tsx +++ b/src/basic/components/product/ProductList.tsx @@ -1,7 +1,7 @@ import React from "react"; import { ProductCard } from "./ProductCard"; -import { Product } from "../../types"; +import { Product } from "../../../types"; interface ProductListProps { products: Product[]; diff --git a/src/basic/Header.tsx b/src/basic/components/ui/Header.tsx similarity index 100% rename from src/basic/Header.tsx rename to src/basic/components/ui/Header.tsx diff --git a/src/basic/NotificationList.tsx b/src/basic/components/ui/NotificationList.tsx similarity index 96% rename from src/basic/NotificationList.tsx rename to src/basic/components/ui/NotificationList.tsx index 7eca1fe5..4832ee21 100644 --- a/src/basic/NotificationList.tsx +++ b/src/basic/components/ui/NotificationList.tsx @@ -1,4 +1,4 @@ -import { Notification } from "./App"; +import { Notification } from "../../App"; export const NotificationList = ({ notifications, diff --git a/src/basic/components/ui/UIToast.ts b/src/basic/components/ui/UIToast.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/basic/components/AdminPage.tsx b/src/basic/pages/AdminPage.tsx similarity index 99% rename from src/basic/components/AdminPage.tsx rename to src/basic/pages/AdminPage.tsx index 14f745c2..df5a9e15 100644 --- a/src/basic/components/AdminPage.tsx +++ b/src/basic/pages/AdminPage.tsx @@ -18,7 +18,7 @@ import { useState, useCallback } from "react"; import { Coupon, Product } from "../../types"; import { displayPrice } from "../utils/formatters"; -import { CouponManagement } from "./CouponManagement"; +import { CouponManagement } from "../components/coupon/CouponManagement"; interface AdminPageProps { products: Product[]; diff --git a/src/basic/components/CartPage.tsx b/src/basic/pages/CartPage.tsx similarity index 100% rename from src/basic/components/CartPage.tsx rename to src/basic/pages/CartPage.tsx diff --git a/src/basic/components/MainPage.tsx b/src/basic/pages/MainPage.tsx similarity index 100% rename from src/basic/components/MainPage.tsx rename to src/basic/pages/MainPage.tsx diff --git a/src/basic/utils/validators.ts b/src/basic/utils/validators.ts deleted file mode 100644 index 7d2dda44..00000000 --- a/src/basic/utils/validators.ts +++ /dev/null @@ -1,8 +0,0 @@ -// TODO: 검증 유틸리티 함수들 -// 구현할 함수: -// - isValidCouponCode(code: string): boolean - 쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자) -// - isValidStock(stock: number): boolean - 재고 수량 검증 (0 이상) -// - isValidPrice(price: number): boolean - 가격 검증 (양수) -// - extractNumbers(value: string): string - 문자열에서 숫자만 추출 - -// TODO: 구현 \ No newline at end of file From dfb5ad4aa561614894ef8d7d779195a793a33000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Wed, 6 Aug 2025 01:20:25 +0900 Subject: [PATCH 28/52] =?UTF-8?q?refactor:=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20imp?= =?UTF-8?q?ort=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 4 ++-- src/basic/components/coupon/CouponForm.tsx | 7 +++++-- src/basic/components/coupon/CouponList.tsx | 4 ++-- src/basic/components/product/ProductCard.tsx | 2 +- src/basic/components/product/ProductForm.tsx | 2 +- src/basic/components/ui/Header.tsx | 7 +++---- src/basic/pages/CartPage.tsx | 21 ------------------- .../pages/{MainPage.tsx => ShopPage.tsx} | 6 +++--- 8 files changed, 17 insertions(+), 36 deletions(-) delete mode 100644 src/basic/pages/CartPage.tsx rename src/basic/pages/{MainPage.tsx => ShopPage.tsx} (99%) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index f0228f8b..b389c0d1 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -3,7 +3,7 @@ import { Coupon } from "../types"; import useFilterSearchParams from "../hooks/useFilterSearchParams"; import { AdminPage } from "./pages/AdminPage"; -import { MainPage } from "./pages/MainPage"; +import { ShopPage } from "./pages/ShopPage"; import { useCart } from "./hooks/useCart"; import { useCoupons } from "./hooks/useCoupons"; @@ -87,7 +87,7 @@ const App = () => { removeCoupon={removeCoupon} /> ) : ( - void; diff --git a/src/basic/components/coupon/CouponList.tsx b/src/basic/components/coupon/CouponList.tsx index b1239edc..72601752 100644 --- a/src/basic/components/coupon/CouponList.tsx +++ b/src/basic/components/coupon/CouponList.tsx @@ -1,5 +1,5 @@ -import { Coupon } from "../../types"; -import { getCouponDisplayText } from "../utils/couponUtils"; +import { Coupon } from "../../../types"; +import { getCouponDisplayText } from "../../utils/couponUtils"; interface CouponListProps { coupons: Coupon[]; diff --git a/src/basic/components/product/ProductCard.tsx b/src/basic/components/product/ProductCard.tsx index 547c3b67..56ad56e7 100644 --- a/src/basic/components/product/ProductCard.tsx +++ b/src/basic/components/product/ProductCard.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Product } from "../../types"; +import { Product } from "../../../types"; interface ProductCardProps { product: Product; diff --git a/src/basic/components/product/ProductForm.tsx b/src/basic/components/product/ProductForm.tsx index 59074b48..bbc6aeb2 100644 --- a/src/basic/components/product/ProductForm.tsx +++ b/src/basic/components/product/ProductForm.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Discount } from "../../types"; +import { Discount } from "../../../types"; interface ProductFormData { name: string; diff --git a/src/basic/components/ui/Header.tsx b/src/basic/components/ui/Header.tsx index dbb2e0b5..b1e10e0b 100644 --- a/src/basic/components/ui/Header.tsx +++ b/src/basic/components/ui/Header.tsx @@ -1,7 +1,6 @@ -import { CartItem } from "../types"; - -import useFilterSearchParams from "../hooks/useFilterSearchParams"; -import { useDebouncedCallback } from "./utils/hooks/useDebounce"; +import { CartItem } from "../../../types"; +import useFilterSearchParams from "../../../hooks/useFilterSearchParams"; +import { useDebouncedCallback } from "../../utils/hooks/useDebounce"; export const Header = ({ isAdmin, diff --git a/src/basic/pages/CartPage.tsx b/src/basic/pages/CartPage.tsx deleted file mode 100644 index 069edafc..00000000 --- a/src/basic/pages/CartPage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// TODO: 장바구니 페이지 컴포넌트 -// 힌트: -// 1. 상품 목록 표시 (검색 기능 포함) -// 2. 장바구니 관리 -// 3. 쿠폰 적용 -// 4. 주문 처리 -// -// 필요한 hooks: -// - useProducts: 상품 목록 관리 -// - useCart: 장바구니 상태 관리 -// - useCoupons: 쿠폰 목록 관리 -// - useDebounce: 검색어 디바운싱 -// -// 하위 컴포넌트: -// - SearchBar: 검색 입력 -// - ProductList: 상품 목록 표시 -// - Cart: 장바구니 표시 및 결제 - -export function CartPage() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/basic/pages/MainPage.tsx b/src/basic/pages/ShopPage.tsx similarity index 99% rename from src/basic/pages/MainPage.tsx rename to src/basic/pages/ShopPage.tsx index 5a5c2f21..78a2b003 100644 --- a/src/basic/pages/MainPage.tsx +++ b/src/basic/pages/ShopPage.tsx @@ -4,7 +4,7 @@ import { displayPrice } from "../utils/formatters"; import { getRefinedProduct } from "../utils/productUtils"; import { getRemainingStock } from "../utils/cartUtils"; -interface MainPageProps { +interface ShopPageProps { products: Product[]; filteredProducts: Product[]; filterSearchParams: { searchTerm?: string }; @@ -28,7 +28,7 @@ interface MainPageProps { getProductRemainingStock: (product: Product) => number; } -export function MainPage({ +export function ShopPage({ products, filteredProducts, filterSearchParams, @@ -43,7 +43,7 @@ export function MainPage({ calculateCartTotal, calculateItemTotal, coupons, -}: MainPageProps) { +}: ShopPageProps) { // 장바구니에 상품 추가 (props로 받은 함수 사용) const handleAddToCart = useCallback( (product: Product) => { From cb8feeddcb3a3c34cc1728f9eca06925107eff80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Wed, 6 Aug 2025 01:38:12 +0900 Subject: [PATCH 29/52] =?UTF-8?q?refactor:=20ProductList=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9E=A5=EB=B0=94=EA=B5=AC=EB=8B=88=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pr.md | 4 + src/basic/App.tsx | 39 +---- src/basic/components/product/ProductList.tsx | 151 +++++++++++++--- src/basic/components/ui/Header.tsx | 5 +- src/basic/hooks/useCart.ts | 10 -- src/basic/pages/ShopPage.tsx | 170 +++---------------- 6 files changed, 163 insertions(+), 216 deletions(-) diff --git a/pr.md b/pr.md index d0a8458c..be74a595 100644 --- a/pr.md +++ b/pr.md @@ -43,6 +43,10 @@ +1. displayPrice +2. usesearchparams +3. 액션함수 순수함수 분리 + ### 과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요? ### 과제를 다시 해보면 더 잘 할 수 있었겠다 아쉬운 점이 있다면 무엇인가요? diff --git a/src/basic/App.tsx b/src/basic/App.tsx index b389c0d1..6b6f2e57 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,20 +1,14 @@ import { useCallback, useState } from "react"; import { Coupon } from "../types"; -import useFilterSearchParams from "../hooks/useFilterSearchParams"; import { AdminPage } from "./pages/AdminPage"; import { ShopPage } from "./pages/ShopPage"; +import { Header } from "./components/ui/Header"; +import { NotificationList } from "./components/ui/NotificationList"; import { useCart } from "./hooks/useCart"; import { useCoupons } from "./hooks/useCoupons"; import { useProducts } from "./hooks/useProducts"; -import { - calculateCartTotal, - calculateItemTotal, - getCartItemCount, -} from "./utils/cartUtils"; -import { NotificationList } from "./components/ui/NotificationList"; -import { Header } from "./components/ui/Header"; export interface Notification { id: string; @@ -34,10 +28,10 @@ const App = () => { const { coupons, addCoupon, removeCoupon } = useCoupons(); const [selectedCoupon, setSelectedCoupon] = useState(null); + const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const { filterSearchParams } = useFilterSearchParams(); + const [notifications, setNotifications] = useState([]); const addNotification = useCallback( (message: string, type: "error" | "success" | "warning" = "success") => { const id = Date.now().toString(); @@ -50,16 +44,8 @@ const App = () => { [] ); - const { - cart, - addToCart, - updateCartItemQuantity, - removeFromCart, - clearCart, - getProductRemainingStock, - } = useCart(); - - const totalItemCount = getCartItemCount(cart); + const { cart, addToCart, updateCartItemQuantity, removeFromCart, clearCart } = + useCart(); return (
@@ -67,12 +53,7 @@ const App = () => { notifications={notifications} setNotifications={setNotifications} /> -
+
{isAdmin ? ( @@ -90,9 +71,6 @@ const App = () => { { selectedCoupon={selectedCoupon} setSelectedCoupon={setSelectedCoupon} addNotification={addNotification} - calculateCartTotal={() => calculateCartTotal(cart, selectedCoupon)} - calculateItemTotal={(item) => calculateItemTotal(item, cart)} coupons={coupons} - getProductRemainingStock={getProductRemainingStock} /> )}
diff --git a/src/basic/components/product/ProductList.tsx b/src/basic/components/product/ProductList.tsx index a2887ae0..a62c1f9e 100644 --- a/src/basic/components/product/ProductList.tsx +++ b/src/basic/components/product/ProductList.tsx @@ -1,48 +1,153 @@ -import React from "react"; +import React, { useCallback } from "react"; -import { ProductCard } from "./ProductCard"; -import { Product } from "../../../types"; +import { CartItem, Product } from "../../../types"; +import { getRefinedProduct } from "../../utils/productUtils"; +import { displayPrice } from "../../utils/formatters"; +import { getRemainingStock } from "../../utils/cartUtils"; +import useFilterSearchParams from "../../../hooks/useFilterSearchParams"; interface ProductListProps { products: Product[]; filteredProducts: Product[]; - debouncedSearchTerm: string; - onAddToCart: (product: Product) => void; - formatPrice: (price: number, productId?: string) => string; - getRemainingStock: (product: Product) => number; + cart: CartItem[]; + addToCart: (product: Product, quantity?: number) => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; } export const ProductList: React.FC = ({ products, filteredProducts, - debouncedSearchTerm, - onAddToCart, - formatPrice, - getRemainingStock, + cart, + addToCart, + addNotification, }) => { + const { filterSearchParams } = useFilterSearchParams(); + + const handleAddToCart = useCallback( + (product: Product) => { + if (!getRemainingStock(product, cart)) { + addNotification("재고가 부족합니다", "error"); + return; + } + + addToCart(product, 1); + + addNotification("장바구니에 담았습니다", "success"); + }, + [cart, addToCart, addNotification] + ); + return ( -
-
+
+

전체 상품

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

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

) : (
- {filteredProducts.map((product) => ( - - ))} + {filteredProducts.map((product) => { + const refinedProduct = getRefinedProduct(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} +

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

+ {displayPrice(refinedProduct, { + prefix: "₩", + })} +

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

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

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

+ 품절임박! {refinedProduct.stock}개 남음 +

+ )} + {refinedProduct.stock > 5 && ( +

+ 재고 {refinedProduct.stock}개 +

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); + })}
)}
diff --git a/src/basic/components/ui/Header.tsx b/src/basic/components/ui/Header.tsx index b1e10e0b..85000a24 100644 --- a/src/basic/components/ui/Header.tsx +++ b/src/basic/components/ui/Header.tsx @@ -1,17 +1,16 @@ import { CartItem } from "../../../types"; import useFilterSearchParams from "../../../hooks/useFilterSearchParams"; import { useDebouncedCallback } from "../../utils/hooks/useDebounce"; +import { getCartItemCount } from "../../utils/cartUtils"; export const Header = ({ isAdmin, setIsAdmin, cart, - totalItemCount, }: { isAdmin: boolean; setIsAdmin: (isAdmin: boolean) => void; cart: CartItem[]; - totalItemCount: number; }) => { const { filterSearchParams, setFilterSearchParams } = useFilterSearchParams(); @@ -77,7 +76,7 @@ export const Header = ({ {cart.length > 0 && ( - {totalItemCount} + {getCartItemCount(cart)} )}
diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts index 16fee0e3..10005cac 100644 --- a/src/basic/hooks/useCart.ts +++ b/src/basic/hooks/useCart.ts @@ -1,6 +1,5 @@ import { useCallback, useState } from "react"; import { CartItem, Product } from "../../types"; -import { getRemainingStock } from "../utils/cartUtils"; export const useCart = () => { const [cart, _setCart] = useState(() => { @@ -77,14 +76,6 @@ export const useCart = () => { setCart([]); }, [setCart]); - // 상품의 재고 확인 - const getProductRemainingStock = useCallback( - (product: Product) => { - return getRemainingStock(product, cart); - }, - [cart] - ); - // 장바구니에 상품이 있는지 확인 const isInCart = useCallback( (productId: string) => { @@ -107,7 +98,6 @@ export const useCart = () => { updateCartItemQuantity, removeFromCart, clearCart, - getProductRemainingStock, isInCart, getCartItem, }; diff --git a/src/basic/pages/ShopPage.tsx b/src/basic/pages/ShopPage.tsx index 78a2b003..74bf30d0 100644 --- a/src/basic/pages/ShopPage.tsx +++ b/src/basic/pages/ShopPage.tsx @@ -1,13 +1,17 @@ import { useCallback } from "react"; import { CartItem, Coupon, Product } from "../../types"; -import { displayPrice } from "../utils/formatters"; -import { getRefinedProduct } from "../utils/productUtils"; -import { getRemainingStock } from "../utils/cartUtils"; + +import { ProductList } from "../components/product/ProductList"; +import { + calculateCartTotal, + calculateItemTotal, + getRemainingStock, +} from "../utils/cartUtils"; interface ShopPageProps { products: Product[]; filteredProducts: Product[]; - filterSearchParams: { searchTerm?: string }; + cart: CartItem[]; addToCart: (product: Product, quantity?: number) => void; updateCartItemQuantity: (productId: string, quantity: number) => void; @@ -19,19 +23,12 @@ interface ShopPageProps { message: string, type: "error" | "success" | "warning" ) => void; - calculateCartTotal: () => { - totalBeforeDiscount: number; - totalAfterDiscount: number; - }; - calculateItemTotal: (item: CartItem) => number; coupons: Coupon[]; - getProductRemainingStock: (product: Product) => number; } export function ShopPage({ products, filteredProducts, - filterSearchParams, cart, addToCart, updateCartItemQuantity, @@ -40,25 +37,8 @@ export function ShopPage({ selectedCoupon, setSelectedCoupon, addNotification, - calculateCartTotal, - calculateItemTotal, coupons, }: ShopPageProps) { - // 장바구니에 상품 추가 (props로 받은 함수 사용) - const handleAddToCart = useCallback( - (product: Product) => { - if (!getRemainingStock(product, cart)) { - addNotification("재고가 부족합니다", "error"); - return; - } - - addToCart(product, 1); - - addNotification("장바구니에 담았습니다", "success"); - }, - [cart, addToCart, addNotification] - ); - // 장바구니 상품 수량 변경 (props로 받은 함수 사용) const handleUpdateQuantity = useCallback( (product: Product, newQuantity: number) => { @@ -85,7 +65,10 @@ export function ShopPage({ // 쿠폰 적용 const applyCoupon = useCallback( (coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalBeforeDiscount; + const currentTotal = calculateCartTotal( + cart, + selectedCoupon + ).totalBeforeDiscount; if (currentTotal < 10000 && coupon.discountType === "percentage") { addNotification( @@ -98,7 +81,7 @@ export function ShopPage({ setSelectedCoupon(coupon); addNotification("쿠폰이 적용되었습니다.", "success"); }, - [addNotification, calculateCartTotal, setSelectedCoupon] + [addNotification, cart, selectedCoupon, setSelectedCoupon] ); // 주문 완료 @@ -112,128 +95,19 @@ export function ShopPage({ setSelectedCoupon(null); }, [addNotification, clearCart, setSelectedCoupon]); - const totals = calculateCartTotal(); + const totals = calculateCartTotal(cart, selectedCoupon); return (
{/* 상품 목록 */} -
-
-

전체 상품

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

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

-
- ) : ( -
- {filteredProducts.map((product) => { - const refinedProduct = getRefinedProduct(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} -

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

- {displayPrice(refinedProduct, { - prefix: "₩", - })} -

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

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

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

- 품절임박! {refinedProduct.stock}개 남음 -

- )} - {refinedProduct.stock > 5 && ( -

- 재고 {refinedProduct.stock}개 -

- )} -
- - {/* 장바구니 버튼 */} - -
-
- ); - })} -
- )} -
+
@@ -275,7 +149,7 @@ export function ShopPage({ ) : (
{cart.map((item) => { - const itemTotal = calculateItemTotal(item); + const itemTotal = calculateItemTotal(item, cart); const originalPrice = item.product.price * item.quantity; const hasDiscount = itemTotal < originalPrice; const discountRate = hasDiscount From 81d290b830088ddc0f1a04f9dfa6a06a6a1a136f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Wed, 6 Aug 2025 01:45:45 +0900 Subject: [PATCH 30/52] =?UTF-8?q?refactor:=20svg=20icon=20component?= =?UTF-8?q?=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 --- .../components/coupon/CouponManagement.tsx | 29 +-- src/basic/components/icons/index.tsx | 218 +++++++++++++++++- src/basic/components/product/ProductCard.tsx | 15 +- src/basic/components/product/ProductForm.tsx | 15 +- src/basic/components/product/ProductList.tsx | 15 +- src/basic/components/ui/Header.tsx | 15 +- src/basic/components/ui/NotificationList.tsx | 15 +- src/basic/pages/AdminPage.tsx | 15 +- src/basic/pages/ShopPage.tsx | 43 +--- 9 files changed, 225 insertions(+), 155 deletions(-) diff --git a/src/basic/components/coupon/CouponManagement.tsx b/src/basic/components/coupon/CouponManagement.tsx index 94649bff..3162a7a6 100644 --- a/src/basic/components/coupon/CouponManagement.tsx +++ b/src/basic/components/coupon/CouponManagement.tsx @@ -6,6 +6,7 @@ import { validateCouponData, isCouponDuplicate, } from "../../utils/couponUtils"; +import { TrashIcon, PlusIcon } from "../icons"; interface CouponManagementProps { coupons: Coupon[]; @@ -81,19 +82,7 @@ export const CouponManagement: React.FC = ({ onClick={() => handleRemoveCoupon(coupon.code)} className="text-gray-400 hover:text-red-600 transition-colors" > - - - +
@@ -104,19 +93,7 @@ export const CouponManagement: React.FC = ({ onClick={() => setShowCouponForm(!showCouponForm)} className="text-gray-400 hover:text-gray-600 flex flex-col items-center" > - - - +

새 쿠폰 추가

diff --git a/src/basic/components/icons/index.tsx b/src/basic/components/icons/index.tsx index 1609d774..d548b556 100644 --- a/src/basic/components/icons/index.tsx +++ b/src/basic/components/icons/index.tsx @@ -1,12 +1,206 @@ -// TODO: SVG 아이콘 컴포넌트들 -// 구현할 아이콘: -// - CartIcon: 장바구니 아이콘 -// - AdminIcon: 관리자 아이콘 -// - PlusIcon: 플러스 아이콘 -// - MinusIcon: 마이너스 아이콘 -// - TrashIcon: 삭제 아이콘 -// - ChevronDownIcon: 아래 화살표 -// - ChevronUpIcon: 위 화살표 -// - CheckIcon: 체크 아이콘 - -// TODO: 구현 \ No newline at end of file +import React from "react"; + +interface IconProps { + className?: string; + size?: number; +} + +export const CartIcon: React.FC = ({ + className = "w-6 h-6", + size, +}) => ( + + + +); + +export const TrashIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const PlusIcon: React.FC = ({ + className = "w-8 h-8", + size, +}) => ( + + + +); + +export const MinusIcon: React.FC = ({ + className = "w-4 h-4", + size, +}) => ( + + + +); + +export const ImageIcon: React.FC = ({ + className = "w-24 h-24", + size, +}) => ( + + + +); + +export const CloseIcon: React.FC = ({ + className = "w-4 h-4", + size, +}) => ( + + + +); + +export const ChevronDownIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const ChevronUpIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const CheckIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const AdminIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); diff --git a/src/basic/components/product/ProductCard.tsx b/src/basic/components/product/ProductCard.tsx index 56ad56e7..90643014 100644 --- a/src/basic/components/product/ProductCard.tsx +++ b/src/basic/components/product/ProductCard.tsx @@ -1,5 +1,6 @@ import React from "react"; import { Product } from "../../../types"; +import { ImageIcon } from "../icons"; interface ProductCardProps { product: Product; @@ -19,19 +20,7 @@ export const ProductCard: React.FC = ({ {/* 상품 이미지 영역 (placeholder) */}
- - - +
{product.isRecommended && ( diff --git a/src/basic/components/product/ProductForm.tsx b/src/basic/components/product/ProductForm.tsx index bbc6aeb2..d00e58a5 100644 --- a/src/basic/components/product/ProductForm.tsx +++ b/src/basic/components/product/ProductForm.tsx @@ -1,5 +1,6 @@ import React from "react"; import { Discount } from "../../../types"; +import { CloseIcon } from "../icons"; interface ProductFormData { name: string; @@ -170,19 +171,7 @@ export const ProductForm: React.FC = ({ }} className="text-red-600 hover:text-red-800" > - - - +
))} diff --git a/src/basic/components/product/ProductList.tsx b/src/basic/components/product/ProductList.tsx index a62c1f9e..0a32bc14 100644 --- a/src/basic/components/product/ProductList.tsx +++ b/src/basic/components/product/ProductList.tsx @@ -5,6 +5,7 @@ import { getRefinedProduct } from "../../utils/productUtils"; import { displayPrice } from "../../utils/formatters"; import { getRemainingStock } from "../../utils/cartUtils"; import useFilterSearchParams from "../../../hooks/useFilterSearchParams"; +import { ImageIcon } from "../icons"; interface ProductListProps { products: Product[]; @@ -65,19 +66,7 @@ export const ProductList: React.FC = ({ {/* 상품 이미지 영역 (placeholder) */}
- - - +
{product.isRecommended && ( diff --git a/src/basic/components/ui/Header.tsx b/src/basic/components/ui/Header.tsx index 85000a24..2829605a 100644 --- a/src/basic/components/ui/Header.tsx +++ b/src/basic/components/ui/Header.tsx @@ -2,6 +2,7 @@ import { CartItem } from "../../../types"; import useFilterSearchParams from "../../../hooks/useFilterSearchParams"; import { useDebouncedCallback } from "../../utils/hooks/useDebounce"; import { getCartItemCount } from "../../utils/cartUtils"; +import { CartIcon } from "../icons"; export const Header = ({ isAdmin, @@ -61,19 +62,7 @@ export const Header = ({ {!isAdmin && (
- - - + {cart.length > 0 && ( {getCartItemCount(cart)} diff --git a/src/basic/components/ui/NotificationList.tsx b/src/basic/components/ui/NotificationList.tsx index 4832ee21..3942ca5c 100644 --- a/src/basic/components/ui/NotificationList.tsx +++ b/src/basic/components/ui/NotificationList.tsx @@ -1,4 +1,5 @@ import { Notification } from "../../App"; +import { CloseIcon } from "../icons"; export const NotificationList = ({ notifications, @@ -27,19 +28,7 @@ export const NotificationList = ({ } className="text-white hover:text-gray-200" > - - - +
))} diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx index df5a9e15..0f5589aa 100644 --- a/src/basic/pages/AdminPage.tsx +++ b/src/basic/pages/AdminPage.tsx @@ -19,6 +19,7 @@ import { useState, useCallback } from "react"; import { Coupon, Product } from "../../types"; import { displayPrice } from "../utils/formatters"; import { CouponManagement } from "../components/coupon/CouponManagement"; +import { CloseIcon } from "../components/icons"; interface AdminPageProps { products: Product[]; @@ -408,19 +409,7 @@ export function AdminPage({ }} className="text-red-600 hover:text-red-800" > - - - +
))} diff --git a/src/basic/pages/ShopPage.tsx b/src/basic/pages/ShopPage.tsx index 74bf30d0..949e928b 100644 --- a/src/basic/pages/ShopPage.tsx +++ b/src/basic/pages/ShopPage.tsx @@ -7,6 +7,7 @@ import { calculateItemTotal, getRemainingStock, } from "../utils/cartUtils"; +import { CartIcon, TrashIcon } from "../components/icons"; interface ShopPageProps { products: Product[]; @@ -114,36 +115,12 @@ export function ShopPage({

- - - + 장바구니

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

장바구니가 비어있습니다

) : ( @@ -169,19 +146,7 @@ export function ShopPage({ onClick={() => removeFromCart(item.product.id)} className="text-gray-400 hover:text-red-500 ml-2" > - - - +
From 23be1f8c2bbae1fe8e47f33f281fcf8fe5ba6847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Wed, 6 Aug 2025 01:56:26 +0900 Subject: [PATCH 31/52] =?UTF-8?q?refactor:=20ShopPage=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 --- src/basic/components/cart/CartSection.tsx | 91 +++++++++ src/basic/components/coupon/CouponSection.tsx | 46 +++++ .../components/payment/PaymentSection.tsx | 52 +++++ src/basic/pages/ShopPage.tsx | 180 ++---------------- 4 files changed, 206 insertions(+), 163 deletions(-) create mode 100644 src/basic/components/cart/CartSection.tsx create mode 100644 src/basic/components/coupon/CouponSection.tsx create mode 100644 src/basic/components/payment/PaymentSection.tsx diff --git a/src/basic/components/cart/CartSection.tsx b/src/basic/components/cart/CartSection.tsx new file mode 100644 index 00000000..768e3138 --- /dev/null +++ b/src/basic/components/cart/CartSection.tsx @@ -0,0 +1,91 @@ +import { CartItem, Product } from "../../../types"; +import { CartIcon, TrashIcon } from "../icons"; +import { calculateItemTotal } from "../../utils/cartUtils"; + +export function CartSection({ + cart, + removeFromCart, + handleUpdateQuantity, +}: { + cart: CartItem[]; + removeFromCart: (productId: string) => void; + handleUpdateQuantity: (product: Product, newQuantity: number) => void; +}) { + 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/components/coupon/CouponSection.tsx b/src/basic/components/coupon/CouponSection.tsx new file mode 100644 index 00000000..868baaa2 --- /dev/null +++ b/src/basic/components/coupon/CouponSection.tsx @@ -0,0 +1,46 @@ +import { Coupon } from "../../../types"; + +export function CouponSection({ + coupons, + selectedCoupon, + setSelectedCoupon, + applyCoupon, +}: { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + applyCoupon: (coupon: Coupon) => void; +}) { + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/basic/components/payment/PaymentSection.tsx b/src/basic/components/payment/PaymentSection.tsx new file mode 100644 index 00000000..91587377 --- /dev/null +++ b/src/basic/components/payment/PaymentSection.tsx @@ -0,0 +1,52 @@ +import { CartTotal } from "../../utils/cartUtils"; + +export function PaymentSection({ + totals, + completeOrder, +}: { + totals: CartTotal; + completeOrder: () => void; +}) { + return ( +
+

결제 정보

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

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

+
+
+ ); +} diff --git a/src/basic/pages/ShopPage.tsx b/src/basic/pages/ShopPage.tsx index 949e928b..a481de64 100644 --- a/src/basic/pages/ShopPage.tsx +++ b/src/basic/pages/ShopPage.tsx @@ -2,12 +2,10 @@ import { useCallback } from "react"; import { CartItem, Coupon, Product } from "../../types"; import { ProductList } from "../components/product/ProductList"; -import { - calculateCartTotal, - calculateItemTotal, - getRemainingStock, -} from "../utils/cartUtils"; -import { CartIcon, TrashIcon } from "../components/icons"; +import { calculateCartTotal, getRemainingStock } from "../utils/cartUtils"; +import { CartSection } from "../components/cart/CartSection"; +import { CouponSection } from "../components/coupon/CouponSection"; +import { PaymentSection } from "../components/payment/PaymentSection"; interface ShopPageProps { products: Product[]; @@ -113,166 +111,22 @@ export function ShopPage({
-
-

- - 장바구니 -

- {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 33275e27f6d703bb6b6f3dc4b979628c8cdba422 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Wed, 6 Aug 2025 02:10:24 +0900 Subject: [PATCH 32/52] =?UTF-8?q?refactor:=20adminPage=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=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 --- .../components/product/ProductAccordion.tsx | 169 ++++++++ src/basic/components/product/ProductForm.tsx | 74 +++- src/basic/pages/AdminPage.tsx | 397 +----------------- 3 files changed, 241 insertions(+), 399 deletions(-) create mode 100644 src/basic/components/product/ProductAccordion.tsx diff --git a/src/basic/components/product/ProductAccordion.tsx b/src/basic/components/product/ProductAccordion.tsx new file mode 100644 index 00000000..92259716 --- /dev/null +++ b/src/basic/components/product/ProductAccordion.tsx @@ -0,0 +1,169 @@ +import React, { useState, useCallback } from "react"; +import { Product } from "../../../types"; +import { displayPrice } from "../../utils/formatters"; +import { ProductForm } from "./ProductForm"; + +interface ProductAccordionProps { + products: Product[]; + onEditProduct: (id: string, updates: Partial) => void; + onDeleteProduct: (productId: string) => void; + onAddNewProduct: (productData: Omit) => Product; + onNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const ProductAccordion: React.FC = ({ + products, + onEditProduct, + onDeleteProduct, + onAddNewProduct, + onNotification, +}) => { + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [selectedProduct, setSelectedProduct] = useState( + undefined + ); + + // 상품 편집 시작 + const handleStartEditProduct = useCallback((product: Product) => { + setSelectedProduct(product); + setEditingProduct(product.id); + setShowProductForm(true); + }, []); + + // 새 상품 추가 시작 + const handleAddNewProduct = useCallback(() => { + setSelectedProduct(undefined); + setEditingProduct("new"); + setShowProductForm(true); + }, []); + + // 상품 폼 취소 + const handleCancelProductForm = useCallback(() => { + setEditingProduct(null); + setSelectedProduct(undefined); + setShowProductForm(false); + }, []); + + // 상품 폼 제출 처리 + const handleProductSubmit = useCallback( + (productData: Omit) => { + if (editingProduct && editingProduct !== "new") { + onEditProduct(editingProduct, productData); + } else { + onAddNewProduct(productData); + } + setEditingProduct(null); + setSelectedProduct(undefined); + setShowProductForm(false); + }, + [editingProduct, onEditProduct, onAddNewProduct] + ); + + // 상품 삭제 + const handleDeleteProduct = useCallback( + (productId: string) => { + onDeleteProduct(productId); + onNotification("상품이 삭제되었습니다.", "success"); + }, + [onDeleteProduct, onNotification] + ); + + return ( + <> +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {displayPrice(product, { + suffix: "원", + })} + + 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 && ( + + )} + + ); +}; diff --git a/src/basic/components/product/ProductForm.tsx b/src/basic/components/product/ProductForm.tsx index d00e58a5..b8ced687 100644 --- a/src/basic/components/product/ProductForm.tsx +++ b/src/basic/components/product/ProductForm.tsx @@ -1,5 +1,5 @@ -import React from "react"; -import { Discount } from "../../../types"; +import React, { useState, useEffect } from "react"; +import { Product } from "../../../types"; import { CloseIcon } from "../icons"; interface ProductFormData { @@ -7,15 +7,14 @@ interface ProductFormData { price: number; stock: number; description: string; - discounts: Discount[]; + discounts: Array<{ quantity: number; rate: number }>; } interface ProductFormProps { - productForm: ProductFormData; editingProduct: string | null; - onSubmit: (e: React.FormEvent) => void; + product?: Product; + onSubmit: (productData: Omit) => void; onCancel: () => void; - onFormChange: (form: ProductFormData) => void; onNotification: ( message: string, type: "error" | "success" | "warning" @@ -23,20 +22,73 @@ interface ProductFormProps { } export const ProductForm: React.FC = ({ - productForm, editingProduct, + product, onSubmit, onCancel, - onFormChange, onNotification, }) => { + const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + + // 상품 데이터가 변경될 때 폼 초기화 + useEffect(() => { + if (product && editingProduct === product.id) { + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [], + }); + } else if (editingProduct === "new") { + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + } + }, [editingProduct, product]); + const updateForm = (updates: Partial) => { - onFormChange({ ...productForm, ...updates }); + setProductForm({ ...productForm, ...updates }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ + ...productForm, + discounts: productForm.discounts, + }); + onNotification( + editingProduct === "new" + ? "상품이 추가되었습니다." + : "상품이 수정되었습니다.", + "success" + ); + }; + + const handleCancel = () => { + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + onCancel(); }; return (
-
+

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

@@ -195,7 +247,7 @@ export const ProductForm: React.FC = ({
-
-
- -
- - - - - - - - - - - - {products.map((product) => ( - - - - - - - - ))} - -
- 상품명 - - 가격 - - 재고 - - 설명 - - 작업 -
- {product.name} - - {displayPrice(product, { - suffix: "원", - })} - - 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="%" - /> - % 할인 - -
- ))} - -
-
- -
- - -
- -
- )} -
+ ) : ( Date: Wed, 6 Aug 2025 21:42:27 +0900 Subject: [PATCH 33/52] =?UTF-8?q?refactor:=20advanced=20=EA=B3=BC=EC=A0=9C?= =?UTF-8?q?=20basic=EA=B3=BC=EC=A0=9C=20=EB=82=B4=EC=9A=A9=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 5days-refactoring-prd.md | 14 +- src/advanced/App.tsx | 1433 +---------------- src/advanced/components/AdminPage.tsx | 20 - src/advanced/components/CartPage.tsx | 21 - src/advanced/components/ProductList.tsx | 50 - src/advanced/components/cart/CartSection.tsx | 91 ++ src/advanced/components/coupon/CouponForm.tsx | 171 ++ src/advanced/components/coupon/CouponList.tsx | 50 + .../components/coupon/CouponManagement.tsx | 114 ++ .../components/coupon/CouponSection.tsx | 46 + src/advanced/components/icons/index.tsx | 218 ++- .../components/payment/PaymentSection.tsx | 52 + .../components/product/ProductAccordion.tsx | 169 ++ .../components/{ => product}/ProductCard.tsx | 17 +- .../components/{ => product}/ProductForm.tsx | 89 +- .../components/product/ProductList.tsx | 144 ++ src/advanced/components/ui/Header.tsx | 78 + .../components/ui/NotificationList.tsx | 37 + src/advanced/components/ui/UIToast.ts | 0 src/advanced/constants/cart.ts | 4 + src/advanced/constants/coupon.ts | 46 + src/advanced/hooks/useCart.ts | 133 +- src/advanced/hooks/useCoupons.ts | 102 +- src/advanced/hooks/useProducts.ts | 48 +- src/advanced/main.tsx | 12 +- src/advanced/models/cart.ts | 2 +- src/advanced/models/productAtoms.ts | 12 - src/advanced/pages/AdminPage.tsx | 84 + src/advanced/pages/ShopPage.tsx | 136 ++ src/advanced/utils/cartUtils.ts | 93 ++ src/advanced/utils/couponUtils.ts | 66 + src/advanced/utils/formatters.ts | 11 +- src/advanced/utils/hooks/useLocalStorage.ts | 15 - src/advanced/utils/hooks/useValidate.ts | 0 src/advanced/utils/productUtils.ts | 13 +- src/advanced/utils/validators.ts | 8 - 36 files changed, 1966 insertions(+), 1633 deletions(-) delete mode 100644 src/advanced/components/AdminPage.tsx delete mode 100644 src/advanced/components/CartPage.tsx delete mode 100644 src/advanced/components/ProductList.tsx create mode 100644 src/advanced/components/cart/CartSection.tsx create mode 100644 src/advanced/components/coupon/CouponForm.tsx create mode 100644 src/advanced/components/coupon/CouponList.tsx create mode 100644 src/advanced/components/coupon/CouponManagement.tsx create mode 100644 src/advanced/components/coupon/CouponSection.tsx create mode 100644 src/advanced/components/payment/PaymentSection.tsx create mode 100644 src/advanced/components/product/ProductAccordion.tsx rename src/advanced/components/{ => product}/ProductCard.tsx (83%) rename src/advanced/components/{ => product}/ProductForm.tsx (81%) create mode 100644 src/advanced/components/product/ProductList.tsx create mode 100644 src/advanced/components/ui/Header.tsx create mode 100644 src/advanced/components/ui/NotificationList.tsx delete mode 100644 src/advanced/components/ui/UIToast.ts create mode 100644 src/advanced/constants/cart.ts create mode 100644 src/advanced/constants/coupon.ts delete mode 100644 src/advanced/models/productAtoms.ts create mode 100644 src/advanced/pages/AdminPage.tsx create mode 100644 src/advanced/pages/ShopPage.tsx create mode 100644 src/advanced/utils/cartUtils.ts create mode 100644 src/advanced/utils/couponUtils.ts delete mode 100644 src/advanced/utils/hooks/useLocalStorage.ts delete mode 100644 src/advanced/utils/hooks/useValidate.ts delete mode 100644 src/advanced/utils/validators.ts diff --git a/5days-refactoring-prd.md b/5days-refactoring-prd.md index 93480ebd..116ca095 100644 --- a/5days-refactoring-prd.md +++ b/5days-refactoring-prd.md @@ -12,34 +12,34 @@ ### 1단계: 레이아웃 컴포넌트 분리 ```typescript -// src/advanced/components/layout/Header.tsx +// src/basic/components/layout/Header.tsx export const Header: React.FC<{ isAdmin: boolean; onToggleAdmin: () => void; cartItemCount: number; }>; -// src/advanced/components/layout/Navigation.tsx +// src/basic/components/layout/Navigation.tsx export const Navigation: React.FC; -// src/advanced/components/layout/MainLayout.tsx +// src/basic/components/layout/MainLayout.tsx export const MainLayout: React.FC; ``` ### 2단계: 페이지 컴포넌트 최종 정리 ```typescript -// src/advanced/components/ProductListPage.tsx +// src/basic/components/ProductListPage.tsx export const ProductListPage: React.FC - 완전한 상품 목록 페이지 -// src/advanced/components/AdminPage.tsx +// src/basic/components/AdminPage.tsx export const AdminPage: React.FC - 완전한 관리자 페이지 ``` ### 3단계: App 컴포넌트 최종 정리 ```typescript -// src/advanced/App.tsx - 최종 모습 (50줄 이하) +// src/basic/App.tsx - 최종 모습 (50줄 이하) const App = () => { const [isAdmin, setIsAdmin] = useState(false); @@ -62,7 +62,7 @@ const App = () => { ### 4단계: 최종 폴더 구조 정리 ``` -src/advanced/ +src/basic/ ├── App.tsx (50줄 이하) ├── components/ │ ├── layout/ - 레이아웃 컴포넌트 diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index 8e6af94f..6b6f2e57 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,150 +1,37 @@ -import { useState, useCallback, useEffect } from "react"; -import { CartItem, Coupon, Product } from "../types"; -import { useAtom } from "jotai"; +import { useCallback, useState } from "react"; +import { Coupon } from "../types"; + +import { AdminPage } from "./pages/AdminPage"; +import { ShopPage } from "./pages/ShopPage"; + +import { Header } from "./components/ui/Header"; +import { NotificationList } from "./components/ui/NotificationList"; +import { useCart } from "./hooks/useCart"; +import { useCoupons } from "./hooks/useCoupons"; import { useProducts } from "./hooks/useProducts"; -import { formatPrice } from "./utils/formatters"; -import { getRemainingStock } from "./utils/productUtils"; -interface Notification { +export interface Notification { id: string; message: string; type: "error" | "success" | "warning"; } -const initialCoupons: Coupon[] = [ - { - name: "5000원 할인", - code: "AMOUNT5000", - discountType: "amount", - discountValue: 5000, - }, - { - name: "10% 할인", - code: "PERCENT10", - discountType: "percentage", - discountValue: 10, - }, -]; - const App = () => { const { products, - + filteredProducts, addProduct, updateProduct, deleteProduct, } = useProducts(); - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem("cart"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem("coupons"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); + const { coupons, addCoupon, removeCoupon } = useCoupons(); 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 getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach((item) => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === "amount") { - totalAfterDiscount = Math.max( - 0, - totalAfterDiscount - selectedCoupon.discountValue - ); - } else { - totalAfterDiscount = Math.round( - totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) - ); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount), - }; - }; + const [isAdmin, setIsAdmin] = useState(false); + const [notifications, setNotifications] = useState([]); const addNotification = useCallback( (message: string, type: "error" | "success" | "warning" = "success") => { const id = Date.now().toString(); @@ -157,1273 +44,43 @@ const App = () => { [] ); - const [totalItemCount, setTotalItemCount] = useState(0); - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - 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: Product) => { - const remainingStock = getRemainingStock(product, cart); - 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 handleAddProduct = useCallback( - (newProduct: Omit) => { - addProduct(newProduct); - addNotification("상품이 추가되었습니다.", "success"); - }, - [addProduct, addNotification] - ); - - const handleUpdateProduct = useCallback( - (productId: string, updates: Partial) => { - updateProduct(productId, updates); - addNotification("상품이 수정되었습니다.", "success"); - }, - [updateProduct, addNotification] - ); - - const handleDeleteProduct = useCallback( - (productId: string) => { - deleteProduct(productId); - addNotification("상품이 삭제되었습니다.", "success"); - }, - [deleteProduct, 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") { - handleUpdateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - handleAddProduct({ - ...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: Product) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || "", - discounts: product.discounts || [], - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter( - (product) => - product.name - .toLowerCase() - .includes(debouncedSearchTerm.toLowerCase()) || - (product.description && - product.description - .toLowerCase() - .includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const { cart, addToCart, updateCartItemQuantity, removeFromCart, clearCart } = + useCart(); 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" - /> -
- )} -
- -
-
-
+ +
{isAdmin ? ( -
-
-

- 관리자 대시보드 -

-

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

-
-
- -
- - {activeTab === "products" ? ( -
-
-
-

상품 목록

- -
-
- -
- - - - - - - - - - - - {(activeTab === "products" ? products : products).map( - (product) => ( - - - - - - - - ) - )} - -
- 상품명 - - 가격 - - 재고 - - 설명 - - 작업 -
- {product.name} - - {`${formatPrice( - product.price, - !getRemainingStock(product, cart) - )}원`} - - 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, 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, - !getRemainingStock(product, cart) - )}`} -

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

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

-
-
- - )} -
-
-
+ )}
diff --git a/src/advanced/components/AdminPage.tsx b/src/advanced/components/AdminPage.tsx deleted file mode 100644 index afb5b1ae..00000000 --- a/src/advanced/components/AdminPage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// TODO: 관리자 페이지 컴포넌트 -// 힌트: -// 1. 탭 UI로 상품 관리와 쿠폰 관리 분리 -// 2. 상품 추가/수정/삭제 기능 -// 3. 쿠폰 생성 기능 -// 4. 할인 규칙 설정 -// -// 필요한 hooks: -// - useProducts: 상품 CRUD -// - useCoupons: 쿠폰 CRUD -// -// 하위 컴포넌트: -// - ProductForm: 새 상품 추가 폼 -// - ProductAccordion: 상품 정보 표시 및 수정 -// - CouponForm: 새 쿠폰 추가 폼 -// - CouponList: 쿠폰 목록 표시 - -export function AdminPage() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/advanced/components/CartPage.tsx b/src/advanced/components/CartPage.tsx deleted file mode 100644 index 069edafc..00000000 --- a/src/advanced/components/CartPage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// TODO: 장바구니 페이지 컴포넌트 -// 힌트: -// 1. 상품 목록 표시 (검색 기능 포함) -// 2. 장바구니 관리 -// 3. 쿠폰 적용 -// 4. 주문 처리 -// -// 필요한 hooks: -// - useProducts: 상품 목록 관리 -// - useCart: 장바구니 상태 관리 -// - useCoupons: 쿠폰 목록 관리 -// - useDebounce: 검색어 디바운싱 -// -// 하위 컴포넌트: -// - SearchBar: 검색 입력 -// - ProductList: 상품 목록 표시 -// - Cart: 장바구니 표시 및 결제 - -export function CartPage() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/advanced/components/ProductList.tsx b/src/advanced/components/ProductList.tsx deleted file mode 100644 index 086d2ccf..00000000 --- a/src/advanced/components/ProductList.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from "react"; - -import { ProductCard } from "./ProductCard"; -import { Product } from "../../types"; - -interface ProductListProps { - products: Product[]; - filteredProducts: Product[]; - debouncedSearchTerm: string; - onAddToCart: (product: Product) => void; - formatPrice: (price: number, productId?: string) => string; - getRemainingStock: (product: Product) => number; -} - -export const ProductList: React.FC = ({ - products, - filteredProducts, - debouncedSearchTerm, - onAddToCart, - formatPrice, - getRemainingStock, -}) => { - return ( -
-
-

전체 상품

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

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

-
- ) : ( -
- {filteredProducts.map((product) => ( - - ))} -
- )} -
- ); -}; diff --git a/src/advanced/components/cart/CartSection.tsx b/src/advanced/components/cart/CartSection.tsx new file mode 100644 index 00000000..768e3138 --- /dev/null +++ b/src/advanced/components/cart/CartSection.tsx @@ -0,0 +1,91 @@ +import { CartItem, Product } from "../../../types"; +import { CartIcon, TrashIcon } from "../icons"; +import { calculateItemTotal } from "../../utils/cartUtils"; + +export function CartSection({ + cart, + removeFromCart, + handleUpdateQuantity, +}: { + cart: CartItem[]; + removeFromCart: (productId: string) => void; + handleUpdateQuantity: (product: Product, newQuantity: number) => void; +}) { + 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/components/coupon/CouponForm.tsx b/src/advanced/components/coupon/CouponForm.tsx new file mode 100644 index 00000000..d24b5807 --- /dev/null +++ b/src/advanced/components/coupon/CouponForm.tsx @@ -0,0 +1,171 @@ +import { useState } from "react"; +import { Coupon } from "../../../types"; +import { + COUPON_FORM_INITIAL_STATE, + COUPON_LIMITS, +} from "../../constants/coupon"; + +interface CouponFormProps { + onSubmit: (coupon: Coupon) => void; + onCancel: () => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const CouponForm: React.FC = ({ + onSubmit, + onCancel, + addNotification, +}) => { + const [formData, setFormData] = useState(COUPON_FORM_INITIAL_STATE); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(formData as Coupon); + setFormData(COUPON_FORM_INITIAL_STATE); + }; + + const handleInputChange = ( + field: keyof typeof formData, + value: string | number + ) => { + setFormData((prev) => ({ + ...prev, + [field]: value, + })); + }; + + const getDiscountValuePlaceholder = () => { + return formData.discountType === "amount" ? "5000" : "10"; + }; + + const getDiscountValueMax = () => { + return COUPON_LIMITS[formData.discountType].max; + }; + + const getDiscountValueMin = () => { + return COUPON_LIMITS[formData.discountType].min; + }; + + return ( +
+

새 쿠폰 생성

+ +
+
+ + handleInputChange("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 + /> +
+ +
+ + + handleInputChange("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)) { + handleInputChange( + "discountValue", + value === "" ? 0 : parseInt(value) + ); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + const max = getDiscountValueMax(); + const min = getDiscountValueMin(); + + if (value > max) { + if (formData.discountType === "percentage") { + addNotification( + `할인율은 ${max}%를 초과할 수 없습니다`, + "error" + ); + } else { + addNotification( + `할인 금액은 ${max}원을 초과할 수 없습니다`, + "error" + ); + } + + handleInputChange("discountValue", max); + } else if (value < min) { + handleInputChange("discountValue", min); + } + }} + 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={getDiscountValuePlaceholder()} + min={getDiscountValueMin()} + max={getDiscountValueMax()} + required + /> +
+
+ +
+ + +
+
+ ); +}; diff --git a/src/advanced/components/coupon/CouponList.tsx b/src/advanced/components/coupon/CouponList.tsx new file mode 100644 index 00000000..72601752 --- /dev/null +++ b/src/advanced/components/coupon/CouponList.tsx @@ -0,0 +1,50 @@ +import { Coupon } from "../../../types"; +import { getCouponDisplayText } from "../../utils/couponUtils"; + +interface CouponListProps { + coupons: Coupon[]; + onApplyCoupon: (coupon: Coupon) => void; + selectedCoupon: Coupon | null; +} + +export const CouponList: React.FC = ({ + coupons, + onApplyCoupon, + selectedCoupon, +}) => { + if (coupons.length === 0) { + return ( +
+

사용 가능한 쿠폰이 없습니다

+
+ ); + } + + return ( +
+ {coupons.map((coupon) => ( +
onApplyCoupon(coupon)} + > +
+
+

{coupon.name}

+

{coupon.code}

+
+
+ + {getCouponDisplayText(coupon)} + +
+
+
+ ))} +
+ ); +}; diff --git a/src/advanced/components/coupon/CouponManagement.tsx b/src/advanced/components/coupon/CouponManagement.tsx new file mode 100644 index 00000000..3162a7a6 --- /dev/null +++ b/src/advanced/components/coupon/CouponManagement.tsx @@ -0,0 +1,114 @@ +import { useState } from "react"; +import { Coupon } from "../../../types"; +import { CouponForm } from "./CouponForm"; +import { + getCouponDisplayText, + validateCouponData, + isCouponDuplicate, +} from "../../utils/couponUtils"; +import { TrashIcon, PlusIcon } from "../icons"; + +interface CouponManagementProps { + coupons: Coupon[]; + onAddCoupon: (coupon: Coupon) => void; + onRemoveCoupon: (couponCode: string) => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const CouponManagement: React.FC = ({ + coupons, + onAddCoupon, + onRemoveCoupon, + addNotification, +}) => { + const [showCouponForm, setShowCouponForm] = useState(false); + + const handleAddCoupon = (coupon: Coupon) => { + // 검증 로직을 먼저 수행 + if (!validateCouponData(coupon)) { + return; + } + + // 중복 검사 + if (isCouponDuplicate(coupons, coupon.code)) { + addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + + // 검증 통과 후 쿠폰 추가 + onAddCoupon(coupon); + addNotification("쿠폰이 추가되었습니다.", "success"); + setShowCouponForm(false); + }; + + const handleRemoveCoupon = (couponCode: string) => { + onRemoveCoupon(couponCode); + addNotification("쿠폰이 삭제되었습니다.", "success"); + }; + + const handleCancelForm = () => { + setShowCouponForm(false); + }; + + return ( +
+
+

쿠폰 관리

+
+ +
+
+ {coupons.map((coupon) => ( +
+
+
+

{coupon.name}

+

+ {coupon.code} +

+
+ + {getCouponDisplayText(coupon)} + +
+
+ +
+
+ ))} + +
+ +
+
+ + {showCouponForm && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/advanced/components/coupon/CouponSection.tsx b/src/advanced/components/coupon/CouponSection.tsx new file mode 100644 index 00000000..868baaa2 --- /dev/null +++ b/src/advanced/components/coupon/CouponSection.tsx @@ -0,0 +1,46 @@ +import { Coupon } from "../../../types"; + +export function CouponSection({ + coupons, + selectedCoupon, + setSelectedCoupon, + applyCoupon, +}: { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + applyCoupon: (coupon: Coupon) => void; +}) { + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/advanced/components/icons/index.tsx b/src/advanced/components/icons/index.tsx index 1609d774..d548b556 100644 --- a/src/advanced/components/icons/index.tsx +++ b/src/advanced/components/icons/index.tsx @@ -1,12 +1,206 @@ -// TODO: SVG 아이콘 컴포넌트들 -// 구현할 아이콘: -// - CartIcon: 장바구니 아이콘 -// - AdminIcon: 관리자 아이콘 -// - PlusIcon: 플러스 아이콘 -// - MinusIcon: 마이너스 아이콘 -// - TrashIcon: 삭제 아이콘 -// - ChevronDownIcon: 아래 화살표 -// - ChevronUpIcon: 위 화살표 -// - CheckIcon: 체크 아이콘 - -// TODO: 구현 \ No newline at end of file +import React from "react"; + +interface IconProps { + className?: string; + size?: number; +} + +export const CartIcon: React.FC = ({ + className = "w-6 h-6", + size, +}) => ( + + + +); + +export const TrashIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const PlusIcon: React.FC = ({ + className = "w-8 h-8", + size, +}) => ( + + + +); + +export const MinusIcon: React.FC = ({ + className = "w-4 h-4", + size, +}) => ( + + + +); + +export const ImageIcon: React.FC = ({ + className = "w-24 h-24", + size, +}) => ( + + + +); + +export const CloseIcon: React.FC = ({ + className = "w-4 h-4", + size, +}) => ( + + + +); + +export const ChevronDownIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const ChevronUpIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const CheckIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const AdminIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); diff --git a/src/advanced/components/payment/PaymentSection.tsx b/src/advanced/components/payment/PaymentSection.tsx new file mode 100644 index 00000000..91587377 --- /dev/null +++ b/src/advanced/components/payment/PaymentSection.tsx @@ -0,0 +1,52 @@ +import { CartTotal } from "../../utils/cartUtils"; + +export function PaymentSection({ + totals, + completeOrder, +}: { + totals: CartTotal; + completeOrder: () => void; +}) { + return ( +
+

결제 정보

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

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

+
+
+ ); +} diff --git a/src/advanced/components/product/ProductAccordion.tsx b/src/advanced/components/product/ProductAccordion.tsx new file mode 100644 index 00000000..92259716 --- /dev/null +++ b/src/advanced/components/product/ProductAccordion.tsx @@ -0,0 +1,169 @@ +import React, { useState, useCallback } from "react"; +import { Product } from "../../../types"; +import { displayPrice } from "../../utils/formatters"; +import { ProductForm } from "./ProductForm"; + +interface ProductAccordionProps { + products: Product[]; + onEditProduct: (id: string, updates: Partial) => void; + onDeleteProduct: (productId: string) => void; + onAddNewProduct: (productData: Omit) => Product; + onNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const ProductAccordion: React.FC = ({ + products, + onEditProduct, + onDeleteProduct, + onAddNewProduct, + onNotification, +}) => { + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [selectedProduct, setSelectedProduct] = useState( + undefined + ); + + // 상품 편집 시작 + const handleStartEditProduct = useCallback((product: Product) => { + setSelectedProduct(product); + setEditingProduct(product.id); + setShowProductForm(true); + }, []); + + // 새 상품 추가 시작 + const handleAddNewProduct = useCallback(() => { + setSelectedProduct(undefined); + setEditingProduct("new"); + setShowProductForm(true); + }, []); + + // 상품 폼 취소 + const handleCancelProductForm = useCallback(() => { + setEditingProduct(null); + setSelectedProduct(undefined); + setShowProductForm(false); + }, []); + + // 상품 폼 제출 처리 + const handleProductSubmit = useCallback( + (productData: Omit) => { + if (editingProduct && editingProduct !== "new") { + onEditProduct(editingProduct, productData); + } else { + onAddNewProduct(productData); + } + setEditingProduct(null); + setSelectedProduct(undefined); + setShowProductForm(false); + }, + [editingProduct, onEditProduct, onAddNewProduct] + ); + + // 상품 삭제 + const handleDeleteProduct = useCallback( + (productId: string) => { + onDeleteProduct(productId); + onNotification("상품이 삭제되었습니다.", "success"); + }, + [onDeleteProduct, onNotification] + ); + + return ( + <> +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {displayPrice(product, { + suffix: "원", + })} + + 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 && ( + + )} + + ); +}; diff --git a/src/advanced/components/ProductCard.tsx b/src/advanced/components/product/ProductCard.tsx similarity index 83% rename from src/advanced/components/ProductCard.tsx rename to src/advanced/components/product/ProductCard.tsx index 547c3b67..90643014 100644 --- a/src/advanced/components/ProductCard.tsx +++ b/src/advanced/components/product/ProductCard.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Product } from "../../types"; +import { Product } from "../../../types"; +import { ImageIcon } from "../icons"; interface ProductCardProps { product: Product; @@ -19,19 +20,7 @@ export const ProductCard: React.FC = ({ {/* 상품 이미지 영역 (placeholder) */}
- - - +
{product.isRecommended && ( diff --git a/src/advanced/components/ProductForm.tsx b/src/advanced/components/product/ProductForm.tsx similarity index 81% rename from src/advanced/components/ProductForm.tsx rename to src/advanced/components/product/ProductForm.tsx index 59074b48..b8ced687 100644 --- a/src/advanced/components/ProductForm.tsx +++ b/src/advanced/components/product/ProductForm.tsx @@ -1,20 +1,20 @@ -import React from "react"; -import { Discount } from "../../types"; +import React, { useState, useEffect } from "react"; +import { Product } from "../../../types"; +import { CloseIcon } from "../icons"; interface ProductFormData { name: string; price: number; stock: number; description: string; - discounts: Discount[]; + discounts: Array<{ quantity: number; rate: number }>; } interface ProductFormProps { - productForm: ProductFormData; editingProduct: string | null; - onSubmit: (e: React.FormEvent) => void; + product?: Product; + onSubmit: (productData: Omit) => void; onCancel: () => void; - onFormChange: (form: ProductFormData) => void; onNotification: ( message: string, type: "error" | "success" | "warning" @@ -22,20 +22,73 @@ interface ProductFormProps { } export const ProductForm: React.FC = ({ - productForm, editingProduct, + product, onSubmit, onCancel, - onFormChange, onNotification, }) => { + const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + + // 상품 데이터가 변경될 때 폼 초기화 + useEffect(() => { + if (product && editingProduct === product.id) { + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [], + }); + } else if (editingProduct === "new") { + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + } + }, [editingProduct, product]); + const updateForm = (updates: Partial) => { - onFormChange({ ...productForm, ...updates }); + setProductForm({ ...productForm, ...updates }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ + ...productForm, + discounts: productForm.discounts, + }); + onNotification( + editingProduct === "new" + ? "상품이 추가되었습니다." + : "상품이 수정되었습니다.", + "success" + ); + }; + + const handleCancel = () => { + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + onCancel(); }; return (
-
+

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

@@ -170,19 +223,7 @@ export const ProductForm: React.FC = ({ }} className="text-red-600 hover:text-red-800" > - - - +
))} @@ -206,7 +247,7 @@ export const ProductForm: React.FC = ({
+
+
+ ); + })} +
+ )} +
+ ); +}; diff --git a/src/advanced/components/ui/Header.tsx b/src/advanced/components/ui/Header.tsx new file mode 100644 index 00000000..2829605a --- /dev/null +++ b/src/advanced/components/ui/Header.tsx @@ -0,0 +1,78 @@ +import { CartItem } from "../../../types"; +import useFilterSearchParams from "../../../hooks/useFilterSearchParams"; +import { useDebouncedCallback } from "../../utils/hooks/useDebounce"; +import { getCartItemCount } from "../../utils/cartUtils"; +import { CartIcon } from "../icons"; + +export const Header = ({ + isAdmin, + setIsAdmin, + cart, +}: { + isAdmin: boolean; + setIsAdmin: (isAdmin: boolean) => void; + cart: CartItem[]; +}) => { + const { filterSearchParams, setFilterSearchParams } = useFilterSearchParams(); + + const handleSearchChange = (e: React.ChangeEvent) => { + const value = e.target.value; + + if (!value) { + setFilterSearchParams({ searchTerm: "" }); + return; + } + + debouncedSetSearchTerm(value); + }; + + const debouncedSetSearchTerm = useDebouncedCallback((value: string) => { + setFilterSearchParams({ searchTerm: value }); + }, 500); + + return ( +
+
+
+
+

SHOP

+ {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} + {!isAdmin && ( +
+ +
+ )} +
+ +
+
+
+ ); +}; diff --git a/src/advanced/components/ui/NotificationList.tsx b/src/advanced/components/ui/NotificationList.tsx new file mode 100644 index 00000000..3942ca5c --- /dev/null +++ b/src/advanced/components/ui/NotificationList.tsx @@ -0,0 +1,37 @@ +import { Notification } from "../../App"; +import { CloseIcon } from "../icons"; + +export const NotificationList = ({ + notifications, + setNotifications, +}: { + notifications: Notification[]; + setNotifications: React.Dispatch>; +}) => { + return ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ); +}; diff --git a/src/advanced/components/ui/UIToast.ts b/src/advanced/components/ui/UIToast.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/advanced/constants/cart.ts b/src/advanced/constants/cart.ts new file mode 100644 index 00000000..3282d3a0 --- /dev/null +++ b/src/advanced/constants/cart.ts @@ -0,0 +1,4 @@ +export const BULK_PURCHASE_THRESHOLD = 10; +export const BULK_PURCHASE_ADDITIONAL_DISCOUNT = 0.05; +export const MAX_DISCOUNT_RATE = 0.5; +export const MIN_PERCENTAGE_COUPON_AMOUNT = 10000; diff --git a/src/advanced/constants/coupon.ts b/src/advanced/constants/coupon.ts new file mode 100644 index 00000000..2a4ca77c --- /dev/null +++ b/src/advanced/constants/coupon.ts @@ -0,0 +1,46 @@ +import { Coupon } from "../../types"; + +// 초기 쿠폰 데이터 +export const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; + +// 쿠폰 폼 초기 상태 +export const COUPON_FORM_INITIAL_STATE = { + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, +}; + +// 쿠폰 검증 규칙 +export const COUPON_VALIDATION_RULES = { + MIN_AMOUNT_DISCOUNT: 1000, + MAX_AMOUNT_DISCOUNT: 100000, + MIN_PERCENTAGE_DISCOUNT: 1, + MAX_PERCENTAGE_DISCOUNT: 100, + MIN_CART_TOTAL_FOR_PERCENTAGE: 10000, +}; + +// 쿠폰 타입별 최대값 +export const COUPON_LIMITS = { + amount: { + min: 1000, + max: 100000, + }, + percentage: { + min: 1, + max: 100, + }, +}; diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts index 6db309aa..10005cac 100644 --- a/src/advanced/hooks/useCart.ts +++ b/src/advanced/hooks/useCart.ts @@ -1,29 +1,104 @@ -// TODO: 장바구니 관리 Hook -// 힌트: -// 1. 장바구니 상태 관리 (localStorage 연동) -// 2. 상품 추가/삭제/수량 변경 -// 3. 쿠폰 적용 -// 4. 총액 계산 -// 5. 재고 확인 -// -// 사용할 모델 함수: -// - cartModel.addItemToCart -// - cartModel.removeItemFromCart -// - cartModel.updateCartItemQuantity -// - cartModel.calculateCartTotal -// - cartModel.getRemainingStock -// -// 반환할 값: -// - cart: 장바구니 아이템 배열 -// - selectedCoupon: 선택된 쿠폰 -// - addToCart: 상품 추가 함수 -// - removeFromCart: 상품 제거 함수 -// - updateQuantity: 수량 변경 함수 -// - applyCoupon: 쿠폰 적용 함수 -// - calculateTotal: 총액 계산 함수 -// - getRemainingStock: 재고 확인 함수 -// - clearCart: 장바구니 비우기 함수 - -export function useCart() { - // TODO: 구현 -} \ No newline at end of file +import { useCallback, useState } from "react"; +import { CartItem, Product } from "../../types"; + +export const useCart = () => { + const [cart, _setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return []; + } + } + return []; + }); + + const setCart: React.Dispatch> = useCallback( + (cart) => { + _setCart((prev) => { + const newValue = typeof cart === "function" ? cart(prev) : cart; + if (newValue.length > 0) { + localStorage.setItem("cart", JSON.stringify(newValue)); + } else { + localStorage.removeItem("cart"); + } + return newValue; + }); + }, + [_setCart] + ); + + // 장바구니에 상품 추가 + const addToCart = useCallback( + (product: Product, quantity: number = 1) => { + setCart((prev) => { + const existingItem = prev.find( + (item) => item.product.id === product.id + ); + + if (existingItem) { + return prev.map((item) => + item.product.id === product.id + ? { ...item, quantity: item.quantity + quantity } + : item + ); + } + + return [...prev, { product, quantity }]; + }); + }, + [setCart] + ); + + // 장바구니에서 상품 수량 변경 + const updateCartItemQuantity = useCallback( + (productId: string, quantity: number) => { + setCart((prev) => { + return prev.map((item) => + item.product.id === productId ? { ...item, quantity } : item + ); + }); + }, + [setCart] + ); + + // 장바구니에서 상품 제거 + const removeFromCart = useCallback( + (productId: string) => { + setCart((prev) => prev.filter((item) => item.product.id !== productId)); + }, + [setCart] + ); + + // 장바구니 비우기 + const clearCart = useCallback(() => { + setCart([]); + }, [setCart]); + + // 장바구니에 상품이 있는지 확인 + const isInCart = useCallback( + (productId: string) => { + return cart.some((item) => item.product.id === productId); + }, + [cart] + ); + + // 장바구니 아이템 가져오기 + const getCartItem = useCallback( + (productId: string) => { + return cart.find((item) => item.product.id === productId); + }, + [cart] + ); + + return { + cart, + addToCart, + updateCartItemQuantity, + removeFromCart, + clearCart, + isInCart, + getCartItem, + }; +}; diff --git a/src/advanced/hooks/useCoupons.ts b/src/advanced/hooks/useCoupons.ts index d2ad82ab..4bf99564 100644 --- a/src/advanced/hooks/useCoupons.ts +++ b/src/advanced/hooks/useCoupons.ts @@ -1,13 +1,89 @@ -// TODO: 쿠폰 관리 Hook -// 힌트: -// 1. 쿠폰 목록 상태 관리 (localStorage 연동 고려) -// 2. 쿠폰 추가/삭제 -// -// 반환할 값: -// - coupons: 쿠폰 배열 -// - addCoupon: 새 쿠폰 추가 -// - removeCoupon: 쿠폰 삭제 - -export function useCoupons() { - // TODO: 구현 -} \ No newline at end of file +import { useCallback, useState } from "react"; +import { Coupon } from "../../types"; +import { initialCoupons } from "../constants/coupon"; +import { isCouponDuplicate, generateCouponCode } from "../utils/couponUtils"; + +export const useCoupons = () => { + const [coupons, _setCoupons] = useState(() => { + const saved = localStorage.getItem("coupons"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialCoupons; + } + } + return initialCoupons; + }); + + const setCoupons: React.Dispatch> = + useCallback( + (coupons) => { + _setCoupons((prev) => { + const newValue = + typeof coupons === "function" ? coupons(prev) : coupons; + localStorage.setItem("coupons", JSON.stringify(newValue)); + return newValue; + }); + }, + [_setCoupons] + ); + + // 쿠폰 추가 + const addCoupon = useCallback( + (couponData: Coupon) => { + setCoupons((prev) => [...prev, couponData]); + return couponData; + }, + [setCoupons] + ); + + // 쿠폰 삭제 + const removeCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((coupon) => coupon.code !== couponCode)); + }, + [setCoupons] + ); + + // 쿠폰 코드로 쿠폰 찾기 + const getCouponByCode = useCallback( + (code: string) => { + return coupons.find((coupon) => coupon.code === code); + }, + [coupons] + ); + + // 쿠폰 존재 여부 확인 + const isCouponExists = useCallback( + (code: string) => { + return coupons.some((coupon) => coupon.code === code); + }, + [coupons] + ); + + // 쿠폰 목록 가져오기 + const getAllCoupons = useCallback(() => { + return coupons; + }, [coupons]); + + // 쿠폰 코드 자동 생성 + const generateUniqueCouponCode = useCallback(() => { + let code: string; + do { + code = generateCouponCode(); + } while (isCouponExists(code)); + return code; + }, [isCouponExists]); + + return { + coupons, + addCoupon, + removeCoupon, + + getCouponByCode, + isCouponExists, + getAllCoupons, + generateUniqueCouponCode, + }; +}; diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts index 66e670e0..d59df4e3 100644 --- a/src/advanced/hooks/useProducts.ts +++ b/src/advanced/hooks/useProducts.ts @@ -1,12 +1,37 @@ -import { useAtom } from "jotai"; -import { useCallback } from "react"; -import { productsAtom } from "../models/productAtoms"; +import { useCallback, useState, useEffect } from "react"; import { Product } from "../../types"; import { generateProductId, validateProductData } from "../utils/productUtils"; +import { initialProducts } from "../constants/product"; +import useFilterSearchParams from "../../hooks/useFilterSearchParams"; export const useProducts = () => { - const [products, setProducts] = useAtom(productsAtom); + const [products, _setProducts] = useState(() => { + const saved = localStorage.getItem("products"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialProducts; + } + } + return initialProducts; + }); + + const setProducts: React.Dispatch> = + useCallback( + (products) => { + _setProducts((prev) => { + const newValue = + typeof products === "function" ? products(prev) : products; + localStorage.setItem("products", JSON.stringify(newValue)); + return newValue; + }); + }, + [_setProducts] + ); + + const { filterSearchParams } = useFilterSearchParams(); // 상품 추가 const addProduct = useCallback((productData: Omit) => { @@ -43,9 +68,22 @@ export const useProducts = () => { ); }, []); + const filteredProducts = filterSearchParams.searchTerm + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(filterSearchParams.searchTerm?.toLowerCase() ?? "") || + (product.description && + product.description + .toLowerCase() + .includes(filterSearchParams.searchTerm?.toLowerCase() ?? "")) + ) + : products; + return { products, - + filteredProducts, addProduct, updateProduct, deleteProduct, diff --git a/src/advanced/main.tsx b/src/advanced/main.tsx index e63eef4a..f46c379c 100644 --- a/src/advanced/main.tsx +++ b/src/advanced/main.tsx @@ -1,9 +1,9 @@ -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"; -ReactDOM.createRoot(document.getElementById('root')!).render( +ReactDOM.createRoot(document.getElementById("root")!).render( - , -) + +); diff --git a/src/advanced/models/cart.ts b/src/advanced/models/cart.ts index 5c681048..590feb7a 100644 --- a/src/advanced/models/cart.ts +++ b/src/advanced/models/cart.ts @@ -15,4 +15,4 @@ // - 외부 상태에 의존하지 않음 // - 모든 필요한 데이터는 파라미터로 전달받음 -// TODO: 구현 \ No newline at end of file +// TODO: 구현 diff --git a/src/advanced/models/productAtoms.ts b/src/advanced/models/productAtoms.ts deleted file mode 100644 index 98b4f8c0..00000000 --- a/src/advanced/models/productAtoms.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { atom } from "jotai"; -import { initialProducts } from "../constants/product"; -import { Product } from "../../types"; -import { atomWithStorage, createJSONStorage } from "jotai/utils"; - -// 기본 atoms -export const productsAtom = atomWithStorage( - "products", - initialProducts, - createJSONStorage(), - { getOnInit: true } -); diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx new file mode 100644 index 00000000..0dc90c68 --- /dev/null +++ b/src/advanced/pages/AdminPage.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { Coupon, Product } from "../../types"; +import { CouponManagement } from "../components/coupon/CouponManagement"; +import { ProductAccordion } from "../components/product/ProductAccordion"; + +interface AdminPageProps { + products: Product[]; + coupons: Coupon[]; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; + addProduct: (productData: Omit) => Product; + updateProduct: (id: string, updates: Partial) => void; + deleteProduct: (id: string) => void; + addCoupon: (coupon: Coupon) => void; + removeCoupon: (couponCode: string) => void; +} + +export function AdminPage({ + products, + coupons, + addNotification, + addProduct, + updateProduct, + deleteProduct, + addCoupon, + removeCoupon, +}: AdminPageProps) { + // 내부 상태 관리 + const [activeTab, setActiveTab] = useState<"products" | "coupons">( + "products" + ); + + return ( +
+
+

관리자 대시보드

+

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

+
+
+ +
+ + {activeTab === "products" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/advanced/pages/ShopPage.tsx b/src/advanced/pages/ShopPage.tsx new file mode 100644 index 00000000..a481de64 --- /dev/null +++ b/src/advanced/pages/ShopPage.tsx @@ -0,0 +1,136 @@ +import { useCallback } from "react"; +import { CartItem, Coupon, Product } from "../../types"; + +import { ProductList } from "../components/product/ProductList"; +import { calculateCartTotal, getRemainingStock } from "../utils/cartUtils"; +import { CartSection } from "../components/cart/CartSection"; +import { CouponSection } from "../components/coupon/CouponSection"; +import { PaymentSection } from "../components/payment/PaymentSection"; + +interface ShopPageProps { + products: Product[]; + filteredProducts: Product[]; + + cart: CartItem[]; + addToCart: (product: Product, quantity?: number) => void; + updateCartItemQuantity: (productId: string, quantity: number) => void; + removeFromCart: (productId: string) => void; + clearCart: () => void; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; + coupons: Coupon[]; +} + +export function ShopPage({ + products, + filteredProducts, + cart, + addToCart, + updateCartItemQuantity, + removeFromCart, + clearCart, + selectedCoupon, + setSelectedCoupon, + addNotification, + coupons, +}: ShopPageProps) { + // 장바구니 상품 수량 변경 (props로 받은 함수 사용) + const handleUpdateQuantity = useCallback( + (product: Product, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(product.id); + return; + } + + if (newQuantity > product.stock) { + addNotification?.(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return; + } + + if (!getRemainingStock(product, cart)) { + addNotification("재고가 부족합니다", "error"); + return; + } + + updateCartItemQuantity(product.id, newQuantity); + }, + [addNotification, cart, removeFromCart, updateCartItemQuantity] + ); + + // 쿠폰 적용 + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal( + cart, + selectedCoupon + ).totalBeforeDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + addNotification( + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", + "error" + ); + return; + } + + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [addNotification, cart, selectedCoupon, setSelectedCoupon] + ); + + // 주문 완료 + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" + ); + clearCart(); + setSelectedCoupon(null); + }, [addNotification, clearCart, setSelectedCoupon]); + + const totals = calculateCartTotal(cart, selectedCoupon); + + return ( +
+
+ {/* 상품 목록 */} + +
+ +
+
+ + + {cart.length > 0 && ( + <> + + + + + )} +
+
+
+ ); +} diff --git a/src/advanced/utils/cartUtils.ts b/src/advanced/utils/cartUtils.ts new file mode 100644 index 00000000..5a3b1d63 --- /dev/null +++ b/src/advanced/utils/cartUtils.ts @@ -0,0 +1,93 @@ +import { CartItem, Coupon, Product } from "../../types"; +import { + BULK_PURCHASE_THRESHOLD, + BULK_PURCHASE_ADDITIONAL_DISCOUNT, + MAX_DISCOUNT_RATE, +} from "../constants/cart"; + +export interface CartTotal { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} + +export const getMaxApplicableDiscount = ( + item: CartItem, + cart: CartItem[] +): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some( + (cartItem) => cartItem.quantity >= BULK_PURCHASE_THRESHOLD + ); + if (hasBulkPurchase) { + return Math.min( + baseDiscount + BULK_PURCHASE_ADDITIONAL_DISCOUNT, + MAX_DISCOUNT_RATE + ); + } + + return baseDiscount; +}; + +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 +): CartTotal => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === "amount") { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue + ); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +export const getRemainingStock = ( + product: Product, + cart: CartItem[] +): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const usedQuantity = cartItem ? cartItem.quantity : 0; + return Math.max(0, product.stock - usedQuantity); +}; + +export const getCartItemCount = (cart: CartItem[]): number => { + return cart.reduce((sum, item) => sum + item.quantity, 0); +}; diff --git a/src/advanced/utils/couponUtils.ts b/src/advanced/utils/couponUtils.ts new file mode 100644 index 00000000..6d293fe0 --- /dev/null +++ b/src/advanced/utils/couponUtils.ts @@ -0,0 +1,66 @@ +import { Coupon } from "../../types"; +import { COUPON_VALIDATION_RULES } from "../constants/coupon"; + +// 쿠폰 할인 적용 +export const applyCouponDiscount = (total: number, coupon: Coupon): number => { + if (!coupon) return total; + + if (coupon.discountType === "amount") { + return Math.max(0, total - coupon.discountValue); + } else if (coupon.discountType === "percentage") { + const discountAmount = (total * coupon.discountValue) / 100; + return Math.max(0, total - discountAmount); + } + + return total; +}; + +// 쿠폰 코드 생성 +export const generateCouponCode = (): string => { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let result = ""; + for (let i = 0; i < 8; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +}; + +// 쿠폰 데이터 검증 +export const validateCouponData = (coupon: Partial): boolean => { + if ( + !coupon.name || + !coupon.code || + !coupon.discountType || + !coupon.discountValue + ) { + return false; + } + + if (coupon.discountType === "amount") { + return ( + coupon.discountValue >= COUPON_VALIDATION_RULES.MIN_AMOUNT_DISCOUNT && + coupon.discountValue <= COUPON_VALIDATION_RULES.MAX_AMOUNT_DISCOUNT + ); + } else if (coupon.discountType === "percentage") { + return ( + coupon.discountValue >= COUPON_VALIDATION_RULES.MIN_PERCENTAGE_DISCOUNT && + coupon.discountValue <= COUPON_VALIDATION_RULES.MAX_PERCENTAGE_DISCOUNT + ); + } + + return false; +}; + +// 쿠폰 중복 검사 +export const isCouponDuplicate = (coupons: Coupon[], code: string): boolean => { + return coupons.some((coupon) => coupon.code === code); +}; + +// 쿠폰 표시 텍스트 생성 +export const getCouponDisplayText = (coupon: Coupon): string => { + if (coupon.discountType === "amount") { + return `${coupon.discountValue.toLocaleString()}원 할인`; + } else { + return `${coupon.discountValue}% 할인`; + } +}; diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts index bfd5d676..b33d888b 100644 --- a/src/advanced/utils/formatters.ts +++ b/src/advanced/utils/formatters.ts @@ -1,11 +1,14 @@ -import { CartItem, Product } from "../../types"; +import { Product } from "../../types"; -export const formatPrice = (price: number, isSoldOut: boolean): string => { - if (isSoldOut) { +export const displayPrice = ( + product: Product, + { suffix = "", prefix = "" }: { suffix?: string; prefix?: string } = {} +): string => { + if (product.stock <= 0) { return "SOLD OUT"; } - return price.toLocaleString(); + return `${prefix}${product.price.toLocaleString()}${suffix}`; }; /** diff --git a/src/advanced/utils/hooks/useLocalStorage.ts b/src/advanced/utils/hooks/useLocalStorage.ts deleted file mode 100644 index 5dc72c50..00000000 --- a/src/advanced/utils/hooks/useLocalStorage.ts +++ /dev/null @@ -1,15 +0,0 @@ -// TODO: LocalStorage Hook -// 힌트: -// 1. localStorage와 React state 동기화 -// 2. 초기값 로드 시 에러 처리 -// 3. 저장 시 JSON 직렬화/역직렬화 -// 4. 빈 배열이나 undefined는 삭제 -// -// 반환값: [저장된 값, 값 설정 함수] - -export function useLocalStorage( - key: string, - initialValue: T -): [T, (value: T | ((val: T) => T)) => void] { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/advanced/utils/hooks/useValidate.ts b/src/advanced/utils/hooks/useValidate.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/advanced/utils/productUtils.ts b/src/advanced/utils/productUtils.ts index 65be485a..747300dc 100644 --- a/src/advanced/utils/productUtils.ts +++ b/src/advanced/utils/productUtils.ts @@ -4,6 +4,7 @@ import { CartItem, Product } from "../../types"; * 상품 검색 필터링 */ export const filterProducts = ( + //TODO: 진석 리팩토링 필요 products: Product[], searchTerm: string ): Product[] => { @@ -41,10 +42,14 @@ export const validateProductData = (product: Partial): boolean => { /** * 남은 재고 계산 */ -export const getRemainingStock = ( - product: Product, - cart: CartItem[] -): number => { +const getRemainingStock = (product: Product, cart: CartItem[]): number => { const cartItem = cart.find((item) => item.product.id === product.id); return product.stock - (cartItem?.quantity || 0); }; + +export const getRefinedProduct = (product: Product, cart: CartItem[]) => { + return { + ...product, + stock: getRemainingStock(product, cart), + } satisfies Product; +}; diff --git a/src/advanced/utils/validators.ts b/src/advanced/utils/validators.ts deleted file mode 100644 index 7d2dda44..00000000 --- a/src/advanced/utils/validators.ts +++ /dev/null @@ -1,8 +0,0 @@ -// TODO: 검증 유틸리티 함수들 -// 구현할 함수: -// - isValidCouponCode(code: string): boolean - 쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자) -// - isValidStock(stock: number): boolean - 재고 수량 검증 (0 이상) -// - isValidPrice(price: number): boolean - 가격 검증 (양수) -// - extractNumbers(value: string): string - 문자열에서 숫자만 추출 - -// TODO: 구현 \ No newline at end of file From ec16324ebea5c93d9688461123fd601161f5c407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Wed, 6 Aug 2025 22:18:25 +0900 Subject: [PATCH 34/52] =?UTF-8?q?refactor:=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88,=20=EC=BF=A0=ED=8F=B0,=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=EB=A5=BC=20Jotai?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/hooks/useCart.ts | 31 ++-------- src/advanced/hooks/useCoupons.ts | 33 ++--------- src/advanced/hooks/useProducts.ts | 96 ++++++++++++++----------------- src/advanced/models/cart.ts | 20 +------ src/advanced/models/coupon.ts | 5 ++ src/advanced/models/product.ts | 8 +++ 6 files changed, 67 insertions(+), 126 deletions(-) diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts index 10005cac..23647e41 100644 --- a/src/advanced/hooks/useCart.ts +++ b/src/advanced/hooks/useCart.ts @@ -1,33 +1,10 @@ -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { CartItem, Product } from "../../types"; +import { useAtom } from "jotai"; +import { cartAtom } from "../models/cart"; export const useCart = () => { - const [cart, _setCart] = useState(() => { - const saved = localStorage.getItem("cart"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const setCart: React.Dispatch> = useCallback( - (cart) => { - _setCart((prev) => { - const newValue = typeof cart === "function" ? cart(prev) : cart; - if (newValue.length > 0) { - localStorage.setItem("cart", JSON.stringify(newValue)); - } else { - localStorage.removeItem("cart"); - } - return newValue; - }); - }, - [_setCart] - ); + const [cart, setCart] = useAtom(cartAtom); // 장바구니에 상품 추가 const addToCart = useCallback( diff --git a/src/advanced/hooks/useCoupons.ts b/src/advanced/hooks/useCoupons.ts index 4bf99564..be0d5e3c 100644 --- a/src/advanced/hooks/useCoupons.ts +++ b/src/advanced/hooks/useCoupons.ts @@ -1,33 +1,11 @@ -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { Coupon } from "../../types"; -import { initialCoupons } from "../constants/coupon"; -import { isCouponDuplicate, generateCouponCode } from "../utils/couponUtils"; +import { useAtom } from "jotai"; +import { couponsAtom } from "../models/coupon"; +import { generateCouponCode } from "../utils/couponUtils"; export const useCoupons = () => { - const [coupons, _setCoupons] = useState(() => { - const saved = localStorage.getItem("coupons"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const setCoupons: React.Dispatch> = - useCallback( - (coupons) => { - _setCoupons((prev) => { - const newValue = - typeof coupons === "function" ? coupons(prev) : coupons; - localStorage.setItem("coupons", JSON.stringify(newValue)); - return newValue; - }); - }, - [_setCoupons] - ); + const [coupons, setCoupons] = useAtom(couponsAtom); // 쿠폰 추가 const addCoupon = useCallback( @@ -80,7 +58,6 @@ export const useCoupons = () => { coupons, addCoupon, removeCoupon, - getCouponByCode, isCouponExists, getAllCoupons, diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts index d59df4e3..e55e2cf5 100644 --- a/src/advanced/hooks/useProducts.ts +++ b/src/advanced/hooks/useProducts.ts @@ -1,72 +1,60 @@ -import { useCallback, useState, useEffect } from "react"; - +import { useCallback } from "react"; import { Product } from "../../types"; +import { useAtom } from "jotai"; +import { productsAtom } from "../models/product"; import { generateProductId, validateProductData } from "../utils/productUtils"; -import { initialProducts } from "../constants/product"; import useFilterSearchParams from "../../hooks/useFilterSearchParams"; export const useProducts = () => { - const [products, _setProducts] = useState(() => { - const saved = localStorage.getItem("products"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const setProducts: React.Dispatch> = - useCallback( - (products) => { - _setProducts((prev) => { - const newValue = - typeof products === "function" ? products(prev) : products; - localStorage.setItem("products", JSON.stringify(newValue)); - return newValue; - }); - }, - [_setProducts] - ); - + const [products, setProducts] = useAtom(productsAtom); const { filterSearchParams } = useFilterSearchParams(); // 상품 추가 - const addProduct = useCallback((productData: Omit) => { - if (!validateProductData(productData)) { - throw new Error("Invalid product data"); - } + const addProduct = useCallback( + (productData: Omit) => { + if (!validateProductData(productData)) { + throw new Error("Invalid product data"); + } - const newProduct: Product = { - ...productData, - id: generateProductId(), - }; + const newProduct: Product = { + ...productData, + id: generateProductId(), + }; - setProducts((prev: Product[]) => [...prev, newProduct]); - return newProduct; - }, []); + setProducts((prev: Product[]) => [...prev, newProduct]); + return newProduct; + }, + [setProducts] + ); // 상품 수정 - const updateProduct = useCallback((id: string, updates: Partial) => { - setProducts((prev: Product[]) => - prev.map((product: Product) => - product.id === id ? { ...product, ...updates } : product - ) - ); - }, []); + const updateProduct = useCallback( + (id: string, updates: Partial) => { + setProducts((prev: Product[]) => + prev.map((product: Product) => + product.id === id ? { ...product, ...updates } : product + ) + ); + }, + [setProducts] + ); - const deleteProduct = useCallback((productId: string) => { - setProducts((prev) => prev.filter((p) => p.id !== productId)); - }, []); + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + }, + [setProducts] + ); // 상품 삭제 - const removeProduct = useCallback((id: string) => { - setProducts((prev: Product[]) => - prev.filter((product: Product) => product.id !== id) - ); - }, []); + const removeProduct = useCallback( + (id: string) => { + setProducts((prev: Product[]) => + prev.filter((product: Product) => product.id !== id) + ); + }, + [setProducts] + ); const filteredProducts = filterSearchParams.searchTerm ? products.filter( diff --git a/src/advanced/models/cart.ts b/src/advanced/models/cart.ts index 590feb7a..f39b8408 100644 --- a/src/advanced/models/cart.ts +++ b/src/advanced/models/cart.ts @@ -1,18 +1,4 @@ -// TODO: 장바구니 비즈니스 로직 (순수 함수) -// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) -// -// 구현할 함수들: -// 1. calculateItemTotal(item): 개별 아이템의 할인 적용 후 총액 계산 -// 2. getMaxApplicableDiscount(item): 적용 가능한 최대 할인율 계산 -// 3. calculateCartTotal(cart, coupon): 장바구니 총액 계산 (할인 전/후, 할인액) -// 4. updateCartItemQuantity(cart, productId, quantity): 수량 변경 -// 5. addItemToCart(cart, product): 상품 추가 -// 6. removeItemFromCart(cart, productId): 상품 제거 -// 7. getRemainingStock(product, cart): 남은 재고 계산 -// -// 원칙: -// - UI와 관련된 로직 없음 -// - 외부 상태에 의존하지 않음 -// - 모든 필요한 데이터는 파라미터로 전달받음 +import { atomWithStorage } from "jotai/utils"; +import { CartItem } from "../../types"; -// TODO: 구현 +export const cartAtom = atomWithStorage("cart", []); diff --git a/src/advanced/models/coupon.ts b/src/advanced/models/coupon.ts index e69de29b..6cd25bae 100644 --- a/src/advanced/models/coupon.ts +++ b/src/advanced/models/coupon.ts @@ -0,0 +1,5 @@ +import { atomWithStorage } from "jotai/utils"; +import { Coupon } from "../../types"; +import { initialCoupons } from "../../basic/constants/coupon"; + +export const couponsAtom = atomWithStorage("coupons", initialCoupons); diff --git a/src/advanced/models/product.ts b/src/advanced/models/product.ts index e69de29b..12d011e1 100644 --- a/src/advanced/models/product.ts +++ b/src/advanced/models/product.ts @@ -0,0 +1,8 @@ +import { atomWithStorage } from "jotai/utils"; +import { Product } from "../../types"; +import { initialProducts } from "../constants/product"; + +export const productsAtom = atomWithStorage( + "products", + initialProducts +); From 51e34fd2579d5c67451246c215ca7e3484a8c291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Wed, 6 Aug 2025 22:25:32 +0900 Subject: [PATCH 35/52] =?UTF-8?q?refactor:=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=EC=9D=84=20CouponManage?= =?UTF-8?q?ment=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20App.tsx=EC=97=90=EC=84=9C=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 | 7 ------- .../components/coupon/CouponManagement.tsx | 12 ++++-------- src/advanced/components/coupon/CouponSection.tsx | 5 +++-- src/advanced/pages/AdminPage.tsx | 15 ++------------- src/advanced/pages/ShopPage.tsx | 3 --- 5 files changed, 9 insertions(+), 33 deletions(-) diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index 6b6f2e57..c8b111e3 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -7,7 +7,6 @@ import { ShopPage } from "./pages/ShopPage"; import { Header } from "./components/ui/Header"; import { NotificationList } from "./components/ui/NotificationList"; import { useCart } from "./hooks/useCart"; -import { useCoupons } from "./hooks/useCoupons"; import { useProducts } from "./hooks/useProducts"; export interface Notification { @@ -25,8 +24,6 @@ const App = () => { deleteProduct, } = useProducts(); - const { coupons, addCoupon, removeCoupon } = useCoupons(); - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); @@ -59,13 +56,10 @@ const App = () => { {isAdmin ? ( ) : ( { selectedCoupon={selectedCoupon} setSelectedCoupon={setSelectedCoupon} addNotification={addNotification} - coupons={coupons} /> )} diff --git a/src/advanced/components/coupon/CouponManagement.tsx b/src/advanced/components/coupon/CouponManagement.tsx index 3162a7a6..f2e787ff 100644 --- a/src/advanced/components/coupon/CouponManagement.tsx +++ b/src/advanced/components/coupon/CouponManagement.tsx @@ -7,11 +7,9 @@ import { isCouponDuplicate, } from "../../utils/couponUtils"; import { TrashIcon, PlusIcon } from "../icons"; +import { useCoupons } from "../../hooks/useCoupons"; interface CouponManagementProps { - coupons: Coupon[]; - onAddCoupon: (coupon: Coupon) => void; - onRemoveCoupon: (couponCode: string) => void; addNotification: ( message: string, type: "error" | "success" | "warning" @@ -19,11 +17,9 @@ interface CouponManagementProps { } export const CouponManagement: React.FC = ({ - coupons, - onAddCoupon, - onRemoveCoupon, addNotification, }) => { + const { coupons, addCoupon, removeCoupon } = useCoupons(); const [showCouponForm, setShowCouponForm] = useState(false); const handleAddCoupon = (coupon: Coupon) => { @@ -39,13 +35,13 @@ export const CouponManagement: React.FC = ({ } // 검증 통과 후 쿠폰 추가 - onAddCoupon(coupon); + addCoupon(coupon); addNotification("쿠폰이 추가되었습니다.", "success"); setShowCouponForm(false); }; const handleRemoveCoupon = (couponCode: string) => { - onRemoveCoupon(couponCode); + removeCoupon(couponCode); addNotification("쿠폰이 삭제되었습니다.", "success"); }; diff --git a/src/advanced/components/coupon/CouponSection.tsx b/src/advanced/components/coupon/CouponSection.tsx index 868baaa2..23a62884 100644 --- a/src/advanced/components/coupon/CouponSection.tsx +++ b/src/advanced/components/coupon/CouponSection.tsx @@ -1,16 +1,17 @@ import { Coupon } from "../../../types"; +import { useCoupons } from "../../hooks/useCoupons"; export function CouponSection({ - coupons, selectedCoupon, setSelectedCoupon, applyCoupon, }: { - coupons: Coupon[]; selectedCoupon: Coupon | null; setSelectedCoupon: (coupon: Coupon | null) => void; applyCoupon: (coupon: Coupon) => void; }) { + const { coupons } = useCoupons(); + return (
diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx index 0dc90c68..56d2b595 100644 --- a/src/advanced/pages/AdminPage.tsx +++ b/src/advanced/pages/AdminPage.tsx @@ -1,11 +1,10 @@ import { useState } from "react"; -import { Coupon, Product } from "../../types"; +import { Product } from "../../types"; import { CouponManagement } from "../components/coupon/CouponManagement"; import { ProductAccordion } from "../components/product/ProductAccordion"; interface AdminPageProps { products: Product[]; - coupons: Coupon[]; addNotification: ( message: string, type: "error" | "success" | "warning" @@ -13,19 +12,14 @@ interface AdminPageProps { addProduct: (productData: Omit) => Product; updateProduct: (id: string, updates: Partial) => void; deleteProduct: (id: string) => void; - addCoupon: (coupon: Coupon) => void; - removeCoupon: (couponCode: string) => void; } export function AdminPage({ products, - coupons, addNotification, addProduct, updateProduct, deleteProduct, - addCoupon, - removeCoupon, }: AdminPageProps) { // 내부 상태 관리 const [activeTab, setActiveTab] = useState<"products" | "coupons">( @@ -72,12 +66,7 @@ export function AdminPage({ onNotification={addNotification} /> ) : ( - + )}
); diff --git a/src/advanced/pages/ShopPage.tsx b/src/advanced/pages/ShopPage.tsx index a481de64..9c2950da 100644 --- a/src/advanced/pages/ShopPage.tsx +++ b/src/advanced/pages/ShopPage.tsx @@ -22,7 +22,6 @@ interface ShopPageProps { message: string, type: "error" | "success" | "warning" ) => void; - coupons: Coupon[]; } export function ShopPage({ @@ -36,7 +35,6 @@ export function ShopPage({ selectedCoupon, setSelectedCoupon, addNotification, - coupons, }: ShopPageProps) { // 장바구니 상품 수량 변경 (props로 받은 함수 사용) const handleUpdateQuantity = useCallback( @@ -120,7 +118,6 @@ export function ShopPage({ {cart.length > 0 && ( <> Date: Wed, 6 Aug 2025 22:37:57 +0900 Subject: [PATCH 36/52] =?UTF-8?q?refactor:=20App.tsx=EC=97=90=EC=84=9C=20u?= =?UTF-8?q?seProducts=20=ED=9B=85=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20Produ?= =?UTF-8?q?ctAccordion,=20ProductList=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EC=97=90=EC=84=9C=20=EC=83=81=ED=92=88=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/App.tsx | 23 ++----------------- .../components/product/ProductAccordion.tsx | 21 +++++++---------- .../components/product/ProductList.tsx | 14 ++++------- src/advanced/pages/AdminPage.tsx | 23 +++---------------- src/advanced/pages/ShopPage.tsx | 15 +----------- 5 files changed, 19 insertions(+), 77 deletions(-) diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index c8b111e3..af31b15f 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -7,7 +7,6 @@ import { ShopPage } from "./pages/ShopPage"; import { Header } from "./components/ui/Header"; import { NotificationList } from "./components/ui/NotificationList"; import { useCart } from "./hooks/useCart"; -import { useProducts } from "./hooks/useProducts"; export interface Notification { id: string; @@ -16,14 +15,6 @@ export interface Notification { } const App = () => { - const { - products, - filteredProducts, - addProduct, - updateProduct, - deleteProduct, - } = useProducts(); - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); @@ -41,8 +32,7 @@ const App = () => { [] ); - const { cart, addToCart, updateCartItemQuantity, removeFromCart, clearCart } = - useCart(); + const { cart, updateCartItemQuantity, removeFromCart, clearCart } = useCart(); return (
@@ -54,19 +44,10 @@ const App = () => {
{isAdmin ? ( - + ) : ( ) => void; - onDeleteProduct: (productId: string) => void; - onAddNewProduct: (productData: Omit) => Product; onNotification: ( message: string, type: "error" | "success" | "warning" @@ -15,12 +12,10 @@ interface ProductAccordionProps { } export const ProductAccordion: React.FC = ({ - products, - onEditProduct, - onDeleteProduct, - onAddNewProduct, onNotification, }) => { + const { products, addProduct, updateProduct, deleteProduct } = useProducts(); + const [showProductForm, setShowProductForm] = useState(false); const [editingProduct, setEditingProduct] = useState(null); const [selectedProduct, setSelectedProduct] = useState( @@ -52,24 +47,24 @@ export const ProductAccordion: React.FC = ({ const handleProductSubmit = useCallback( (productData: Omit) => { if (editingProduct && editingProduct !== "new") { - onEditProduct(editingProduct, productData); + updateProduct(editingProduct, productData); } else { - onAddNewProduct(productData); + addProduct(productData); } setEditingProduct(null); setSelectedProduct(undefined); setShowProductForm(false); }, - [editingProduct, onEditProduct, onAddNewProduct] + [editingProduct, updateProduct, addProduct] ); // 상품 삭제 const handleDeleteProduct = useCallback( (productId: string) => { - onDeleteProduct(productId); + deleteProduct(productId); onNotification("상품이 삭제되었습니다.", "success"); }, - [onDeleteProduct, onNotification] + [deleteProduct, onNotification] ); return ( diff --git a/src/advanced/components/product/ProductList.tsx b/src/advanced/components/product/ProductList.tsx index 0a32bc14..4029c787 100644 --- a/src/advanced/components/product/ProductList.tsx +++ b/src/advanced/components/product/ProductList.tsx @@ -1,17 +1,15 @@ import React, { useCallback } from "react"; -import { CartItem, Product } from "../../../types"; +import { Product } from "../../../types"; import { getRefinedProduct } from "../../utils/productUtils"; import { displayPrice } from "../../utils/formatters"; import { getRemainingStock } from "../../utils/cartUtils"; import useFilterSearchParams from "../../../hooks/useFilterSearchParams"; +import { useProducts } from "../../hooks/useProducts"; +import { useCart } from "../../hooks/useCart"; import { ImageIcon } from "../icons"; interface ProductListProps { - products: Product[]; - filteredProducts: Product[]; - cart: CartItem[]; - addToCart: (product: Product, quantity?: number) => void; addNotification: ( message: string, type: "error" | "success" | "warning" @@ -19,12 +17,10 @@ interface ProductListProps { } export const ProductList: React.FC = ({ - products, - filteredProducts, - cart, - addToCart, addNotification, }) => { + const { products, filteredProducts } = useProducts(); + const { cart, addToCart } = useCart(); const { filterSearchParams } = useFilterSearchParams(); const handleAddToCart = useCallback( diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx index 56d2b595..75ae895c 100644 --- a/src/advanced/pages/AdminPage.tsx +++ b/src/advanced/pages/AdminPage.tsx @@ -1,26 +1,15 @@ import { useState } from "react"; -import { Product } from "../../types"; -import { CouponManagement } from "../components/coupon/CouponManagement"; import { ProductAccordion } from "../components/product/ProductAccordion"; +import { CouponManagement } from "../components/coupon/CouponManagement"; interface AdminPageProps { - products: Product[]; addNotification: ( message: string, type: "error" | "success" | "warning" ) => void; - addProduct: (productData: Omit) => Product; - updateProduct: (id: string, updates: Partial) => void; - deleteProduct: (id: string) => void; } -export function AdminPage({ - products, - addNotification, - addProduct, - updateProduct, - deleteProduct, -}: AdminPageProps) { +export function AdminPage({ addNotification }: AdminPageProps) { // 내부 상태 관리 const [activeTab, setActiveTab] = useState<"products" | "coupons">( "products" @@ -58,13 +47,7 @@ export function AdminPage({
{activeTab === "products" ? ( - + ) : ( )} diff --git a/src/advanced/pages/ShopPage.tsx b/src/advanced/pages/ShopPage.tsx index 9c2950da..9d5bbf28 100644 --- a/src/advanced/pages/ShopPage.tsx +++ b/src/advanced/pages/ShopPage.tsx @@ -8,11 +8,7 @@ import { CouponSection } from "../components/coupon/CouponSection"; import { PaymentSection } from "../components/payment/PaymentSection"; interface ShopPageProps { - products: Product[]; - filteredProducts: Product[]; - cart: CartItem[]; - addToCart: (product: Product, quantity?: number) => void; updateCartItemQuantity: (productId: string, quantity: number) => void; removeFromCart: (productId: string) => void; clearCart: () => void; @@ -25,10 +21,7 @@ interface ShopPageProps { } export function ShopPage({ - products, - filteredProducts, cart, - addToCart, updateCartItemQuantity, removeFromCart, clearCart, @@ -98,13 +91,7 @@ export function ShopPage({
{/* 상품 목록 */} - +
From bd952fef4f0843ca8fceb92652f05b42a089a161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Wed, 6 Aug 2025 22:56:36 +0900 Subject: [PATCH 37/52] =?UTF-8?q?refactor:=20useCart=20=ED=9B=85=EC=9D=84?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20=EC=9E=A5=EB=B0=94?= =?UTF-8?q?=EA=B5=AC=EB=8B=88=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EB=B0=8F=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=84=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/App.tsx | 9 +---- src/advanced/components/cart/CartSection.tsx | 9 ++--- .../components/payment/PaymentSection.tsx | 31 ++++++++++++--- src/advanced/components/ui/Header.tsx | 5 +-- src/advanced/pages/ShopPage.tsx | 38 +++++-------------- 5 files changed, 43 insertions(+), 49 deletions(-) diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index af31b15f..819acc52 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -6,7 +6,6 @@ import { ShopPage } from "./pages/ShopPage"; import { Header } from "./components/ui/Header"; import { NotificationList } from "./components/ui/NotificationList"; -import { useCart } from "./hooks/useCart"; export interface Notification { id: string; @@ -32,25 +31,19 @@ const App = () => { [] ); - const { cart, updateCartItemQuantity, removeFromCart, clearCart } = useCart(); - return (
-
+
{isAdmin ? ( ) : ( void; handleUpdateQuantity: (product: Product, newQuantity: number) => void; }) { + const { cart, removeFromCart } = useCart(); + return (

diff --git a/src/advanced/components/payment/PaymentSection.tsx b/src/advanced/components/payment/PaymentSection.tsx index 91587377..b5ae9b8f 100644 --- a/src/advanced/components/payment/PaymentSection.tsx +++ b/src/advanced/components/payment/PaymentSection.tsx @@ -1,12 +1,33 @@ -import { CartTotal } from "../../utils/cartUtils"; +import { calculateCartTotal } from "../../utils/cartUtils"; +import { useCart } from "../../hooks/useCart"; +import { Coupon } from "../../../types"; export function PaymentSection({ - totals, - completeOrder, + selectedCoupon, + setSelectedCoupon, + addNotification, }: { - totals: CartTotal; - completeOrder: () => void; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; }) { + const { cart, clearCart } = useCart(); + + const totals = calculateCartTotal(cart, selectedCoupon); + + const completeOrder = () => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" + ); + clearCart(); + setSelectedCoupon(null); + }; + return (

결제 정보

diff --git a/src/advanced/components/ui/Header.tsx b/src/advanced/components/ui/Header.tsx index 2829605a..8817ff11 100644 --- a/src/advanced/components/ui/Header.tsx +++ b/src/advanced/components/ui/Header.tsx @@ -1,18 +1,17 @@ -import { CartItem } from "../../../types"; import useFilterSearchParams from "../../../hooks/useFilterSearchParams"; import { useDebouncedCallback } from "../../utils/hooks/useDebounce"; import { getCartItemCount } from "../../utils/cartUtils"; import { CartIcon } from "../icons"; +import { useCart } from "../../hooks/useCart"; export const Header = ({ isAdmin, setIsAdmin, - cart, }: { isAdmin: boolean; setIsAdmin: (isAdmin: boolean) => void; - cart: CartItem[]; }) => { + const { cart } = useCart(); const { filterSearchParams, setFilterSearchParams } = useFilterSearchParams(); const handleSearchChange = (e: React.ChangeEvent) => { diff --git a/src/advanced/pages/ShopPage.tsx b/src/advanced/pages/ShopPage.tsx index 9d5bbf28..9b4f739e 100644 --- a/src/advanced/pages/ShopPage.tsx +++ b/src/advanced/pages/ShopPage.tsx @@ -1,17 +1,14 @@ import { useCallback } from "react"; -import { CartItem, Coupon, Product } from "../../types"; +import { Coupon, Product } from "../../types"; import { ProductList } from "../components/product/ProductList"; import { calculateCartTotal, getRemainingStock } from "../utils/cartUtils"; import { CartSection } from "../components/cart/CartSection"; import { CouponSection } from "../components/coupon/CouponSection"; import { PaymentSection } from "../components/payment/PaymentSection"; +import { useCart } from "../hooks/useCart"; interface ShopPageProps { - cart: CartItem[]; - updateCartItemQuantity: (productId: string, quantity: number) => void; - removeFromCart: (productId: string) => void; - clearCart: () => void; selectedCoupon: Coupon | null; setSelectedCoupon: (coupon: Coupon | null) => void; addNotification: ( @@ -21,14 +18,12 @@ interface ShopPageProps { } export function ShopPage({ - cart, - updateCartItemQuantity, - removeFromCart, - clearCart, selectedCoupon, setSelectedCoupon, addNotification, }: ShopPageProps) { + const { cart, updateCartItemQuantity, removeFromCart } = useCart(); + // 장바구니 상품 수량 변경 (props로 받은 함수 사용) const handleUpdateQuantity = useCallback( (product: Product, newQuantity: number) => { @@ -74,19 +69,6 @@ export function ShopPage({ [addNotification, cart, selectedCoupon, setSelectedCoupon] ); - // 주문 완료 - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification( - `주문이 완료되었습니다. 주문번호: ${orderNumber}`, - "success" - ); - clearCart(); - setSelectedCoupon(null); - }, [addNotification, clearCart, setSelectedCoupon]); - - const totals = calculateCartTotal(cart, selectedCoupon); - return (
@@ -96,11 +78,7 @@ export function ShopPage({
- + {cart.length > 0 && ( <> @@ -110,7 +88,11 @@ export function ShopPage({ applyCoupon={applyCoupon} /> - + )}
From 8120be89541fa584a364fe27929aecbd85676ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Wed, 6 Aug 2025 23:31:23 +0900 Subject: [PATCH 38/52] =?UTF-8?q?refactor:=20hook=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=88=9C=EC=88=98=ED=95=A8=EC=88=98=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20handle=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=A9=EC=95=88=20=EB=85=BC=EC=9D=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pr.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pr.md b/pr.md index be74a595..f6010d64 100644 --- a/pr.md +++ b/pr.md @@ -46,6 +46,8 @@ 1. displayPrice 2. usesearchparams 3. 액션함수 순수함수 분리 + hook에서는 순수함수만 사용하도록하고 + handle이 중복된다면 hook으로 따로 빼는걸 고려하는 방식이 옳은 방식이라고 생각했는데 어떤까요? ### 과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요? From 4c699d60199a5108bdb9c637e17e0f0725a7a3fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Wed, 6 Aug 2025 23:33:44 +0900 Subject: [PATCH 39/52] =?UTF-8?q?refactor:=20ShopPage=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=BF=A0=ED=8F=B0=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20=EB=82=B4=EB=B6=80=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20App.tsx?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B4=80=EB=A0=A8=20props=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/App.tsx | 8 +------- src/advanced/pages/ShopPage.tsx | 12 ++++-------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index 819acc52..f940ae08 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -14,8 +14,6 @@ export interface Notification { } const App = () => { - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); const [notifications, setNotifications] = useState([]); @@ -43,11 +41,7 @@ const App = () => { {isAdmin ? ( ) : ( - + )}

diff --git a/src/advanced/pages/ShopPage.tsx b/src/advanced/pages/ShopPage.tsx index 9b4f739e..b956efd4 100644 --- a/src/advanced/pages/ShopPage.tsx +++ b/src/advanced/pages/ShopPage.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { Coupon, Product } from "../../types"; import { ProductList } from "../components/product/ProductList"; @@ -9,19 +9,15 @@ import { PaymentSection } from "../components/payment/PaymentSection"; import { useCart } from "../hooks/useCart"; interface ShopPageProps { - selectedCoupon: Coupon | null; - setSelectedCoupon: (coupon: Coupon | null) => void; addNotification: ( message: string, type: "error" | "success" | "warning" ) => void; } -export function ShopPage({ - selectedCoupon, - setSelectedCoupon, - addNotification, -}: ShopPageProps) { +export function ShopPage({ addNotification }: ShopPageProps) { + const [selectedCoupon, setSelectedCoupon] = useState(null); + const { cart, updateCartItemQuantity, removeFromCart } = useCart(); // 장바구니 상품 수량 변경 (props로 받은 함수 사용) From 0600499d93be40cc052a5b184cce4cc0b730bf8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Thu, 7 Aug 2025 00:45:26 +0900 Subject: [PATCH 40/52] =?UTF-8?q?feat:=20jotai=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/__tests__/origin.test.tsx | 640 +++++++++++++------------ 1 file changed, 346 insertions(+), 294 deletions(-) diff --git a/src/advanced/__tests__/origin.test.tsx b/src/advanced/__tests__/origin.test.tsx index 3f5c3d55..01ef29bd 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -1,528 +1,580 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; -import { vi } from 'vitest'; -import App from '../App'; -import '../../setupTests'; +import { + render, + screen, + fireEvent, + within, + waitFor, +} from "@testing-library/react"; +import { vi } from "vitest"; +import App from "../App"; +import "../../setupTests"; +import { notificationsAtom } from "../models/notificationAtom"; +import { useAtom } from "jotai"; +import React from "react"; -describe('쇼핑몰 앱 통합 테스트', () => { +describe("쇼핑몰 앱 통합 테스트", () => { beforeEach(() => { // localStorage 초기화 localStorage.clear(); // console 경고 무시 - vi.spyOn(console, 'warn').mockImplementation(() => {}); - vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); + + // 알림 상태 초기화 (Jotai atom 사용 시 필요) + const TestComponent = () => { + const [, setNotifications] = useAtom(notificationsAtom); + React.useEffect(() => { + setNotifications([]); + }, [setNotifications]); + return null; + }; + render(); }); afterEach(() => { vi.restoreAllMocks(); }); - describe('고객 쇼핑 플로우', () => { - test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { + describe("고객 쇼핑 플로우", () => { + test("상품을 검색하고 장바구니에 추가할 수 있다", async () => { render(); - + // 검색창에 "프리미엄" 입력 - const searchInput = screen.getByPlaceholderText('상품 검색...'); - fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + const searchInput = screen.getByPlaceholderText("상품 검색..."); + fireEvent.change(searchInput, { target: { value: "프리미엄" } }); + // 디바운스 대기 - await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - }, { timeout: 600 }); - + await waitFor( + () => { + expect( + screen.getByText("최고급 품질의 프리미엄 상품입니다.") + ).toBeInTheDocument(); + }, + { timeout: 600 } + ); + // 검색된 상품을 장바구니에 추가 (첫 번째 버튼 선택) - const addButtons = screen.getAllByText('장바구니 담기'); + const addButtons = screen.getAllByText("장바구니 담기"); fireEvent.click(addButtons[0]); - + // 알림 메시지 확인 await waitFor(() => { - expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); + expect(screen.getByText("장바구니에 담았습니다")).toBeInTheDocument(); }); - + // 장바구니에 추가됨 확인 (장바구니 섹션에서) - const cartSection = screen.getByText('장바구니').closest('section'); - expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); + const cartSection = screen.getByText("장바구니").closest("section"); + expect(within(cartSection).getByText("상품1")).toBeInTheDocument(); }); - test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { + test("장바구니에서 수량을 조절하고 할인을 확인할 수 있다", () => { render(); - + // 상품1을 장바구니에 추가 - const product1 = screen.getAllByText('장바구니 담기')[0]; + const product1 = screen.getAllByText("장바구니 담기")[0]; fireEvent.click(product1); - + // 수량을 10개로 증가 (10% 할인 적용) - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + for (let i = 0; i < 9; i++) { fireEvent.click(plusButton); } - + // 10% 할인 적용 확인 - 15% (대량 구매 시 추가 5% 포함) - expect(screen.getByText('-15%')).toBeInTheDocument(); + expect(screen.getByText("-15%")).toBeInTheDocument(); }); - test('쿠폰을 선택하고 적용할 수 있다', () => { + test("쿠폰을 선택하고 적용할 수 있다", () => { render(); - + // 상품 추가 - const addButton = screen.getAllByText('장바구니 담기')[0]; + const addButton = screen.getAllByText("장바구니 담기")[0]; fireEvent.click(addButton); - + // 쿠폰 선택 - const couponSelect = screen.getByRole('combobox'); - fireEvent.change(couponSelect, { target: { value: 'AMOUNT5000' } }); - + const couponSelect = screen.getByRole("combobox"); + fireEvent.change(couponSelect, { target: { value: "AMOUNT5000" } }); + // 결제 정보에서 할인 금액 확인 - const paymentSection = screen.getByText('결제 정보').closest('section'); - const discountRow = within(paymentSection).getByText('할인 금액').closest('div'); - expect(within(discountRow).getByText('-5,000원')).toBeInTheDocument(); + const paymentSection = screen.getByText("결제 정보").closest("section"); + const discountRow = within(paymentSection) + .getByText("할인 금액") + .closest("div"); + expect(within(discountRow).getByText("-5,000원")).toBeInTheDocument(); }); - test('품절 임박 상품에 경고가 표시된다', async () => { + test("품절 임박 상품에 경고가 표시된다", async () => { render(); - + // 관리자 모드로 전환 - fireEvent.click(screen.getByText('관리자 페이지로')); - + fireEvent.click(screen.getByText("관리자 페이지로")); + // 상품 수정 - const editButton = screen.getAllByText('수정')[0]; + const editButton = screen.getAllByText("수정")[0]; fireEvent.click(editButton); - + // 재고를 5개로 변경 - const stockInputs = screen.getAllByPlaceholderText('숫자만 입력'); + const stockInputs = screen.getAllByPlaceholderText("숫자만 입력"); const stockInput = stockInputs[1]; // 재고 입력 필드는 두 번째 - fireEvent.change(stockInput, { target: { value: '5' } }); + fireEvent.change(stockInput, { target: { value: "5" } }); fireEvent.blur(stockInput); - + // 수정 완료 버튼 클릭 - const editButtons = screen.getAllByText('수정'); + const editButtons = screen.getAllByText("수정"); const completeEditButton = editButtons[editButtons.length - 1]; // 마지막 수정 버튼 (완료 버튼) fireEvent.click(completeEditButton); - + // 쇼핑몰로 돌아가기 - fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + fireEvent.click(screen.getByText("쇼핑몰로 돌아가기")); + // 품절임박 메시지 확인 - 재고가 5개 이하면 품절임박 표시 await waitFor(() => { - expect(screen.getByText('품절임박! 5개 남음')).toBeInTheDocument(); + expect(screen.getByText("품절임박! 5개 남음")).toBeInTheDocument(); }); }); - test('주문을 완료할 수 있다', () => { + test("주문을 완료할 수 있다", () => { render(); - + // 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 결제하기 버튼 클릭 const orderButton = screen.getByText(/원 결제하기/); fireEvent.click(orderButton); - + // 주문 완료 알림 확인 expect(screen.getByText(/주문이 완료되었습니다/)).toBeInTheDocument(); - + // 장바구니가 비어있는지 확인 - expect(screen.getByText('장바구니가 비어있습니다')).toBeInTheDocument(); + expect(screen.getByText("장바구니가 비어있습니다")).toBeInTheDocument(); }); - test('장바구니에서 상품을 삭제할 수 있다', () => { + test("장바구니에서 상품을 삭제할 수 있다", () => { render(); - + // 상품 2개 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[1]); + // 장바구니 섹션 확인 - const cartSection = screen.getByText('장바구니').closest('section'); - expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); - expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); - + const cartSection = screen.getByText("장바구니").closest("section"); + expect(within(cartSection).getByText("상품1")).toBeInTheDocument(); + expect(within(cartSection).getByText("상품2")).toBeInTheDocument(); + // 첫 번째 상품 삭제 (X 버튼) - const deleteButtons = within(cartSection).getAllByRole('button').filter( - button => button.querySelector('svg') - ); + const deleteButtons = within(cartSection) + .getAllByRole("button") + .filter((button) => button.querySelector("svg")); fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되고 상품2만 남음 - expect(within(cartSection).queryByText('상품1')).not.toBeInTheDocument(); - expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); + expect(within(cartSection).queryByText("상품1")).not.toBeInTheDocument(); + expect(within(cartSection).getByText("상품2")).toBeInTheDocument(); }); - test('재고를 초과하여 구매할 수 없다', async () => { + test("재고를 초과하여 구매할 수 없다", async () => { render(); - + // 상품1 장바구니에 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 수량을 재고(20개) 이상으로 증가 시도 - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + // 19번 클릭하여 총 20개로 만듦 for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 한 번 더 클릭 시도 (21개가 되려고 함) fireEvent.click(plusButton); - + // 수량이 20개에서 멈춰있어야 함 - expect(within(cartSection).getByText('20')).toBeInTheDocument(); - + expect(within(cartSection).getByText("20")).toBeInTheDocument(); + // 재고 부족 메시지 확인 await waitFor(() => { - expect(screen.getByText(/재고는.*개까지만 있습니다/)).toBeInTheDocument(); + expect( + screen.getByText(/재고는.*개까지만 있습니다/) + ).toBeInTheDocument(); }); }); - test('장바구니에서 수량을 감소시킬 수 있다', () => { + test("장바구니에서 수량을 감소시킬 수 있다", () => { render(); - + // 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - const minusButton = within(cartSection).getByText('−'); // U+2212 마이너스 기호 - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + const minusButton = within(cartSection).getByText("−"); // U+2212 마이너스 기호 + // 수량 3개로 증가 fireEvent.click(plusButton); fireEvent.click(plusButton); - expect(within(cartSection).getByText('3')).toBeInTheDocument(); - + expect(within(cartSection).getByText("3")).toBeInTheDocument(); + // 수량 감소 fireEvent.click(minusButton); - expect(within(cartSection).getByText('2')).toBeInTheDocument(); - + expect(within(cartSection).getByText("2")).toBeInTheDocument(); + // 1개로 더 감소 fireEvent.click(minusButton); - expect(within(cartSection).getByText('1')).toBeInTheDocument(); - + expect(within(cartSection).getByText("1")).toBeInTheDocument(); + // 1개에서 한 번 더 감소하면 장바구니에서 제거될 수도 있음 fireEvent.click(minusButton); // 장바구니가 비었는지 확인 - const emptyMessage = screen.queryByText('장바구니가 비어있습니다'); + const emptyMessage = screen.queryByText("장바구니가 비어있습니다"); if (emptyMessage) { expect(emptyMessage).toBeInTheDocument(); } else { // 또는 수량이 1에서 멈춤 - expect(within(cartSection).getByText('1')).toBeInTheDocument(); + expect(within(cartSection).getByText("1")).toBeInTheDocument(); } }); - test('20개 이상 구매 시 최대 할인이 적용된다', async () => { + test("20개 이상 구매 시 최대 할인이 적용된다", async () => { render(); - + // 관리자 모드로 전환하여 상품1의 재고를 늘림 - fireEvent.click(screen.getByText('관리자 페이지로')); - fireEvent.click(screen.getAllByText('수정')[0]); - - const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; - fireEvent.change(stockInput, { target: { value: '30' } }); - - const editButtons = screen.getAllByText('수정'); + fireEvent.click(screen.getByText("관리자 페이지로")); + fireEvent.click(screen.getAllByText("수정")[0]); + + const stockInput = screen.getAllByPlaceholderText("숫자만 입력")[1]; + fireEvent.change(stockInput, { target: { value: "30" } }); + + const editButtons = screen.getAllByText("수정"); fireEvent.click(editButtons[editButtons.length - 1]); - + // 쇼핑몰로 돌아가기 - fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + fireEvent.click(screen.getByText("쇼핑몰로 돌아가기")); + // 상품1을 장바구니에 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 수량을 20개로 증가 - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 25% 할인 적용 확인 (또는 대량 구매 시 30%) await waitFor(() => { - const discount25 = screen.queryByText('-25%'); - const discount30 = screen.queryByText('-30%'); + const discount25 = screen.queryByText("-25%"); + const discount30 = screen.queryByText("-30%"); expect(discount25 || discount30).toBeTruthy(); }); }); }); - describe('관리자 기능', () => { + describe("관리자 기능", () => { beforeEach(() => { render(); // 관리자 모드로 전환 - fireEvent.click(screen.getByText('관리자 페이지로')); + fireEvent.click(screen.getByText("관리자 페이지로")); }); - test('새 상품을 추가할 수 있다', () => { + test("새 상품을 추가할 수 있다", () => { // 새 상품 추가 버튼 클릭 - fireEvent.click(screen.getByText('새 상품 추가')); - + fireEvent.click(screen.getByText("새 상품 추가")); + // 폼 입력 - 상품명 입력 - const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); - const nameInput = nameLabel.closest('div').querySelector('input'); - fireEvent.change(nameInput, { target: { value: '테스트 상품' } }); - - const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - fireEvent.change(priceInput, { target: { value: '25000' } }); - - const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; - fireEvent.change(stockInput, { target: { value: '50' } }); - - const descLabels = screen.getAllByText('설명'); - const descLabel = descLabels.find(el => el.tagName === 'LABEL'); - const descInput = descLabel.closest('div').querySelector('input'); - fireEvent.change(descInput, { target: { value: '테스트 설명' } }); - + const labels = screen.getAllByText("상품명"); + const nameLabel = labels.find((el) => el.tagName === "LABEL"); + const nameInput = nameLabel.closest("div").querySelector("input"); + fireEvent.change(nameInput, { target: { value: "테스트 상품" } }); + + const priceInput = screen.getAllByPlaceholderText("숫자만 입력")[0]; + fireEvent.change(priceInput, { target: { value: "25000" } }); + + const stockInput = screen.getAllByPlaceholderText("숫자만 입력")[1]; + fireEvent.change(stockInput, { target: { value: "50" } }); + + const descLabels = screen.getAllByText("설명"); + const descLabel = descLabels.find((el) => el.tagName === "LABEL"); + const descInput = descLabel.closest("div").querySelector("input"); + fireEvent.change(descInput, { target: { value: "테스트 설명" } }); + // 저장 - fireEvent.click(screen.getByText('추가')); - + fireEvent.click(screen.getByText("추가")); + // 추가된 상품 확인 - expect(screen.getByText('테스트 상품')).toBeInTheDocument(); - expect(screen.getByText('25,000원')).toBeInTheDocument(); + expect(screen.getByText("테스트 상품")).toBeInTheDocument(); + expect(screen.getByText("25,000원")).toBeInTheDocument(); }); - test('쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다', () => { + test("쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다", () => { // 쿠폰 관리 탭으로 전환 - fireEvent.click(screen.getByText('쿠폰 관리')); - + fireEvent.click(screen.getByText("쿠폰 관리")); + // 새 쿠폰 추가 버튼 클릭 - const addCouponButton = screen.getByText('새 쿠폰 추가'); + const addCouponButton = screen.getByText("새 쿠폰 추가"); fireEvent.click(addCouponButton); - + // 쿠폰 정보 입력 - fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { target: { value: '테스트 쿠폰' } }); - fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { target: { value: 'TEST2024' } }); - - const discountInput = screen.getByPlaceholderText('5000'); - fireEvent.change(discountInput, { target: { value: '7000' } }); - + fireEvent.change(screen.getByPlaceholderText("신규 가입 쿠폰"), { + target: { value: "테스트 쿠폰" }, + }); + fireEvent.change(screen.getByPlaceholderText("WELCOME2024"), { + target: { value: "TEST2024" }, + }); + + const discountInput = screen.getByPlaceholderText("5000"); + fireEvent.change(discountInput, { target: { value: "7000" } }); + // 쿠폰 생성 - fireEvent.click(screen.getByText('쿠폰 생성')); - + fireEvent.click(screen.getByText("쿠폰 생성")); + // 생성된 쿠폰 확인 - expect(screen.getByText('테스트 쿠폰')).toBeInTheDocument(); - expect(screen.getByText('TEST2024')).toBeInTheDocument(); - expect(screen.getByText('7,000원 할인')).toBeInTheDocument(); + expect(screen.getByText("테스트 쿠폰")).toBeInTheDocument(); + expect(screen.getByText("TEST2024")).toBeInTheDocument(); + expect(screen.getByText("7,000원 할인")).toBeInTheDocument(); }); - test('상품의 가격 입력 시 숫자만 허용된다', async () => { + test("상품의 가격 입력 시 숫자만 허용된다", async () => { // 상품 수정 - fireEvent.click(screen.getAllByText('수정')[0]); - - const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - + fireEvent.click(screen.getAllByText("수정")[0]); + + const priceInput = screen.getAllByPlaceholderText("숫자만 입력")[0]; + // 문자와 숫자 혼합 입력 시도 - 숫자만 남음 - fireEvent.change(priceInput, { target: { value: 'abc123def' } }); - expect(priceInput.value).toBe('10000'); // 유효하지 않은 입력은 무시됨 - + fireEvent.change(priceInput, { target: { value: "abc123def" } }); + expect(priceInput.value).toBe("10000"); // 유효하지 않은 입력은 무시됨 + // 숫자만 입력 - fireEvent.change(priceInput, { target: { value: '123' } }); - expect(priceInput.value).toBe('123'); - + fireEvent.change(priceInput, { target: { value: "123" } }); + expect(priceInput.value).toBe("123"); + // 음수 입력 시도 - regex가 매치되지 않아 값이 변경되지 않음 - fireEvent.change(priceInput, { target: { value: '-100' } }); - expect(priceInput.value).toBe('123'); // 이전 값 유지 - + fireEvent.change(priceInput, { target: { value: "-100" } }); + expect(priceInput.value).toBe("123"); // 이전 값 유지 + // 유효한 음수 입력하기 위해 먼저 1 입력 후 앞에 - 추가는 불가능 // 대신 blur 이벤트를 통해 음수 검증을 테스트 // parseInt()는 실제로 음수를 파싱할 수 있으므로 다른 방법으로 테스트 - + // 공백 입력 시도 - fireEvent.change(priceInput, { target: { value: ' ' } }); - expect(priceInput.value).toBe('123'); // 유효하지 않은 입력은 무시됨 + fireEvent.change(priceInput, { target: { value: " " } }); + expect(priceInput.value).toBe("123"); // 유효하지 않은 입력은 무시됨 }); - test('쿠폰 할인율 검증이 작동한다', async () => { + test("쿠폰 할인율 검증이 작동한다", async () => { // 쿠폰 관리 탭으로 전환 - fireEvent.click(screen.getByText('쿠폰 관리')); - + fireEvent.click(screen.getByText("쿠폰 관리")); + // 새 쿠폰 추가 - fireEvent.click(screen.getByText('새 쿠폰 추가')); - + fireEvent.click(screen.getByText("새 쿠폰 추가")); + // 퍼센트 타입으로 변경 - 쿠폰 폼 내의 select 찾기 - const couponFormSelects = screen.getAllByRole('combobox'); + const couponFormSelects = screen.getAllByRole("combobox"); const typeSelect = couponFormSelects[couponFormSelects.length - 1]; // 마지막 select가 타입 선택 - fireEvent.change(typeSelect, { target: { value: 'percentage' } }); - + fireEvent.change(typeSelect, { target: { value: "percentage" } }); + // 100% 초과 할인율 입력 - const discountInput = screen.getByPlaceholderText('10'); - fireEvent.change(discountInput, { target: { value: '150' } }); + const discountInput = screen.getByPlaceholderText("10"); + fireEvent.change(discountInput, { target: { value: "150" } }); fireEvent.blur(discountInput); - + // 에러 메시지 확인 await waitFor(() => { - expect(screen.getByText('할인율은 100%를 초과할 수 없습니다')).toBeInTheDocument(); + expect( + screen.getByText("할인율은 100%를 초과할 수 없습니다") + ).toBeInTheDocument(); }); }); - test('상품을 삭제할 수 있다', () => { + test("상품을 삭제할 수 있다", () => { // 초기 상품명들 확인 (테이블에서) - const productTable = screen.getByRole('table'); - expect(within(productTable).getByText('상품1')).toBeInTheDocument(); - + const productTable = screen.getByRole("table"); + expect(within(productTable).getByText("상품1")).toBeInTheDocument(); + // 삭제 버튼들 찾기 - const deleteButtons = within(productTable).getAllByRole('button').filter( - button => button.textContent === '삭제' - ); - + const deleteButtons = within(productTable) + .getAllByRole("button") + .filter((button) => button.textContent === "삭제"); + // 첫 번째 상품 삭제 fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되었는지 확인 - expect(within(productTable).queryByText('상품1')).not.toBeInTheDocument(); - expect(within(productTable).getByText('상품2')).toBeInTheDocument(); + expect(within(productTable).queryByText("상품1")).not.toBeInTheDocument(); + expect(within(productTable).getByText("상품2")).toBeInTheDocument(); }); - test('쿠폰을 삭제할 수 있다', () => { + test("쿠폰을 삭제할 수 있다", () => { // 쿠폰 관리 탭으로 전환 - fireEvent.click(screen.getByText('쿠폰 관리')); - + fireEvent.click(screen.getByText("쿠폰 관리")); + // 초기 쿠폰들 확인 (h3 제목에서) - const couponTitles = screen.getAllByRole('heading', { level: 3 }); - const coupon5000 = couponTitles.find(el => el.textContent === '5000원 할인'); - const coupon10 = couponTitles.find(el => el.textContent === '10% 할인'); + const couponTitles = screen.getAllByRole("heading", { level: 3 }); + const coupon5000 = couponTitles.find( + (el) => el.textContent === "5000원 할인" + ); + const coupon10 = couponTitles.find((el) => el.textContent === "10% 할인"); expect(coupon5000).toBeInTheDocument(); expect(coupon10).toBeInTheDocument(); - + // 삭제 버튼 찾기 (SVG 아이콘을 포함한 버튼) - const deleteButtons = screen.getAllByRole('button').filter(button => { - return button.querySelector('svg') && - button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path + const deleteButtons = screen.getAllByRole("button").filter((button) => { + return ( + button.querySelector("svg") && + button.querySelector('path[d*="M19 7l"]') + ); // 삭제 아이콘 path }); - + // 첫 번째 쿠폰 삭제 fireEvent.click(deleteButtons[0]); - + // 쿠폰이 삭제되었는지 확인 - expect(screen.queryByText('5000원 할인')).not.toBeInTheDocument(); + expect(screen.queryByText("5000원 할인")).not.toBeInTheDocument(); }); - }); - describe('로컬스토리지 동기화', () => { - test('상품, 장바구니, 쿠폰이 localStorage에 저장된다', () => { + describe("로컬스토리지 동기화", () => { + test("상품, 장바구니, 쿠폰이 localStorage에 저장된다", () => { render(); - + // 상품을 장바구니에 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // localStorage 확인 - expect(localStorage.getItem('cart')).toBeTruthy(); - expect(JSON.parse(localStorage.getItem('cart'))).toHaveLength(1); - + expect(localStorage.getItem("cart")).toBeTruthy(); + expect(JSON.parse(localStorage.getItem("cart"))).toHaveLength(1); + // 관리자 모드로 전환하여 새 상품 추가 - fireEvent.click(screen.getByText('관리자 페이지로')); - fireEvent.click(screen.getByText('새 상품 추가')); - - const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); - const nameInput = nameLabel.closest('div').querySelector('input'); - fireEvent.change(nameInput, { target: { value: '저장 테스트' } }); - - const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - fireEvent.change(priceInput, { target: { value: '10000' } }); - - const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; - fireEvent.change(stockInput, { target: { value: '10' } }); - - fireEvent.click(screen.getByText('추가')); - + fireEvent.click(screen.getByText("관리자 페이지로")); + fireEvent.click(screen.getByText("새 상품 추가")); + + const labels = screen.getAllByText("상품명"); + const nameLabel = labels.find((el) => el.tagName === "LABEL"); + const nameInput = nameLabel.closest("div").querySelector("input"); + fireEvent.change(nameInput, { target: { value: "저장 테스트" } }); + + const priceInput = screen.getAllByPlaceholderText("숫자만 입력")[0]; + fireEvent.change(priceInput, { target: { value: "10000" } }); + + const stockInput = screen.getAllByPlaceholderText("숫자만 입력")[1]; + fireEvent.change(stockInput, { target: { value: "10" } }); + + fireEvent.click(screen.getByText("추가")); + // localStorage에 products가 저장되었는지 확인 - expect(localStorage.getItem('products')).toBeTruthy(); - const products = JSON.parse(localStorage.getItem('products')); - expect(products.some(p => p.name === '저장 테스트')).toBe(true); + expect(localStorage.getItem("products")).toBeTruthy(); + const products = JSON.parse(localStorage.getItem("products")); + expect(products.some((p) => p.name === "저장 테스트")).toBe(true); }); - test('페이지 새로고침 후에도 데이터가 유지된다', () => { + test("페이지 새로고침 후에도 데이터가 유지된다", () => { const { unmount } = render(); - + // 장바구니에 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[1]); + // 컴포넌트 unmount unmount(); - + // 다시 mount render(); - + // 장바구니 아이템이 유지되는지 확인 - const cartSection = screen.getByText('장바구니').closest('section'); - expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); - expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); + const cartSection = screen.getByText("장바구니").closest("section"); + expect(within(cartSection).getByText("상품1")).toBeInTheDocument(); + expect(within(cartSection).getByText("상품2")).toBeInTheDocument(); }); }); - describe('UI 상태 관리', () => { - test('할인이 있을 때 할인율이 표시된다', async () => { + describe("UI 상태 관리", () => { + test("할인이 있을 때 할인율이 표시된다", async () => { render(); - + // 상품을 10개 담아서 할인 발생 - const addButton = screen.getAllByText('장바구니 담기')[0]; + const addButton = screen.getAllByText("장바구니 담기")[0]; for (let i = 0; i < 10; i++) { fireEvent.click(addButton); } - + // 할인율 표시 확인 - 대량 구매로 15% 할인 await waitFor(() => { - expect(screen.getByText('-15%')).toBeInTheDocument(); + expect(screen.getByText("-15%")).toBeInTheDocument(); }); }); - test('장바구니 아이템 개수가 헤더에 표시된다', () => { + test("장바구니 아이템 개수가 헤더에 표시된다", () => { render(); - + // 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[1]); + // 헤더의 장바구니 아이콘 옆 숫자 확인 - const cartCount = screen.getByText('3'); + const cartCount = screen.getByText("3"); expect(cartCount).toBeInTheDocument(); }); - test('검색을 초기화할 수 있다', async () => { + test("검색을 초기화할 수 있다", async () => { render(); - + // 검색어 입력 - const searchInput = screen.getByPlaceholderText('상품 검색...'); - fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + const searchInput = screen.getByPlaceholderText("상품 검색..."); + fireEvent.change(searchInput, { target: { value: "프리미엄" } }); + // 검색 결과 확인 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText("최고급 품질의 프리미엄 상품입니다.") + ).toBeInTheDocument(); // 다른 상품들은 보이지 않음 - expect(screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.')).not.toBeInTheDocument(); + expect( + screen.queryByText("다양한 기능을 갖춘 실용적인 상품입니다.") + ).not.toBeInTheDocument(); }); - + // 검색어 초기화 - fireEvent.change(searchInput, { target: { value: '' } }); - + fireEvent.change(searchInput, { target: { value: "" } }); + // 모든 상품이 다시 표시됨 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('다양한 기능을 갖춘 실용적인 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('대용량과 고성능을 자랑하는 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText("최고급 품질의 프리미엄 상품입니다.") + ).toBeInTheDocument(); + expect( + screen.getByText("다양한 기능을 갖춘 실용적인 상품입니다.") + ).toBeInTheDocument(); + expect( + screen.getByText("대용량과 고성능을 자랑하는 상품입니다.") + ).toBeInTheDocument(); }); }); - test('알림 메시지가 자동으로 사라진다', async () => { + test("알림 메시지가 자동으로 사라진다", async () => { render(); - + // 상품 추가하여 알림 발생 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 알림 메시지 확인 - expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); - + expect(screen.getByText("장바구니에 담았습니다")).toBeInTheDocument(); + // 3초 후 알림이 사라짐 - await waitFor(() => { - expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); - }, { timeout: 4000 }); + await waitFor( + () => { + expect( + screen.queryByText("장바구니에 담았습니다") + ).not.toBeInTheDocument(); + }, + { timeout: 4000 } + ); }); }); -}); \ No newline at end of file +}); From 9d9f89395becab770c75ea9bf7af39e011cd40cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Thu, 7 Aug 2025 00:58:06 +0900 Subject: [PATCH 41/52] =?UTF-8?q?refactor:=20notification=20domain=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/advanced/App.tsx | 27 ++---------- src/advanced/components/coupon/CouponForm.tsx | 7 +--- .../components/coupon/CouponManagement.tsx | 14 ++----- .../components/payment/PaymentSection.tsx | 7 +--- .../components/product/ProductAccordion.tsx | 18 +++----- .../components/product/ProductForm.tsx | 18 ++++---- .../components/product/ProductList.tsx | 13 ++---- .../components/ui/NotificationList.tsx | 16 +++---- src/advanced/hooks/useNotifications.ts | 42 +++++++++++++++++++ src/advanced/models/notification.ts | 14 +++++++ src/advanced/models/notificationAtom.ts | 4 ++ src/advanced/pages/AdminPage.tsx | 15 +------ src/advanced/pages/ShopPage.tsx | 14 ++----- 13 files changed, 98 insertions(+), 111 deletions(-) create mode 100644 src/advanced/hooks/useNotifications.ts create mode 100644 src/advanced/models/notification.ts create mode 100644 src/advanced/models/notificationAtom.ts diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index f940ae08..5a9c9854 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,5 +1,4 @@ -import { useCallback, useState } from "react"; -import { Coupon } from "../types"; +import { useState } from "react"; import { AdminPage } from "./pages/AdminPage"; import { ShopPage } from "./pages/ShopPage"; @@ -16,33 +15,13 @@ export interface Notification { const App = () => { const [isAdmin, setIsAdmin] = useState(false); - 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); - }, - [] - ); - return (
- +
- {isAdmin ? ( - - ) : ( - - )} + {isAdmin ? : }
); diff --git a/src/advanced/components/coupon/CouponForm.tsx b/src/advanced/components/coupon/CouponForm.tsx index d24b5807..1e44802a 100644 --- a/src/advanced/components/coupon/CouponForm.tsx +++ b/src/advanced/components/coupon/CouponForm.tsx @@ -4,21 +4,18 @@ import { COUPON_FORM_INITIAL_STATE, COUPON_LIMITS, } from "../../constants/coupon"; +import { useNotifications } from "../../hooks/useNotifications"; interface CouponFormProps { onSubmit: (coupon: Coupon) => void; onCancel: () => void; - addNotification: ( - message: string, - type: "error" | "success" | "warning" - ) => void; } export const CouponForm: React.FC = ({ onSubmit, onCancel, - addNotification, }) => { + const { addNotification } = useNotifications(); const [formData, setFormData] = useState(COUPON_FORM_INITIAL_STATE); const handleSubmit = (e: React.FormEvent) => { diff --git a/src/advanced/components/coupon/CouponManagement.tsx b/src/advanced/components/coupon/CouponManagement.tsx index f2e787ff..0b0bcfa1 100644 --- a/src/advanced/components/coupon/CouponManagement.tsx +++ b/src/advanced/components/coupon/CouponManagement.tsx @@ -8,18 +8,11 @@ import { } from "../../utils/couponUtils"; import { TrashIcon, PlusIcon } from "../icons"; import { useCoupons } from "../../hooks/useCoupons"; +import { useNotifications } from "../../hooks/useNotifications"; -interface CouponManagementProps { - addNotification: ( - message: string, - type: "error" | "success" | "warning" - ) => void; -} - -export const CouponManagement: React.FC = ({ - addNotification, -}) => { +export const CouponManagement: React.FC = () => { const { coupons, addCoupon, removeCoupon } = useCoupons(); + const { addNotification } = useNotifications(); const [showCouponForm, setShowCouponForm] = useState(false); const handleAddCoupon = (coupon: Coupon) => { @@ -100,7 +93,6 @@ export const CouponManagement: React.FC = ({
)} diff --git a/src/advanced/components/payment/PaymentSection.tsx b/src/advanced/components/payment/PaymentSection.tsx index b5ae9b8f..c691ccfb 100644 --- a/src/advanced/components/payment/PaymentSection.tsx +++ b/src/advanced/components/payment/PaymentSection.tsx @@ -1,20 +1,17 @@ import { calculateCartTotal } from "../../utils/cartUtils"; import { useCart } from "../../hooks/useCart"; +import { useNotifications } from "../../hooks/useNotifications"; import { Coupon } from "../../../types"; export function PaymentSection({ selectedCoupon, setSelectedCoupon, - addNotification, }: { selectedCoupon: Coupon | null; setSelectedCoupon: (coupon: Coupon | null) => void; - addNotification: ( - message: string, - type: "error" | "success" | "warning" - ) => void; }) { const { cart, clearCart } = useCart(); + const { addNotification } = useNotifications(); const totals = calculateCartTotal(cart, selectedCoupon); diff --git a/src/advanced/components/product/ProductAccordion.tsx b/src/advanced/components/product/ProductAccordion.tsx index 90774ec9..10b5ba15 100644 --- a/src/advanced/components/product/ProductAccordion.tsx +++ b/src/advanced/components/product/ProductAccordion.tsx @@ -3,18 +3,11 @@ import { Product } from "../../../types"; import { displayPrice } from "../../utils/formatters"; import { ProductForm } from "./ProductForm"; import { useProducts } from "../../hooks/useProducts"; +import { useNotifications } from "../../hooks/useNotifications"; -interface ProductAccordionProps { - onNotification: ( - message: string, - type: "error" | "success" | "warning" - ) => void; -} - -export const ProductAccordion: React.FC = ({ - onNotification, -}) => { +export const ProductAccordion: React.FC = () => { const { products, addProduct, updateProduct, deleteProduct } = useProducts(); + const { addNotification } = useNotifications(); const [showProductForm, setShowProductForm] = useState(false); const [editingProduct, setEditingProduct] = useState(null); @@ -62,9 +55,9 @@ export const ProductAccordion: React.FC = ({ const handleDeleteProduct = useCallback( (productId: string) => { deleteProduct(productId); - onNotification("상품이 삭제되었습니다.", "success"); + addNotification("상품이 삭제되었습니다.", "success"); }, - [deleteProduct, onNotification] + [deleteProduct, addNotification] ); return ( @@ -156,7 +149,6 @@ export const ProductAccordion: React.FC = ({ product={selectedProduct} onSubmit={handleProductSubmit} onCancel={handleCancelProductForm} - onNotification={onNotification} /> )} diff --git a/src/advanced/components/product/ProductForm.tsx b/src/advanced/components/product/ProductForm.tsx index b8ced687..4dbd6213 100644 --- a/src/advanced/components/product/ProductForm.tsx +++ b/src/advanced/components/product/ProductForm.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from "react"; import { Product } from "../../../types"; import { CloseIcon } from "../icons"; +import { useNotifications } from "../../hooks/useNotifications"; interface ProductFormData { name: string; @@ -15,10 +16,6 @@ interface ProductFormProps { product?: Product; onSubmit: (productData: Omit) => void; onCancel: () => void; - onNotification: ( - message: string, - type: "error" | "success" | "warning" - ) => void; } export const ProductForm: React.FC = ({ @@ -26,8 +23,8 @@ export const ProductForm: React.FC = ({ product, onSubmit, onCancel, - onNotification, }) => { + const { addNotification } = useNotifications(); const [productForm, setProductForm] = useState({ name: "", price: 0, @@ -67,7 +64,7 @@ export const ProductForm: React.FC = ({ ...productForm, discounts: productForm.discounts, }); - onNotification( + addNotification( editingProduct === "new" ? "상품이 추가되었습니다." : "상품이 수정되었습니다.", @@ -134,7 +131,7 @@ export const ProductForm: React.FC = ({ if (value === "") { updateForm({ price: 0 }); } else if (parseInt(value) < 0) { - onNotification("가격은 0보다 커야 합니다", "error"); + addNotification("가격은 0보다 커야 합니다", "error"); updateForm({ price: 0 }); } }} @@ -161,10 +158,13 @@ export const ProductForm: React.FC = ({ if (value === "") { updateForm({ stock: 0 }); } else if (parseInt(value) < 0) { - onNotification("재고는 0보다 커야 합니다", "error"); + addNotification("재고는 0보다 커야 합니다", "error"); updateForm({ stock: 0 }); } else if (parseInt(value) > 9999) { - onNotification("재고는 9999개를 초과할 수 없습니다", "error"); + addNotification( + "재고는 9999개를 초과할 수 없습니다", + "error" + ); updateForm({ stock: 9999 }); } }} diff --git a/src/advanced/components/product/ProductList.tsx b/src/advanced/components/product/ProductList.tsx index 4029c787..a102e955 100644 --- a/src/advanced/components/product/ProductList.tsx +++ b/src/advanced/components/product/ProductList.tsx @@ -7,20 +7,13 @@ import { getRemainingStock } from "../../utils/cartUtils"; import useFilterSearchParams from "../../../hooks/useFilterSearchParams"; import { useProducts } from "../../hooks/useProducts"; import { useCart } from "../../hooks/useCart"; +import { useNotifications } from "../../hooks/useNotifications"; import { ImageIcon } from "../icons"; -interface ProductListProps { - addNotification: ( - message: string, - type: "error" | "success" | "warning" - ) => void; -} - -export const ProductList: React.FC = ({ - addNotification, -}) => { +export const ProductList: React.FC = () => { const { products, filteredProducts } = useProducts(); const { cart, addToCart } = useCart(); + const { addNotification } = useNotifications(); const { filterSearchParams } = useFilterSearchParams(); const handleAddToCart = useCallback( diff --git a/src/advanced/components/ui/NotificationList.tsx b/src/advanced/components/ui/NotificationList.tsx index 3942ca5c..25fcc348 100644 --- a/src/advanced/components/ui/NotificationList.tsx +++ b/src/advanced/components/ui/NotificationList.tsx @@ -1,13 +1,9 @@ -import { Notification } from "../../App"; +import { useNotifications } from "../../hooks/useNotifications"; import { CloseIcon } from "../icons"; -export const NotificationList = ({ - notifications, - setNotifications, -}: { - notifications: Notification[]; - setNotifications: React.Dispatch>; -}) => { +export const NotificationList = () => { + const { notifications, removeNotification } = useNotifications(); + return (
{notifications.map((notif) => ( @@ -23,9 +19,7 @@ export const NotificationList = ({ > {notif.message}
- {activeTab === "products" ? ( - - ) : ( - - )} + {activeTab === "products" ? : }
); } diff --git a/src/advanced/pages/ShopPage.tsx b/src/advanced/pages/ShopPage.tsx index b956efd4..371e4fe4 100644 --- a/src/advanced/pages/ShopPage.tsx +++ b/src/advanced/pages/ShopPage.tsx @@ -7,18 +7,13 @@ import { CartSection } from "../components/cart/CartSection"; import { CouponSection } from "../components/coupon/CouponSection"; import { PaymentSection } from "../components/payment/PaymentSection"; import { useCart } from "../hooks/useCart"; +import { useNotifications } from "../hooks/useNotifications"; -interface ShopPageProps { - addNotification: ( - message: string, - type: "error" | "success" | "warning" - ) => void; -} - -export function ShopPage({ addNotification }: ShopPageProps) { +export function ShopPage() { const [selectedCoupon, setSelectedCoupon] = useState(null); const { cart, updateCartItemQuantity, removeFromCart } = useCart(); + const { addNotification } = useNotifications(); // 장바구니 상품 수량 변경 (props로 받은 함수 사용) const handleUpdateQuantity = useCallback( @@ -69,7 +64,7 @@ export function ShopPage({ addNotification }: ShopPageProps) {
{/* 상품 목록 */} - +
@@ -87,7 +82,6 @@ export function ShopPage({ addNotification }: ShopPageProps) { )} From 9fc68e34af5890fd6642fa3f43e2cba7f37b3185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Thu, 7 Aug 2025 22:38:13 +0900 Subject: [PATCH 42/52] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/hooks/useNotifications.ts | 4 +-- src/advanced/models/discount.ts | 0 src/advanced/models/notification.ts | 3 ++ src/advanced/models/notificationAtom.ts | 4 --- src/basic/App.tsx | 23 +++++--------- src/basic/hooks/useNotifications.ts | 41 +++++++++++++++++++++++++ src/basic/models/cart.ts | 18 ----------- src/basic/models/coupon.ts | 0 src/basic/models/discount.ts | 0 src/basic/models/notification.ts | 14 +++++++++ src/basic/models/product.ts | 0 11 files changed, 67 insertions(+), 40 deletions(-) delete mode 100644 src/advanced/models/discount.ts delete mode 100644 src/advanced/models/notificationAtom.ts create mode 100644 src/basic/hooks/useNotifications.ts delete mode 100644 src/basic/models/cart.ts delete mode 100644 src/basic/models/coupon.ts delete mode 100644 src/basic/models/discount.ts create mode 100644 src/basic/models/notification.ts delete mode 100644 src/basic/models/product.ts diff --git a/src/advanced/hooks/useNotifications.ts b/src/advanced/hooks/useNotifications.ts index 35faf541..217e9abf 100644 --- a/src/advanced/hooks/useNotifications.ts +++ b/src/advanced/hooks/useNotifications.ts @@ -1,7 +1,7 @@ import { useAtom } from "jotai"; import { useCallback } from "react"; -import { notificationsAtom } from "../models/notificationAtom"; -import { Notification } from "../models/notification"; + +import { Notification, notificationsAtom } from "../models/notification"; export const useNotifications = () => { const [notifications, setNotifications] = useAtom(notificationsAtom); diff --git a/src/advanced/models/discount.ts b/src/advanced/models/discount.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/advanced/models/notification.ts b/src/advanced/models/notification.ts index 660ec8bc..9d2e8927 100644 --- a/src/advanced/models/notification.ts +++ b/src/advanced/models/notification.ts @@ -1,3 +1,4 @@ +import { atom } from "jotai"; export interface Notification { id: string; message: string; @@ -12,3 +13,5 @@ export interface AddNotificationParams { message: string; type: "error" | "success" | "warning"; } + +export const notificationsAtom = atom([]); diff --git a/src/advanced/models/notificationAtom.ts b/src/advanced/models/notificationAtom.ts deleted file mode 100644 index 358053b3..00000000 --- a/src/advanced/models/notificationAtom.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { atom } from "jotai"; -import { Notification } from "./notification"; - -export const notificationsAtom = atom([]); diff --git a/src/basic/App.tsx b/src/basic/App.tsx index 6b6f2e57..cf566076 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useState } from "react"; import { Coupon } from "../types"; import { AdminPage } from "./pages/AdminPage"; @@ -8,6 +8,7 @@ import { Header } from "./components/ui/Header"; import { NotificationList } from "./components/ui/NotificationList"; import { useCart } from "./hooks/useCart"; import { useCoupons } from "./hooks/useCoupons"; +import { useNotifications } from "./hooks/useNotifications"; import { useProducts } from "./hooks/useProducts"; export interface Notification { @@ -17,6 +18,11 @@ export interface Notification { } const App = () => { + const [isAdmin, setIsAdmin] = useState(false); + + const { notifications, addNotification, setNotifications } = + useNotifications(); + const { products, filteredProducts, @@ -29,21 +35,6 @@ const App = () => { const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); - - 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 { cart, addToCart, updateCartItemQuantity, removeFromCart, clearCart } = useCart(); diff --git a/src/basic/hooks/useNotifications.ts b/src/basic/hooks/useNotifications.ts new file mode 100644 index 00000000..ed1dc701 --- /dev/null +++ b/src/basic/hooks/useNotifications.ts @@ -0,0 +1,41 @@ +import { useCallback, useState } from "react"; + +import { Notification } from "../models/notification"; + +export const useNotifications = () => { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = `${Date.now()}-${Math.random()}`; + const newNotification: Notification = { id, message, type }; + + setNotifications((prev) => [...prev, newNotification]); + + // 3초 후 자동으로 제거 + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [setNotifications] + ); + + const removeNotification = useCallback( + (id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, + [setNotifications] + ); + + const clearAllNotifications = useCallback(() => { + setNotifications([]); + }, [setNotifications]); + + return { + notifications, + addNotification, + removeNotification, + clearAllNotifications, + setNotifications, + }; +}; diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts deleted file mode 100644 index 5c681048..00000000 --- a/src/basic/models/cart.ts +++ /dev/null @@ -1,18 +0,0 @@ -// TODO: 장바구니 비즈니스 로직 (순수 함수) -// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) -// -// 구현할 함수들: -// 1. calculateItemTotal(item): 개별 아이템의 할인 적용 후 총액 계산 -// 2. getMaxApplicableDiscount(item): 적용 가능한 최대 할인율 계산 -// 3. calculateCartTotal(cart, coupon): 장바구니 총액 계산 (할인 전/후, 할인액) -// 4. updateCartItemQuantity(cart, productId, quantity): 수량 변경 -// 5. addItemToCart(cart, product): 상품 추가 -// 6. removeItemFromCart(cart, productId): 상품 제거 -// 7. getRemainingStock(product, cart): 남은 재고 계산 -// -// 원칙: -// - UI와 관련된 로직 없음 -// - 외부 상태에 의존하지 않음 -// - 모든 필요한 데이터는 파라미터로 전달받음 - -// TODO: 구현 \ No newline at end of file diff --git a/src/basic/models/coupon.ts b/src/basic/models/coupon.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/basic/models/discount.ts b/src/basic/models/discount.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/basic/models/notification.ts b/src/basic/models/notification.ts new file mode 100644 index 00000000..660ec8bc --- /dev/null +++ b/src/basic/models/notification.ts @@ -0,0 +1,14 @@ +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +export interface NotificationState { + notifications: Notification[]; +} + +export interface AddNotificationParams { + message: string; + type: "error" | "success" | "warning"; +} diff --git a/src/basic/models/product.ts b/src/basic/models/product.ts deleted file mode 100644 index e69de29b..00000000 From 78d4b42e6c65840e38deb5771441915eccbc50ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Fri, 8 Aug 2025 01:22:14 +0900 Subject: [PATCH 43/52] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=ED=8F=BC=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=83=80=EC=9E=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20Jotai=EB=A5=BC=20=ED=86=B5=ED=95=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/__tests__/origin.test.tsx | 3 +- .../components/product/ProductForm.tsx | 96 +++++++------------ src/advanced/constants/product.ts | 6 +- src/advanced/main.tsx | 5 +- src/advanced/utils/couponUtils.ts | 23 +++-- src/advanced/utils/productUtils.ts | 14 ++- src/types.ts | 8 ++ 7 files changed, 75 insertions(+), 80 deletions(-) diff --git a/src/advanced/__tests__/origin.test.tsx b/src/advanced/__tests__/origin.test.tsx index 01ef29bd..a1daa950 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -9,9 +9,10 @@ import { import { vi } from "vitest"; import App from "../App"; import "../../setupTests"; -import { notificationsAtom } from "../models/notificationAtom"; + import { useAtom } from "jotai"; import React from "react"; +import { notificationsAtom } from "../models/notification"; describe("쇼핑몰 앱 통합 테스트", () => { beforeEach(() => { diff --git a/src/advanced/components/product/ProductForm.tsx b/src/advanced/components/product/ProductForm.tsx index 4dbd6213..46547507 100644 --- a/src/advanced/components/product/ProductForm.tsx +++ b/src/advanced/components/product/ProductForm.tsx @@ -1,58 +1,29 @@ -import React, { useState, useEffect } from "react"; -import { Product } from "../../../types"; +import React, { useState } from "react"; +import { Product, ProductFormData } from "../../../types"; import { CloseIcon } from "../icons"; import { useNotifications } from "../../hooks/useNotifications"; -interface ProductFormData { - name: string; - price: number; - stock: number; - description: string; - discounts: Array<{ quantity: number; rate: number }>; -} - interface ProductFormProps { - editingProduct: string | null; - product?: Product; - onSubmit: (productData: Omit) => void; + initProductFormData: Product | null; + onSubmit: (productData: Product) => void; onCancel: () => void; } export const ProductForm: React.FC = ({ - editingProduct, - product, + initProductFormData, onSubmit, onCancel, }) => { const { addNotification } = useNotifications(); - const [productForm, setProductForm] = useState({ + const [productForm, setProductForm] = useState>({ name: "", + description: "", price: 0, stock: 0, - description: "", discounts: [], }); - // 상품 데이터가 변경될 때 폼 초기화 - useEffect(() => { - if (product && editingProduct === product.id) { - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || "", - discounts: product.discounts || [], - }); - } else if (editingProduct === "new") { - setProductForm({ - name: "", - price: 0, - stock: 0, - description: "", - discounts: [], - }); - } - }, [editingProduct, product]); + const isNewProduct = initProductFormData?.id === "new"; const updateForm = (updates: Partial) => { setProductForm({ ...productForm, ...updates }); @@ -60,34 +31,31 @@ export const ProductForm: React.FC = ({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - onSubmit({ - ...productForm, - discounts: productForm.discounts, - }); - addNotification( - editingProduct === "new" - ? "상품이 추가되었습니다." - : "상품이 수정되었습니다.", - "success" - ); - }; - const handleCancel = () => { - setProductForm({ - name: "", - price: 0, - stock: 0, - description: "", - discounts: [], + if ( + !productForm?.name || + !productForm?.price || + productForm?.stock === undefined + ) { + addNotification("필수 필드를 모두 입력해주세요.", "error"); + return; + } + + onSubmit({ + name: productForm.name, + price: productForm.price, + stock: productForm.stock, + description: productForm.description ?? "", + discounts: productForm.discounts ?? [], + id: initProductFormData?.id ?? "new", }); - onCancel(); }; return (

- {editingProduct === "new" ? "새 상품 추가" : "상품 수정"} + {initProductFormData?.id === "new" ? "새 상품 추가" : "상품 수정"}

@@ -179,7 +147,7 @@ export const ProductForm: React.FC = ({ 할인 정책
- {productForm.discounts.map((discount, index) => ( + {productForm.discounts?.map((discount, index) => (
= ({ type="number" value={discount.quantity} onChange={(e) => { - const newDiscounts = [...productForm.discounts]; + const newDiscounts = [...(productForm.discounts ?? [])]; newDiscounts[index].quantity = parseInt(e.target.value) || 0; updateForm({ discounts: newDiscounts }); @@ -202,7 +170,7 @@ export const ProductForm: React.FC = ({ type="number" value={discount.rate * 100} onChange={(e) => { - const newDiscounts = [...productForm.discounts]; + const newDiscounts = [...(productForm.discounts ?? [])]; newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; updateForm({ discounts: newDiscounts }); @@ -216,7 +184,7 @@ export const ProductForm: React.FC = ({
diff --git a/src/advanced/constants/product.ts b/src/advanced/constants/product.ts index c9700aa3..77b77beb 100644 --- a/src/advanced/constants/product.ts +++ b/src/advanced/constants/product.ts @@ -34,10 +34,14 @@ export const initialProducts: Product[] = [ }, ]; -export const PRODUCT_FORM_INITIAL_STATE = { +export const NEW_PRODUCT_FORM_KEY = "new"; + +export const PRODUCT_FORM_INITIAL_STATE: Product = { name: "", price: 0, stock: 0, description: "", isRecommended: false, + discounts: [], + id: NEW_PRODUCT_FORM_KEY, }; diff --git a/src/advanced/main.tsx b/src/advanced/main.tsx index f46c379c..a8c4ccfb 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"; +import { Provider } from "jotai"; ReactDOM.createRoot(document.getElementById("root")!).render( - + + + ); diff --git a/src/advanced/utils/couponUtils.ts b/src/advanced/utils/couponUtils.ts index 6d293fe0..961e8a93 100644 --- a/src/advanced/utils/couponUtils.ts +++ b/src/advanced/utils/couponUtils.ts @@ -18,11 +18,9 @@ export const applyCouponDiscount = (total: number, coupon: Coupon): number => { // 쿠폰 코드 생성 export const generateCouponCode = (): string => { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let result = ""; - for (let i = 0; i < 8; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; + return Array.from({ length: 8 }, () => + chars.charAt(Math.floor(Math.random() * chars.length)) + ).join(""); }; // 쿠폰 데이터 검증 @@ -41,7 +39,9 @@ export const validateCouponData = (coupon: Partial): boolean => { coupon.discountValue >= COUPON_VALIDATION_RULES.MIN_AMOUNT_DISCOUNT && coupon.discountValue <= COUPON_VALIDATION_RULES.MAX_AMOUNT_DISCOUNT ); - } else if (coupon.discountType === "percentage") { + } + + if (coupon.discountType === "percentage") { return ( coupon.discountValue >= COUPON_VALIDATION_RULES.MIN_PERCENTAGE_DISCOUNT && coupon.discountValue <= COUPON_VALIDATION_RULES.MAX_PERCENTAGE_DISCOUNT @@ -58,9 +58,12 @@ export const isCouponDuplicate = (coupons: Coupon[], code: string): boolean => { // 쿠폰 표시 텍스트 생성 export const getCouponDisplayText = (coupon: Coupon): string => { - if (coupon.discountType === "amount") { - return `${coupon.discountValue.toLocaleString()}원 할인`; - } else { - return `${coupon.discountValue}% 할인`; + switch (coupon.discountType) { + case "amount": + return `${coupon.discountValue.toLocaleString()}원 할인`; + case "percentage": + return `${coupon.discountValue}% 할인`; + default: + return ""; } }; diff --git a/src/advanced/utils/productUtils.ts b/src/advanced/utils/productUtils.ts index 747300dc..b55c19a3 100644 --- a/src/advanced/utils/productUtils.ts +++ b/src/advanced/utils/productUtils.ts @@ -1,15 +1,23 @@ +import { filterSearchParamsSchemaType } from "../../hooks/useFilterSearchParams"; import { CartItem, Product } from "../../types"; /** * 상품 검색 필터링 */ export const filterProducts = ( - //TODO: 진석 리팩토링 필요 products: Product[], - searchTerm: string + filterQuery: filterSearchParamsSchemaType ): Product[] => { - if (!searchTerm) return products; + return filterProductsBySearchTerm(products, filterQuery.searchTerm ?? ""); +}; +const filterProductsBySearchTerm = ( + products: Product[], + searchTerm: string +) => { + if (!searchTerm.trim()) { + return products; + } return products.filter( (product) => product.name.toLowerCase().includes(searchTerm.toLowerCase()) || diff --git a/src/types.ts b/src/types.ts index c4071b5f..b6da5bf7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,14 @@ export interface Product { isRecommended?: boolean; } +export interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} + export interface Discount { quantity: number; rate: number; From 8d52559f2dcaa64b59284ad18f52f127bfd11ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Fri, 8 Aug 2025 01:39:52 +0900 Subject: [PATCH 44/52] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=ED=8F=BC=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/product/ProductAccordion.tsx | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/src/advanced/components/product/ProductAccordion.tsx b/src/advanced/components/product/ProductAccordion.tsx index 10b5ba15..c554e159 100644 --- a/src/advanced/components/product/ProductAccordion.tsx +++ b/src/advanced/components/product/ProductAccordion.tsx @@ -4,51 +4,58 @@ import { displayPrice } from "../../utils/formatters"; import { ProductForm } from "./ProductForm"; import { useProducts } from "../../hooks/useProducts"; import { useNotifications } from "../../hooks/useNotifications"; +import { + NEW_PRODUCT_FORM_KEY, + PRODUCT_FORM_INITIAL_STATE, +} from "../../constants/product"; export const ProductAccordion: React.FC = () => { const { products, addProduct, updateProduct, deleteProduct } = useProducts(); const { addNotification } = useNotifications(); - const [showProductForm, setShowProductForm] = useState(false); - const [editingProduct, setEditingProduct] = useState(null); - const [selectedProduct, setSelectedProduct] = useState( - undefined - ); + const [initProductFormData, setInitProductFormData] = + useState(null); + + const showProductForm = initProductFormData !== null; // 상품 편집 시작 const handleStartEditProduct = useCallback((product: Product) => { - setSelectedProduct(product); - setEditingProduct(product.id); - setShowProductForm(true); + setInitProductFormData(product); }, []); // 새 상품 추가 시작 const handleAddNewProduct = useCallback(() => { - setSelectedProduct(undefined); - setEditingProduct("new"); - setShowProductForm(true); + setInitProductFormData(PRODUCT_FORM_INITIAL_STATE); }, []); // 상품 폼 취소 const handleCancelProductForm = useCallback(() => { - setEditingProduct(null); - setSelectedProduct(undefined); - setShowProductForm(false); + setInitProductFormData(null); }, []); // 상품 폼 제출 처리 const handleProductSubmit = useCallback( - (productData: Omit) => { - if (editingProduct && editingProduct !== "new") { - updateProduct(editingProduct, productData); - } else { + (productData: Product) => { + addNotification( + initProductFormData?.id === NEW_PRODUCT_FORM_KEY + ? "상품이 추가되었습니다." + : "상품이 수정되었습니다.", + "success" + ); + setInitProductFormData(null); + + if (!initProductFormData?.id) { + return; + } + + if (initProductFormData.id === NEW_PRODUCT_FORM_KEY) { addProduct(productData); + return; } - setEditingProduct(null); - setSelectedProduct(undefined); - setShowProductForm(false); + + updateProduct(initProductFormData.id, productData); }, - [editingProduct, updateProduct, addProduct] + [addNotification, initProductFormData?.id, updateProduct, addProduct] ); // 상품 삭제 @@ -145,8 +152,8 @@ export const ProductAccordion: React.FC = () => {
{showProductForm && ( From e2738ebbf78dfdb99afb8f8db550e7646c03c744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Fri, 8 Aug 2025 01:50:02 +0900 Subject: [PATCH 45/52] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=ED=8F=BC=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81=EC=9D=84=20?= =?UTF-8?q?=EC=83=81=EC=88=98=EB=A1=9C=20=ED=86=B5=ED=95=A9=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/components/product/ProductForm.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/advanced/components/product/ProductForm.tsx b/src/advanced/components/product/ProductForm.tsx index 46547507..7e38d86f 100644 --- a/src/advanced/components/product/ProductForm.tsx +++ b/src/advanced/components/product/ProductForm.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { Product, ProductFormData } from "../../../types"; import { CloseIcon } from "../icons"; import { useNotifications } from "../../hooks/useNotifications"; +import { PRODUCT_FORM_INITIAL_STATE } from "../../constants/product"; interface ProductFormProps { initProductFormData: Product | null; @@ -15,13 +16,9 @@ export const ProductForm: React.FC = ({ onCancel, }) => { const { addNotification } = useNotifications(); - const [productForm, setProductForm] = useState>({ - name: "", - description: "", - price: 0, - stock: 0, - discounts: [], - }); + const [productForm, setProductForm] = useState>( + initProductFormData ?? PRODUCT_FORM_INITIAL_STATE + ); const isNewProduct = initProductFormData?.id === "new"; From f0a9c2043ef7fac1aa2322badf7ecd23c61a051c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Fri, 8 Aug 2025 02:13:40 +0900 Subject: [PATCH 46/52] =?UTF-8?q?feat:=20Vite=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20GitHub=20Pages=20=EB=B0=B0=ED=8F=AC=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 38 ++++++++++++++++++++++++++++++++ vite.config.ts | 42 ++++++++++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..5c6bb0c4 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,38 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 10.12.4 + + - name: Install dependencies + run: pnpm install + + - name: Build + run: pnpm build + env: + NODE_ENV: production + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + if: github.ref == 'refs/heads/main' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist diff --git a/vite.config.ts b/vite.config.ts index e6c4016b..3dd07470 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,46 @@ -import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react-swc'; +import { defineConfig as defineTestConfig, mergeConfig } from "vitest/config"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; export default mergeConfig( defineConfig({ plugins: [react()], + root: ".", + base: "/front_6th_chapter2-2/", + build: { + rollupOptions: { + input: "./index.advanced.html", + }, + outDir: "dist", + copyPublicDir: true, + }, }), defineTestConfig({ test: { globals: true, - environment: 'jsdom', - setupFiles: './src/setupTests.ts' + environment: "jsdom", + setupFiles: "./src/setupTests.ts", }, }) -) +); + +// import { defineConfig } from 'vitest/config'; +// import react from '@vitejs/plugin-react'; + +// export default defineConfig({ +// plugins: [react()], +// root: '.', +// base: '/front_6th_chapter2-1/', +// build: { +// rollupOptions: { +// input: './index.html', +// }, +// outDir: 'dist', +// copyPublicDir: true, +// }, +// test: { +// globals: true, +// environment: 'jsdom', +// setupFiles: 'src/setupTests.js', +// }, +// }); From f559536266e510a3c89abb5071dda1d7531340d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Fri, 8 Aug 2025 02:18:17 +0900 Subject: [PATCH 47/52] =?UTF-8?q?refactor:=20pr.md=EC=97=90=20=EB=8B=A8?= =?UTF-8?q?=EC=88=9C=20=EA=B3=84=EC=82=B0=20=EC=9C=A0=ED=8B=B8=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=82=AC=EC=9A=A9=20=EC=97=AC=EB=B6=80=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=EC=84=A0=ED=83=9D=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20vite.config.ts=EC=97=90=EC=84=9C=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B2=98=EB=A6=AC=EB=90=9C=20=EC=BD=94=EB=93=9C=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 --- pr.md | 2 ++ vite.config.ts | 21 --------------------- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/pr.md b/pr.md index f6010d64..eb6fcd70 100644 --- a/pr.md +++ b/pr.md @@ -49,6 +49,8 @@ hook에서는 순수함수만 사용하도록하고 handle이 중복된다면 hook으로 따로 빼는걸 고려하는 방식이 옳은 방식이라고 생각했는데 어떤까요? +4. 단순계산은 굳이 유틸함수로 빼지 않는 선택을 했습니다. + ### 과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요? ### 과제를 다시 해보면 더 잘 할 수 있었겠다 아쉬운 점이 있다면 무엇인가요? diff --git a/vite.config.ts b/vite.config.ts index 3dd07470..d12732d5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,24 +23,3 @@ export default mergeConfig( }, }) ); - -// import { defineConfig } from 'vitest/config'; -// import react from '@vitejs/plugin-react'; - -// export default defineConfig({ -// plugins: [react()], -// root: '.', -// base: '/front_6th_chapter2-1/', -// build: { -// rollupOptions: { -// input: './index.html', -// }, -// outDir: 'dist', -// copyPublicDir: true, -// }, -// test: { -// globals: true, -// environment: 'jsdom', -// setupFiles: 'src/setupTests.js', -// }, -// }); From 3556115457ea5f9c311b71b3297c87d64a12d617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Fri, 8 Aug 2025 02:27:46 +0900 Subject: [PATCH 48/52] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/utils/hooks/useDebounce.ts | 3 +- src/basic/components/product/ProductForm.tsx | 2 +- src/basic/hooks/useCoupons.ts | 2 +- src/basic/hooks/useProducts.ts | 6 ++-- src/basic/utils/hooks/useDebounce.ts | 3 +- src/hooks/useFilterSearchParams.ts | 4 ++- src/refactoring(hint)/App.tsx | 12 -------- .../components/AdminPage.tsx | 20 ------------- src/refactoring(hint)/components/CartPage.tsx | 21 -------------- .../components/icons/index.tsx | 12 -------- .../components/ui/UIToast.ts | 0 src/refactoring(hint)/constants/index.ts | 8 ----- src/refactoring(hint)/hooks/useCart.ts | 29 ------------------- src/refactoring(hint)/hooks/useCoupons.ts | 13 --------- src/refactoring(hint)/hooks/useProducts.ts | 18 ------------ src/refactoring(hint)/main.tsx | 4 --- src/refactoring(hint)/models/cart.ts | 18 ------------ src/refactoring(hint)/models/coupon.ts | 0 src/refactoring(hint)/models/discount.ts | 0 src/refactoring(hint)/models/product.ts | 0 src/refactoring(hint)/utils/formatters.ts | 7 ----- .../utils/hooks/useDebounce.ts | 11 ------- .../utils/hooks/useLocalStorage.ts | 15 ---------- .../utils/hooks/useValidate.ts | 0 src/refactoring(hint)/utils/validators.ts | 8 ----- 25 files changed, 12 insertions(+), 204 deletions(-) delete mode 100644 src/refactoring(hint)/App.tsx delete mode 100644 src/refactoring(hint)/components/AdminPage.tsx delete mode 100644 src/refactoring(hint)/components/CartPage.tsx delete mode 100644 src/refactoring(hint)/components/icons/index.tsx delete mode 100644 src/refactoring(hint)/components/ui/UIToast.ts delete mode 100644 src/refactoring(hint)/constants/index.ts delete mode 100644 src/refactoring(hint)/hooks/useCart.ts delete mode 100644 src/refactoring(hint)/hooks/useCoupons.ts delete mode 100644 src/refactoring(hint)/hooks/useProducts.ts delete mode 100644 src/refactoring(hint)/main.tsx delete mode 100644 src/refactoring(hint)/models/cart.ts delete mode 100644 src/refactoring(hint)/models/coupon.ts delete mode 100644 src/refactoring(hint)/models/discount.ts delete mode 100644 src/refactoring(hint)/models/product.ts delete mode 100644 src/refactoring(hint)/utils/formatters.ts delete mode 100644 src/refactoring(hint)/utils/hooks/useDebounce.ts delete mode 100644 src/refactoring(hint)/utils/hooks/useLocalStorage.ts delete mode 100644 src/refactoring(hint)/utils/hooks/useValidate.ts delete mode 100644 src/refactoring(hint)/utils/validators.ts diff --git a/src/advanced/utils/hooks/useDebounce.ts b/src/advanced/utils/hooks/useDebounce.ts index 14e641cc..67b48cff 100644 --- a/src/advanced/utils/hooks/useDebounce.ts +++ b/src/advanced/utils/hooks/useDebounce.ts @@ -1,9 +1,10 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useEffect, useCallback, useRef } from "react"; /** * 디바운스된 콜백 Hook * 함수 호출을 지정된 시간만큼 지연시킵니다 */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function useDebouncedCallback any>( callback: T, delay: number diff --git a/src/basic/components/product/ProductForm.tsx b/src/basic/components/product/ProductForm.tsx index b8ced687..a5ceee14 100644 --- a/src/basic/components/product/ProductForm.tsx +++ b/src/basic/components/product/ProductForm.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { Product } from "../../../types"; import { CloseIcon } from "../icons"; diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts index 4bf99564..f1fb5fa7 100644 --- a/src/basic/hooks/useCoupons.ts +++ b/src/basic/hooks/useCoupons.ts @@ -1,7 +1,7 @@ import { useCallback, useState } from "react"; import { Coupon } from "../../types"; import { initialCoupons } from "../constants/coupon"; -import { isCouponDuplicate, generateCouponCode } from "../utils/couponUtils"; +import { generateCouponCode } from "../utils/couponUtils"; export const useCoupons = () => { const [coupons, _setCoupons] = useState(() => { diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts index d59df4e3..4b95ee9c 100644 --- a/src/basic/hooks/useProducts.ts +++ b/src/basic/hooks/useProducts.ts @@ -1,9 +1,9 @@ -import { useCallback, useState, useEffect } from "react"; +import { useCallback, useState } from "react"; +import useFilterSearchParams from "../../hooks/useFilterSearchParams"; import { Product } from "../../types"; -import { generateProductId, validateProductData } from "../utils/productUtils"; import { initialProducts } from "../constants/product"; -import useFilterSearchParams from "../../hooks/useFilterSearchParams"; +import { generateProductId, validateProductData } from "../utils/productUtils"; export const useProducts = () => { const [products, _setProducts] = useState(() => { diff --git a/src/basic/utils/hooks/useDebounce.ts b/src/basic/utils/hooks/useDebounce.ts index 14e641cc..c2224b46 100644 --- a/src/basic/utils/hooks/useDebounce.ts +++ b/src/basic/utils/hooks/useDebounce.ts @@ -1,9 +1,10 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; /** * 디바운스된 콜백 Hook * 함수 호출을 지정된 시간만큼 지연시킵니다 */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function useDebouncedCallback any>( callback: T, delay: number diff --git a/src/hooks/useFilterSearchParams.ts b/src/hooks/useFilterSearchParams.ts index 11fae1b6..c3626f82 100644 --- a/src/hooks/useFilterSearchParams.ts +++ b/src/hooks/useFilterSearchParams.ts @@ -6,7 +6,9 @@ const filterSearchParamsSchema = z.object({ searchTerm: z.string().optional(), }); -type filterSearchParamsSchemaType = z.infer; +export type filterSearchParamsSchemaType = z.infer< + typeof filterSearchParamsSchema +>; /** * searchParams를 Order requestType에 맞는 타입으로 변환해서 return diff --git a/src/refactoring(hint)/App.tsx b/src/refactoring(hint)/App.tsx deleted file mode 100644 index d8cc004c..00000000 --- a/src/refactoring(hint)/App.tsx +++ /dev/null @@ -1,12 +0,0 @@ -// TODO: 메인 App 컴포넌트 -// 힌트: -// 1. isAdmin 상태를 관리하여 쇼핑몰/관리자 모드 전환 -// 2. 네비게이션 바에 모드 전환 버튼 포함 -// 3. 조건부 렌더링으로 CartPage 또는 AdminPage 표시 -// 4. 상태 관리는 각 페이지 컴포넌트에서 처리 (App은 라우팅만 담당) - -export function App() { - // TODO: 구현 -} - -export default App; \ No newline at end of file diff --git a/src/refactoring(hint)/components/AdminPage.tsx b/src/refactoring(hint)/components/AdminPage.tsx deleted file mode 100644 index afb5b1ae..00000000 --- a/src/refactoring(hint)/components/AdminPage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// TODO: 관리자 페이지 컴포넌트 -// 힌트: -// 1. 탭 UI로 상품 관리와 쿠폰 관리 분리 -// 2. 상품 추가/수정/삭제 기능 -// 3. 쿠폰 생성 기능 -// 4. 할인 규칙 설정 -// -// 필요한 hooks: -// - useProducts: 상품 CRUD -// - useCoupons: 쿠폰 CRUD -// -// 하위 컴포넌트: -// - ProductForm: 새 상품 추가 폼 -// - ProductAccordion: 상품 정보 표시 및 수정 -// - CouponForm: 새 쿠폰 추가 폼 -// - CouponList: 쿠폰 목록 표시 - -export function AdminPage() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/components/CartPage.tsx b/src/refactoring(hint)/components/CartPage.tsx deleted file mode 100644 index 069edafc..00000000 --- a/src/refactoring(hint)/components/CartPage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// TODO: 장바구니 페이지 컴포넌트 -// 힌트: -// 1. 상품 목록 표시 (검색 기능 포함) -// 2. 장바구니 관리 -// 3. 쿠폰 적용 -// 4. 주문 처리 -// -// 필요한 hooks: -// - useProducts: 상품 목록 관리 -// - useCart: 장바구니 상태 관리 -// - useCoupons: 쿠폰 목록 관리 -// - useDebounce: 검색어 디바운싱 -// -// 하위 컴포넌트: -// - SearchBar: 검색 입력 -// - ProductList: 상품 목록 표시 -// - Cart: 장바구니 표시 및 결제 - -export function CartPage() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/components/icons/index.tsx b/src/refactoring(hint)/components/icons/index.tsx deleted file mode 100644 index 1609d774..00000000 --- a/src/refactoring(hint)/components/icons/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -// TODO: SVG 아이콘 컴포넌트들 -// 구현할 아이콘: -// - CartIcon: 장바구니 아이콘 -// - AdminIcon: 관리자 아이콘 -// - PlusIcon: 플러스 아이콘 -// - MinusIcon: 마이너스 아이콘 -// - TrashIcon: 삭제 아이콘 -// - ChevronDownIcon: 아래 화살표 -// - ChevronUpIcon: 위 화살표 -// - CheckIcon: 체크 아이콘 - -// TODO: 구현 \ No newline at end of file diff --git a/src/refactoring(hint)/components/ui/UIToast.ts b/src/refactoring(hint)/components/ui/UIToast.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/constants/index.ts b/src/refactoring(hint)/constants/index.ts deleted file mode 100644 index bef3834f..00000000 --- a/src/refactoring(hint)/constants/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// TODO: 초기 데이터 상수 -// 정의할 상수들: -// - initialProducts: 초기 상품 목록 (상품1, 상품2, 상품3 + 설명 필드 포함) -// - initialCoupons: 초기 쿠폰 목록 (5000원 할인, 10% 할인) -// -// 참고: origin/App.tsx의 초기 데이터 구조를 참조 - -// TODO: 구현 \ No newline at end of file diff --git a/src/refactoring(hint)/hooks/useCart.ts b/src/refactoring(hint)/hooks/useCart.ts deleted file mode 100644 index 6db309aa..00000000 --- a/src/refactoring(hint)/hooks/useCart.ts +++ /dev/null @@ -1,29 +0,0 @@ -// TODO: 장바구니 관리 Hook -// 힌트: -// 1. 장바구니 상태 관리 (localStorage 연동) -// 2. 상품 추가/삭제/수량 변경 -// 3. 쿠폰 적용 -// 4. 총액 계산 -// 5. 재고 확인 -// -// 사용할 모델 함수: -// - cartModel.addItemToCart -// - cartModel.removeItemFromCart -// - cartModel.updateCartItemQuantity -// - cartModel.calculateCartTotal -// - cartModel.getRemainingStock -// -// 반환할 값: -// - cart: 장바구니 아이템 배열 -// - selectedCoupon: 선택된 쿠폰 -// - addToCart: 상품 추가 함수 -// - removeFromCart: 상품 제거 함수 -// - updateQuantity: 수량 변경 함수 -// - applyCoupon: 쿠폰 적용 함수 -// - calculateTotal: 총액 계산 함수 -// - getRemainingStock: 재고 확인 함수 -// - clearCart: 장바구니 비우기 함수 - -export function useCart() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/hooks/useCoupons.ts b/src/refactoring(hint)/hooks/useCoupons.ts deleted file mode 100644 index d2ad82ab..00000000 --- a/src/refactoring(hint)/hooks/useCoupons.ts +++ /dev/null @@ -1,13 +0,0 @@ -// TODO: 쿠폰 관리 Hook -// 힌트: -// 1. 쿠폰 목록 상태 관리 (localStorage 연동 고려) -// 2. 쿠폰 추가/삭제 -// -// 반환할 값: -// - coupons: 쿠폰 배열 -// - addCoupon: 새 쿠폰 추가 -// - removeCoupon: 쿠폰 삭제 - -export function useCoupons() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/hooks/useProducts.ts b/src/refactoring(hint)/hooks/useProducts.ts deleted file mode 100644 index f4bef103..00000000 --- a/src/refactoring(hint)/hooks/useProducts.ts +++ /dev/null @@ -1,18 +0,0 @@ -// TODO: 상품 관리 Hook -// 힌트: -// 1. 상품 목록 상태 관리 (localStorage 연동 고려) -// 2. 상품 CRUD 작업 -// 3. 재고 업데이트 -// 4. 할인 규칙 추가/삭제 -// -// 반환할 값: -// - products: 상품 배열 -// - updateProduct: 상품 정보 수정 -// - addProduct: 새 상품 추가 -// - updateProductStock: 재고 수정 -// - addProductDiscount: 할인 규칙 추가 -// - removeProductDiscount: 할인 규칙 삭제 - -export function useProducts() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/main.tsx b/src/refactoring(hint)/main.tsx deleted file mode 100644 index 589b1645..00000000 --- a/src/refactoring(hint)/main.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// TODO: React 앱 엔트리 포인트 -// App 컴포넌트를 root DOM 요소에 렌더링 - -// TODO: 구현 \ No newline at end of file diff --git a/src/refactoring(hint)/models/cart.ts b/src/refactoring(hint)/models/cart.ts deleted file mode 100644 index 5c681048..00000000 --- a/src/refactoring(hint)/models/cart.ts +++ /dev/null @@ -1,18 +0,0 @@ -// TODO: 장바구니 비즈니스 로직 (순수 함수) -// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) -// -// 구현할 함수들: -// 1. calculateItemTotal(item): 개별 아이템의 할인 적용 후 총액 계산 -// 2. getMaxApplicableDiscount(item): 적용 가능한 최대 할인율 계산 -// 3. calculateCartTotal(cart, coupon): 장바구니 총액 계산 (할인 전/후, 할인액) -// 4. updateCartItemQuantity(cart, productId, quantity): 수량 변경 -// 5. addItemToCart(cart, product): 상품 추가 -// 6. removeItemFromCart(cart, productId): 상품 제거 -// 7. getRemainingStock(product, cart): 남은 재고 계산 -// -// 원칙: -// - UI와 관련된 로직 없음 -// - 외부 상태에 의존하지 않음 -// - 모든 필요한 데이터는 파라미터로 전달받음 - -// TODO: 구현 \ No newline at end of file diff --git a/src/refactoring(hint)/models/coupon.ts b/src/refactoring(hint)/models/coupon.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/models/discount.ts b/src/refactoring(hint)/models/discount.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/models/product.ts b/src/refactoring(hint)/models/product.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/utils/formatters.ts b/src/refactoring(hint)/utils/formatters.ts deleted file mode 100644 index ff157f5c..00000000 --- a/src/refactoring(hint)/utils/formatters.ts +++ /dev/null @@ -1,7 +0,0 @@ -// TODO: 포맷팅 유틸리티 함수들 -// 구현할 함수: -// - formatPrice(price: number): string - 가격을 한국 원화 형식으로 포맷 -// - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷 -// - formatPercentage(rate: number): string - 소수를 퍼센트로 변환 (0.1 → 10%) - -// TODO: 구현 \ No newline at end of file diff --git a/src/refactoring(hint)/utils/hooks/useDebounce.ts b/src/refactoring(hint)/utils/hooks/useDebounce.ts deleted file mode 100644 index 53c8a374..00000000 --- a/src/refactoring(hint)/utils/hooks/useDebounce.ts +++ /dev/null @@ -1,11 +0,0 @@ -// TODO: 디바운스 Hook -// 힌트: -// 1. 값이 변경되어도 지정된 시간 동안 대기 -// 2. 대기 시간 동안 값이 다시 변경되면 타이머 리셋 -// 3. 최종적으로 안정된 값만 반환 -// -// 사용 예시: 검색어 입력 디바운싱 - -export function useDebounce(value: T, delay: number): T { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts deleted file mode 100644 index 5dc72c50..00000000 --- a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts +++ /dev/null @@ -1,15 +0,0 @@ -// TODO: LocalStorage Hook -// 힌트: -// 1. localStorage와 React state 동기화 -// 2. 초기값 로드 시 에러 처리 -// 3. 저장 시 JSON 직렬화/역직렬화 -// 4. 빈 배열이나 undefined는 삭제 -// -// 반환값: [저장된 값, 값 설정 함수] - -export function useLocalStorage( - key: string, - initialValue: T -): [T, (value: T | ((val: T) => T)) => void] { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/utils/hooks/useValidate.ts b/src/refactoring(hint)/utils/hooks/useValidate.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/utils/validators.ts b/src/refactoring(hint)/utils/validators.ts deleted file mode 100644 index 7d2dda44..00000000 --- a/src/refactoring(hint)/utils/validators.ts +++ /dev/null @@ -1,8 +0,0 @@ -// TODO: 검증 유틸리티 함수들 -// 구현할 함수: -// - isValidCouponCode(code: string): boolean - 쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자) -// - isValidStock(stock: number): boolean - 재고 수량 검증 (0 이상) -// - isValidPrice(price: number): boolean - 가격 검증 (양수) -// - extractNumbers(value: string): string - 문자열에서 숫자만 추출 - -// TODO: 구현 \ No newline at end of file From 9084ba97473a194a0b5a21978f58c0fcf0035736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Fri, 8 Aug 2025 02:39:46 +0900 Subject: [PATCH 49/52] =?UTF-8?q?refactor:=20useLocalStorage=20=ED=9B=85?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EC=9E=A5?= =?UTF-8?q?=EB=B0=94=EA=B5=AC=EB=8B=88,=20=EC=BF=A0=ED=8F=B0,=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/hooks/useCart.ts | 30 +++----------------------- src/basic/hooks/useCoupons.ts | 31 ++++++--------------------- src/basic/hooks/useLocalStorage.ts | 34 ++++++++++++++++++++++++++++++ src/basic/hooks/useProducts.ts | 31 ++++++--------------------- 4 files changed, 49 insertions(+), 77 deletions(-) create mode 100644 src/basic/hooks/useLocalStorage.ts diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts index 10005cac..d0dcf9e4 100644 --- a/src/basic/hooks/useCart.ts +++ b/src/basic/hooks/useCart.ts @@ -1,33 +1,9 @@ -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { CartItem, Product } from "../../types"; +import { useLocalStorage } from "./useLocalStorage"; export const useCart = () => { - const [cart, _setCart] = useState(() => { - const saved = localStorage.getItem("cart"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const setCart: React.Dispatch> = useCallback( - (cart) => { - _setCart((prev) => { - const newValue = typeof cart === "function" ? cart(prev) : cart; - if (newValue.length > 0) { - localStorage.setItem("cart", JSON.stringify(newValue)); - } else { - localStorage.removeItem("cart"); - } - return newValue; - }); - }, - [_setCart] - ); + const [cart, setCart] = useLocalStorage("cart", []); // 장바구니에 상품 추가 const addToCart = useCallback( diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts index f1fb5fa7..ca0da9a5 100644 --- a/src/basic/hooks/useCoupons.ts +++ b/src/basic/hooks/useCoupons.ts @@ -1,33 +1,14 @@ -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { Coupon } from "../../types"; import { initialCoupons } from "../constants/coupon"; import { generateCouponCode } from "../utils/couponUtils"; +import { useLocalStorage } from "./useLocalStorage"; export const useCoupons = () => { - const [coupons, _setCoupons] = useState(() => { - const saved = localStorage.getItem("coupons"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const setCoupons: React.Dispatch> = - useCallback( - (coupons) => { - _setCoupons((prev) => { - const newValue = - typeof coupons === "function" ? coupons(prev) : coupons; - localStorage.setItem("coupons", JSON.stringify(newValue)); - return newValue; - }); - }, - [_setCoupons] - ); + const [coupons, setCoupons] = useLocalStorage( + "coupons", + initialCoupons + ); // 쿠폰 추가 const addCoupon = useCallback( diff --git a/src/basic/hooks/useLocalStorage.ts b/src/basic/hooks/useLocalStorage.ts new file mode 100644 index 00000000..73109f28 --- /dev/null +++ b/src/basic/hooks/useLocalStorage.ts @@ -0,0 +1,34 @@ +import { useCallback, useState } from "react"; + +export const useLocalStorage = (key: string, initialValue: T) => { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(`Error reading localStorage key "${key}":`, error); + return initialValue; + } + }); + + const setValue: React.Dispatch> = useCallback( + (value) => { + try { + const valueToStore = + value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + + if (valueToStore !== null && valueToStore !== undefined) { + localStorage.setItem(key, JSON.stringify(valueToStore)); + } else { + localStorage.removeItem(key); + } + } catch (error) { + console.error(`Error setting localStorage key "${key}":`, error); + } + }, + [key, storedValue] + ); + + return [storedValue, setValue] as const; +}; diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts index 4b95ee9c..9211d253 100644 --- a/src/basic/hooks/useProducts.ts +++ b/src/basic/hooks/useProducts.ts @@ -1,35 +1,16 @@ -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import useFilterSearchParams from "../../hooks/useFilterSearchParams"; import { Product } from "../../types"; import { initialProducts } from "../constants/product"; import { generateProductId, validateProductData } from "../utils/productUtils"; +import { useLocalStorage } from "./useLocalStorage"; export const useProducts = () => { - const [products, _setProducts] = useState(() => { - const saved = localStorage.getItem("products"); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const setProducts: React.Dispatch> = - useCallback( - (products) => { - _setProducts((prev) => { - const newValue = - typeof products === "function" ? products(prev) : products; - localStorage.setItem("products", JSON.stringify(newValue)); - return newValue; - }); - }, - [_setProducts] - ); + const [products, setProducts] = useLocalStorage( + "products", + initialProducts + ); const { filterSearchParams } = useFilterSearchParams(); From 070f4682f172afef5982a3c1468908e05d732305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Fri, 8 Aug 2025 04:08:17 +0900 Subject: [PATCH 50/52] =?UTF-8?q?feat:=20index.html=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Vite=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9E=85=EB=A0=A5=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.advanced.html => index.html | 0 pr.md | 243 +++++++++++++++++++++++++++--- src/advanced/hooks/useProducts.ts | 19 +-- vite.config.ts | 2 +- 4 files changed, 229 insertions(+), 35 deletions(-) rename index.advanced.html => index.html (100%) diff --git a/index.advanced.html b/index.html similarity index 100% rename from index.advanced.html rename to index.html diff --git a/pr.md b/pr.md index eb6fcd70..9556a527 100644 --- a/pr.md +++ b/pr.md @@ -18,15 +18,15 @@ - 뷰데이터와 엔티티데이터의 분리에 대한 이해 - entities -> features -> UI 계층에 대한 이해 -- [ ] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? -- [ ] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? -- [ ] 계산함수는 순수함수로 작성이 되었나요? -- [ ] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? -- [ ] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? -- [ ] 계산함수는 순수함수로 작성이 되었나요? -- [ ] 특정 Entitiy만 다루는 함수는 분리되어 있나요? -- [ ] 특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? -- [ ] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? +- [x] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? +- [x] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? +- [x] 계산함수는 순수함수로 작성이 되었나요? +- [x] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? +- [x] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? +- [x] 계산함수는 순수함수로 작성이 되었나요? +- [x] 특정 Entitiy만 다루는 함수는 분리되어 있나요? +- [x] 특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? +- [x] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? ### 심화과제 @@ -35,26 +35,227 @@ - 재사용 가능한 Custom 유틸 함수를 만들어 보기 - 그래서 엔티티와는 어떤 다른 계층적 특징을 가지는지 이해하기 -- [ ] UI 컴포넌트 계층과 엔티티 컴포넌트의 계층의 성격이 다르다는 것을 이해하고 적용했는가? -- [ ] 엔티티 Hook과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가? -- [ ] 엔티티 순수함수와 유틸리티 함수의 계층의 성격이 다르다는 것을 이해하고 적용했는가? +- [x] UI 컴포넌트 계층과 엔티티 컴포넌트의 계층의 성격이 다르다는 것을 이해하고 적용했는가? +- [x] 엔티티 Hook과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가? +- [x] 엔티티 순수함수와 유틸리티 함수의 계층의 성격이 다르다는 것을 이해하고 적용했는가? ## 과제 셀프회고 - +### 과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요? -1. displayPrice -2. usesearchparams -3. 액션함수 순수함수 분리 - hook에서는 순수함수만 사용하도록하고 - handle이 중복된다면 hook으로 따로 빼는걸 고려하는 방식이 옳은 방식이라고 생각했는데 어떤까요? +1. **함수 분리와 책임 분담** -4. 단순계산은 굳이 유틸함수로 빼지 않는 선택을 했습니다. + - 기존 `formatPrice` 함수가 너무 많은 책임을 가지고 있었습니다. admin 여부 확인, 도메인 체크, 카트 데이터와의 결합, 개수 계산 등 여러 로직이 하나의 함수에 얽혀있었습니다. + - 이를 논리적으로 분리하여 `getRefinedProduct`로 상품 데이터와 장바구니 데이터를 결합한 새로운 상품 객체를 정의하고, `displayPrice`로 순수하게 가격 표시 로직만 담당하도록 리팩토링했습니다. + - `displayPrice`는 admin 도메인 여부와 관계없이 독립적으로 suffix, prefix를 받아 자유롭게 가격을 노출 시킬 수 있도록 리팩토링하였습니다. -### 과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요? +2. **MVVM 패턴으로 계층 구조의 명확한 구분** + + - **View Layer (UI Components)**: 사용자 인터페이스 표시 + - **ViewModel Layer (Hooks)**: 상태 관리와 비즈니스 로직 조합 + - **Model Layer (Store)**: 데이터 저장과 상태 관리 + +3. **불필요한 상태, 계산 함수 제거** + +불필요한 상태를 사용하고 있는 ProductAccordion 컴포넌트에서 하나의 상태만 사용하도록 리팩토링 + +```tsx +// Basic 버전 - 복잡한 상태 관리 +const [showProductForm, setShowProductForm] = useState(false); +const [editingProduct, setEditingProduct] = useState(null); +const [selectedProduct, setSelectedProduct] = useState( + undefined +); + +// Advanced 버전 - 단순화된 상태 관리 +const [initProductFormData, setInitProductFormData] = useState( + null +); +const showProductForm = initProductFormData !== null; +``` + +### 4. useSearchParams 활용한 URL 기반 상태 관리 + +기존의 상태 관리 방식을 URL 기반 상태 관리로 개선하여 더 나은 사용자 경험과 상태 동기화를 구현했습니다. + +```ts +import { useState, useEffect, useCallback } from "react"; + +/** + * useState와 useEffect를 활용한 간단한 useSearchParams 훅 + * URL의 search parameters를 구독하고 업데이트할 수 있습니다. + */ +export function useSearchParams(): [ + URLSearchParams, + (params: URLSearchParams | Record) => void +] { + const [searchParams, setSearchParamsState] = useState(() => { + if (typeof window === "undefined") { + return new URLSearchParams(); + } + return new URLSearchParams(window.location.search); + }); + + // URL 변경 감지 + useEffect(() => { + if (typeof window === "undefined") return; + + const handlePopState = () => { + setSearchParamsState(new URLSearchParams(window.location.search)); + }; + + const handleSearchParamsChanged = (event: CustomEvent) => { + setSearchParamsState(event.detail.searchParams); + }; + + window.addEventListener("popstate", handlePopState); + window.addEventListener( + "searchParamsChanged", + handleSearchParamsChanged as EventListener + ); + + return () => { + window.removeEventListener("popstate", handlePopState); + window.removeEventListener( + "searchParamsChanged", + handleSearchParamsChanged as EventListener + ); + }; + }, []); + + // searchParams 업데이트 함수 + const setSearchParams = useCallback( + (params: URLSearchParams | Record) => { + if (typeof window === "undefined") return; + + const newSearchParams = + params instanceof URLSearchParams + ? params + : new URLSearchParams(params); + + const newUrl = `${window.location.pathname}${ + newSearchParams.toString() ? `?${newSearchParams.toString()}` : "" + }${window.location.hash}`; + + history.replaceState(null, "", newUrl); + + // replaceState 후 상태 동기화를 위한 커스텀 이벤트 발생 + window.dispatchEvent( + new CustomEvent("searchParamsChanged", { + detail: { searchParams: newSearchParams }, + }) + ); + }, + [] + ); + + return [searchParams, setSearchParams]; +} +``` + +#### 4.1 Zod를 활용한 product query hook 구성 + +```tsx +// useFilterSearchParams.ts - URL 기반 상태 관리 전용 Hook +export default function useFilterSearchParams() { + const [searchParams, setSearchParams] = useSearchParams(); + + // 데이터 변환 로직 + const filterSearchParams: filterSearchParamsSchemaType = React.useMemo(() => { + const paramsObject = Object.fromEntries(searchParams.entries()); + const validSearchParams = filterSearchParamsSchema.safeParse(paramsObject); + + if (!validSearchParams.success) { + return { searchTerm: "" }; + } + + return { ...validSearchParams.data }; + }, [searchParams]); + + // 액션 함수 + const setFilterSearchParams = ( + value: Partial + ) => { + const newSearchParams = { ...filterSearchParams, ...value }; + + // 빈 값들은 제거하고 URL에 설정 + const filteredParams = Object.fromEntries( + Object.entries(newSearchParams).filter( + ([_, v]) => v !== "" && v !== undefined + ) + ); + + setSearchParams(filteredParams); + }; + + return { filterSearchParams, setFilterSearchParams }; +} +``` + +#### 4.2 기존에 단순히 searchTerm으로만 쿼리하던 로직을 향후 다른 query를 반영할 수 있도록 확장성 있게 구성 + +```tsx +/** + * 상품 검색 필터링 + */ +export const filterProducts = ( + products: Product[], + filterQuery: filterSearchParamsSchemaType +): Product[] => { + return filterProductsBySearchTerm(products, filterQuery.searchTerm ?? ""); +}; + +const filterProductsBySearchTerm = ( + products: Product[], + searchTerm: string +) => { + if (!searchTerm.trim()) { + return products; + } + return products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(searchTerm.toLowerCase())) + ); +}; +``` ### 과제를 다시 해보면 더 잘 할 수 있었겠다 아쉬운 점이 있다면 무엇인가요? -### 리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :) +### 1. Hook에서 데이터와 액션의 분리가 안됨 + +현재 `useCart`와 같은 Hook에서 데이터(`cart`)와 액션 함수들(`addToCart`, `updateCartItemQuantity` 등)을 함께 제공하고 있는데, 이 부분이 함수형 프로그래밍 원칙에 완전히 부합하는지 의문이 들었습니다. + +**고민했던 점:** + +- 데이터와 액션을 완전히 분리하면 순수성은 높아지지만 DX(Developer Experience)가 나빠질 수 있음 +- 분리하지 않으면 편리하지만 Hook의 책임이 모호해질 수 있음 + +### 2. Handle 함수에서 validation을 담당 + +현재 구현에서는 `handleUpdateQuantity` 같은 함수에서 매번 검증 코드를 작성하고 있어서 재사용성이 떨어지는 문제가 있었습니다. 아래 방법들중 하나를 채택해서 수정하는게 좋았을 것 같습니다. + +1. **액션 함수 내부에서 에러 처리** + + - 장점: 재사용성 높음 + - 단점: Hook이 UI 의존성을 가질 수 있음 + +2. **전용 Handle Hook 분리** + + - 장점: 관심사 분리 명확 + - 단점: Hook이 너무 세분화될 수 있음 + +3. **에러 핸들러 패턴** + + - 장점: 유연한 에러 처리 + - 단점: 사용하는 쪽에서 매번 핸들러 전달 필요 ## 리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 + +1. 위에서 얘기한 아쉬운점에 대한 내용들에 대해서 의견을 받아보고 싶습니다. + +2. MVVM패턴으로 분리가 되었다고 생각했는데, 지금 분리된 구조가 MVVM 패턴이 맞을까요? + 추가적으로 테오는 어떤 패턴을 가장 선호하시나요? + +3. 전체적으로 제가 나눠놓은 함수들이 함수형패턴에 위배되지 않는지 피드백 받고싶습니다. diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts index e55e2cf5..9844adc2 100644 --- a/src/advanced/hooks/useProducts.ts +++ b/src/advanced/hooks/useProducts.ts @@ -2,7 +2,11 @@ import { useCallback } from "react"; import { Product } from "../../types"; import { useAtom } from "jotai"; import { productsAtom } from "../models/product"; -import { generateProductId, validateProductData } from "../utils/productUtils"; +import { + filterProducts, + generateProductId, + validateProductData, +} from "../utils/productUtils"; import useFilterSearchParams from "../../hooks/useFilterSearchParams"; export const useProducts = () => { @@ -56,18 +60,7 @@ export const useProducts = () => { [setProducts] ); - const filteredProducts = filterSearchParams.searchTerm - ? products.filter( - (product) => - product.name - .toLowerCase() - .includes(filterSearchParams.searchTerm?.toLowerCase() ?? "") || - (product.description && - product.description - .toLowerCase() - .includes(filterSearchParams.searchTerm?.toLowerCase() ?? "")) - ) - : products; + const filteredProducts = filterProducts(products, filterSearchParams); return { products, diff --git a/vite.config.ts b/vite.config.ts index d12732d5..52b3d647 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,7 @@ export default mergeConfig( base: "/front_6th_chapter2-2/", build: { rollupOptions: { - input: "./index.advanced.html", + input: "./index.html", }, outDir: "dist", copyPublicDir: true, From 365d3675985e303f7b4665e99ac09588b5d7cc10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Sat, 9 Aug 2025 15:13:36 +0900 Subject: [PATCH 51/52] =?UTF-8?q?feat(cart):=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EC=88=98=EB=9F=89=20=EB=B3=80=EA=B2=BD=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 장바구니 상품 수량 변경 기능을 useCallback 훅을 사용하여 최적화 - 재고 부족 및 수량 초과 시 알림 추가 - props로 전달받던 handleUpdateQuantity 제거 --- src/advanced/components/cart/CartSection.tsx | 37 ++++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/advanced/components/cart/CartSection.tsx b/src/advanced/components/cart/CartSection.tsx index fa48e553..5f079542 100644 --- a/src/advanced/components/cart/CartSection.tsx +++ b/src/advanced/components/cart/CartSection.tsx @@ -1,14 +1,37 @@ import { Product } from "../../../types"; import { CartIcon, TrashIcon } from "../icons"; -import { calculateItemTotal } from "../../utils/cartUtils"; +import { calculateItemTotal, getRemainingStock } from "../../utils/cartUtils"; import { useCart } from "../../hooks/useCart"; +import { useCallback } from "react"; +import { useNotifications } from "../../hooks/useNotifications"; -export function CartSection({ - handleUpdateQuantity, -}: { - handleUpdateQuantity: (product: Product, newQuantity: number) => void; -}) { - const { cart, removeFromCart } = useCart(); +export function CartSection({}: {}) { + const { cart, removeFromCart, updateCartItemQuantity } = useCart(); + + const { addNotification } = useNotifications(); + + // 장바구니 상품 수량 변경 (props로 받은 함수 사용) + const handleUpdateQuantity = useCallback( + (product: Product, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(product.id); + return; + } + + if (newQuantity > product.stock) { + addNotification?.(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return; + } + + if (!getRemainingStock(product, cart)) { + addNotification("재고가 부족합니다", "error"); + return; + } + + updateCartItemQuantity(product.id, newQuantity); + }, + [addNotification, cart, removeFromCart, updateCartItemQuantity] + ); return (
From cdf94ae8818f30495ecc1a55912b65fb401293e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=84=EB=8F=8C?= Date: Sat, 9 Aug 2025 15:13:54 +0900 Subject: [PATCH 52/52] =?UTF-8?q?refactor(shop):=20=EC=9E=A5=EB=B0=94?= =?UTF-8?q?=EA=B5=AC=EB=8B=88=20=EC=88=98=EB=9F=89=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 장바구니 상품 수량 변경 관련 코드 삭제 - CartSection에서 handleUpdateQuantity prop 제거 --- src/advanced/pages/ShopPage.tsx | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/src/advanced/pages/ShopPage.tsx b/src/advanced/pages/ShopPage.tsx index 371e4fe4..87fbba6d 100644 --- a/src/advanced/pages/ShopPage.tsx +++ b/src/advanced/pages/ShopPage.tsx @@ -12,32 +12,9 @@ import { useNotifications } from "../hooks/useNotifications"; export function ShopPage() { const [selectedCoupon, setSelectedCoupon] = useState(null); - const { cart, updateCartItemQuantity, removeFromCart } = useCart(); + const { cart } = useCart(); const { addNotification } = useNotifications(); - // 장바구니 상품 수량 변경 (props로 받은 함수 사용) - const handleUpdateQuantity = useCallback( - (product: Product, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(product.id); - return; - } - - if (newQuantity > product.stock) { - addNotification?.(`재고는 ${product.stock}개까지만 있습니다.`, "error"); - return; - } - - if (!getRemainingStock(product, cart)) { - addNotification("재고가 부족합니다", "error"); - return; - } - - updateCartItemQuantity(product.id, newQuantity); - }, - [addNotification, cart, removeFromCart, updateCartItemQuantity] - ); - // 쿠폰 적용 const applyCoupon = useCallback( (coupon: Coupon) => { @@ -69,7 +46,7 @@ export function ShopPage() {
- + {cart.length > 0 && ( <>