From 1b98277cfa937ed20f37991a7a4c8c4f457f5756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Tue, 2 Dec 2025 12:46:49 +0900 Subject: [PATCH 01/38] =?UTF-8?q?=EA=B3=BC=EC=A0=9C=20=EC=A0=9C=EC=B6=9C?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EB=B9=88=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=20=EB=82=A0=EB=A6=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From cd8a1fcee50bfae19ef0789cee41ad46f64ec975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 15:34:25 +0900 Subject: [PATCH 02/38] =?UTF-8?q?feat:=20icon=20components=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/components/icons/index.tsx | 71 ++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/basic/components/icons/index.tsx diff --git a/src/basic/components/icons/index.tsx b/src/basic/components/icons/index.tsx new file mode 100644 index 000000000..188b5a3cb --- /dev/null +++ b/src/basic/components/icons/index.tsx @@ -0,0 +1,71 @@ +import { SVGProps } from "react"; + +export interface IconProps extends Omit, "width" | "height"> { + size?: number; +} + +const buildProps = ({ size = 24, ...rest }: IconProps = {}) => ({ + width: size, + height: size, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 2, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + ...rest, +}); + +export const CartIcon = (props: IconProps) => ( + + + + + + +); + +export const AdminIcon = (props: IconProps) => ( + + + + + +); + +export const PlusIcon = (props: IconProps) => ( + + + +); + +export const MinusIcon = (props: IconProps) => ( + + + +); + +export const TrashIcon = (props: IconProps) => ( + + + +); + +export const ChevronDownIcon = (props: IconProps) => ( + + + +); + +export const ChevronUpIcon = (props: IconProps) => ( + + + +); + +export const CheckIcon = (props: IconProps) => ( + + + +); + From 7013b8164a72edc444dc8d4cc771dc470c6009a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 15:35:14 +0900 Subject: [PATCH 03/38] =?UTF-8?q?feat:=20initData=20=EC=83=81=EC=88=98?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/constants/index.ts | 58 ++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/basic/constants/index.ts diff --git a/src/basic/constants/index.ts b/src/basic/constants/index.ts new file mode 100644 index 000000000..df2e22a5f --- /dev/null +++ b/src/basic/constants/index.ts @@ -0,0 +1,58 @@ +import { Coupon, Product } from '../../types'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +// 초기 데이터 +export const initialProducts: ProductWithUI[] = [ + { + id: 'p1', + name: '상품1', + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 } + ], + description: '최고급 품질의 프리미엄 상품입니다.' + }, + { + id: 'p2', + name: '상품2', + price: 20000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.15 } + ], + description: '다양한 기능을 갖춘 실용적인 상품입니다.', + isRecommended: true + }, + { + id: 'p3', + name: '상품3', + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 } + ], + description: '대용량과 고성능을 자랑하는 상품입니다.' + } +]; + +export const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000 + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10 + } +]; \ No newline at end of file From ab4cb72c1ed22f4b1c797328cfbd8d2cda903577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 15:36:02 +0900 Subject: [PATCH 04/38] =?UTF-8?q?feat:=20[Layout]=20Header=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/components/layout/Header.tsx | 60 ++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/basic/components/layout/Header.tsx diff --git a/src/basic/components/layout/Header.tsx b/src/basic/components/layout/Header.tsx new file mode 100644 index 000000000..fa16986d9 --- /dev/null +++ b/src/basic/components/layout/Header.tsx @@ -0,0 +1,60 @@ +import { CartItem } from "../../../types"; +import { CartIcon } from "../icons"; + +interface HeaderProps { + isAdmin: boolean; + searchTerm: string; + setSearchTerm: (value: string) => void; + setIsAdmin: (value: boolean) => void; + cart: CartItem[]; + totalItemCount: number; +} + +const Header = ({ isAdmin, searchTerm, setSearchTerm, setIsAdmin, cart, totalItemCount }: HeaderProps) => { + return ( +
+
+
+
+

SHOP

+ {!isAdmin && ( +
+ setSearchTerm(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ )} +
+ +
+
+
+ ) +} + +export default Header; \ No newline at end of file From 26e6d6b9f8b06adeb9190c02d62c62e68c992931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 15:36:52 +0900 Subject: [PATCH 05/38] =?UTF-8?q?feat:=20[Ui]=20Badge,=20Button,=20Input?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/components/ui/Badge.tsx | 33 +++++++++++++++++++++++ src/basic/components/ui/Button.tsx | 43 ++++++++++++++++++++++++++++++ src/basic/components/ui/Input.tsx | 33 +++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 src/basic/components/ui/Badge.tsx create mode 100644 src/basic/components/ui/Button.tsx create mode 100644 src/basic/components/ui/Input.tsx diff --git a/src/basic/components/ui/Badge.tsx b/src/basic/components/ui/Badge.tsx new file mode 100644 index 000000000..afc64731d --- /dev/null +++ b/src/basic/components/ui/Badge.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +interface BadgeProps { + children: React.ReactNode; + variant?: 'primary' | 'success' | 'warning' | 'danger' | 'info'; + size?: 'sm' | 'md'; + className?: string; +} + +export const Badge: React.FC = ({ + children, + variant = 'primary', + size = 'md', + className = '' +}) => { + const baseStyles = "text-white rounded inline-flex items-center justify-center"; + const sizeStyles = size === 'sm' ? "text-xs px-2 py-0.5" : "text-sm px-2.5 py-1"; + + const variantStyles = { + primary: "bg-gray-900", + success: "bg-green-500", + warning: "bg-orange-500", + danger: "bg-red-500", + info: "bg-blue-500", + }; + + return ( + + {children} + + ); +}; + diff --git a/src/basic/components/ui/Button.tsx b/src/basic/components/ui/Button.tsx new file mode 100644 index 000000000..b03dd74a7 --- /dev/null +++ b/src/basic/components/ui/Button.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + fullWidth?: boolean; +} + +export const Button: React.FC = ({ + children, + variant = 'primary', + size = 'md', + fullWidth = false, + className = '', + ...props +}) => { + const baseStyles = "rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 inline-flex items-center justify-center"; + + const variantStyles = { + primary: "bg-gray-900 text-white hover:bg-gray-800 focus:ring-gray-900", + secondary: "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-gray-500", + danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-600", + ghost: "text-gray-600 hover:bg-gray-100 focus:ring-gray-200", + }; + + const sizeStyles = { + sm: "px-3 py-1.5 text-sm", + md: "px-4 py-2 text-base", + lg: "px-5 py-2.5 text-lg", + }; + + const widthStyle = fullWidth ? "w-full" : ""; + + return ( + + ); +}; + diff --git a/src/basic/components/ui/Input.tsx b/src/basic/components/ui/Input.tsx new file mode 100644 index 000000000..b24fd4b71 --- /dev/null +++ b/src/basic/components/ui/Input.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +interface InputProps extends React.InputHTMLAttributes { + label?: string; + error?: string; +} + +export const Input: React.FC = ({ + label, + error, + className = '', + ...props +}) => { + return ( +
+ {label && ( + + )} + + {error && ( +

{error}

+ )} +
+ ); +}; + From e671b88b48fc5bb0936129a19bbbea9284bc9688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 15:38:21 +0900 Subject: [PATCH 06/38] =?UTF-8?q?feat:=20[Hooks]=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=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 useAdminPage, useCart, useCouponForm, useCoupons, useCouponValidation, useNotifications, useProductForm, useProducts, useSearch --- src/basic/hooks/useAdminPage.ts | 109 ++++++++++++++++++ src/basic/hooks/useCart.ts | 120 ++++++++++++++++++++ src/basic/hooks/useCouponForm.ts | 137 +++++++++++++++++++++++ src/basic/hooks/useCouponValidation.ts | 91 +++++++++++++++ src/basic/hooks/useCoupons.ts | 54 +++++++++ src/basic/hooks/useNotifications.ts | 56 ++++++++++ src/basic/hooks/useProductForm.ts | 149 +++++++++++++++++++++++++ src/basic/hooks/useProducts.ts | 100 +++++++++++++++++ src/basic/hooks/useSearch.ts | 44 ++++++++ 9 files changed, 860 insertions(+) create mode 100644 src/basic/hooks/useAdminPage.ts create mode 100644 src/basic/hooks/useCart.ts create mode 100644 src/basic/hooks/useCouponForm.ts create mode 100644 src/basic/hooks/useCouponValidation.ts create mode 100644 src/basic/hooks/useCoupons.ts create mode 100644 src/basic/hooks/useNotifications.ts create mode 100644 src/basic/hooks/useProductForm.ts create mode 100644 src/basic/hooks/useProducts.ts create mode 100644 src/basic/hooks/useSearch.ts diff --git a/src/basic/hooks/useAdminPage.ts b/src/basic/hooks/useAdminPage.ts new file mode 100644 index 000000000..bf36a636a --- /dev/null +++ b/src/basic/hooks/useAdminPage.ts @@ -0,0 +1,109 @@ +import { useState, useCallback } from 'react'; +import { Product, Coupon } from '../../types'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface UseAdminPageOptions { + onAddProduct: (newProduct: Omit) => void; + onUpdateProduct: (productId: string, updates: Partial) => void; + onAddCoupon: (newCoupon: Coupon) => void; +} + +/** + * 관리자 페이지의 UI 상태와 로직을 관리하는 Hook + */ +export function useAdminPage({ + onAddProduct, + onUpdateProduct, + onAddCoupon +}: UseAdminPageOptions) { + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [showProductForm, setShowProductForm] = useState(false); + const [showCouponForm, setShowCouponForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + + /** + * 상품 수정 시작 + */ + const startEditProduct = useCallback((product: ProductWithUI) => { + setEditingProduct(product); + setShowProductForm(true); + }, []); + + /** + * 새 상품 추가 폼 열기 + */ + const startAddProduct = useCallback(() => { + setEditingProduct(null); + setShowProductForm(true); + }, []); + + /** + * 상품 폼 제출 + */ + const handleProductSubmit = useCallback((product: Omit) => { + if (editingProduct) { + onUpdateProduct(editingProduct.id, product); + } else { + onAddProduct(product); + } + setEditingProduct(null); + setShowProductForm(false); + }, [editingProduct, onAddProduct, onUpdateProduct]); + + /** + * 상품 폼 취소 + */ + const handleProductCancel = useCallback(() => { + setEditingProduct(null); + setShowProductForm(false); + }, []); + + /** + * 쿠폰 폼 제출 + */ + const handleCouponSubmit = useCallback((coupon: Coupon) => { + onAddCoupon(coupon); + setShowCouponForm(false); + }, [onAddCoupon]); + + /** + * 쿠폰 폼 취소 + */ + const handleCouponCancel = useCallback(() => { + setShowCouponForm(false); + }, []); + + /** + * 쿠폰 폼 토글 + */ + const toggleCouponForm = useCallback(() => { + setShowCouponForm(prev => !prev); + }, []); + + return { + // 상태 + activeTab, + showProductForm, + showCouponForm, + editingProduct, + + // 상태 변경 함수 + setActiveTab, + + // 상품 관련 + startEditProduct, + startAddProduct, + handleProductSubmit, + handleProductCancel, + + // 쿠폰 관련 + handleCouponSubmit, + handleCouponCancel, + toggleCouponForm + }; +} + diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..c657eeffa --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,120 @@ +import { useCallback, useMemo } from "react"; +import { CartItem, Product } from "../../types"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; +import { formatDate } from "../utils/formatters"; + +interface UseCartResult { + success: boolean; + error?: string; + message?: string; +} + +export function useCart() { + const [cart, setCart] = useLocalStorage('cart', []); + + // 총 아이템 개수 계산 + const totalItemCount = useMemo(() => { + return cart.reduce((sum, item) => sum + item.quantity, 0); + }, [cart]); + + // 재고 확인 함수 + const getRemainingStock = useCallback((product: Product): number => { + const cartItem = cart.find(item => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); + }, [cart]); + + // 장바구니에 상품 추가 + const addToCart = useCallback((product: Product): UseCartResult => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + return { success: false, error: '재고가 부족합니다!' }; + } + + let result: UseCartResult = { success: true, message: '장바구니에 담았습니다' }; + + setCart(prevCart => { + const existingItem = prevCart.find(item => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + result = { success: false, error: `재고는 ${product.stock}개까지만 있습니다.` }; + return prevCart; + } + + return prevCart.map(item => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + return result; + }, [getRemainingStock, setCart]); + + // 장바구니에서 상품 제거 + const removeFromCart = useCallback((productId: string) => { + setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + }, [setCart]); + + // 수량 변경 + const updateQuantity = useCallback(( + productId: string, + newQuantity: number, + products: Product[] + ): UseCartResult => { + if (newQuantity <= 0) { + setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + return { success: true }; + } + + const product = products.find(p => p.id === productId); + if (!product) { + return { success: false, error: '상품을 찾을 수 없습니다.' }; + } + + const maxStock = product.stock; + if (newQuantity > maxStock) { + return { success: false, error: `재고는 ${maxStock}개까지만 있습니다.` }; + } + + setCart(prevCart => + prevCart.map(item => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ) + ); + + return { success: true }; + }, [setCart]); + + // 주문 완료 (장바구니 비우기) + const completeOrder = useCallback((): UseCartResult => { + const now = new Date(); + const dateStr = formatDate(now).replace(/-/g, ''); + const timeStr = now.getHours().toString().padStart(2, '0') + now.getMinutes().toString().padStart(2, '0'); + const orderNumber = `ORD-${dateStr}-${timeStr}`; + + setCart([]); + + return { + success: true, + message: `주문이 완료되었습니다. 주문번호: ${orderNumber}` + }; + }, [setCart]); + + return { + cart, + totalItemCount, + addToCart, + removeFromCart, + updateQuantity, + getRemainingStock, + completeOrder, + }; +} diff --git a/src/basic/hooks/useCouponForm.ts b/src/basic/hooks/useCouponForm.ts new file mode 100644 index 000000000..a547c5bc1 --- /dev/null +++ b/src/basic/hooks/useCouponForm.ts @@ -0,0 +1,137 @@ +import { useState, useCallback } from 'react'; +import { Coupon } from '../../types'; +import { + validateCouponCode, + validateCouponName, + validateCouponDiscountValue +} from '../utils/validators'; + +interface UseCouponFormOptions { + onValidationError?: (message: string) => void; +} + +interface CouponFormValidationResult { + isValid: boolean; + errors: string[]; +} + +/** + * 쿠폰 폼의 모든 비즈니스 로직을 관리하는 Hook + */ +export function useCouponForm({ onValidationError }: UseCouponFormOptions = {}) { + const [coupon, setCoupon] = useState({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + + /** + * 필드 변경 + */ + const handleFieldChange = useCallback((field: keyof Coupon, value: string | number) => { + setCoupon(prev => ({ ...prev, [field]: value })); + }, []); + + /** + * 쿠폰 코드 변경 (대문자로 변환) + */ + const handleCodeChange = useCallback((value: string) => { + setCoupon(prev => ({ ...prev, code: value.toUpperCase() })); + }, []); + + /** + * 할인 타입 변경 + */ + const handleDiscountTypeChange = useCallback((type: 'amount' | 'percentage') => { + setCoupon(prev => ({ ...prev, discountType: type })); + }, []); + + /** + * 숫자 입력 처리 (숫자만 허용) + */ + const handleValueChange = useCallback((value: string) => { + if (value === '' || /^\d+$/.test(value)) { + setCoupon(prev => ({ + ...prev, + discountValue: value === '' ? 0 : parseInt(value) + })); + } + }, []); + + /** + * 숫자 입력 검증 (blur 시) + */ + const handleValueBlur = useCallback((value: string) => { + const numValue = parseInt(value) || 0; + + const validation = validateCouponDiscountValue(numValue, coupon.discountType); + if (!validation.isValid) { + onValidationError?.(validation.error!); + + // 최대값 설정 + if (coupon.discountType === 'percentage') { + setCoupon(prev => ({ + ...prev, + discountValue: numValue > 100 ? 100 : 0 + })); + } else { + setCoupon(prev => ({ + ...prev, + discountValue: numValue > 100000 ? 100000 : 0 + })); + } + } + }, [coupon.discountType, onValidationError]); + + /** + * 폼 검증 + */ + const validateForm = useCallback((): CouponFormValidationResult => { + const errors: string[] = []; + + const nameValidation = validateCouponName(coupon.name); + if (!nameValidation.isValid) { + errors.push(nameValidation.error!); + } + + const codeValidation = validateCouponCode(coupon.code); + if (!codeValidation.isValid) { + errors.push(codeValidation.error!); + } + + const valueValidation = validateCouponDiscountValue(coupon.discountValue, coupon.discountType); + if (!valueValidation.isValid) { + errors.push(valueValidation.error!); + } + + return { + isValid: errors.length === 0, + errors + }; + }, [coupon]); + + /** + * 폼 리셋 + */ + const resetForm = useCallback(() => { + setCoupon({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + }, []); + + return { + coupon, + handleFieldChange, + handleCodeChange, + handleDiscountTypeChange, + handleValueChange, + handleValueBlur, + validateForm, + resetForm + }; +} + diff --git a/src/basic/hooks/useCouponValidation.ts b/src/basic/hooks/useCouponValidation.ts new file mode 100644 index 000000000..d8004bd1e --- /dev/null +++ b/src/basic/hooks/useCouponValidation.ts @@ -0,0 +1,91 @@ +import { useEffect } from 'react'; +import { Coupon, CartItem } from '../../types'; +import { calculateCartTotal } from '../utils/cartCalculations'; + +interface UseCouponValidationOptions { + selectedCoupon: Coupon | null; + coupons: Coupon[]; + cart: CartItem[]; + onCouponInvalid?: () => void; + onMinimumAmountWarning?: (message: string) => void; +} + +interface CouponValidationResult { + isValid: boolean; + warningMessage?: string; + errorMessage?: string; +} + +/** + * 쿠폰 검증 로직을 처리하는 Hook + */ +export function useCouponValidation({ + selectedCoupon, + coupons, + cart, + onCouponInvalid, + onMinimumAmountWarning +}: UseCouponValidationOptions) { + + // 선택된 쿠폰이 삭제되었는지 확인 + useEffect(() => { + if (selectedCoupon && !coupons.some(coupon => coupon.code === selectedCoupon.code)) { + onCouponInvalid?.(); + } + }, [coupons, selectedCoupon, onCouponInvalid]); + + // 장바구니가 비어있으면 쿠폰 초기화 + useEffect(() => { + if (cart.length === 0 && selectedCoupon) { + onCouponInvalid?.(); + } + }, [cart.length, selectedCoupon, onCouponInvalid]); + + /** + * 쿠폰 적용 가능 여부 검증 + */ + const validateCouponApplicability = (coupon: Coupon | null): CouponValidationResult => { + if (!coupon) { + return { isValid: true }; + } + + // 장바구니가 비어있는 경우 + if (cart.length === 0) { + return { + isValid: false, + errorMessage: '장바구니에 상품을 추가해주세요' + }; + } + + // 비율 할인 쿠폰의 최소 금액 체크 + if (coupon.discountType === 'percentage') { + const { subtotal } = calculateCartTotal(cart, null); + + if (subtotal < 10000) { + return { + isValid: false, + warningMessage: '10,000원 이상 구매시 쿠폰을 사용할 수 있습니다!' + }; + } + } + + return { isValid: true }; + }; + + /** + * 쿠폰 적용 시 경고 메시지 표시 + */ + useEffect(() => { + if (selectedCoupon) { + const validation = validateCouponApplicability(selectedCoupon); + if (validation.warningMessage && onMinimumAmountWarning) { + onMinimumAmountWarning(validation.warningMessage); + } + } + }, [selectedCoupon, cart]); + + return { + validateCouponApplicability + }; +} + diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 000000000..dd33ae08f --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect } from "react"; +import { Coupon } from "../../types"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +interface UseCouponsOptions { + initialCoupons?: Coupon[]; +} + +interface UseCouponsResult { + success: boolean; + error?: string; + message?: string; +} + +export function useCoupons(options?: UseCouponsOptions) { + const { initialCoupons = [] } = options || {}; + + const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); + + // 초기 데이터가 변경될 때 localStorage에 반영 + useEffect(() => { + if (initialCoupons.length > 0 && coupons.length === 0) { + setCoupons(initialCoupons); + } + }, [initialCoupons, coupons.length, setCoupons]); + + // 쿠폰 추가 + const addCoupon = useCallback((newCoupon: Coupon): UseCouponsResult => { + let result: UseCouponsResult = { success: true, message: '쿠폰이 추가되었습니다.' }; + + setCoupons(prevCoupons => { + const existingCoupon = prevCoupons.find(c => c.code === newCoupon.code); + if (existingCoupon) { + result = { success: false, error: '이미 존재하는 쿠폰 코드입니다.' }; + return prevCoupons; + } + return [...prevCoupons, newCoupon]; + }); + + return result; + }, [setCoupons]); + + // 쿠폰 삭제 + const deleteCoupon = useCallback((couponCode: string): UseCouponsResult => { + setCoupons(prevCoupons => prevCoupons.filter(c => c.code !== couponCode)); + return { success: true, message: '쿠폰이 삭제되었습니다.' }; + }, [setCoupons]); + + return { + coupons, + addCoupon, + deleteCoupon, + }; +} diff --git a/src/basic/hooks/useNotifications.ts b/src/basic/hooks/useNotifications.ts new file mode 100644 index 000000000..113e95bc2 --- /dev/null +++ b/src/basic/hooks/useNotifications.ts @@ -0,0 +1,56 @@ +import { useState, useCallback } from "react"; + +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +interface UseNotificationsOptions { + duration?: number; // 알림 표시 시간 (ms) +} + +/** + * 알림 메시지를 관리하는 Hook + * @param options duration (기본값: 3000ms) + * @returns { notifications, addNotification, removeNotification } + */ +export function useNotifications(options?: UseNotificationsOptions) { + const { duration = 3000 } = options || {}; + + const [notifications, setNotifications] = useState([]); + + /** + * 알림 추가 + * @param message 알림 메시지 + * @param type 알림 타입 (error | success | warning) + */ + 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)); + }, duration); + }, [duration]); + + /** + * 알림 수동 제거 + * @param id 제거할 알림 ID + */ + const removeNotification = useCallback((id: string) => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, []); + + + return { + notifications, + addNotification, + removeNotification, + }; +} + diff --git a/src/basic/hooks/useProductForm.ts b/src/basic/hooks/useProductForm.ts new file mode 100644 index 000000000..fd0ef83f0 --- /dev/null +++ b/src/basic/hooks/useProductForm.ts @@ -0,0 +1,149 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Product, Discount } from '../../types'; +import { validatePrice, validateStock } from '../utils/validators'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface UseProductFormOptions { + initialProduct?: Partial; + onValidationError?: (message: string) => void; +} + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} + +/** + * 상품 폼의 모든 비즈니스 로직을 관리하는 Hook + */ +export function useProductForm({ initialProduct, onValidationError }: UseProductFormOptions = {}) { + const [product, setProduct] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + ...initialProduct + }); + + // initialProduct가 변경되면 폼 초기화 + useEffect(() => { + setProduct({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + ...initialProduct + }); + }, [initialProduct]); + + /** + * 숫자 입력 처리 (숫자만 허용) + */ + const handleNumberChange = useCallback((field: 'price' | 'stock', value: string) => { + if (value === '' || /^\d+$/.test(value)) { + setProduct(prev => ({ + ...prev, + [field]: value === '' ? 0 : parseInt(value) + })); + } + }, []); + + /** + * 숫자 입력 검증 (blur 시) + */ + const handleNumberBlur = useCallback((field: 'price' | 'stock', value: string) => { + const numValue = parseInt(value) || 0; + + if (field === 'price') { + const validation = validatePrice(numValue); + if (!validation.isValid) { + onValidationError?.(validation.error!); + setProduct(prev => ({ ...prev, price: 0 })); + } + } else if (field === 'stock') { + const validation = validateStock(numValue); + if (!validation.isValid) { + onValidationError?.(validation.error!); + // 최대값 초과 시 9999로 설정, 음수는 0으로 설정 + setProduct(prev => ({ + ...prev, + stock: numValue > 9999 ? 9999 : 0 + })); + } + } + }, [onValidationError]); + + /** + * 텍스트 필드 변경 + */ + const handleFieldChange = useCallback((field: keyof ProductFormData, value: string) => { + setProduct(prev => ({ ...prev, [field]: value })); + }, []); + + /** + * 할인 추가 + */ + const addDiscount = useCallback(() => { + setProduct(prev => ({ + ...prev, + discounts: [...prev.discounts, { quantity: 10, rate: 0.1 }] + })); + }, []); + + /** + * 할인 제거 + */ + const removeDiscount = useCallback((index: number) => { + setProduct(prev => ({ + ...prev, + discounts: prev.discounts.filter((_, i) => i !== index) + })); + }, []); + + /** + * 할인 업데이트 + */ + const updateDiscount = useCallback((index: number, field: keyof Discount, value: number) => { + setProduct(prev => ({ + ...prev, + discounts: prev.discounts.map((d, i) => + i === index ? { ...d, [field]: value } : d + ) + })); + }, []); + + /** + * 폼 리셋 + */ + const resetForm = useCallback(() => { + setProduct({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + ...initialProduct + }); + }, [initialProduct]); + + return { + product, + handleNumberChange, + handleNumberBlur, + handleFieldChange, + addDiscount, + removeDiscount, + updateDiscount, + resetForm + }; +} + diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 000000000..d01b58742 --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,100 @@ +import { useCallback, useEffect } from "react"; +import { Product, Discount } from "../../types"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +interface UseProductsOptions { + initialProducts?: T[]; +} + +interface UseProductsResult { + success: boolean; + error?: string; + message?: string; +} + +export function useProducts(options?: UseProductsOptions) { + const { initialProducts = [] as T[] } = options || {}; + + const [products, setProducts] = useLocalStorage('products', initialProducts); + + // 초기 데이터가 변경될 때 localStorage에 반영 + useEffect(() => { + if (initialProducts.length > 0 && products.length === 0) { + setProducts(initialProducts); + } + }, [initialProducts, products.length, setProducts]); + + // 상품 추가 + const addProduct = useCallback((newProduct: Omit): UseProductsResult => { + const product: T = { + ...newProduct, + id: `p${Date.now()}` + } as T; + setProducts(prev => [...prev, product]); + return { success: true, message: '상품이 추가되었습니다.' }; + }, [setProducts]); + + // 상품 수정 + const updateProduct = useCallback((productId: string, updates: Partial): UseProductsResult => { + setProducts(prev => + prev.map(product => + product.id === productId + ? { ...product, ...updates } + : product + ) + ); + return { success: true, message: '상품이 수정되었습니다.' }; + }, [setProducts]); + + // 상품 삭제 + const deleteProduct = useCallback((productId: string): UseProductsResult => { + setProducts(prev => prev.filter(p => p.id !== productId)); + return { success: true, message: '상품이 삭제되었습니다.' }; + }, [setProducts]); + + // 재고 수정 + const updateProductStock = useCallback((productId: string, stock: number): UseProductsResult => { + setProducts(prev => + prev.map(product => + product.id === productId + ? { ...product, stock } + : product + ) + ); + return { success: true, message: '재고가 수정되었습니다.' }; + }, [setProducts]); + + // 할인 규칙 추가 + const addProductDiscount = useCallback((productId: string, discount: Discount): UseProductsResult => { + setProducts(prev => + prev.map(product => + product.id === productId + ? { ...product, discounts: [...product.discounts, discount] } + : product + ) + ); + return { success: true, message: '할인 규칙이 추가되었습니다.' }; + }, [setProducts]); + + // 할인 규칙 삭제 + const removeProductDiscount = useCallback((productId: string, discountIndex: number): UseProductsResult => { + setProducts(prev => + prev.map(product => + product.id === productId + ? { ...product, discounts: product.discounts.filter((_, index) => index !== discountIndex) } + : product + ) + ); + return { success: true, message: '할인 규칙이 삭제되었습니다.' }; + }, [setProducts]); + + return { + products, + addProduct, + updateProduct, + deleteProduct, + updateProductStock, + addProductDiscount, + removeProductDiscount, + }; +} diff --git a/src/basic/hooks/useSearch.ts b/src/basic/hooks/useSearch.ts new file mode 100644 index 000000000..81f852ee4 --- /dev/null +++ b/src/basic/hooks/useSearch.ts @@ -0,0 +1,44 @@ +import { useMemo } from 'react'; +import { Product } from '../../types'; + +interface UseSearchOptions { + items: T[]; + searchTerm: string; + searchFields?: (keyof T)[]; +} + +/** + * 검색/필터링 로직을 처리하는 Hook + * @param items 검색할 아이템 목록 + * @param searchTerm 검색어 + * @param searchFields 검색할 필드 목록 (기본값: ['name', 'description']) + * @returns 필터링된 아이템 목록 + */ +export function useSearch({ + items, + searchTerm, + searchFields = ['name', 'description'] as (keyof T)[] +}: UseSearchOptions) { + const filteredItems = useMemo(() => { + if (!searchTerm || searchTerm.trim() === '') { + return items; + } + + const lowerSearchTerm = searchTerm.toLowerCase(); + + return items.filter(item => { + return searchFields.some(field => { + const value = item[field]; + if (typeof value === 'string') { + return value.toLowerCase().includes(lowerSearchTerm); + } + return false; + }); + }); + }, [items, searchTerm, searchFields]); + + return { + filteredItems, + }; +} + From 0a39eefa6a45f2683c401120c0512ea148103dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 15:38:50 +0900 Subject: [PATCH 07/38] =?UTF-8?q?feat:=20[Utils]=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=ED=9B=85=20=EC=B6=94=EA=B0=80=20-=20useDebounce,?= =?UTF-8?q?=20useLocalStorage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/utils/hooks/useDebounce.ts | 28 ++++++++++ src/basic/utils/hooks/useLocalStorage.ts | 69 ++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src/basic/utils/hooks/useDebounce.ts create mode 100644 src/basic/utils/hooks/useLocalStorage.ts diff --git a/src/basic/utils/hooks/useDebounce.ts b/src/basic/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..a21ef452a --- /dev/null +++ b/src/basic/utils/hooks/useDebounce.ts @@ -0,0 +1,28 @@ +import { useState, useEffect } from "react"; + +/** + * 디바운스 Hook - 값이 변경되어도 지정된 시간 동안 대기 후 반환 + * @param value 디바운싱할 값 + * @param delay 지연 시간 (ms) + * @returns 디바운싱된 값 + * + * 사용 예시: 검색어 입력 디바운싱 + * const debouncedSearchTerm = useDebounce(searchTerm, 500); + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + // delay 시간 후에 debouncedValue 업데이트 + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // cleanup: 값이 다시 변경되면 이전 타이머 취소 + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} \ No newline at end of file diff --git a/src/basic/utils/hooks/useLocalStorage.ts b/src/basic/utils/hooks/useLocalStorage.ts new file mode 100644 index 000000000..a4bd7ed64 --- /dev/null +++ b/src/basic/utils/hooks/useLocalStorage.ts @@ -0,0 +1,69 @@ +import { useState, useEffect, useCallback } from "react"; + +/** + * localStorage와 React state를 동기화하는 Hook + * @param key localStorage 키 + * @param initialValue 초기값 + * @returns [저장된 값, 값 설정 함수] + */ +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + // localStorage에서 초기값 로드 + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + if (item) { + return JSON.parse(item); + } + return initialValue; + } catch (error) { + console.error(`Error loading localStorage key "${key}":`, error); + return initialValue; + } + }); + + // 값 설정 함수 + // setValue가 storedValue를 dependency로 가지면, 값이 변경될 때마다 새로운 함수가 생성됨 + // addToCart 등의 함수가 오래된 setValue를 참조하여 상태 업데이트 실패 + // 함수형 업데이트를 사용하면 항상 최신 상태를 보장 + // 해결 효과: + // ✅ 장바구니 추가 기능 정상 작동 + // ✅ 수량 변경 기능 정상 작동 + // ✅ 상태 업데이트의 일관성 보장 + // ✅ 불필요한 리렌더링 방지 + // 모든 lint 에러가 없습니다. 테스트를 다시 실행해주세요! + const setValue = useCallback((value: T | ((val: T) => T)) => { + try { + // 함수형 업데이트 지원 + if (value instanceof Function) { + setStoredValue((prevValue) => value(prevValue)); + } else { + setStoredValue(value); + } + } catch (error) { + console.error(`Error setting localStorage key "${key}":`, error); + } + }, [key]); + + // localStorage 동기화 + useEffect(() => { + try { + // 빈 배열이나 undefined/null은 삭제 + if ( + storedValue === undefined || + storedValue === null || + (Array.isArray(storedValue) && storedValue.length === 0) + ) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(storedValue)); + } + } catch (error) { + console.error(`Error syncing localStorage key "${key}":`, error); + } + }, [key, storedValue]); + + return [storedValue, setValue]; +} \ No newline at end of file From 69469ffaf12761018e8a472909b6eb3f889da411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 15:39:23 +0900 Subject: [PATCH 08/38] =?UTF-8?q?feat:=20[Utils]=20cartCalculations,=20for?= =?UTF-8?q?matters,=20validators=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/utils/cartCalculations.ts | 92 ++++++++++++++++++++++++ src/basic/utils/formatters.ts | 47 +++++++++++++ src/basic/utils/validators.ts | 104 ++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 src/basic/utils/cartCalculations.ts create mode 100644 src/basic/utils/formatters.ts create mode 100644 src/basic/utils/validators.ts diff --git a/src/basic/utils/cartCalculations.ts b/src/basic/utils/cartCalculations.ts new file mode 100644 index 000000000..531544420 --- /dev/null +++ b/src/basic/utils/cartCalculations.ts @@ -0,0 +1,92 @@ +import { CartItem, Coupon } from "../../types"; + +/** + * 장바구니 아이템에 적용 가능한 최대 할인율을 계산 + * @param item 장바구니 아이템 + * @param cart 전체 장바구니 (대량 구매 할인 확인용) + * @returns 적용 가능한 최대 할인율 (0.0 ~ 1.0) + */ +export const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + // 대량 구매 여부 확인 (10개 이상 구매 시 추가 5% 할인) + const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + + // 상품별 할인 규칙에서 최대 할인율 찾기 + const baseDiscount = item.product.discounts.reduce((maxDiscount, discount) => { + return item.quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + // 대량 구매 시 추가 5% 할인 (최대 50%) + return hasBulkPurchase ? Math.min(baseDiscount + 0.05, 0.5) : baseDiscount; +}; + +/** + * 장바구니 아이템의 총 금액을 계산 (할인 적용 후) + * @param item 장바구니 아이템 + * @param cart 전체 장바구니 + * @returns 할인 적용 후 금액 + */ +export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => { + const discount = getMaxApplicableDiscount(item, cart); + return Math.round(item.product.price * item.quantity * (1 - discount)); +}; + +/** + * 장바구니 전체 금액을 계산 + * @param cart 장바구니 아이템 목록 + * @param selectedCoupon 선택된 쿠폰 + * @returns { subtotal: 소계, discountAmount: 쿠폰 할인 금액, total: 최종 금액 } + */ +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): { subtotal: number; discountAmount: number; total: number } => { + // 1. 소계 계산 (상품 할인 적용 후) + const subtotal = cart.reduce((sum, item) => sum + calculateItemTotal(item, cart), 0); + + // 2. 쿠폰 할인 계산 + let discountAmount = 0; + if (selectedCoupon) { + if (selectedCoupon.discountType === 'percentage') { + // 비율 할인 (10,000원 이상일 때만 적용) + if (subtotal >= 10000) { + discountAmount = Math.round(subtotal * (selectedCoupon.discountValue / 100)); + } + } else { + // 정액 할인 + discountAmount = selectedCoupon.discountValue; + } + } + + // 3. 최종 금액 (음수 방지) + const total = Math.max(0, subtotal - discountAmount); + + return { subtotal, discountAmount, total }; +}; + +/** + * 장바구니 아이템 수량 업데이트 + * @param cart 현재 장바구니 + * @param productId 상품 ID + * @param newQuantity 새로운 수량 + * @returns 업데이트된 장바구니 + */ +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + newQuantity: number +): CartItem[] => { + // 수량이 0 이하면 제거 + if (newQuantity <= 0) { + return cart.filter(item => item.product.id !== productId); + } + + // 수량 업데이트 + return cart.map(item => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ); +}; + diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts new file mode 100644 index 000000000..c7274405d --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,47 @@ +/** + * 관리자용 가격 포맷팅 (숫자 + 원) + * @param price 가격 + * @returns 포맷된 가격 문자열 (예: "10,000원") + */ +export const formatAdminPrice = (price: number): string => { + return `${price.toLocaleString()}원`; +}; + +/** + * 고객용 가격 포맷팅 (₩ 기호 포함) + * @param price 가격 + * @returns 포맷된 가격 문자열 (예: "₩10,000") + */ +export const formatCustomerPrice = (price: number): string => { + return `₩${price.toLocaleString()}`; +}; + +/** + * 날짜를 YYYY-MM-DD 형식으로 포맷 + * @param date 날짜 객체 + * @returns 포맷된 날짜 문자열 (예: "2025-12-03") + */ +export const formatDate = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +/** + * 소수를 퍼센트로 변환 + * @param rate 소수 형태의 비율 (0 ~ 1) + * @returns 퍼센트 문자열 (예: "10%") + */ +export const formatPercentage = (rate: number): string => { + return `${(rate * 100).toFixed(0)}%`; +}; + +/** + * 할인 금액 포맷팅 (마이너스 기호 포함) + * @param amount 할인 금액 + * @returns 포맷된 할인 금액 문자열 (예: "-5,000원") + */ +export const formatDiscountAmount = (amount: number): string => { + return `-${amount.toLocaleString()}원`; +}; diff --git a/src/basic/utils/validators.ts b/src/basic/utils/validators.ts new file mode 100644 index 000000000..8b2f0fe79 --- /dev/null +++ b/src/basic/utils/validators.ts @@ -0,0 +1,104 @@ +/** + * 상품 검증 관련 유틸리티 + */ + +export interface ValidationResult { + isValid: boolean; + error?: string; +} + +/** + * 가격 검증 + */ +export const validatePrice = (price: number): ValidationResult => { + if (price < 0) { + return { isValid: false, error: '가격은 0보다 커야 합니다' }; + } + return { isValid: true }; +}; + +/** + * 재고 검증 + */ +export const validateStock = (stock: number): ValidationResult => { + if (stock < 0) { + return { isValid: false, error: '재고는 0보다 커야 합니다' }; + } + if (stock > 9999) { + return { isValid: false, error: '재고는 9999개를 초과할 수 없습니다' }; + } + return { isValid: true }; +}; + +/** + * 상품명 검증 + */ +export const validateProductName = (name: string): ValidationResult => { + if (!name || name.trim().length === 0) { + return { isValid: false, error: '상품명을 입력해주세요' }; + } + if (name.length > 100) { + return { isValid: false, error: '상품명은 100자를 초과할 수 없습니다' }; + } + return { isValid: true }; +}; + +/** + * 할인율 검증 + */ +export const validateDiscountRate = (rate: number): ValidationResult => { + if (rate < 0 || rate > 1) { + return { isValid: false, error: '할인율은 0%에서 100% 사이여야 합니다' }; + } + return { isValid: true }; +}; + +/** + * 할인 수량 검증 + */ +export const validateDiscountQuantity = (quantity: number): ValidationResult => { + if (quantity < 1) { + return { isValid: false, error: '할인 수량은 1개 이상이어야 합니다' }; + } + return { isValid: true }; +}; + +/** + * 쿠폰 코드 검증 + */ +export const validateCouponCode = (code: string): ValidationResult => { + if (!code || code.trim().length === 0) { + return { isValid: false, error: '쿠폰 코드를 입력해주세요' }; + } + if (!/^[A-Z0-9_-]+$/.test(code)) { + return { isValid: false, error: '쿠폰 코드는 영문 대문자, 숫자, -, _만 사용 가능합니다' }; + } + return { isValid: true }; +}; + +/** + * 쿠폰 이름 검증 + */ +export const validateCouponName = (name: string): ValidationResult => { + if (!name || name.trim().length === 0) { + return { isValid: false, error: '쿠폰 이름을 입력해주세요' }; + } + return { isValid: true }; +}; + +/** + * 쿠폰 할인값 검증 + */ +export const validateCouponDiscountValue = ( + value: number, + type: 'amount' | 'percentage' +): ValidationResult => { + if (value <= 0) { + return { isValid: false, error: '할인값은 0보다 커야 합니다' }; + } + if (type === 'percentage' && value > 100) { + return { isValid: false, error: '할인율은 100%를 초과할 수 없습니다' }; + } + return { isValid: true }; +}; + From 54d773c4dcefe2e04d447d5f1ed5730414fba316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 15:39:58 +0900 Subject: [PATCH 09/38] =?UTF-8?q?feat:=20[entities]=20Cart,=20CartItem,=20?= =?UTF-8?q?CouponForm,=20ProductCard,=20ProductForm=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/components/entities/Cart/index.tsx | 122 +++++++++++++++ .../components/entities/CartItem/index.tsx | 68 +++++++++ .../components/entities/CouponForm/index.tsx | 103 +++++++++++++ .../components/entities/ProductCard/index.tsx | 91 +++++++++++ .../components/entities/ProductForm/index.tsx | 143 ++++++++++++++++++ 5 files changed, 527 insertions(+) create mode 100644 src/basic/components/entities/Cart/index.tsx create mode 100644 src/basic/components/entities/CartItem/index.tsx create mode 100644 src/basic/components/entities/CouponForm/index.tsx create mode 100644 src/basic/components/entities/ProductCard/index.tsx create mode 100644 src/basic/components/entities/ProductForm/index.tsx diff --git a/src/basic/components/entities/Cart/index.tsx b/src/basic/components/entities/Cart/index.tsx new file mode 100644 index 000000000..c34a74264 --- /dev/null +++ b/src/basic/components/entities/Cart/index.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { CartItem as CartItemType, Coupon } from '../../../../types'; +import { CartIcon } from '../../icons'; +import { Button } from '../../ui/Button'; +import { CartItem } from '../CartItem'; +import { formatCustomerPrice, formatDiscountAmount } from '../../../utils/formatters'; + +interface CartProps { + cart: CartItemType[]; + totals: { + subtotal: number; + discountAmount: number; + total: number; + }; + coupons: Coupon[]; + selectedCouponCode: string | null; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemoveFromCart: (productId: string) => void; + onSelectCoupon: (couponCode: string) => void; + onCompleteOrder: () => void; + getMaxApplicableDiscount: (item: CartItemType) => number; +} + +export const Cart: React.FC = ({ + cart, + totals, + coupons, + selectedCouponCode, + onUpdateQuantity, + onRemoveFromCart, + onSelectCoupon, + onCompleteOrder, + getMaxApplicableDiscount +}) => { + return ( +
+ {/* 장바구니 */} +
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map(item => ( + + ))} +
+ )} +
+ + {/* 결제 정보 */} + {cart.length > 0 && ( +
+

결제 정보

+ +
+
+ 소계 + {formatCustomerPrice(totals.subtotal)} +
+ {totals.discountAmount > 0 && ( +
+ 할인 금액 + {formatDiscountAmount(totals.discountAmount)} +
+ )} +
+ 합계 + {formatCustomerPrice(totals.total)} +
+
+ + {/* 쿠폰 선택 */} +
+ + +
+ + {/* 주문 버튼 */} +
+ +
+
+ )} +
+ ); +}; diff --git a/src/basic/components/entities/CartItem/index.tsx b/src/basic/components/entities/CartItem/index.tsx new file mode 100644 index 000000000..057bf8066 --- /dev/null +++ b/src/basic/components/entities/CartItem/index.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { CartItem as CartItemType } from '../../../../types'; +import { TrashIcon } from '../../icons'; +import { formatCustomerPrice, formatPercentage } from '../../../utils/formatters'; + +interface CartItemProps { + item: CartItemType; + discount: number; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemove: (productId: string) => void; +} + +export const CartItem: React.FC = ({ + item, + discount, + onUpdateQuantity, + onRemove +}) => { + const originalPrice = item.product.price * item.quantity; + const itemTotal = Math.round(originalPrice * (1 - discount)); + const hasDiscount = discount > 0; + const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; + + return ( +
+
+

+ {item.product.name} +

+ +
+
+
+ + {item.quantity} + +
+
+ {hasDiscount && ( + -{formatPercentage(discountRate / 100)} + )} +

+ {formatCustomerPrice(itemTotal)} +

+
+
+
+ ); +}; + diff --git a/src/basic/components/entities/CouponForm/index.tsx b/src/basic/components/entities/CouponForm/index.tsx new file mode 100644 index 000000000..4824b96de --- /dev/null +++ b/src/basic/components/entities/CouponForm/index.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Coupon } from '../../../../types'; +import { Input } from '../../ui/Input'; +import { Button } from '../../ui/Button'; +import { useCouponForm } from '../../../hooks/useCouponForm'; + +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 { + coupon, + handleFieldChange, + handleCodeChange, + handleDiscountTypeChange, + handleValueChange, + handleValueBlur, + validateForm, + resetForm + } = useCouponForm({ + onValidationError: (message) => addNotification(message, 'error') + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const validation = validateForm(); + if (!validation.isValid) { + validation.errors.forEach(error => addNotification(error, 'error')); + return; + } + + onSubmit(coupon); + resetForm(); + }; + + return ( +
+
+

새 쿠폰 생성

+ +
+ handleFieldChange('name', e.target.value)} + placeholder="신규 가입 쿠폰" + className="text-sm" + required + /> + + handleCodeChange(e.target.value)} + placeholder="WELCOME2024" + className="text-sm font-mono" + required + /> + +
+ + +
+ + handleValueChange(e.target.value)} + onBlur={(e) => handleValueBlur(e.target.value)} + placeholder={coupon.discountType === 'amount' ? '5000' : '10'} + className="text-sm" + required + /> +
+ +
+ + +
+
+
+ ); +}; diff --git a/src/basic/components/entities/ProductCard/index.tsx b/src/basic/components/entities/ProductCard/index.tsx new file mode 100644 index 000000000..64fa81524 --- /dev/null +++ b/src/basic/components/entities/ProductCard/index.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Product } from '../../../../types'; +import { Button } from '../../ui/Button'; +import { Badge } from '../../ui/Badge'; +import { formatCustomerPrice, formatPercentage } from '../../../utils/formatters'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface ProductCardProps { + product: ProductWithUI; + remainingStock: number; + onAddToCart: (product: Product) => void; +} + +export const ProductCard: React.FC = ({ + product, + remainingStock, + onAddToCart +}) => { + const maxDiscountRate = product.discounts.length > 0 + ? Math.max(...product.discounts.map(d => d.rate)) + : 0; + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ + {/* 뱃지 */} + {product.isRecommended && ( + + BEST + + )} + {maxDiscountRate > 0 && ( + + ~{formatPercentage(maxDiscountRate)} + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

{product.description}

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

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

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

+ {product.discounts[0].quantity}개 이상 구매시 할인 {formatPercentage(product.discounts[0].rate)} +

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

품절임박! {remainingStock}개 남음

+ ) : remainingStock > 5 ? ( +

재고 {remainingStock}개

+ ) : null} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; + diff --git a/src/basic/components/entities/ProductForm/index.tsx b/src/basic/components/entities/ProductForm/index.tsx new file mode 100644 index 000000000..d9cb5efcb --- /dev/null +++ b/src/basic/components/entities/ProductForm/index.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { Product } from '../../../../types'; +import { Input } from '../../ui/Input'; +import { Button } from '../../ui/Button'; +import { PlusIcon, TrashIcon } from '../../icons'; +import { useProductForm } from '../../../hooks/useProductForm'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface ProductFormProps { + initialProduct?: Partial; + onSubmit: (product: Omit) => void; + onCancel: () => void; + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; + isEditing: boolean; +} + +export const ProductForm: React.FC = ({ + initialProduct, + onSubmit, + onCancel, + addNotification, + isEditing +}) => { + const { + product, + handleNumberChange, + handleNumberBlur, + handleFieldChange, + addDiscount, + removeDiscount, + updateDiscount + } = useProductForm({ + initialProduct, + onValidationError: (message) => addNotification(message, 'error') + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(product as Omit); + }; + + return ( +
+
+

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

+ +
+ handleFieldChange('name', e.target.value)} + required + /> + + handleFieldChange('description', e.target.value)} + /> + + handleNumberChange('price', e.target.value)} + onBlur={(e) => handleNumberBlur('price', e.target.value)} + placeholder="숫자만 입력" + required + /> + + handleNumberChange('stock', e.target.value)} + onBlur={(e) => handleNumberBlur('stock', e.target.value)} + placeholder="숫자만 입력" + required + /> +
+ +
+ +
+ {product.discounts.map((discount, index) => ( +
+ updateDiscount(index, 'quantity', parseInt(e.target.value) || 0)} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + updateDiscount(index, 'rate', (parseInt(e.target.value) || 0) / 100)} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; From 41c4dae2ec97b2a55d1e6d5e13e307a7c3f905dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 15:40:14 +0900 Subject: [PATCH 10/38] =?UTF-8?q?feat:=20[Pages]=20AdminPage,=20CartPage?= =?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 --- src/basic/pages/AdminPage.tsx | 214 ++++++++++++++++++++++++++++++++++ src/basic/pages/CartPage.tsx | 121 +++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 src/basic/pages/AdminPage.tsx create mode 100644 src/basic/pages/CartPage.tsx diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx new file mode 100644 index 000000000..6232c43dc --- /dev/null +++ b/src/basic/pages/AdminPage.tsx @@ -0,0 +1,214 @@ +import { PlusIcon, TrashIcon } from "../components/icons"; +import { Product, Coupon } from "../../types"; +import { formatAdminPrice, formatPercentage } from "../utils/formatters"; +import { Button } from "../components/ui/Button"; +import { ProductForm } from "../components/entities/ProductForm"; +import { CouponForm } from "../components/entities/CouponForm"; +import { useAdminPage } from "../hooks/useAdminPage"; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface AdminPageProps { + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; + products: ProductWithUI[]; + coupons: Coupon[]; + addProduct: (newProduct: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; + addCoupon: (newCoupon: Coupon) => void; + deleteCoupon: (couponCode: string) => void; +} + +const AdminPage = ({ + addNotification, + products, + coupons, + addProduct, + updateProduct, + deleteProduct, + addCoupon, + deleteCoupon +}: AdminPageProps) => { + // 관리자 페이지 상태 관리 로직 분리 + const { + activeTab, + showProductForm, + showCouponForm, + editingProduct, + setActiveTab, + startEditProduct, + startAddProduct, + handleProductSubmit, + handleProductCancel, + handleCouponSubmit, + handleCouponCancel, + toggleCouponForm + } = useAdminPage({ + onAddProduct: addProduct, + onUpdateProduct: updateProduct, + onAddCoupon: addCoupon + }); + + return ( +
+
+

관리자 대시보드

+

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

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

상품 목록

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

쿠폰 관리

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

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${formatPercentage(coupon.discountValue / 100)} 할인`} + +
+
+ +
+
+ ))} + +
+ +
+
+ + {showCouponForm && ( + + )} +
+
+ )} +
+ ) +} + +export default AdminPage; diff --git a/src/basic/pages/CartPage.tsx b/src/basic/pages/CartPage.tsx new file mode 100644 index 000000000..445b4d93b --- /dev/null +++ b/src/basic/pages/CartPage.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; + +import { CartItem as CartItemType, Product, Coupon } from "../../types"; +import { ProductCard } from "../components/entities/ProductCard"; +import { Cart } from "../components/entities/Cart"; +import { + getMaxApplicableDiscount, + calculateCartTotal +} from "../utils/cartCalculations"; +import { useSearch } from "../hooks/useSearch"; +import { useCouponValidation } from "../hooks/useCouponValidation"; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface CartPageProps { + products: ProductWithUI[]; + cart: CartItemType[]; + debouncedSearchTerm: string; + getRemainingStock: (product: ProductWithUI) => number; + addToCart: (product: ProductWithUI) => void; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; + completeOrder: () => void; + coupons: Coupon[]; + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; +} + +const CartPage = ({ + products, + cart, + debouncedSearchTerm, + getRemainingStock, + addToCart, + removeFromCart, + updateQuantity, + completeOrder, + coupons, + addNotification +}: CartPageProps) => { + const [selectedCoupon, setSelectedCoupon] = useState(null); + + // 검색/필터링 로직 분리 + const { filteredItems: filteredProducts } = useSearch({ + items: products, + searchTerm: debouncedSearchTerm, + searchFields: ['name', 'description'] + }); + + // 쿠폰 검증 로직 분리 + useCouponValidation({ + selectedCoupon, + coupons, + cart, + onCouponInvalid: () => setSelectedCoupon(null), + onMinimumAmountWarning: (message) => addNotification(message, 'warning') + }); + + // 장바구니 총액 계산 + const totals = calculateCartTotal(cart, selectedCoupon); + + const handleCouponChange = (couponCode: string) => { + const coupon = coupons.find(c => c.code === couponCode) || null; + setSelectedCoupon(coupon); + }; + + const handleCompleteOrder = () => { + completeOrder(); + setSelectedCoupon(null); + }; + + return ( +
+
+ {/* 상품 목록 */} +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map(product => ( + + ))} +
+ )} +
+
+ +
+ getMaxApplicableDiscount(item, cart)} + /> +
+
+ ); +} + +export default CartPage; From 29c63d2b33051ddeb0c04cc77b622b18f5c7c290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 15:40:33 +0900 Subject: [PATCH 11/38] =?UTF-8?q?feat:=20notifications=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=EB=A1=9C=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/components/notifications/index.tsx | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/basic/components/notifications/index.tsx diff --git a/src/basic/components/notifications/index.tsx b/src/basic/components/notifications/index.tsx new file mode 100644 index 000000000..5be9dc927 --- /dev/null +++ b/src/basic/components/notifications/index.tsx @@ -0,0 +1,40 @@ +interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +interface NotificationsProps { + notifications: Notification[]; + onRemove: (id: string) => void; +} + +const Notifications = ({ notifications, onRemove }: NotificationsProps) => { + return ( +
+ {notifications.map(notif => ( +
+ {notif.message} + +
+ ))} +
+ ) +} + +export default Notifications; \ No newline at end of file From 9ecabbe6a2591b35b1404591c56174050b718c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 15:40:46 +0900 Subject: [PATCH 12/38] =?UTF-8?q?refactor:=20App.tsx=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 1204 +++++---------------------------------------- 1 file changed, 113 insertions(+), 1091 deletions(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..fc8320316 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,1124 +1,146 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState } from 'react'; +import { Product } from '../types'; + +import CartPage from './pages/CartPage'; +import AdminPage from './pages/AdminPage'; + +import Notifications from './components/notifications'; +import Header from './components/layout/Header'; +import { useCart } from './hooks/useCart'; +import { useCoupons } from './hooks/useCoupons'; +import { useProducts } from './hooks/useProducts'; +import { useNotifications } from './hooks/useNotifications'; +import { useDebounce } from './utils/hooks/useDebounce'; +import { initialProducts, initialCoupons } from './constants'; interface ProductWithUI extends Product { description?: string; isRecommended?: boolean; } -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; - const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } + const debouncedSearchTerm = useDebounce(searchTerm, 500); + + // 알림 관리 Hook + const { notifications, addNotification, removeNotification } = useNotifications(); + + const { + cart, + totalItemCount, + addToCart: addToCartAction, + removeFromCart, + updateQuantity: updateQuantityAction, + getRemainingStock, + completeOrder: completeOrderAction, + } = useCart(); + + const { + coupons, + addCoupon: addCouponAction, + deleteCoupon, + } = useCoupons({ initialCoupons }); + + const { + products, + addProduct: addProductAction, + updateProduct: updateProductAction, + deleteProduct, + } = useProducts({ initialProducts }); + + // Wrapper functions to handle results and show notifications + const addToCart = (product: ProductWithUI) => { + const result = addToCartAction(product); + if (result.error) { + addNotification(result.error, 'error'); + } else if (result.message) { + addNotification(result.message, 'success'); } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; }; - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + const updateQuantity = (productId: string, quantity: number) => { + const result = updateQuantityAction(productId, quantity, products); + if (result.error) { + addNotification(result.error, 'error'); } - - 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)); - } + const completeOrder = () => { + const result = completeOrderAction(); + if (result.message) { + addNotification(result.message, 'success'); } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; }; - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); + const addProduct = (newProduct: Omit) => { + const result = addProductAction(newProduct); + if (result.message) { + addNotification(result.message, 'success'); } - 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 updateProduct = (productId: string, updates: Partial) => { + const result = updateProductAction(productId, updates); + if (result.message) { + addNotification(result.message, 'success'); + } }; - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); + const addCoupon = (newCoupon: any) => { + const result = addCouponAction(newCoupon); + if (result.error) { + addNotification(result.error, 'error'); + } else if (result.message) { + addNotification(result.message, 'success'); + } }; - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; - return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && ( -
- setSearchTerm(e.target.value)} - placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" - /> -
- )} -
- -
-
-
- + {notifications.length > 0 && + + } +
- {isAdmin ? ( -
-
-

관리자 대시보드

-

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

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

상품 목록

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

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

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

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

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

새 쿠폰 생성

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

전체 상품

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

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

-
-
- - )} -
-
-
- )} + {isAdmin ? + + : + + }
); }; -export default App; \ No newline at end of file +export default App; From e8afa4a0769c63fa6fe01c2bc77e4ad334e6c901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 15:54:06 +0900 Subject: [PATCH 13/38] =?UTF-8?q?chore:=20jotai=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/package.json b/package.json index 17b18de25..03811e474 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "jotai": "^2.15.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85f..3f6655757 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + jotai: + specifier: ^2.15.2 + version: 2.15.2(@types/react@19.1.9)(react@19.1.1) react: specifier: ^19.1.1 version: 19.1.1 @@ -1056,6 +1059,24 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jotai@2.15.2: + resolution: {integrity: sha512-El86CCfXNMEOytp20NPfppqGGmcp6H6kIA+tJHdmASEUURJCYW4fh8nTHEnB8rUXEFAY1pm8PdHPwnrcPGwdEg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2413,6 +2434,11 @@ snapshots: isexe@2.0.0: {} + jotai@2.15.2(@types/react@19.1.9)(react@19.1.1): + optionalDependencies: + '@types/react': 19.1.9 + react: 19.1.1 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} From 11a06cd2876fbf92ca75d88d9311190131a832ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 21:07:37 +0900 Subject: [PATCH 14/38] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/basic/App.tsx b/src/basic/App.tsx index fc8320316..e3d93c21f 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -49,7 +49,6 @@ const App = () => { deleteProduct, } = useProducts({ initialProducts }); - // Wrapper functions to handle results and show notifications const addToCart = (product: ProductWithUI) => { const result = addToCartAction(product); if (result.error) { From 4dfd60a77afa2fc7e0e0a75b9b9268670f0b5954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 21:46:55 +0900 Subject: [PATCH 15/38] =?UTF-8?q?fix:=20basic=20->=20advanced=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/entities/Cart/index.tsx | 122 ++++++++++ .../components/entities/CartItem/index.tsx | 68 ++++++ .../components/entities/CouponForm/index.tsx | 103 +++++++++ .../components/entities/ProductCard/index.tsx | 91 ++++++++ .../components/entities/ProductForm/index.tsx | 143 ++++++++++++ src/advanced/components/icons/index.tsx | 71 ++++++ src/advanced/components/layout/Header.tsx | 60 +++++ .../components/notifications/index.tsx | 40 ++++ src/advanced/components/ui/Badge.tsx | 33 +++ src/advanced/components/ui/Button.tsx | 43 ++++ src/advanced/components/ui/Input.tsx | 33 +++ src/advanced/constants/index.ts | 58 +++++ src/advanced/hooks/useAdminPage.ts | 109 +++++++++ src/advanced/hooks/useCart.ts | 120 ++++++++++ src/advanced/hooks/useCouponForm.ts | 137 +++++++++++ src/advanced/hooks/useCouponValidation.ts | 91 ++++++++ src/advanced/hooks/useCoupons.ts | 54 +++++ src/advanced/hooks/useNotifications.ts | 56 +++++ src/advanced/hooks/useProductForm.ts | 149 ++++++++++++ src/advanced/hooks/useProducts.ts | 100 ++++++++ src/advanced/hooks/useSearch.ts | 44 ++++ src/advanced/pages/AdminPage.tsx | 214 ++++++++++++++++++ src/advanced/pages/CartPage.tsx | 121 ++++++++++ src/advanced/utils/cartCalculations.ts | 92 ++++++++ src/advanced/utils/formatters.ts | 47 ++++ src/advanced/utils/hooks/useDebounce.ts | 28 +++ src/advanced/utils/hooks/useLocalStorage.ts | 69 ++++++ src/advanced/utils/validators.ts | 104 +++++++++ 28 files changed, 2400 insertions(+) create mode 100644 src/advanced/components/entities/Cart/index.tsx create mode 100644 src/advanced/components/entities/CartItem/index.tsx create mode 100644 src/advanced/components/entities/CouponForm/index.tsx create mode 100644 src/advanced/components/entities/ProductCard/index.tsx create mode 100644 src/advanced/components/entities/ProductForm/index.tsx create mode 100644 src/advanced/components/icons/index.tsx create mode 100644 src/advanced/components/layout/Header.tsx create mode 100644 src/advanced/components/notifications/index.tsx create mode 100644 src/advanced/components/ui/Badge.tsx create mode 100644 src/advanced/components/ui/Button.tsx create mode 100644 src/advanced/components/ui/Input.tsx create mode 100644 src/advanced/constants/index.ts create mode 100644 src/advanced/hooks/useAdminPage.ts create mode 100644 src/advanced/hooks/useCart.ts create mode 100644 src/advanced/hooks/useCouponForm.ts create mode 100644 src/advanced/hooks/useCouponValidation.ts create mode 100644 src/advanced/hooks/useCoupons.ts create mode 100644 src/advanced/hooks/useNotifications.ts create mode 100644 src/advanced/hooks/useProductForm.ts create mode 100644 src/advanced/hooks/useProducts.ts create mode 100644 src/advanced/hooks/useSearch.ts create mode 100644 src/advanced/pages/AdminPage.tsx create mode 100644 src/advanced/pages/CartPage.tsx create mode 100644 src/advanced/utils/cartCalculations.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/validators.ts diff --git a/src/advanced/components/entities/Cart/index.tsx b/src/advanced/components/entities/Cart/index.tsx new file mode 100644 index 000000000..c34a74264 --- /dev/null +++ b/src/advanced/components/entities/Cart/index.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { CartItem as CartItemType, Coupon } from '../../../../types'; +import { CartIcon } from '../../icons'; +import { Button } from '../../ui/Button'; +import { CartItem } from '../CartItem'; +import { formatCustomerPrice, formatDiscountAmount } from '../../../utils/formatters'; + +interface CartProps { + cart: CartItemType[]; + totals: { + subtotal: number; + discountAmount: number; + total: number; + }; + coupons: Coupon[]; + selectedCouponCode: string | null; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemoveFromCart: (productId: string) => void; + onSelectCoupon: (couponCode: string) => void; + onCompleteOrder: () => void; + getMaxApplicableDiscount: (item: CartItemType) => number; +} + +export const Cart: React.FC = ({ + cart, + totals, + coupons, + selectedCouponCode, + onUpdateQuantity, + onRemoveFromCart, + onSelectCoupon, + onCompleteOrder, + getMaxApplicableDiscount +}) => { + return ( +
+ {/* 장바구니 */} +
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map(item => ( + + ))} +
+ )} +
+ + {/* 결제 정보 */} + {cart.length > 0 && ( +
+

결제 정보

+ +
+
+ 소계 + {formatCustomerPrice(totals.subtotal)} +
+ {totals.discountAmount > 0 && ( +
+ 할인 금액 + {formatDiscountAmount(totals.discountAmount)} +
+ )} +
+ 합계 + {formatCustomerPrice(totals.total)} +
+
+ + {/* 쿠폰 선택 */} +
+ + +
+ + {/* 주문 버튼 */} +
+ +
+
+ )} +
+ ); +}; diff --git a/src/advanced/components/entities/CartItem/index.tsx b/src/advanced/components/entities/CartItem/index.tsx new file mode 100644 index 000000000..057bf8066 --- /dev/null +++ b/src/advanced/components/entities/CartItem/index.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { CartItem as CartItemType } from '../../../../types'; +import { TrashIcon } from '../../icons'; +import { formatCustomerPrice, formatPercentage } from '../../../utils/formatters'; + +interface CartItemProps { + item: CartItemType; + discount: number; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemove: (productId: string) => void; +} + +export const CartItem: React.FC = ({ + item, + discount, + onUpdateQuantity, + onRemove +}) => { + const originalPrice = item.product.price * item.quantity; + const itemTotal = Math.round(originalPrice * (1 - discount)); + const hasDiscount = discount > 0; + const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; + + return ( +
+
+

+ {item.product.name} +

+ +
+
+
+ + {item.quantity} + +
+
+ {hasDiscount && ( + -{formatPercentage(discountRate / 100)} + )} +

+ {formatCustomerPrice(itemTotal)} +

+
+
+
+ ); +}; + diff --git a/src/advanced/components/entities/CouponForm/index.tsx b/src/advanced/components/entities/CouponForm/index.tsx new file mode 100644 index 000000000..4824b96de --- /dev/null +++ b/src/advanced/components/entities/CouponForm/index.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Coupon } from '../../../../types'; +import { Input } from '../../ui/Input'; +import { Button } from '../../ui/Button'; +import { useCouponForm } from '../../../hooks/useCouponForm'; + +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 { + coupon, + handleFieldChange, + handleCodeChange, + handleDiscountTypeChange, + handleValueChange, + handleValueBlur, + validateForm, + resetForm + } = useCouponForm({ + onValidationError: (message) => addNotification(message, 'error') + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const validation = validateForm(); + if (!validation.isValid) { + validation.errors.forEach(error => addNotification(error, 'error')); + return; + } + + onSubmit(coupon); + resetForm(); + }; + + return ( +
+
+

새 쿠폰 생성

+ +
+ handleFieldChange('name', e.target.value)} + placeholder="신규 가입 쿠폰" + className="text-sm" + required + /> + + handleCodeChange(e.target.value)} + placeholder="WELCOME2024" + className="text-sm font-mono" + required + /> + +
+ + +
+ + handleValueChange(e.target.value)} + onBlur={(e) => handleValueBlur(e.target.value)} + placeholder={coupon.discountType === 'amount' ? '5000' : '10'} + className="text-sm" + required + /> +
+ +
+ + +
+
+
+ ); +}; diff --git a/src/advanced/components/entities/ProductCard/index.tsx b/src/advanced/components/entities/ProductCard/index.tsx new file mode 100644 index 000000000..64fa81524 --- /dev/null +++ b/src/advanced/components/entities/ProductCard/index.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Product } from '../../../../types'; +import { Button } from '../../ui/Button'; +import { Badge } from '../../ui/Badge'; +import { formatCustomerPrice, formatPercentage } from '../../../utils/formatters'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface ProductCardProps { + product: ProductWithUI; + remainingStock: number; + onAddToCart: (product: Product) => void; +} + +export const ProductCard: React.FC = ({ + product, + remainingStock, + onAddToCart +}) => { + const maxDiscountRate = product.discounts.length > 0 + ? Math.max(...product.discounts.map(d => d.rate)) + : 0; + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ + {/* 뱃지 */} + {product.isRecommended && ( + + BEST + + )} + {maxDiscountRate > 0 && ( + + ~{formatPercentage(maxDiscountRate)} + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

{product.description}

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

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

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

+ {product.discounts[0].quantity}개 이상 구매시 할인 {formatPercentage(product.discounts[0].rate)} +

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

품절임박! {remainingStock}개 남음

+ ) : remainingStock > 5 ? ( +

재고 {remainingStock}개

+ ) : null} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; + diff --git a/src/advanced/components/entities/ProductForm/index.tsx b/src/advanced/components/entities/ProductForm/index.tsx new file mode 100644 index 000000000..d9cb5efcb --- /dev/null +++ b/src/advanced/components/entities/ProductForm/index.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { Product } from '../../../../types'; +import { Input } from '../../ui/Input'; +import { Button } from '../../ui/Button'; +import { PlusIcon, TrashIcon } from '../../icons'; +import { useProductForm } from '../../../hooks/useProductForm'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface ProductFormProps { + initialProduct?: Partial; + onSubmit: (product: Omit) => void; + onCancel: () => void; + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; + isEditing: boolean; +} + +export const ProductForm: React.FC = ({ + initialProduct, + onSubmit, + onCancel, + addNotification, + isEditing +}) => { + const { + product, + handleNumberChange, + handleNumberBlur, + handleFieldChange, + addDiscount, + removeDiscount, + updateDiscount + } = useProductForm({ + initialProduct, + onValidationError: (message) => addNotification(message, 'error') + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(product as Omit); + }; + + return ( +
+
+

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

+ +
+ handleFieldChange('name', e.target.value)} + required + /> + + handleFieldChange('description', e.target.value)} + /> + + handleNumberChange('price', e.target.value)} + onBlur={(e) => handleNumberBlur('price', e.target.value)} + placeholder="숫자만 입력" + required + /> + + handleNumberChange('stock', e.target.value)} + onBlur={(e) => handleNumberBlur('stock', e.target.value)} + placeholder="숫자만 입력" + required + /> +
+ +
+ +
+ {product.discounts.map((discount, index) => ( +
+ updateDiscount(index, 'quantity', parseInt(e.target.value) || 0)} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + updateDiscount(index, 'rate', (parseInt(e.target.value) || 0) / 100)} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/advanced/components/icons/index.tsx b/src/advanced/components/icons/index.tsx new file mode 100644 index 000000000..188b5a3cb --- /dev/null +++ b/src/advanced/components/icons/index.tsx @@ -0,0 +1,71 @@ +import { SVGProps } from "react"; + +export interface IconProps extends Omit, "width" | "height"> { + size?: number; +} + +const buildProps = ({ size = 24, ...rest }: IconProps = {}) => ({ + width: size, + height: size, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 2, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + ...rest, +}); + +export const CartIcon = (props: IconProps) => ( + + + + + + +); + +export const AdminIcon = (props: IconProps) => ( + + + + + +); + +export const PlusIcon = (props: IconProps) => ( + + + +); + +export const MinusIcon = (props: IconProps) => ( + + + +); + +export const TrashIcon = (props: IconProps) => ( + + + +); + +export const ChevronDownIcon = (props: IconProps) => ( + + + +); + +export const ChevronUpIcon = (props: IconProps) => ( + + + +); + +export const CheckIcon = (props: IconProps) => ( + + + +); + diff --git a/src/advanced/components/layout/Header.tsx b/src/advanced/components/layout/Header.tsx new file mode 100644 index 000000000..fa16986d9 --- /dev/null +++ b/src/advanced/components/layout/Header.tsx @@ -0,0 +1,60 @@ +import { CartItem } from "../../../types"; +import { CartIcon } from "../icons"; + +interface HeaderProps { + isAdmin: boolean; + searchTerm: string; + setSearchTerm: (value: string) => void; + setIsAdmin: (value: boolean) => void; + cart: CartItem[]; + totalItemCount: number; +} + +const Header = ({ isAdmin, searchTerm, setSearchTerm, setIsAdmin, cart, totalItemCount }: HeaderProps) => { + return ( +
+
+
+
+

SHOP

+ {!isAdmin && ( +
+ setSearchTerm(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ )} +
+ +
+
+
+ ) +} + +export default Header; \ No newline at end of file diff --git a/src/advanced/components/notifications/index.tsx b/src/advanced/components/notifications/index.tsx new file mode 100644 index 000000000..5be9dc927 --- /dev/null +++ b/src/advanced/components/notifications/index.tsx @@ -0,0 +1,40 @@ +interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +interface NotificationsProps { + notifications: Notification[]; + onRemove: (id: string) => void; +} + +const Notifications = ({ notifications, onRemove }: NotificationsProps) => { + return ( +
+ {notifications.map(notif => ( +
+ {notif.message} + +
+ ))} +
+ ) +} + +export default Notifications; \ No newline at end of file diff --git a/src/advanced/components/ui/Badge.tsx b/src/advanced/components/ui/Badge.tsx new file mode 100644 index 000000000..afc64731d --- /dev/null +++ b/src/advanced/components/ui/Badge.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +interface BadgeProps { + children: React.ReactNode; + variant?: 'primary' | 'success' | 'warning' | 'danger' | 'info'; + size?: 'sm' | 'md'; + className?: string; +} + +export const Badge: React.FC = ({ + children, + variant = 'primary', + size = 'md', + className = '' +}) => { + const baseStyles = "text-white rounded inline-flex items-center justify-center"; + const sizeStyles = size === 'sm' ? "text-xs px-2 py-0.5" : "text-sm px-2.5 py-1"; + + const variantStyles = { + primary: "bg-gray-900", + success: "bg-green-500", + warning: "bg-orange-500", + danger: "bg-red-500", + info: "bg-blue-500", + }; + + return ( + + {children} + + ); +}; + diff --git a/src/advanced/components/ui/Button.tsx b/src/advanced/components/ui/Button.tsx new file mode 100644 index 000000000..b03dd74a7 --- /dev/null +++ b/src/advanced/components/ui/Button.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + fullWidth?: boolean; +} + +export const Button: React.FC = ({ + children, + variant = 'primary', + size = 'md', + fullWidth = false, + className = '', + ...props +}) => { + const baseStyles = "rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 inline-flex items-center justify-center"; + + const variantStyles = { + primary: "bg-gray-900 text-white hover:bg-gray-800 focus:ring-gray-900", + secondary: "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-gray-500", + danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-600", + ghost: "text-gray-600 hover:bg-gray-100 focus:ring-gray-200", + }; + + const sizeStyles = { + sm: "px-3 py-1.5 text-sm", + md: "px-4 py-2 text-base", + lg: "px-5 py-2.5 text-lg", + }; + + const widthStyle = fullWidth ? "w-full" : ""; + + return ( + + ); +}; + diff --git a/src/advanced/components/ui/Input.tsx b/src/advanced/components/ui/Input.tsx new file mode 100644 index 000000000..b24fd4b71 --- /dev/null +++ b/src/advanced/components/ui/Input.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +interface InputProps extends React.InputHTMLAttributes { + label?: string; + error?: string; +} + +export const Input: React.FC = ({ + label, + error, + className = '', + ...props +}) => { + return ( +
+ {label && ( + + )} + + {error && ( +

{error}

+ )} +
+ ); +}; + diff --git a/src/advanced/constants/index.ts b/src/advanced/constants/index.ts new file mode 100644 index 000000000..df2e22a5f --- /dev/null +++ b/src/advanced/constants/index.ts @@ -0,0 +1,58 @@ +import { Coupon, Product } from '../../types'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +// 초기 데이터 +export const initialProducts: ProductWithUI[] = [ + { + id: 'p1', + name: '상품1', + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 } + ], + description: '최고급 품질의 프리미엄 상품입니다.' + }, + { + id: 'p2', + name: '상품2', + price: 20000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.15 } + ], + description: '다양한 기능을 갖춘 실용적인 상품입니다.', + isRecommended: true + }, + { + id: 'p3', + name: '상품3', + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 } + ], + description: '대용량과 고성능을 자랑하는 상품입니다.' + } +]; + +export const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000 + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10 + } +]; \ No newline at end of file diff --git a/src/advanced/hooks/useAdminPage.ts b/src/advanced/hooks/useAdminPage.ts new file mode 100644 index 000000000..bf36a636a --- /dev/null +++ b/src/advanced/hooks/useAdminPage.ts @@ -0,0 +1,109 @@ +import { useState, useCallback } from 'react'; +import { Product, Coupon } from '../../types'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface UseAdminPageOptions { + onAddProduct: (newProduct: Omit) => void; + onUpdateProduct: (productId: string, updates: Partial) => void; + onAddCoupon: (newCoupon: Coupon) => void; +} + +/** + * 관리자 페이지의 UI 상태와 로직을 관리하는 Hook + */ +export function useAdminPage({ + onAddProduct, + onUpdateProduct, + onAddCoupon +}: UseAdminPageOptions) { + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [showProductForm, setShowProductForm] = useState(false); + const [showCouponForm, setShowCouponForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + + /** + * 상품 수정 시작 + */ + const startEditProduct = useCallback((product: ProductWithUI) => { + setEditingProduct(product); + setShowProductForm(true); + }, []); + + /** + * 새 상품 추가 폼 열기 + */ + const startAddProduct = useCallback(() => { + setEditingProduct(null); + setShowProductForm(true); + }, []); + + /** + * 상품 폼 제출 + */ + const handleProductSubmit = useCallback((product: Omit) => { + if (editingProduct) { + onUpdateProduct(editingProduct.id, product); + } else { + onAddProduct(product); + } + setEditingProduct(null); + setShowProductForm(false); + }, [editingProduct, onAddProduct, onUpdateProduct]); + + /** + * 상품 폼 취소 + */ + const handleProductCancel = useCallback(() => { + setEditingProduct(null); + setShowProductForm(false); + }, []); + + /** + * 쿠폰 폼 제출 + */ + const handleCouponSubmit = useCallback((coupon: Coupon) => { + onAddCoupon(coupon); + setShowCouponForm(false); + }, [onAddCoupon]); + + /** + * 쿠폰 폼 취소 + */ + const handleCouponCancel = useCallback(() => { + setShowCouponForm(false); + }, []); + + /** + * 쿠폰 폼 토글 + */ + const toggleCouponForm = useCallback(() => { + setShowCouponForm(prev => !prev); + }, []); + + return { + // 상태 + activeTab, + showProductForm, + showCouponForm, + editingProduct, + + // 상태 변경 함수 + setActiveTab, + + // 상품 관련 + startEditProduct, + startAddProduct, + handleProductSubmit, + handleProductCancel, + + // 쿠폰 관련 + handleCouponSubmit, + handleCouponCancel, + toggleCouponForm + }; +} + diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts new file mode 100644 index 000000000..c657eeffa --- /dev/null +++ b/src/advanced/hooks/useCart.ts @@ -0,0 +1,120 @@ +import { useCallback, useMemo } from "react"; +import { CartItem, Product } from "../../types"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; +import { formatDate } from "../utils/formatters"; + +interface UseCartResult { + success: boolean; + error?: string; + message?: string; +} + +export function useCart() { + const [cart, setCart] = useLocalStorage('cart', []); + + // 총 아이템 개수 계산 + const totalItemCount = useMemo(() => { + return cart.reduce((sum, item) => sum + item.quantity, 0); + }, [cart]); + + // 재고 확인 함수 + const getRemainingStock = useCallback((product: Product): number => { + const cartItem = cart.find(item => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); + }, [cart]); + + // 장바구니에 상품 추가 + const addToCart = useCallback((product: Product): UseCartResult => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + return { success: false, error: '재고가 부족합니다!' }; + } + + let result: UseCartResult = { success: true, message: '장바구니에 담았습니다' }; + + setCart(prevCart => { + const existingItem = prevCart.find(item => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + result = { success: false, error: `재고는 ${product.stock}개까지만 있습니다.` }; + return prevCart; + } + + return prevCart.map(item => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + return result; + }, [getRemainingStock, setCart]); + + // 장바구니에서 상품 제거 + const removeFromCart = useCallback((productId: string) => { + setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + }, [setCart]); + + // 수량 변경 + const updateQuantity = useCallback(( + productId: string, + newQuantity: number, + products: Product[] + ): UseCartResult => { + if (newQuantity <= 0) { + setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + return { success: true }; + } + + const product = products.find(p => p.id === productId); + if (!product) { + return { success: false, error: '상품을 찾을 수 없습니다.' }; + } + + const maxStock = product.stock; + if (newQuantity > maxStock) { + return { success: false, error: `재고는 ${maxStock}개까지만 있습니다.` }; + } + + setCart(prevCart => + prevCart.map(item => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ) + ); + + return { success: true }; + }, [setCart]); + + // 주문 완료 (장바구니 비우기) + const completeOrder = useCallback((): UseCartResult => { + const now = new Date(); + const dateStr = formatDate(now).replace(/-/g, ''); + const timeStr = now.getHours().toString().padStart(2, '0') + now.getMinutes().toString().padStart(2, '0'); + const orderNumber = `ORD-${dateStr}-${timeStr}`; + + setCart([]); + + return { + success: true, + message: `주문이 완료되었습니다. 주문번호: ${orderNumber}` + }; + }, [setCart]); + + return { + cart, + totalItemCount, + addToCart, + removeFromCart, + updateQuantity, + getRemainingStock, + completeOrder, + }; +} diff --git a/src/advanced/hooks/useCouponForm.ts b/src/advanced/hooks/useCouponForm.ts new file mode 100644 index 000000000..a547c5bc1 --- /dev/null +++ b/src/advanced/hooks/useCouponForm.ts @@ -0,0 +1,137 @@ +import { useState, useCallback } from 'react'; +import { Coupon } from '../../types'; +import { + validateCouponCode, + validateCouponName, + validateCouponDiscountValue +} from '../utils/validators'; + +interface UseCouponFormOptions { + onValidationError?: (message: string) => void; +} + +interface CouponFormValidationResult { + isValid: boolean; + errors: string[]; +} + +/** + * 쿠폰 폼의 모든 비즈니스 로직을 관리하는 Hook + */ +export function useCouponForm({ onValidationError }: UseCouponFormOptions = {}) { + const [coupon, setCoupon] = useState({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + + /** + * 필드 변경 + */ + const handleFieldChange = useCallback((field: keyof Coupon, value: string | number) => { + setCoupon(prev => ({ ...prev, [field]: value })); + }, []); + + /** + * 쿠폰 코드 변경 (대문자로 변환) + */ + const handleCodeChange = useCallback((value: string) => { + setCoupon(prev => ({ ...prev, code: value.toUpperCase() })); + }, []); + + /** + * 할인 타입 변경 + */ + const handleDiscountTypeChange = useCallback((type: 'amount' | 'percentage') => { + setCoupon(prev => ({ ...prev, discountType: type })); + }, []); + + /** + * 숫자 입력 처리 (숫자만 허용) + */ + const handleValueChange = useCallback((value: string) => { + if (value === '' || /^\d+$/.test(value)) { + setCoupon(prev => ({ + ...prev, + discountValue: value === '' ? 0 : parseInt(value) + })); + } + }, []); + + /** + * 숫자 입력 검증 (blur 시) + */ + const handleValueBlur = useCallback((value: string) => { + const numValue = parseInt(value) || 0; + + const validation = validateCouponDiscountValue(numValue, coupon.discountType); + if (!validation.isValid) { + onValidationError?.(validation.error!); + + // 최대값 설정 + if (coupon.discountType === 'percentage') { + setCoupon(prev => ({ + ...prev, + discountValue: numValue > 100 ? 100 : 0 + })); + } else { + setCoupon(prev => ({ + ...prev, + discountValue: numValue > 100000 ? 100000 : 0 + })); + } + } + }, [coupon.discountType, onValidationError]); + + /** + * 폼 검증 + */ + const validateForm = useCallback((): CouponFormValidationResult => { + const errors: string[] = []; + + const nameValidation = validateCouponName(coupon.name); + if (!nameValidation.isValid) { + errors.push(nameValidation.error!); + } + + const codeValidation = validateCouponCode(coupon.code); + if (!codeValidation.isValid) { + errors.push(codeValidation.error!); + } + + const valueValidation = validateCouponDiscountValue(coupon.discountValue, coupon.discountType); + if (!valueValidation.isValid) { + errors.push(valueValidation.error!); + } + + return { + isValid: errors.length === 0, + errors + }; + }, [coupon]); + + /** + * 폼 리셋 + */ + const resetForm = useCallback(() => { + setCoupon({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0, + }); + }, []); + + return { + coupon, + handleFieldChange, + handleCodeChange, + handleDiscountTypeChange, + handleValueChange, + handleValueBlur, + validateForm, + resetForm + }; +} + diff --git a/src/advanced/hooks/useCouponValidation.ts b/src/advanced/hooks/useCouponValidation.ts new file mode 100644 index 000000000..d8004bd1e --- /dev/null +++ b/src/advanced/hooks/useCouponValidation.ts @@ -0,0 +1,91 @@ +import { useEffect } from 'react'; +import { Coupon, CartItem } from '../../types'; +import { calculateCartTotal } from '../utils/cartCalculations'; + +interface UseCouponValidationOptions { + selectedCoupon: Coupon | null; + coupons: Coupon[]; + cart: CartItem[]; + onCouponInvalid?: () => void; + onMinimumAmountWarning?: (message: string) => void; +} + +interface CouponValidationResult { + isValid: boolean; + warningMessage?: string; + errorMessage?: string; +} + +/** + * 쿠폰 검증 로직을 처리하는 Hook + */ +export function useCouponValidation({ + selectedCoupon, + coupons, + cart, + onCouponInvalid, + onMinimumAmountWarning +}: UseCouponValidationOptions) { + + // 선택된 쿠폰이 삭제되었는지 확인 + useEffect(() => { + if (selectedCoupon && !coupons.some(coupon => coupon.code === selectedCoupon.code)) { + onCouponInvalid?.(); + } + }, [coupons, selectedCoupon, onCouponInvalid]); + + // 장바구니가 비어있으면 쿠폰 초기화 + useEffect(() => { + if (cart.length === 0 && selectedCoupon) { + onCouponInvalid?.(); + } + }, [cart.length, selectedCoupon, onCouponInvalid]); + + /** + * 쿠폰 적용 가능 여부 검증 + */ + const validateCouponApplicability = (coupon: Coupon | null): CouponValidationResult => { + if (!coupon) { + return { isValid: true }; + } + + // 장바구니가 비어있는 경우 + if (cart.length === 0) { + return { + isValid: false, + errorMessage: '장바구니에 상품을 추가해주세요' + }; + } + + // 비율 할인 쿠폰의 최소 금액 체크 + if (coupon.discountType === 'percentage') { + const { subtotal } = calculateCartTotal(cart, null); + + if (subtotal < 10000) { + return { + isValid: false, + warningMessage: '10,000원 이상 구매시 쿠폰을 사용할 수 있습니다!' + }; + } + } + + return { isValid: true }; + }; + + /** + * 쿠폰 적용 시 경고 메시지 표시 + */ + useEffect(() => { + if (selectedCoupon) { + const validation = validateCouponApplicability(selectedCoupon); + if (validation.warningMessage && onMinimumAmountWarning) { + onMinimumAmountWarning(validation.warningMessage); + } + } + }, [selectedCoupon, cart]); + + return { + validateCouponApplicability + }; +} + diff --git a/src/advanced/hooks/useCoupons.ts b/src/advanced/hooks/useCoupons.ts new file mode 100644 index 000000000..dd33ae08f --- /dev/null +++ b/src/advanced/hooks/useCoupons.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect } from "react"; +import { Coupon } from "../../types"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +interface UseCouponsOptions { + initialCoupons?: Coupon[]; +} + +interface UseCouponsResult { + success: boolean; + error?: string; + message?: string; +} + +export function useCoupons(options?: UseCouponsOptions) { + const { initialCoupons = [] } = options || {}; + + const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); + + // 초기 데이터가 변경될 때 localStorage에 반영 + useEffect(() => { + if (initialCoupons.length > 0 && coupons.length === 0) { + setCoupons(initialCoupons); + } + }, [initialCoupons, coupons.length, setCoupons]); + + // 쿠폰 추가 + const addCoupon = useCallback((newCoupon: Coupon): UseCouponsResult => { + let result: UseCouponsResult = { success: true, message: '쿠폰이 추가되었습니다.' }; + + setCoupons(prevCoupons => { + const existingCoupon = prevCoupons.find(c => c.code === newCoupon.code); + if (existingCoupon) { + result = { success: false, error: '이미 존재하는 쿠폰 코드입니다.' }; + return prevCoupons; + } + return [...prevCoupons, newCoupon]; + }); + + return result; + }, [setCoupons]); + + // 쿠폰 삭제 + const deleteCoupon = useCallback((couponCode: string): UseCouponsResult => { + setCoupons(prevCoupons => prevCoupons.filter(c => c.code !== couponCode)); + return { success: true, message: '쿠폰이 삭제되었습니다.' }; + }, [setCoupons]); + + return { + coupons, + addCoupon, + deleteCoupon, + }; +} diff --git a/src/advanced/hooks/useNotifications.ts b/src/advanced/hooks/useNotifications.ts new file mode 100644 index 000000000..113e95bc2 --- /dev/null +++ b/src/advanced/hooks/useNotifications.ts @@ -0,0 +1,56 @@ +import { useState, useCallback } from "react"; + +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +interface UseNotificationsOptions { + duration?: number; // 알림 표시 시간 (ms) +} + +/** + * 알림 메시지를 관리하는 Hook + * @param options duration (기본값: 3000ms) + * @returns { notifications, addNotification, removeNotification } + */ +export function useNotifications(options?: UseNotificationsOptions) { + const { duration = 3000 } = options || {}; + + const [notifications, setNotifications] = useState([]); + + /** + * 알림 추가 + * @param message 알림 메시지 + * @param type 알림 타입 (error | success | warning) + */ + 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)); + }, duration); + }, [duration]); + + /** + * 알림 수동 제거 + * @param id 제거할 알림 ID + */ + const removeNotification = useCallback((id: string) => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, []); + + + return { + notifications, + addNotification, + removeNotification, + }; +} + diff --git a/src/advanced/hooks/useProductForm.ts b/src/advanced/hooks/useProductForm.ts new file mode 100644 index 000000000..fd0ef83f0 --- /dev/null +++ b/src/advanced/hooks/useProductForm.ts @@ -0,0 +1,149 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Product, Discount } from '../../types'; +import { validatePrice, validateStock } from '../utils/validators'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface UseProductFormOptions { + initialProduct?: Partial; + onValidationError?: (message: string) => void; +} + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} + +/** + * 상품 폼의 모든 비즈니스 로직을 관리하는 Hook + */ +export function useProductForm({ initialProduct, onValidationError }: UseProductFormOptions = {}) { + const [product, setProduct] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + ...initialProduct + }); + + // initialProduct가 변경되면 폼 초기화 + useEffect(() => { + setProduct({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + ...initialProduct + }); + }, [initialProduct]); + + /** + * 숫자 입력 처리 (숫자만 허용) + */ + const handleNumberChange = useCallback((field: 'price' | 'stock', value: string) => { + if (value === '' || /^\d+$/.test(value)) { + setProduct(prev => ({ + ...prev, + [field]: value === '' ? 0 : parseInt(value) + })); + } + }, []); + + /** + * 숫자 입력 검증 (blur 시) + */ + const handleNumberBlur = useCallback((field: 'price' | 'stock', value: string) => { + const numValue = parseInt(value) || 0; + + if (field === 'price') { + const validation = validatePrice(numValue); + if (!validation.isValid) { + onValidationError?.(validation.error!); + setProduct(prev => ({ ...prev, price: 0 })); + } + } else if (field === 'stock') { + const validation = validateStock(numValue); + if (!validation.isValid) { + onValidationError?.(validation.error!); + // 최대값 초과 시 9999로 설정, 음수는 0으로 설정 + setProduct(prev => ({ + ...prev, + stock: numValue > 9999 ? 9999 : 0 + })); + } + } + }, [onValidationError]); + + /** + * 텍스트 필드 변경 + */ + const handleFieldChange = useCallback((field: keyof ProductFormData, value: string) => { + setProduct(prev => ({ ...prev, [field]: value })); + }, []); + + /** + * 할인 추가 + */ + const addDiscount = useCallback(() => { + setProduct(prev => ({ + ...prev, + discounts: [...prev.discounts, { quantity: 10, rate: 0.1 }] + })); + }, []); + + /** + * 할인 제거 + */ + const removeDiscount = useCallback((index: number) => { + setProduct(prev => ({ + ...prev, + discounts: prev.discounts.filter((_, i) => i !== index) + })); + }, []); + + /** + * 할인 업데이트 + */ + const updateDiscount = useCallback((index: number, field: keyof Discount, value: number) => { + setProduct(prev => ({ + ...prev, + discounts: prev.discounts.map((d, i) => + i === index ? { ...d, [field]: value } : d + ) + })); + }, []); + + /** + * 폼 리셋 + */ + const resetForm = useCallback(() => { + setProduct({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + ...initialProduct + }); + }, [initialProduct]); + + return { + product, + handleNumberChange, + handleNumberBlur, + handleFieldChange, + addDiscount, + removeDiscount, + updateDiscount, + resetForm + }; +} + diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts new file mode 100644 index 000000000..d01b58742 --- /dev/null +++ b/src/advanced/hooks/useProducts.ts @@ -0,0 +1,100 @@ +import { useCallback, useEffect } from "react"; +import { Product, Discount } from "../../types"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +interface UseProductsOptions { + initialProducts?: T[]; +} + +interface UseProductsResult { + success: boolean; + error?: string; + message?: string; +} + +export function useProducts(options?: UseProductsOptions) { + const { initialProducts = [] as T[] } = options || {}; + + const [products, setProducts] = useLocalStorage('products', initialProducts); + + // 초기 데이터가 변경될 때 localStorage에 반영 + useEffect(() => { + if (initialProducts.length > 0 && products.length === 0) { + setProducts(initialProducts); + } + }, [initialProducts, products.length, setProducts]); + + // 상품 추가 + const addProduct = useCallback((newProduct: Omit): UseProductsResult => { + const product: T = { + ...newProduct, + id: `p${Date.now()}` + } as T; + setProducts(prev => [...prev, product]); + return { success: true, message: '상품이 추가되었습니다.' }; + }, [setProducts]); + + // 상품 수정 + const updateProduct = useCallback((productId: string, updates: Partial): UseProductsResult => { + setProducts(prev => + prev.map(product => + product.id === productId + ? { ...product, ...updates } + : product + ) + ); + return { success: true, message: '상품이 수정되었습니다.' }; + }, [setProducts]); + + // 상품 삭제 + const deleteProduct = useCallback((productId: string): UseProductsResult => { + setProducts(prev => prev.filter(p => p.id !== productId)); + return { success: true, message: '상품이 삭제되었습니다.' }; + }, [setProducts]); + + // 재고 수정 + const updateProductStock = useCallback((productId: string, stock: number): UseProductsResult => { + setProducts(prev => + prev.map(product => + product.id === productId + ? { ...product, stock } + : product + ) + ); + return { success: true, message: '재고가 수정되었습니다.' }; + }, [setProducts]); + + // 할인 규칙 추가 + const addProductDiscount = useCallback((productId: string, discount: Discount): UseProductsResult => { + setProducts(prev => + prev.map(product => + product.id === productId + ? { ...product, discounts: [...product.discounts, discount] } + : product + ) + ); + return { success: true, message: '할인 규칙이 추가되었습니다.' }; + }, [setProducts]); + + // 할인 규칙 삭제 + const removeProductDiscount = useCallback((productId: string, discountIndex: number): UseProductsResult => { + setProducts(prev => + prev.map(product => + product.id === productId + ? { ...product, discounts: product.discounts.filter((_, index) => index !== discountIndex) } + : product + ) + ); + return { success: true, message: '할인 규칙이 삭제되었습니다.' }; + }, [setProducts]); + + return { + products, + addProduct, + updateProduct, + deleteProduct, + updateProductStock, + addProductDiscount, + removeProductDiscount, + }; +} diff --git a/src/advanced/hooks/useSearch.ts b/src/advanced/hooks/useSearch.ts new file mode 100644 index 000000000..81f852ee4 --- /dev/null +++ b/src/advanced/hooks/useSearch.ts @@ -0,0 +1,44 @@ +import { useMemo } from 'react'; +import { Product } from '../../types'; + +interface UseSearchOptions { + items: T[]; + searchTerm: string; + searchFields?: (keyof T)[]; +} + +/** + * 검색/필터링 로직을 처리하는 Hook + * @param items 검색할 아이템 목록 + * @param searchTerm 검색어 + * @param searchFields 검색할 필드 목록 (기본값: ['name', 'description']) + * @returns 필터링된 아이템 목록 + */ +export function useSearch({ + items, + searchTerm, + searchFields = ['name', 'description'] as (keyof T)[] +}: UseSearchOptions) { + const filteredItems = useMemo(() => { + if (!searchTerm || searchTerm.trim() === '') { + return items; + } + + const lowerSearchTerm = searchTerm.toLowerCase(); + + return items.filter(item => { + return searchFields.some(field => { + const value = item[field]; + if (typeof value === 'string') { + return value.toLowerCase().includes(lowerSearchTerm); + } + return false; + }); + }); + }, [items, searchTerm, searchFields]); + + return { + filteredItems, + }; +} + diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx new file mode 100644 index 000000000..6232c43dc --- /dev/null +++ b/src/advanced/pages/AdminPage.tsx @@ -0,0 +1,214 @@ +import { PlusIcon, TrashIcon } from "../components/icons"; +import { Product, Coupon } from "../../types"; +import { formatAdminPrice, formatPercentage } from "../utils/formatters"; +import { Button } from "../components/ui/Button"; +import { ProductForm } from "../components/entities/ProductForm"; +import { CouponForm } from "../components/entities/CouponForm"; +import { useAdminPage } from "../hooks/useAdminPage"; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface AdminPageProps { + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; + products: ProductWithUI[]; + coupons: Coupon[]; + addProduct: (newProduct: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; + addCoupon: (newCoupon: Coupon) => void; + deleteCoupon: (couponCode: string) => void; +} + +const AdminPage = ({ + addNotification, + products, + coupons, + addProduct, + updateProduct, + deleteProduct, + addCoupon, + deleteCoupon +}: AdminPageProps) => { + // 관리자 페이지 상태 관리 로직 분리 + const { + activeTab, + showProductForm, + showCouponForm, + editingProduct, + setActiveTab, + startEditProduct, + startAddProduct, + handleProductSubmit, + handleProductCancel, + handleCouponSubmit, + handleCouponCancel, + toggleCouponForm + } = useAdminPage({ + onAddProduct: addProduct, + onUpdateProduct: updateProduct, + onAddCoupon: addCoupon + }); + + return ( +
+
+

관리자 대시보드

+

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

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

상품 목록

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

쿠폰 관리

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

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${formatPercentage(coupon.discountValue / 100)} 할인`} + +
+
+ +
+
+ ))} + +
+ +
+
+ + {showCouponForm && ( + + )} +
+
+ )} +
+ ) +} + +export default AdminPage; diff --git a/src/advanced/pages/CartPage.tsx b/src/advanced/pages/CartPage.tsx new file mode 100644 index 000000000..445b4d93b --- /dev/null +++ b/src/advanced/pages/CartPage.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; + +import { CartItem as CartItemType, Product, Coupon } from "../../types"; +import { ProductCard } from "../components/entities/ProductCard"; +import { Cart } from "../components/entities/Cart"; +import { + getMaxApplicableDiscount, + calculateCartTotal +} from "../utils/cartCalculations"; +import { useSearch } from "../hooks/useSearch"; +import { useCouponValidation } from "../hooks/useCouponValidation"; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface CartPageProps { + products: ProductWithUI[]; + cart: CartItemType[]; + debouncedSearchTerm: string; + getRemainingStock: (product: ProductWithUI) => number; + addToCart: (product: ProductWithUI) => void; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; + completeOrder: () => void; + coupons: Coupon[]; + addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; +} + +const CartPage = ({ + products, + cart, + debouncedSearchTerm, + getRemainingStock, + addToCart, + removeFromCart, + updateQuantity, + completeOrder, + coupons, + addNotification +}: CartPageProps) => { + const [selectedCoupon, setSelectedCoupon] = useState(null); + + // 검색/필터링 로직 분리 + const { filteredItems: filteredProducts } = useSearch({ + items: products, + searchTerm: debouncedSearchTerm, + searchFields: ['name', 'description'] + }); + + // 쿠폰 검증 로직 분리 + useCouponValidation({ + selectedCoupon, + coupons, + cart, + onCouponInvalid: () => setSelectedCoupon(null), + onMinimumAmountWarning: (message) => addNotification(message, 'warning') + }); + + // 장바구니 총액 계산 + const totals = calculateCartTotal(cart, selectedCoupon); + + const handleCouponChange = (couponCode: string) => { + const coupon = coupons.find(c => c.code === couponCode) || null; + setSelectedCoupon(coupon); + }; + + const handleCompleteOrder = () => { + completeOrder(); + setSelectedCoupon(null); + }; + + return ( +
+
+ {/* 상품 목록 */} +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map(product => ( + + ))} +
+ )} +
+
+ +
+ getMaxApplicableDiscount(item, cart)} + /> +
+
+ ); +} + +export default CartPage; diff --git a/src/advanced/utils/cartCalculations.ts b/src/advanced/utils/cartCalculations.ts new file mode 100644 index 000000000..531544420 --- /dev/null +++ b/src/advanced/utils/cartCalculations.ts @@ -0,0 +1,92 @@ +import { CartItem, Coupon } from "../../types"; + +/** + * 장바구니 아이템에 적용 가능한 최대 할인율을 계산 + * @param item 장바구니 아이템 + * @param cart 전체 장바구니 (대량 구매 할인 확인용) + * @returns 적용 가능한 최대 할인율 (0.0 ~ 1.0) + */ +export const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + // 대량 구매 여부 확인 (10개 이상 구매 시 추가 5% 할인) + const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + + // 상품별 할인 규칙에서 최대 할인율 찾기 + const baseDiscount = item.product.discounts.reduce((maxDiscount, discount) => { + return item.quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + // 대량 구매 시 추가 5% 할인 (최대 50%) + return hasBulkPurchase ? Math.min(baseDiscount + 0.05, 0.5) : baseDiscount; +}; + +/** + * 장바구니 아이템의 총 금액을 계산 (할인 적용 후) + * @param item 장바구니 아이템 + * @param cart 전체 장바구니 + * @returns 할인 적용 후 금액 + */ +export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => { + const discount = getMaxApplicableDiscount(item, cart); + return Math.round(item.product.price * item.quantity * (1 - discount)); +}; + +/** + * 장바구니 전체 금액을 계산 + * @param cart 장바구니 아이템 목록 + * @param selectedCoupon 선택된 쿠폰 + * @returns { subtotal: 소계, discountAmount: 쿠폰 할인 금액, total: 최종 금액 } + */ +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): { subtotal: number; discountAmount: number; total: number } => { + // 1. 소계 계산 (상품 할인 적용 후) + const subtotal = cart.reduce((sum, item) => sum + calculateItemTotal(item, cart), 0); + + // 2. 쿠폰 할인 계산 + let discountAmount = 0; + if (selectedCoupon) { + if (selectedCoupon.discountType === 'percentage') { + // 비율 할인 (10,000원 이상일 때만 적용) + if (subtotal >= 10000) { + discountAmount = Math.round(subtotal * (selectedCoupon.discountValue / 100)); + } + } else { + // 정액 할인 + discountAmount = selectedCoupon.discountValue; + } + } + + // 3. 최종 금액 (음수 방지) + const total = Math.max(0, subtotal - discountAmount); + + return { subtotal, discountAmount, total }; +}; + +/** + * 장바구니 아이템 수량 업데이트 + * @param cart 현재 장바구니 + * @param productId 상품 ID + * @param newQuantity 새로운 수량 + * @returns 업데이트된 장바구니 + */ +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + newQuantity: number +): CartItem[] => { + // 수량이 0 이하면 제거 + if (newQuantity <= 0) { + return cart.filter(item => item.product.id !== productId); + } + + // 수량 업데이트 + return cart.map(item => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ); +}; + diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts new file mode 100644 index 000000000..c7274405d --- /dev/null +++ b/src/advanced/utils/formatters.ts @@ -0,0 +1,47 @@ +/** + * 관리자용 가격 포맷팅 (숫자 + 원) + * @param price 가격 + * @returns 포맷된 가격 문자열 (예: "10,000원") + */ +export const formatAdminPrice = (price: number): string => { + return `${price.toLocaleString()}원`; +}; + +/** + * 고객용 가격 포맷팅 (₩ 기호 포함) + * @param price 가격 + * @returns 포맷된 가격 문자열 (예: "₩10,000") + */ +export const formatCustomerPrice = (price: number): string => { + return `₩${price.toLocaleString()}`; +}; + +/** + * 날짜를 YYYY-MM-DD 형식으로 포맷 + * @param date 날짜 객체 + * @returns 포맷된 날짜 문자열 (예: "2025-12-03") + */ +export const formatDate = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +/** + * 소수를 퍼센트로 변환 + * @param rate 소수 형태의 비율 (0 ~ 1) + * @returns 퍼센트 문자열 (예: "10%") + */ +export const formatPercentage = (rate: number): string => { + return `${(rate * 100).toFixed(0)}%`; +}; + +/** + * 할인 금액 포맷팅 (마이너스 기호 포함) + * @param amount 할인 금액 + * @returns 포맷된 할인 금액 문자열 (예: "-5,000원") + */ +export const formatDiscountAmount = (amount: number): string => { + return `-${amount.toLocaleString()}원`; +}; diff --git a/src/advanced/utils/hooks/useDebounce.ts b/src/advanced/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..a21ef452a --- /dev/null +++ b/src/advanced/utils/hooks/useDebounce.ts @@ -0,0 +1,28 @@ +import { useState, useEffect } from "react"; + +/** + * 디바운스 Hook - 값이 변경되어도 지정된 시간 동안 대기 후 반환 + * @param value 디바운싱할 값 + * @param delay 지연 시간 (ms) + * @returns 디바운싱된 값 + * + * 사용 예시: 검색어 입력 디바운싱 + * const debouncedSearchTerm = useDebounce(searchTerm, 500); + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + // delay 시간 후에 debouncedValue 업데이트 + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // cleanup: 값이 다시 변경되면 이전 타이머 취소 + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} \ No newline at end of file diff --git a/src/advanced/utils/hooks/useLocalStorage.ts b/src/advanced/utils/hooks/useLocalStorage.ts new file mode 100644 index 000000000..a4bd7ed64 --- /dev/null +++ b/src/advanced/utils/hooks/useLocalStorage.ts @@ -0,0 +1,69 @@ +import { useState, useEffect, useCallback } from "react"; + +/** + * localStorage와 React state를 동기화하는 Hook + * @param key localStorage 키 + * @param initialValue 초기값 + * @returns [저장된 값, 값 설정 함수] + */ +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + // localStorage에서 초기값 로드 + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + if (item) { + return JSON.parse(item); + } + return initialValue; + } catch (error) { + console.error(`Error loading localStorage key "${key}":`, error); + return initialValue; + } + }); + + // 값 설정 함수 + // setValue가 storedValue를 dependency로 가지면, 값이 변경될 때마다 새로운 함수가 생성됨 + // addToCart 등의 함수가 오래된 setValue를 참조하여 상태 업데이트 실패 + // 함수형 업데이트를 사용하면 항상 최신 상태를 보장 + // 해결 효과: + // ✅ 장바구니 추가 기능 정상 작동 + // ✅ 수량 변경 기능 정상 작동 + // ✅ 상태 업데이트의 일관성 보장 + // ✅ 불필요한 리렌더링 방지 + // 모든 lint 에러가 없습니다. 테스트를 다시 실행해주세요! + const setValue = useCallback((value: T | ((val: T) => T)) => { + try { + // 함수형 업데이트 지원 + if (value instanceof Function) { + setStoredValue((prevValue) => value(prevValue)); + } else { + setStoredValue(value); + } + } catch (error) { + console.error(`Error setting localStorage key "${key}":`, error); + } + }, [key]); + + // localStorage 동기화 + useEffect(() => { + try { + // 빈 배열이나 undefined/null은 삭제 + if ( + storedValue === undefined || + storedValue === null || + (Array.isArray(storedValue) && storedValue.length === 0) + ) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(storedValue)); + } + } catch (error) { + console.error(`Error syncing localStorage key "${key}":`, error); + } + }, [key, storedValue]); + + return [storedValue, setValue]; +} \ No newline at end of file diff --git a/src/advanced/utils/validators.ts b/src/advanced/utils/validators.ts new file mode 100644 index 000000000..8b2f0fe79 --- /dev/null +++ b/src/advanced/utils/validators.ts @@ -0,0 +1,104 @@ +/** + * 상품 검증 관련 유틸리티 + */ + +export interface ValidationResult { + isValid: boolean; + error?: string; +} + +/** + * 가격 검증 + */ +export const validatePrice = (price: number): ValidationResult => { + if (price < 0) { + return { isValid: false, error: '가격은 0보다 커야 합니다' }; + } + return { isValid: true }; +}; + +/** + * 재고 검증 + */ +export const validateStock = (stock: number): ValidationResult => { + if (stock < 0) { + return { isValid: false, error: '재고는 0보다 커야 합니다' }; + } + if (stock > 9999) { + return { isValid: false, error: '재고는 9999개를 초과할 수 없습니다' }; + } + return { isValid: true }; +}; + +/** + * 상품명 검증 + */ +export const validateProductName = (name: string): ValidationResult => { + if (!name || name.trim().length === 0) { + return { isValid: false, error: '상품명을 입력해주세요' }; + } + if (name.length > 100) { + return { isValid: false, error: '상품명은 100자를 초과할 수 없습니다' }; + } + return { isValid: true }; +}; + +/** + * 할인율 검증 + */ +export const validateDiscountRate = (rate: number): ValidationResult => { + if (rate < 0 || rate > 1) { + return { isValid: false, error: '할인율은 0%에서 100% 사이여야 합니다' }; + } + return { isValid: true }; +}; + +/** + * 할인 수량 검증 + */ +export const validateDiscountQuantity = (quantity: number): ValidationResult => { + if (quantity < 1) { + return { isValid: false, error: '할인 수량은 1개 이상이어야 합니다' }; + } + return { isValid: true }; +}; + +/** + * 쿠폰 코드 검증 + */ +export const validateCouponCode = (code: string): ValidationResult => { + if (!code || code.trim().length === 0) { + return { isValid: false, error: '쿠폰 코드를 입력해주세요' }; + } + if (!/^[A-Z0-9_-]+$/.test(code)) { + return { isValid: false, error: '쿠폰 코드는 영문 대문자, 숫자, -, _만 사용 가능합니다' }; + } + return { isValid: true }; +}; + +/** + * 쿠폰 이름 검증 + */ +export const validateCouponName = (name: string): ValidationResult => { + if (!name || name.trim().length === 0) { + return { isValid: false, error: '쿠폰 이름을 입력해주세요' }; + } + return { isValid: true }; +}; + +/** + * 쿠폰 할인값 검증 + */ +export const validateCouponDiscountValue = ( + value: number, + type: 'amount' | 'percentage' +): ValidationResult => { + if (value <= 0) { + return { isValid: false, error: '할인값은 0보다 커야 합니다' }; + } + if (type === 'percentage' && value > 100) { + return { isValid: false, error: '할인율은 100%를 초과할 수 없습니다' }; + } + return { isValid: true }; +}; + From 934c350bcfa5153b64e74cf5ae082205bcfb28e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 22:12:30 +0900 Subject: [PATCH 16/38] =?UTF-8?q?refactor:=20CartPage=20=EB=B9=84=EC=A6=88?= =?UTF-8?q?=EB=8B=88=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20hook=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/hooks/useCartPage.ts | 42 ++++++++++++++++++++++++++++++++++ src/basic/pages/CartPage.tsx | 38 ++++++++++++------------------ 2 files changed, 57 insertions(+), 23 deletions(-) create mode 100644 src/basic/hooks/useCartPage.ts diff --git a/src/basic/hooks/useCartPage.ts b/src/basic/hooks/useCartPage.ts new file mode 100644 index 000000000..ca6e61389 --- /dev/null +++ b/src/basic/hooks/useCartPage.ts @@ -0,0 +1,42 @@ +import { useState, useCallback, useMemo } from 'react'; +import { Coupon, CartItem } from '../../types'; +import { calculateCartTotal } from '../utils/cartCalculations'; + +interface UseCartPageOptions { + coupons: Coupon[]; + cart: CartItem[]; + onCompleteOrder: () => void; +} + +export function useCartPage({ + coupons, + cart, + onCompleteOrder +}: UseCartPageOptions) { + const [selectedCoupon, setSelectedCoupon] = useState(null); + + // 쿠폰 선택 핸들러 + const handleCouponChange = useCallback((couponCode: string) => { + const coupon = coupons.find(c => c.code === couponCode) || null; + setSelectedCoupon(coupon); + }, [coupons]); + + // 주문 완료 핸들러 + const handleCompleteOrder = useCallback(() => { + onCompleteOrder(); + setSelectedCoupon(null); + }, [onCompleteOrder]); + + // 장바구니 총액 계산 (메모이제이션) + const totals = useMemo(() => { + return calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + return { + selectedCoupon, + totals, + handleCouponChange, + handleCompleteOrder + }; +} + diff --git a/src/basic/pages/CartPage.tsx b/src/basic/pages/CartPage.tsx index 445b4d93b..9e3642dc4 100644 --- a/src/basic/pages/CartPage.tsx +++ b/src/basic/pages/CartPage.tsx @@ -1,14 +1,10 @@ -import { useState } from "react"; - import { CartItem as CartItemType, Product, Coupon } from "../../types"; import { ProductCard } from "../components/entities/ProductCard"; import { Cart } from "../components/entities/Cart"; -import { - getMaxApplicableDiscount, - calculateCartTotal -} from "../utils/cartCalculations"; +import { getMaxApplicableDiscount } from "../utils/cartCalculations"; import { useSearch } from "../hooks/useSearch"; import { useCouponValidation } from "../hooks/useCouponValidation"; +import { useCartPage } from "../hooks/useCartPage"; interface ProductWithUI extends Product { description?: string; @@ -40,36 +36,32 @@ const CartPage = ({ coupons, addNotification }: CartPageProps) => { - const [selectedCoupon, setSelectedCoupon] = useState(null); + const { + selectedCoupon, + totals, + handleCouponChange, + handleCompleteOrder + } = useCartPage({ + coupons, + cart, + onCompleteOrder: completeOrder + }); - // 검색/필터링 로직 분리 + // 검색/필터링 로직 const { filteredItems: filteredProducts } = useSearch({ items: products, searchTerm: debouncedSearchTerm, searchFields: ['name', 'description'] }); - // 쿠폰 검증 로직 분리 + // 쿠폰 검증 로직 (자동 감시) useCouponValidation({ selectedCoupon, coupons, cart, - onCouponInvalid: () => setSelectedCoupon(null), + onCouponInvalid: () => handleCouponChange(''), onMinimumAmountWarning: (message) => addNotification(message, 'warning') }); - - // 장바구니 총액 계산 - const totals = calculateCartTotal(cart, selectedCoupon); - - const handleCouponChange = (couponCode: string) => { - const coupon = coupons.find(c => c.code === couponCode) || null; - setSelectedCoupon(coupon); - }; - - const handleCompleteOrder = () => { - completeOrder(); - setSelectedCoupon(null); - }; return (
From 29f294a19ca6868cecd241987f88771bbc4f7049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 22:21:15 +0900 Subject: [PATCH 17/38] =?UTF-8?q?feat:=20[atom]=20cartAtoms=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/atoms/cartAtoms.ts | 114 ++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/advanced/atoms/cartAtoms.ts diff --git a/src/advanced/atoms/cartAtoms.ts b/src/advanced/atoms/cartAtoms.ts new file mode 100644 index 000000000..0bba966c0 --- /dev/null +++ b/src/advanced/atoms/cartAtoms.ts @@ -0,0 +1,114 @@ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import { CartItem, Product } from '../../types'; +import { formatDate } from '../utils/formatters'; + +// Cart 상태 +export const cartAtom = atomWithStorage('cart', []); + +// 총 아이템 개수 (derived atom) +export const totalItemCountAtom = atom((get) => { + const cart = get(cartAtom); + return cart.reduce((sum, item) => sum + item.quantity, 0); +}); + +// 재고 확인 함수 atom +export const getRemainingStockAtom = atom( + null, + (get, _set, product: Product) => { + const cart = get(cartAtom); + const cartItem = cart.find(item => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); + } +); + +// 장바구니에 상품 추가 +export const addToCartAtom = atom( + null, + (get, set, product: Product) => { + const cart = get(cartAtom); + const cartItem = cart.find(item => item.product.id === product.id); + const remainingStock = product.stock - (cartItem?.quantity || 0); + + if (remainingStock <= 0) { + return { success: false, error: '재고가 부족합니다!' }; + } + + if (cartItem) { + const newQuantity = cartItem.quantity + 1; + + if (newQuantity > product.stock) { + return { success: false, error: `재고는 ${product.stock}개까지만 있습니다.` }; + } + + set(cartAtom, cart.map(item => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + )); + } else { + set(cartAtom, [...cart, { product, quantity: 1 }]); + } + + return { success: true, message: '장바구니에 담았습니다' }; + } +); + +// 장바구니에서 상품 제거 +export const removeFromCartAtom = atom( + null, + (get, set, productId: string) => { + const cart = get(cartAtom); + set(cartAtom, cart.filter(item => item.product.id !== productId)); + } +); + +// 수량 변경 +export const updateQuantityAtom = atom( + null, + (get, set, { productId, newQuantity, products }: { productId: string; newQuantity: number; products: Product[] }) => { + const cart = get(cartAtom); + + if (newQuantity <= 0) { + set(cartAtom, cart.filter(item => item.product.id !== productId)); + return { success: true }; + } + + const product = products.find(p => p.id === productId); + if (!product) { + return { success: false, error: '상품을 찾을 수 없습니다.' }; + } + + const maxStock = product.stock; + if (newQuantity > maxStock) { + return { success: false, error: `재고는 ${maxStock}개까지만 있습니다.` }; + } + + set(cartAtom, cart.map(item => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + )); + + return { success: true }; + } +); + +// 주문 완료 +export const completeOrderAtom = atom( + null, + (_get, set) => { + const now = new Date(); + const dateStr = formatDate(now).replace(/-/g, ''); + const timeStr = now.getHours().toString().padStart(2, '0') + now.getMinutes().toString().padStart(2, '0'); + const orderNumber = `ORD-${dateStr}-${timeStr}`; + + set(cartAtom, []); + + return { + success: true, + message: `주문이 완료되었습니다. 주문번호: ${orderNumber}` + }; + } +); + From b01100d1df89fec35789df1a5bd65f7414565775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 22:21:29 +0900 Subject: [PATCH 18/38] =?UTF-8?q?feat:=20[atom]=20couponAtoms=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/atoms/couponAtoms.ts | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/advanced/atoms/couponAtoms.ts diff --git a/src/advanced/atoms/couponAtoms.ts b/src/advanced/atoms/couponAtoms.ts new file mode 100644 index 000000000..2c6857cc7 --- /dev/null +++ b/src/advanced/atoms/couponAtoms.ts @@ -0,0 +1,33 @@ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import { Coupon } from '../../types'; + +// Coupon 상태 - localStorage에서 읽거나 빈 배열로 시작 +export const couponsAtom = atomWithStorage('coupons', []); + +// 쿠폰 추가 +export const addCouponAtom = atom( + null, + (get, set, newCoupon: Coupon) => { + const coupons = get(couponsAtom); + const existingCoupon = coupons.find(c => c.code === newCoupon.code); + + if (existingCoupon) { + return { success: false, error: '이미 존재하는 쿠폰 코드입니다.' }; + } + + set(couponsAtom, [...coupons, newCoupon]); + return { success: true, message: '쿠폰이 추가되었습니다.' }; + } +); + +// 쿠폰 삭제 +export const deleteCouponAtom = atom( + null, + (get, set, couponCode: string) => { + const coupons = get(couponsAtom); + set(couponsAtom, coupons.filter(c => c.code !== couponCode)); + return { success: true, message: '쿠폰이 삭제되었습니다.' }; + } +); + From 299517568d72c9c246a7b2d702ed753ca36ff29c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 22:21:52 +0900 Subject: [PATCH 19/38] =?UTF-8?q?feat:=20[atom]=20notificationAtoms=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/atoms/notificationAtoms.ts | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/advanced/atoms/notificationAtoms.ts diff --git a/src/advanced/atoms/notificationAtoms.ts b/src/advanced/atoms/notificationAtoms.ts new file mode 100644 index 000000000..b5f661ed8 --- /dev/null +++ b/src/advanced/atoms/notificationAtoms.ts @@ -0,0 +1,39 @@ +import { atom } from 'jotai'; + +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +// Notification 상태 +export const notificationsAtom = atom([]); + +// 알림 추가 +export const addNotificationAtom = atom( + null, + (get, set, { message, type }: { message: string; type: 'error' | 'success' | 'warning' }) => { + const notifications = get(notificationsAtom); + const newNotification: Notification = { + id: `notif-${Date.now()}`, + message, + type + }; + set(notificationsAtom, [...notifications, newNotification]); + + // 3초 후 자동 제거 + setTimeout(() => { + set(notificationsAtom, (prev) => prev.filter(n => n.id !== newNotification.id)); + }, 3000); + } +); + +// 알림 제거 +export const removeNotificationAtom = atom( + null, + (get, set, id: string) => { + const notifications = get(notificationsAtom); + set(notificationsAtom, notifications.filter(n => n.id !== id)); + } +); + From 905f6b1298d50343ac20e5dfceca6bd7912cf334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 22:22:23 +0900 Subject: [PATCH 20/38] =?UTF-8?q?feat:=20[atom]=20uiAtoms=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/atoms/uiAtoms.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/advanced/atoms/uiAtoms.ts diff --git a/src/advanced/atoms/uiAtoms.ts b/src/advanced/atoms/uiAtoms.ts new file mode 100644 index 000000000..a1baee250 --- /dev/null +++ b/src/advanced/atoms/uiAtoms.ts @@ -0,0 +1,8 @@ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; + +// UI 상태 +export const isAdminAtom = atom(false); +export const searchTermAtom = atom(''); +export const debouncedSearchTermAtom = atom(''); + From c3f1725292ae741ef21dc83647c8e43a7107bc37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 22:22:35 +0900 Subject: [PATCH 21/38] =?UTF-8?q?feat:=20[atom]=20productAtoms=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/atoms/productAtoms.ts | 87 ++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/advanced/atoms/productAtoms.ts diff --git a/src/advanced/atoms/productAtoms.ts b/src/advanced/atoms/productAtoms.ts new file mode 100644 index 000000000..ede4f49fc --- /dev/null +++ b/src/advanced/atoms/productAtoms.ts @@ -0,0 +1,87 @@ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import { Product, Discount } from '../../types'; + +// Product 상태 - localStorage에서 읽거나 빈 배열로 시작 +export const productsAtom = atomWithStorage('products', []); + +// 상품 추가 +export const addProductAtom = atom( + null, + (get, set, newProduct: Omit) => { + const products = get(productsAtom); + const product: Product = { + ...newProduct, + id: `p${Date.now()}` + }; + set(productsAtom, [...products, product]); + return { success: true, message: '상품이 추가되었습니다.' }; + } +); + +// 상품 수정 +export const updateProductAtom = atom( + null, + (get, set, { productId, updates }: { productId: string; updates: Partial }) => { + const products = get(productsAtom); + set(productsAtom, products.map(product => + product.id === productId + ? { ...product, ...updates } + : product + )); + return { success: true, message: '상품이 수정되었습니다.' }; + } +); + +// 상품 삭제 +export const deleteProductAtom = atom( + null, + (get, set, productId: string) => { + const products = get(productsAtom); + set(productsAtom, products.filter(p => p.id !== productId)); + return { success: true, message: '상품이 삭제되었습니다.' }; + } +); + +// 재고 수정 +export const updateProductStockAtom = atom( + null, + (get, set, { productId, stock }: { productId: string; stock: number }) => { + const products = get(productsAtom); + set(productsAtom, products.map(product => + product.id === productId + ? { ...product, stock } + : product + )); + return { success: true, message: '재고가 수정되었습니다.' }; + } +); + +// 할인 규칙 추가 +export const addProductDiscountAtom = atom( + null, + (get, set, { productId, discount }: { productId: string; discount: Discount }) => { + const products = get(productsAtom); + set(productsAtom, products.map(product => + product.id === productId + ? { ...product, discounts: [...product.discounts, discount] } + : product + )); + return { success: true, message: '할인 규칙이 추가되었습니다.' }; + } +); + +// 할인 규칙 삭제 +export const removeProductDiscountAtom = atom( + null, + (get, set, { productId, discountIndex }: { productId: string; discountIndex: number }) => { + const products = get(productsAtom); + set(productsAtom, products.map(product => + product.id === productId + ? { ...product, discounts: product.discounts.filter((_, index) => index !== discountIndex) } + : product + )); + return { success: true, message: '할인 규칙이 삭제되었습니다.' }; + } +); + From 176cb94c358c6720c4a82d55439ce8bdddd029da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 22:22:49 +0900 Subject: [PATCH 22/38] =?UTF-8?q?feat:=20[atom]=20index=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/atoms/index.ts | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/advanced/atoms/index.ts diff --git a/src/advanced/atoms/index.ts b/src/advanced/atoms/index.ts new file mode 100644 index 000000000..4b368218e --- /dev/null +++ b/src/advanced/atoms/index.ts @@ -0,0 +1,45 @@ +// Cart atoms +export { + cartAtom, + totalItemCountAtom, + getRemainingStockAtom, + addToCartAtom, + removeFromCartAtom, + updateQuantityAtom, + completeOrderAtom +} from './cartAtoms'; + +// Product atoms +export { + productsAtom, + addProductAtom, + updateProductAtom, + deleteProductAtom, + updateProductStockAtom, + addProductDiscountAtom, + removeProductDiscountAtom +} from './productAtoms'; + +// Coupon atoms +export { + couponsAtom, + addCouponAtom, + deleteCouponAtom +} from './couponAtoms'; + +// UI atoms +export { + isAdminAtom, + searchTermAtom, + debouncedSearchTermAtom +} from './uiAtoms'; + +// Notification atoms +export { + notificationsAtom, + addNotificationAtom, + removeNotificationAtom +} from './notificationAtoms'; + +export type { Notification } from './notificationAtoms'; + From 0e6eda7112e82b1138c7821ba6c7535f61c94f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 22:23:37 +0900 Subject: [PATCH 23/38] =?UTF-8?q?fix:=20Header=20props=20->=20atom=20?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=83=81=ED=83=9C=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/components/layout/Header.tsx | 105 +++++++++++----------- 1 file changed, 51 insertions(+), 54 deletions(-) diff --git a/src/advanced/components/layout/Header.tsx b/src/advanced/components/layout/Header.tsx index fa16986d9..3aa56cf18 100644 --- a/src/advanced/components/layout/Header.tsx +++ b/src/advanced/components/layout/Header.tsx @@ -1,60 +1,57 @@ -import { CartItem } from "../../../types"; +import { useAtom } from 'jotai'; import { CartIcon } from "../icons"; +import { isAdminAtom, searchTermAtom, cartAtom, totalItemCountAtom } from '../../atoms'; -interface HeaderProps { - isAdmin: boolean; - searchTerm: string; - setSearchTerm: (value: string) => void; - setIsAdmin: (value: boolean) => void; - cart: CartItem[]; - totalItemCount: number; -} +const Header = () => { + const [isAdmin, setIsAdmin] = useAtom(isAdminAtom); + const [searchTerm, setSearchTerm] = useAtom(searchTermAtom); + const [cart] = useAtom(cartAtom); + const [totalItemCount] = useAtom(totalItemCountAtom); -const Header = ({ isAdmin, searchTerm, setSearchTerm, setIsAdmin, cart, totalItemCount }: HeaderProps) => { - return ( -
-
-
-
-

SHOP

- {!isAdmin && ( -
- setSearchTerm(e.target.value)} - placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" - /> -
- )} -
- + return ( +
+
+
+
+

SHOP

+ {!isAdmin && ( +
+ setSearchTerm(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ )}
+
-
- ) -} +
+
+ ); +}; -export default Header; \ No newline at end of file +export default Header; From c8ad1c37c6ec0e70d04c11a4f2c53f1280bfc1e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 22:23:57 +0900 Subject: [PATCH 24/38] =?UTF-8?q?fix:=20AdminPage=20props=20->=20atom=20?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=83=81=ED=83=9C=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/pages/AdminPage.tsx | 201 ++++++++++++++++++------------- 1 file changed, 117 insertions(+), 84 deletions(-) diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx index 6232c43dc..659e60d09 100644 --- a/src/advanced/pages/AdminPage.tsx +++ b/src/advanced/pages/AdminPage.tsx @@ -1,3 +1,4 @@ +import { useAtom, useSetAtom } from 'jotai'; import { PlusIcon, TrashIcon } from "../components/icons"; import { Product, Coupon } from "../../types"; import { formatAdminPrice, formatPercentage } from "../utils/formatters"; @@ -6,85 +7,117 @@ import { ProductForm } from "../components/entities/ProductForm"; import { CouponForm } from "../components/entities/CouponForm"; import { useAdminPage } from "../hooks/useAdminPage"; +import { + productsAtom, + couponsAtom, + addProductAtom, + updateProductAtom, + deleteProductAtom, + addCouponAtom, + deleteCouponAtom, + addNotificationAtom +} from '../atoms'; + interface ProductWithUI extends Product { description?: string; isRecommended?: boolean; } -interface AdminPageProps { - addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; - products: ProductWithUI[]; - coupons: Coupon[]; - addProduct: (newProduct: Omit) => void; - updateProduct: (productId: string, updates: Partial) => void; - deleteProduct: (productId: string) => void; - addCoupon: (newCoupon: Coupon) => void; - deleteCoupon: (couponCode: string) => void; -} +const AdminPage = () => { + const [products] = useAtom(productsAtom); + const [coupons] = useAtom(couponsAtom); + + const addProduct = useSetAtom(addProductAtom); + const updateProduct = useSetAtom(updateProductAtom); + const deleteProduct = useSetAtom(deleteProductAtom); + const addCoupon = useSetAtom(addCouponAtom); + const deleteCoupon = useSetAtom(deleteCouponAtom); + const addNotification = useSetAtom(addNotificationAtom); + + const handleAddProduct = (newProduct: Omit) => { + const result = addProduct(newProduct); + if (result.message) { + addNotification({ message: result.message, type: 'success' }); + } + }; + + const handleUpdateProduct = (productId: string, updates: Partial) => { + const result = updateProduct({ productId, updates }); + if (result.message) { + addNotification({ message: result.message, type: 'success' }); + } + }; + + const handleDeleteProduct = (productId: string) => { + deleteProduct(productId); + }; -const AdminPage = ({ - addNotification, - products, - coupons, - addProduct, - updateProduct, - deleteProduct, - addCoupon, - deleteCoupon -}: AdminPageProps) => { - // 관리자 페이지 상태 관리 로직 분리 - const { - activeTab, - showProductForm, - showCouponForm, - editingProduct, - setActiveTab, - startEditProduct, - startAddProduct, - handleProductSubmit, - handleProductCancel, - handleCouponSubmit, - handleCouponCancel, - toggleCouponForm - } = useAdminPage({ - onAddProduct: addProduct, - onUpdateProduct: updateProduct, - onAddCoupon: addCoupon - }); + const handleAddCoupon = (newCoupon: Coupon) => { + const result = addCoupon(newCoupon); + if (result.error) { + addNotification({ message: result.error, type: 'error' }); + } else if (result.message) { + addNotification({ message: result.message, type: 'success' }); + } + }; - return ( -
-
-

관리자 대시보드

-

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

-
-
- -
+ const handleDeleteCoupon = (couponCode: string) => { + deleteCoupon(couponCode); + }; - {activeTab === 'products' ? ( -
+ // 관리자 페이지 상태 관리 로직 분리 + const { + activeTab, + showProductForm, + showCouponForm, + editingProduct, + setActiveTab, + startEditProduct, + startAddProduct, + handleProductSubmit, + handleProductCancel, + handleCouponSubmit, + handleCouponCancel, + toggleCouponForm + } = useAdminPage({ + onAddProduct: handleAddProduct, + onUpdateProduct: handleUpdateProduct, + onAddCoupon: handleAddCoupon + }); + + return ( +
+
+

관리자 대시보드

+

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

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

상품 목록

@@ -124,16 +157,16 @@ const AdminPage = ({ {product.stock}개 - {product.description || '-'} + {(product as ProductWithUI).description || '-'}
- ) : ( -
+
+ ) : ( +

쿠폰 관리

@@ -176,7 +209,7 @@ const AdminPage = ({
- - )} - - ) + + )} + + ); } export default AdminPage; From 44e9618dee12644c571f4a0ad5d0c1560bd80d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 22:24:15 +0900 Subject: [PATCH 25/38] =?UTF-8?q?fix:=20CartPage=20props=20->=20atom=20?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=83=81=ED=83=9C=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/pages/CartPage.tsx | 223 ++++++++++++++++++-------------- 1 file changed, 123 insertions(+), 100 deletions(-) diff --git a/src/advanced/pages/CartPage.tsx b/src/advanced/pages/CartPage.tsx index 445b4d93b..207fa3858 100644 --- a/src/advanced/pages/CartPage.tsx +++ b/src/advanced/pages/CartPage.tsx @@ -1,121 +1,144 @@ -import { useState } from "react"; +import { useAtom, useSetAtom } from 'jotai'; -import { CartItem as CartItemType, Product, Coupon } from "../../types"; +import { Product } from "../../types"; import { ProductCard } from "../components/entities/ProductCard"; import { Cart } from "../components/entities/Cart"; -import { - getMaxApplicableDiscount, - calculateCartTotal -} from "../utils/cartCalculations"; +import { getMaxApplicableDiscount } from "../utils/cartCalculations"; import { useSearch } from "../hooks/useSearch"; import { useCouponValidation } from "../hooks/useCouponValidation"; +import { useCartPage } from "../hooks/useCartPage"; + +import { + cartAtom, + productsAtom, + couponsAtom, + debouncedSearchTermAtom, + addToCartAtom, + removeFromCartAtom, + updateQuantityAtom, + completeOrderAtom, + getRemainingStockAtom, + addNotificationAtom +} from '../atoms'; interface ProductWithUI extends Product { description?: string; isRecommended?: boolean; } -interface CartPageProps { - products: ProductWithUI[]; - cart: CartItemType[]; - debouncedSearchTerm: string; - getRemainingStock: (product: ProductWithUI) => number; - addToCart: (product: ProductWithUI) => void; - removeFromCart: (productId: string) => void; - updateQuantity: (productId: string, quantity: number) => void; - completeOrder: () => void; - coupons: Coupon[]; - addNotification: (message: string, type: 'error' | 'success' | 'warning') => void; -} - -const CartPage = ({ - products, - cart, - debouncedSearchTerm, - getRemainingStock, - addToCart, - removeFromCart, - updateQuantity, - completeOrder, - coupons, - addNotification -}: CartPageProps) => { - const [selectedCoupon, setSelectedCoupon] = useState(null); - - // 검색/필터링 로직 분리 - const { filteredItems: filteredProducts } = useSearch({ - items: products, - searchTerm: debouncedSearchTerm, - searchFields: ['name', 'description'] - }); +const CartPage = () => { + const [cart] = useAtom(cartAtom); + const [products] = useAtom(productsAtom); + const [coupons] = useAtom(couponsAtom); + const [debouncedSearchTerm] = useAtom(debouncedSearchTermAtom); + + const addToCart = useSetAtom(addToCartAtom); + const removeFromCart = useSetAtom(removeFromCartAtom); + const updateQuantity = useSetAtom(updateQuantityAtom); + const completeOrderAction = useSetAtom(completeOrderAtom); + const getRemainingStock = useSetAtom(getRemainingStockAtom); + const addNotification = useSetAtom(addNotificationAtom); + + // 페이지 비즈니스 로직 (쿠폰 관련) + const { + selectedCoupon, + totals, + handleCouponChange, + handleCompleteOrder: handleCompleteOrderFromHook + } = useCartPage({ + coupons, + cart, + onCompleteOrder: () => { + const result = completeOrderAction(); + if (result.message) { + addNotification({ message: result.message, type: 'success' }); + } + return result; + } + }); + + // 검색/필터링 로직 + const { filteredItems: filteredProducts } = useSearch({ + items: products as ProductWithUI[], + searchTerm: debouncedSearchTerm, + searchFields: ['name', 'description'] + }); - // 쿠폰 검증 로직 분리 - useCouponValidation({ - selectedCoupon, - coupons, - cart, - onCouponInvalid: () => setSelectedCoupon(null), - onMinimumAmountWarning: (message) => addNotification(message, 'warning') - }); + // 쿠폰 검증 로직 (자동 감시) + useCouponValidation({ + selectedCoupon, + coupons, + cart, + onCouponInvalid: () => handleCouponChange(''), + onMinimumAmountWarning: (message) => addNotification({ message, type: 'warning' }) + }); - // 장바구니 총액 계산 - const totals = calculateCartTotal(cart, selectedCoupon); + const handleAddToCart = (product: ProductWithUI) => { + const result = addToCart(product); + if (result.error) { + addNotification({ message: result.error, type: 'error' }); + } else if (result.message) { + addNotification({ message: result.message, type: 'success' }); + } + }; - const handleCouponChange = (couponCode: string) => { - const coupon = coupons.find(c => c.code === couponCode) || null; - setSelectedCoupon(coupon); - }; + const handleUpdateQuantity = (productId: string, quantity: number) => { + const result = updateQuantity({ productId, newQuantity: quantity, products }); + if (result.error) { + addNotification({ message: result.error, type: 'error' }); + } + }; - const handleCompleteOrder = () => { - completeOrder(); - setSelectedCoupon(null); - }; - - return ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

-
- 총 {products.length}개 상품 -
+ // Wrapper for hook's complete order + const handleCompleteOrder = () => { + handleCompleteOrderFromHook(); + }; + + return ( +
+
+ {/* 상품 목록 */} +
+
+

전체 상품

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

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

-
- ) : ( -
- {filteredProducts.map(product => ( - - ))} -
- )} -
-
+
+ {filteredProducts.length === 0 ? ( +
+

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

+
+ ) : ( +
+ {filteredProducts.map(product => ( + + ))} +
+ )} +
+
-
- getMaxApplicableDiscount(item, cart)} - /> -
+
+ getMaxApplicableDiscount(item, cart)} + />
- ); +
+ ); } export default CartPage; From 31412ff8f58efc470eedc9d1a89028becd1e2241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 22:25:33 +0900 Subject: [PATCH 26/38] =?UTF-8?q?refactor:=20CartPage=20=EB=B9=84=EC=A6=88?= =?UTF-8?q?=EB=8B=88=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20hook=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/hooks/useCartPage.ts | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/advanced/hooks/useCartPage.ts diff --git a/src/advanced/hooks/useCartPage.ts b/src/advanced/hooks/useCartPage.ts new file mode 100644 index 000000000..3c1e6e931 --- /dev/null +++ b/src/advanced/hooks/useCartPage.ts @@ -0,0 +1,45 @@ +import { useState, useCallback, useMemo } from 'react'; +import { Coupon, CartItem } from '../../types'; +import { calculateCartTotal } from '../utils/cartCalculations'; + +interface UseCartPageOptions { + coupons: Coupon[]; + cart: CartItem[]; + onCompleteOrder: () => { success: boolean; message?: string }; +} + +export function useCartPage({ + coupons, + cart, + onCompleteOrder +}: UseCartPageOptions) { + const [selectedCoupon, setSelectedCoupon] = useState(null); + + // 쿠폰 선택 핸들러 + const handleCouponChange = useCallback((couponCode: string) => { + const coupon = coupons.find(c => c.code === couponCode) || null; + setSelectedCoupon(coupon); + }, [coupons]); + + // 주문 완료 핸들러 + const handleCompleteOrder = useCallback(() => { + const result = onCompleteOrder(); + if (result.success) { + setSelectedCoupon(null); + } + return result; + }, [onCompleteOrder]); + + // 장바구니 총액 계산 (메모이제이션) + const totals = useMemo(() => { + return calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + return { + selectedCoupon, + totals, + handleCouponChange, + handleCompleteOrder + }; +} + From dbf6a04f94c35619fee41c06f338ec98e7ae45eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 22:25:54 +0900 Subject: [PATCH 27/38] =?UTF-8?q?refactor:=20App=20props=20drilling=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 | 1158 ++---------------------------------------- 1 file changed, 54 insertions(+), 1104 deletions(-) diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1d..7cb7b1128 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1124 +1,74 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import { useEffect } from 'react'; +import { useAtom, useSetAtom } from 'jotai'; + +import CartPage from './pages/CartPage'; +import AdminPage from './pages/AdminPage'; + +import Notifications from './components/notifications'; +import Header from './components/layout/Header'; +import { useDebounce } from './utils/hooks/useDebounce'; +import { initialProducts, initialCoupons } from './constants'; + +import { + isAdminAtom, + searchTermAtom, + debouncedSearchTermAtom, + notificationsAtom, + removeNotificationAtom, + productsAtom, + couponsAtom +} from './atoms'; const App = () => { + const [isAdmin, setIsAdmin] = useAtom(isAdminAtom); + const [searchTerm, setSearchTerm] = useAtom(searchTermAtom); + const [notifications, setNotifications] = useAtom(notificationsAtom); + const setProducts = useSetAtom(productsAtom); + const setCoupons = useSetAtom(couponsAtom); + const setDebouncedSearchTerm = useSetAtom(debouncedSearchTermAtom); + const removeNotification = useSetAtom(removeNotificationAtom); - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; + const debouncedSearchTerm = useDebounce(searchTerm, 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); + // 초기화 - localStorage 기반으로 체크하고 UI 상태 초기화 + useEffect(() => { + const storedProducts = localStorage.getItem('products'); + const storedCoupons = localStorage.getItem('coupons'); - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + // localStorage가 비어있으면 초기값 설정 + if (!storedProducts) { + setProducts(initialProducts); } - - 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)); - } + if (!storedCoupons) { + setCoupons(initialCoupons); } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); + // UI 상태 초기화 (테스트용) + setIsAdmin(false); + setSearchTerm(''); + setNotifications([]); }, []); - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - + // Debounced search term을 atom에 동기화 useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + setDebouncedSearchTerm(debouncedSearchTerm); + }, [debouncedSearchTerm, setDebouncedSearchTerm]); 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" - /> -
- )} -
- -
-
-
- + {notifications.length > 0 && + removeNotification(id)} + /> + } +
+
- {isAdmin ? ( -
-
-

관리자 대시보드

-

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

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

상품 목록

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

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

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

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

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

새 쿠폰 생성

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

전체 상품

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

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

-
-
- - )} -
-
-
- )} + {isAdmin ? : }
); }; -export default App; \ No newline at end of file +export default App; From 259567aaa937a3513f6c7496ab5d6d3ee49c2c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 23:14:12 +0900 Subject: [PATCH 28/38] =?UTF-8?q?fix:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20import=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/atoms/uiAtoms.ts | 1 - src/basic/App.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/advanced/atoms/uiAtoms.ts b/src/advanced/atoms/uiAtoms.ts index a1baee250..44c724006 100644 --- a/src/advanced/atoms/uiAtoms.ts +++ b/src/advanced/atoms/uiAtoms.ts @@ -1,5 +1,4 @@ import { atom } from 'jotai'; -import { atomWithStorage } from 'jotai/utils'; // UI 상태 export const isAdminAtom = atom(false); diff --git a/src/basic/App.tsx b/src/basic/App.tsx index e3d93c21f..c6c777cc3 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; import { Product } from '../types'; import CartPage from './pages/CartPage'; From b477adaa8b1b09562c0126f67e9ee9ee03f2c2d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 23:21:13 +0900 Subject: [PATCH 29/38] =?UTF-8?q?chore:=20gh-pages=20=EB=B0=8F=20deploy=20?= =?UTF-8?q?script=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 + pnpm-lock.yaml | 258 ++++++++++++++++++++++++++++++++++++++++++--- public/.nojekyll | 0 tsconfig.app.json | 3 +- tsconfig.node.json | 3 +- vite.config.ts | 21 +++- 6 files changed, 268 insertions(+), 20 deletions(-) create mode 100644 public/.nojekyll diff --git a/package.json b/package.json index 03811e474..fe83ed37e 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", "build": "tsc -b && vite build", + "deploy": "pnpm run build && gh-pages -d dist", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "gh-pages": "^6.3.0", "jotai": "^2.15.2", "react": "^19.1.1", "react-dom": "^19.1.1" @@ -24,6 +26,7 @@ "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", "@typescript-eslint/eslint-plugin": "^8.38.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f6655757..26e1088db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + gh-pages: + specifier: ^6.3.0 + version: 6.3.0 jotai: specifier: ^2.15.2 version: 2.15.2(@types/react@19.1.9)(react@19.1.1) @@ -27,6 +30,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 '@types/react': specifier: ^19.1.9 version: 19.1.9 @@ -41,7 +47,7 @@ importers: version: 8.38.0(eslint@9.32.0)(typescript@5.9.2) '@vitejs/plugin-react-swc': specifier: ^3.11.0 - version: 3.11.0(vite@7.0.6) + version: 3.11.0(vite@7.0.6(@types/node@24.10.1)) '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) @@ -62,10 +68,10 @@ importers: version: 5.9.2 vite: specifier: ^7.0.6 - version: 7.0.6 + version: 7.0.6(@types/node@24.10.1) vitest: specifier: ^3.2.4 - version: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) + version: 3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0) packages: @@ -586,6 +592,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/react-dom@19.1.7': resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} peerDependencies: @@ -735,10 +744,17 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -789,6 +805,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -842,12 +865,19 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + email-addresses@5.0.0: + resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -959,10 +989,26 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filename-reserved-regex@2.0.0: + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} + + filenamify@4.3.0: + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -977,11 +1023,20 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + engines: {node: '>=14.14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gh-pages@6.3.0: + resolution: {integrity: sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==} + engines: {node: '>=10'} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -994,6 +1049,13 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1105,6 +1167,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1112,6 +1177,10 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1138,6 +1207,10 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1179,14 +1252,26 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1202,6 +1287,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1224,6 +1313,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1291,6 +1384,10 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.6.3: resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} @@ -1311,6 +1408,10 @@ packages: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1332,6 +1433,10 @@ packages: strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + strip-outer@1.0.1: + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -1388,6 +1493,10 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -1403,6 +1512,13 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1907,6 +2023,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + '@types/react-dom@19.1.7(@types/react@19.1.9)': dependencies: '@types/react': 19.1.9 @@ -2008,11 +2128,11 @@ snapshots: '@typescript-eslint/types': 8.38.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6)': + '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6(@types/node@24.10.1))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 '@swc/core': 1.13.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) transitivePeerDependencies: - '@swc/helpers' @@ -2024,13 +2144,13 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.0.6)': + '@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.10.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -2061,7 +2181,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) + vitest: 3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0) '@vitest/utils@3.2.4': dependencies: @@ -2104,8 +2224,12 @@ snapshots: aria-query@5.3.2: {} + array-union@2.1.0: {} + assertion-error@2.0.1: {} + async@3.2.6: {} + balanced-match@1.0.2: {} brace-expansion@1.1.11: @@ -2158,6 +2282,10 @@ snapshots: color-name@1.1.4: {} + commander@13.1.0: {} + + commondir@1.0.1: {} + concat-map@0.0.1: {} cross-spawn@7.0.6: @@ -2196,10 +2324,16 @@ snapshots: dequal@2.0.3: {} + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} + email-addresses@5.0.0: {} + entities@4.5.0: {} es-module-lexer@1.7.0: {} @@ -2350,10 +2484,29 @@ snapshots: dependencies: flat-cache: 4.0.1 + filename-reserved-regex@2.0.0: {} + + filenamify@4.3.0: + dependencies: + filename-reserved-regex: 2.0.0 + strip-outer: 1.0.1 + trim-repeated: 1.0.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -2368,9 +2521,25 @@ snapshots: flatted@3.3.3: {} + fs-extra@11.3.2: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fsevents@2.3.3: optional: true + gh-pages@6.3.0: + dependencies: + async: 3.2.6 + commander: 13.1.0 + email-addresses: 5.0.0 + filenamify: 4.3.0 + find-cache-dir: 3.3.2 + fs-extra: 11.3.2 + globby: 11.1.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2381,6 +2550,17 @@ snapshots: globals@14.0.0: {} + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + graceful-fs@4.2.11: {} + graphemer@1.4.0: {} has-flag@3.0.0: {} @@ -2480,6 +2660,12 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -2489,6 +2675,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -2509,6 +2699,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + merge2@1.4.1: {} micromatch@4.0.8: @@ -2545,14 +2739,24 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -2565,6 +2769,8 @@ snapshots: path-key@3.1.1: {} + path-type@4.0.0: {} + pathe@2.0.3: {} pathval@2.0.0: {} @@ -2577,6 +2783,10 @@ snapshots: picomatch@4.0.3: {} + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2655,6 +2865,8 @@ snapshots: scheduler@0.26.0: {} + semver@6.3.1: {} + semver@7.6.3: {} shebang-command@2.0.0: @@ -2671,6 +2883,8 @@ snapshots: mrmime: 2.0.0 totalist: 3.0.1 + slash@3.0.0: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} @@ -2687,6 +2901,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -2732,6 +2950,10 @@ snapshots: dependencies: punycode: 2.3.1 + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + ts-api-utils@2.1.0(typescript@5.9.2): dependencies: typescript: 5.9.2 @@ -2742,17 +2964,21 @@ snapshots: typescript@5.9.2: {} + undici-types@7.16.0: {} + + universalify@2.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite-node@3.2.4: + vite-node@3.2.4(@types/node@24.10.1): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) transitivePeerDependencies: - '@types/node' - jiti @@ -2767,7 +2993,7 @@ snapshots: - tsx - yaml - vite@7.0.6: + vite@7.0.6(@types/node@24.10.1): dependencies: esbuild: 0.25.8 fdir: 6.4.6(picomatch@4.0.3) @@ -2776,13 +3002,14 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.14 optionalDependencies: + '@types/node': 24.10.1 fsevents: 2.3.3 - vitest@3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0): + vitest@3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.0.6) + '@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.10.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -2800,10 +3027,11 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.6 - vite-node: 3.2.4 + vite: 7.0.6(@types/node@24.10.1) + vite-node: 3.2.4(@types/node@24.10.1) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 24.10.1 '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 26.1.0 transitivePeerDependencies: diff --git a/public/.nojekyll b/public/.nojekyll new file mode 100644 index 000000000..e69de29bb diff --git a/tsconfig.app.json b/tsconfig.app.json index d739292ae..287d73d51 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,5 +23,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/basic", "src/origin", "src/refactoring(hint)"] } diff --git a/tsconfig.node.json b/tsconfig.node.json index 3afdd6e38..bccc62268 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -7,7 +7,8 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "strict": true, - "noEmit": true + "noEmit": true, + "types": ["node"] }, "include": ["vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index e6c4016bc..8654f83e4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,31 @@ import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; +import { resolve } from "path"; + +const base: string = process.env.NODE_ENV === "production" ? "/front_7th_chapter3-2/" : ""; export default mergeConfig( defineConfig({ + base, plugins: [react()], + root: '.', + build: { + rollupOptions: { + input: { + main: resolve(__dirname, "index.basic.html"), + origin: resolve(__dirname, "index.origin.html"), + basic: resolve(__dirname, "index.basic.html"), + advanced: resolve(__dirname, "index.advanced.html"), + }, + }, + }, }), defineTestConfig({ test: { globals: true, - environment: 'jsdom', - setupFiles: './src/setupTests.ts' + environment: "jsdom", + setupFiles: "./src/setupTests.ts", }, }) -) +); From 4d1ea10337df055a4435fb8b2648f876c1db9e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 23:40:28 +0900 Subject: [PATCH 30/38] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=20=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 --- tsconfig.node.json | 1 - vite.config.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/tsconfig.node.json b/tsconfig.node.json index bccc62268..6b34fc5b6 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -8,7 +8,6 @@ "allowSyntheticDefaultImports": true, "strict": true, "noEmit": true, - "types": ["node"] }, "include": ["vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 8654f83e4..f76efee53 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,11 +9,9 @@ export default mergeConfig( defineConfig({ base, plugins: [react()], - root: '.', build: { rollupOptions: { input: { - main: resolve(__dirname, "index.basic.html"), origin: resolve(__dirname, "index.origin.html"), basic: resolve(__dirname, "index.basic.html"), advanced: resolve(__dirname, "index.advanced.html"), From 73651449cc38d62101c3f4ebe2a0eb5acd9108f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 4 Dec 2025 23:43:12 +0900 Subject: [PATCH 31/38] =?UTF-8?q?refactor:=20import=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/origin/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/origin/App.tsx b/src/origin/App.tsx index a4369fe1d..a83932857 100644 --- a/src/origin/App.tsx +++ b/src/origin/App.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { CartItem, Coupon, Product } from '../types'; interface ProductWithUI extends Product { From ab05de0c9033a5282a37e703c5d9617039e055f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Fri, 5 Dec 2025 09:48:13 +0900 Subject: [PATCH 32/38] =?UTF-8?q?feat:=20[utils]=20idGenerator=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/atoms/cartAtoms.ts | 10 ++-------- src/advanced/atoms/productAtoms.ts | 8 ++------ src/advanced/pages/AdminPage.tsx | 8 +++++++- src/advanced/pages/CartPage.tsx | 7 ++++++- src/advanced/utils/idGenerator.ts | 31 ++++++++++++++++++++++++++++++ 5 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 src/advanced/utils/idGenerator.ts diff --git a/src/advanced/atoms/cartAtoms.ts b/src/advanced/atoms/cartAtoms.ts index 0bba966c0..d7028e009 100644 --- a/src/advanced/atoms/cartAtoms.ts +++ b/src/advanced/atoms/cartAtoms.ts @@ -1,7 +1,6 @@ import { atom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; import { CartItem, Product } from '../../types'; -import { formatDate } from '../utils/formatters'; // Cart 상태 export const cartAtom = atomWithStorage('cart', []); @@ -94,15 +93,10 @@ export const updateQuantityAtom = atom( } ); -// 주문 완료 +// 주문 완료 (순수 함수 - 주문번호는 외부에서 주입) export const completeOrderAtom = atom( null, - (_get, set) => { - const now = new Date(); - const dateStr = formatDate(now).replace(/-/g, ''); - const timeStr = now.getHours().toString().padStart(2, '0') + now.getMinutes().toString().padStart(2, '0'); - const orderNumber = `ORD-${dateStr}-${timeStr}`; - + (_get, set, orderNumber: string) => { set(cartAtom, []); return { diff --git a/src/advanced/atoms/productAtoms.ts b/src/advanced/atoms/productAtoms.ts index ede4f49fc..0b2d7c2ba 100644 --- a/src/advanced/atoms/productAtoms.ts +++ b/src/advanced/atoms/productAtoms.ts @@ -5,15 +5,11 @@ import { Product, Discount } from '../../types'; // Product 상태 - localStorage에서 읽거나 빈 배열로 시작 export const productsAtom = atomWithStorage('products', []); -// 상품 추가 +// 상품 추가 (순수 함수 - ID는 외부에서 주입) export const addProductAtom = atom( null, - (get, set, newProduct: Omit) => { + (get, set, product: Product) => { const products = get(productsAtom); - const product: Product = { - ...newProduct, - id: `p${Date.now()}` - }; set(productsAtom, [...products, product]); return { success: true, message: '상품이 추가되었습니다.' }; } diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx index 659e60d09..9a698c945 100644 --- a/src/advanced/pages/AdminPage.tsx +++ b/src/advanced/pages/AdminPage.tsx @@ -2,6 +2,7 @@ import { useAtom, useSetAtom } from 'jotai'; import { PlusIcon, TrashIcon } from "../components/icons"; import { Product, Coupon } from "../../types"; import { formatAdminPrice, formatPercentage } from "../utils/formatters"; +import { generateProductId } from "../utils/idGenerator"; import { Button } from "../components/ui/Button"; import { ProductForm } from "../components/entities/ProductForm"; import { CouponForm } from "../components/entities/CouponForm"; @@ -35,7 +36,12 @@ const AdminPage = () => { const addNotification = useSetAtom(addNotificationAtom); const handleAddProduct = (newProduct: Omit) => { - const result = addProduct(newProduct); + // 액션: ID 생성 (시간 의존) + const id = generateProductId(); + const product: ProductWithUI = { ...newProduct, id }; + + // 순수: 상태 업데이트 + const result = addProduct(product); if (result.message) { addNotification({ message: result.message, type: 'success' }); } diff --git a/src/advanced/pages/CartPage.tsx b/src/advanced/pages/CartPage.tsx index 207fa3858..3037562bc 100644 --- a/src/advanced/pages/CartPage.tsx +++ b/src/advanced/pages/CartPage.tsx @@ -4,6 +4,7 @@ import { Product } from "../../types"; import { ProductCard } from "../components/entities/ProductCard"; import { Cart } from "../components/entities/Cart"; import { getMaxApplicableDiscount } from "../utils/cartCalculations"; +import { generateOrderNumber } from "../utils/idGenerator"; import { useSearch } from "../hooks/useSearch"; import { useCouponValidation } from "../hooks/useCouponValidation"; import { useCartPage } from "../hooks/useCartPage"; @@ -49,7 +50,11 @@ const CartPage = () => { coupons, cart, onCompleteOrder: () => { - const result = completeOrderAction(); + // 액션: 주문번호 생성 (시간 의존) + const orderNumber = generateOrderNumber(); + + // 순수: 상태 업데이트 + const result = completeOrderAction(orderNumber); if (result.message) { addNotification({ message: result.message, type: 'success' }); } diff --git a/src/advanced/utils/idGenerator.ts b/src/advanced/utils/idGenerator.ts new file mode 100644 index 000000000..61f65e25b --- /dev/null +++ b/src/advanced/utils/idGenerator.ts @@ -0,0 +1,31 @@ + +import { formatDate } from './formatters'; + +/** + * 상품 ID 생성 (액션) + * @returns 고유한 상품 ID (예: "p1733396042123") + */ +export const generateProductId = (): string => { + return `p${Date.now()}`; +}; + +/** + * 주문번호 생성 (액션) + * @returns 고유한 주문번호 (예: "ORD-20251205-1430") + */ +export const generateOrderNumber = (): string => { + const now = new Date(); + const dateStr = formatDate(now).replace(/-/g, ''); + const timeStr = now.getHours().toString().padStart(2, '0') + + now.getMinutes().toString().padStart(2, '0'); + return `ORD-${dateStr}-${timeStr}`; +}; + +/** + * 쿠폰 ID 생성 (액션) + * @returns 고유한 쿠폰 ID (예: "c1733396042123") + */ +export const generateCouponId = (): string => { + return `c${Date.now()}`; +}; + From 790e485acdbe6243e2644f3e28432bd91bda8083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Fri, 5 Dec 2025 09:49:17 +0900 Subject: [PATCH 33/38] =?UTF-8?q?feat:=20[utils]=20idGenerator=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20basic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/App.tsx | 14 ++++++++++++-- src/basic/hooks/useCart.ts | 10 ++-------- src/basic/hooks/useNotifications.ts | 12 +++++++----- src/basic/hooks/useProducts.ts | 8 ++------ src/basic/utils/idGenerator.ts | 30 +++++++++++++++++++++++++++++ 5 files changed, 53 insertions(+), 21 deletions(-) create mode 100644 src/basic/utils/idGenerator.ts diff --git a/src/basic/App.tsx b/src/basic/App.tsx index c6c777cc3..9d775dd7f 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -11,6 +11,7 @@ import { useCoupons } from './hooks/useCoupons'; import { useProducts } from './hooks/useProducts'; import { useNotifications } from './hooks/useNotifications'; import { useDebounce } from './utils/hooks/useDebounce'; +import { generateProductId, generateOrderNumber } from './utils/idGenerator'; import { initialProducts, initialCoupons } from './constants'; interface ProductWithUI extends Product { @@ -66,14 +67,23 @@ const App = () => { }; const completeOrder = () => { - const result = completeOrderAction(); + // 액션: 주문번호 생성 (시간 의존) + const orderNumber = generateOrderNumber(); + + // 순수: 상태 업데이트 + const result = completeOrderAction(orderNumber); if (result.message) { addNotification(result.message, 'success'); } }; const addProduct = (newProduct: Omit) => { - const result = addProductAction(newProduct); + // 액션: ID 생성 (시간 의존) + const id = generateProductId(); + const product: ProductWithUI = { ...newProduct, id }; + + // 순수: 상태 업데이트 + const result = addProductAction(product); if (result.message) { addNotification(result.message, 'success'); } diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts index c657eeffa..a218b543b 100644 --- a/src/basic/hooks/useCart.ts +++ b/src/basic/hooks/useCart.ts @@ -1,7 +1,6 @@ import { useCallback, useMemo } from "react"; import { CartItem, Product } from "../../types"; import { useLocalStorage } from "../utils/hooks/useLocalStorage"; -import { formatDate } from "../utils/formatters"; interface UseCartResult { success: boolean; @@ -93,13 +92,8 @@ export function useCart() { return { success: true }; }, [setCart]); - // 주문 완료 (장바구니 비우기) - const completeOrder = useCallback((): UseCartResult => { - const now = new Date(); - const dateStr = formatDate(now).replace(/-/g, ''); - const timeStr = now.getHours().toString().padStart(2, '0') + now.getMinutes().toString().padStart(2, '0'); - const orderNumber = `ORD-${dateStr}-${timeStr}`; - + // 주문 완료 (순수 함수 - 주문번호는 외부에서 주입) + const completeOrder = useCallback((orderNumber: string): UseCartResult => { setCart([]); return { diff --git a/src/basic/hooks/useNotifications.ts b/src/basic/hooks/useNotifications.ts index 113e95bc2..f9237a5c5 100644 --- a/src/basic/hooks/useNotifications.ts +++ b/src/basic/hooks/useNotifications.ts @@ -21,20 +21,22 @@ export function useNotifications(options?: UseNotificationsOptions) { const [notifications, setNotifications] = useState([]); /** - * 알림 추가 + * 알림 추가 (순수 함수 - ID는 외부에서 주입) * @param message 알림 메시지 * @param type 알림 타입 (error | success | warning) + * @param id 알림 ID (선택적 - 테스트용) */ const addNotification = useCallback(( message: string, - type: 'error' | 'success' | 'warning' = 'success' + type: 'error' | 'success' | 'warning' = 'success', + id?: string ) => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); + const notificationId = id || Date.now().toString(); + setNotifications(prev => [...prev, { id: notificationId, message, type }]); // 자동으로 제거 setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); + setNotifications(prev => prev.filter(n => n.id !== notificationId)); }, duration); }, [duration]); diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts index d01b58742..5f19eb327 100644 --- a/src/basic/hooks/useProducts.ts +++ b/src/basic/hooks/useProducts.ts @@ -24,12 +24,8 @@ export function useProducts(options?: UseProductsOp } }, [initialProducts, products.length, setProducts]); - // 상품 추가 - const addProduct = useCallback((newProduct: Omit): UseProductsResult => { - const product: T = { - ...newProduct, - id: `p${Date.now()}` - } as T; + // 상품 추가 (순수 함수 - ID는 외부에서 주입) + const addProduct = useCallback((product: T): UseProductsResult => { setProducts(prev => [...prev, product]); return { success: true, message: '상품이 추가되었습니다.' }; }, [setProducts]); diff --git a/src/basic/utils/idGenerator.ts b/src/basic/utils/idGenerator.ts new file mode 100644 index 000000000..43077671f --- /dev/null +++ b/src/basic/utils/idGenerator.ts @@ -0,0 +1,30 @@ +import { formatDate } from './formatters'; + +/** + * 상품 ID 생성 (액션) + * @returns 고유한 상품 ID (예: "p1733396042123") + */ +export const generateProductId = (): string => { + return `p${Date.now()}`; +}; + +/** + * 주문번호 생성 (액션) + * @returns 고유한 주문번호 (예: "ORD-20251205-1430") + */ +export const generateOrderNumber = (): string => { + const now = new Date(); + const dateStr = formatDate(now).replace(/-/g, ''); + const timeStr = now.getHours().toString().padStart(2, '0') + + now.getMinutes().toString().padStart(2, '0'); + return `ORD-${dateStr}-${timeStr}`; +}; + +/** + * 알림 ID 생성 (액션) + * @returns 고유한 알림 ID (타임스탬프 기반) + */ +export const generateNotificationId = (): string => { + return Date.now().toString(); +}; + From 70c0b7f214609bf3dcc65ca3d130e3950e6a85b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Fri, 5 Dec 2025 11:23:40 +0900 Subject: [PATCH 34/38] =?UTF-8?q?feat:=20[utils]=20Entity=EB=B3=84=20?= =?UTF-8?q?=ED=97=AC=ED=8D=BC=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/entities/ProductCard/index.tsx | 11 +-- src/advanced/pages/AdminPage.tsx | 5 +- src/advanced/utils/couponHelpers.ts | 75 ++++++++++++++++++ src/advanced/utils/productHelpers.ts | 77 +++++++++++++++++++ 4 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 src/advanced/utils/couponHelpers.ts create mode 100644 src/advanced/utils/productHelpers.ts diff --git a/src/advanced/components/entities/ProductCard/index.tsx b/src/advanced/components/entities/ProductCard/index.tsx index 64fa81524..48f73a439 100644 --- a/src/advanced/components/entities/ProductCard/index.tsx +++ b/src/advanced/components/entities/ProductCard/index.tsx @@ -3,6 +3,7 @@ import { Product } from '../../../../types'; import { Button } from '../../ui/Button'; import { Badge } from '../../ui/Badge'; import { formatCustomerPrice, formatPercentage } from '../../../utils/formatters'; +import { getMaxDiscountRate, getFirstDiscount, hasDiscounts } from '../../../utils/productHelpers'; interface ProductWithUI extends Product { description?: string; @@ -20,9 +21,9 @@ export const ProductCard: React.FC = ({ remainingStock, onAddToCart }) => { - const maxDiscountRate = product.discounts.length > 0 - ? Math.max(...product.discounts.map(d => d.rate)) - : 0; + // Entity 헬퍼 함수 사용 + const maxDiscountRate = getMaxDiscountRate(product); + const firstDiscount = getFirstDiscount(product); return (
@@ -59,9 +60,9 @@ export const ProductCard: React.FC = ({

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

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

- {product.discounts[0].quantity}개 이상 구매시 할인 {formatPercentage(product.discounts[0].rate)} + {firstDiscount.quantity}개 이상 구매시 할인 {formatPercentage(firstDiscount.rate)}

)}
diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx index 9a698c945..26011d19b 100644 --- a/src/advanced/pages/AdminPage.tsx +++ b/src/advanced/pages/AdminPage.tsx @@ -3,6 +3,7 @@ import { PlusIcon, TrashIcon } from "../components/icons"; import { Product, Coupon } from "../../types"; import { formatAdminPrice, formatPercentage } from "../utils/formatters"; import { generateProductId } from "../utils/idGenerator"; +import { getCouponDisplayText } from "../utils/couponHelpers"; import { Button } from "../components/ui/Button"; import { ProductForm } from "../components/entities/ProductForm"; import { CouponForm } from "../components/entities/CouponForm"; @@ -208,9 +209,7 @@ const AdminPage = () => {

{coupon.code}

- {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${formatPercentage(coupon.discountValue / 100)} 할인`} + {getCouponDisplayText(coupon)}
diff --git a/src/advanced/utils/couponHelpers.ts b/src/advanced/utils/couponHelpers.ts new file mode 100644 index 000000000..8e1e5d813 --- /dev/null +++ b/src/advanced/utils/couponHelpers.ts @@ -0,0 +1,75 @@ +/** + * Coupon 엔티티 관련 헬퍼 함수들 + */ + +import { Coupon } from '../../types'; + +/** + * 쿠폰이 비율 할인인지 확인 + * @param coupon 쿠폰 정보 + * @returns 비율 할인 여부 + */ +export const isPercentageCoupon = (coupon: Coupon): boolean => { + return coupon.discountType === 'percentage'; +}; + +/** + * 쿠폰이 정액 할인인지 확인 + * @param coupon 쿠폰 정보 + * @returns 정액 할인 여부 + */ +export const isAmountCoupon = (coupon: Coupon): boolean => { + return coupon.discountType === 'amount'; +}; + +/** + * 쿠폰의 할인 금액을 계산 + * @param coupon 쿠폰 정보 + * @param subtotal 소계 금액 + * @returns 할인 금액 + */ +export const calculateCouponDiscount = (coupon: Coupon, subtotal: number): number => { + if (isPercentageCoupon(coupon)) { + // 비율 할인은 10,000원 이상일 때만 적용 + if (subtotal < 10000) { + return 0; + } + return Math.round(subtotal * (coupon.discountValue / 100)); + } + + // 정액 할인 + return coupon.discountValue; +}; + +/** + * 쿠폰 적용 가능 여부 확인 + * @param coupon 쿠폰 정보 + * @param subtotal 소계 금액 + * @returns { isApplicable: 적용 가능 여부, reason?: 불가 사유 } + */ +export const checkCouponApplicability = ( + coupon: Coupon, + subtotal: number +): { isApplicable: boolean; reason?: string } => { + if (isPercentageCoupon(coupon) && subtotal < 10000) { + return { + isApplicable: false, + reason: '10,000원 이상 구매시 쿠폰을 사용할 수 있습니다' + }; + } + + return { isApplicable: true }; +}; + +/** + * 쿠폰 표시용 텍스트 생성 + * @param coupon 쿠폰 정보 + * @returns 할인 정보 텍스트 (예: "5,000원 할인", "10% 할인") + */ +export const getCouponDisplayText = (coupon: Coupon): string => { + if (isAmountCoupon(coupon)) { + return `${coupon.discountValue.toLocaleString()}원 할인`; + } + return `${coupon.discountValue}% 할인`; +}; + diff --git a/src/advanced/utils/productHelpers.ts b/src/advanced/utils/productHelpers.ts new file mode 100644 index 000000000..9c1a9beb4 --- /dev/null +++ b/src/advanced/utils/productHelpers.ts @@ -0,0 +1,77 @@ +/** + * Product 엔티티 관련 헬퍼 함수들 + */ + +import { Product, Discount } from '../../types'; + +/** + * 상품의 최대 할인율을 계산 + * @param product 상품 정보 + * @returns 최대 할인율 (0.0 ~ 1.0) + */ +export const getMaxDiscountRate = (product: Product): number => { + if (product.discounts.length === 0) { + return 0; + } + return Math.max(...product.discounts.map(d => d.rate)); +}; + +/** + * 상품의 첫 번째 할인 정보를 가져옴 + * @param product 상품 정보 + * @returns 첫 번째 할인 정보 또는 null + */ +export const getFirstDiscount = (product: Product): Discount | null => { + return product.discounts.length > 0 ? product.discounts[0] : null; +}; + +/** + * 상품에 할인이 있는지 확인 + * @param product 상품 정보 + * @returns 할인 존재 여부 + */ +export const hasDiscounts = (product: Product): boolean => { + return product.discounts.length > 0; +}; + +/** + * 특정 수량에 적용 가능한 할인율을 계산 + * @param product 상품 정보 + * @param quantity 구매 수량 + * @returns 적용 가능한 할인율 (0.0 ~ 1.0) + */ +export const getApplicableDiscountRate = (product: Product, quantity: number): number => { + if (product.discounts.length === 0) { + return 0; + } + + // 수량 조건을 만족하는 할인 중 최대 할인율 찾기 + const applicableDiscounts = product.discounts.filter(d => quantity >= d.quantity); + + if (applicableDiscounts.length === 0) { + return 0; + } + + return Math.max(...applicableDiscounts.map(d => d.rate)); +}; + +/** + * 상품의 할인 정보를 수량 기준으로 정렬 + * @param product 상품 정보 + * @returns 수량 기준 오름차순으로 정렬된 할인 목록 + */ +export const getSortedDiscounts = (product: Product): Discount[] => { + return [...product.discounts].sort((a, b) => a.quantity - b.quantity); +}; + +/** + * 재고 상태를 판단 + * @param stock 재고 수량 + * @returns 'soldout' | 'low' | 'available' + */ +export const getStockStatus = (stock: number): 'soldout' | 'low' | 'available' => { + if (stock <= 0) return 'soldout'; + if (stock <= 5) return 'low'; + return 'available'; +}; + From 4c8515833cb2a75ff44ac41e172fc9385eec537e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Fri, 5 Dec 2025 11:23:56 +0900 Subject: [PATCH 35/38] =?UTF-8?q?feat:=20[utils]=20Entity=EB=B3=84=20?= =?UTF-8?q?=ED=97=AC=ED=8D=BC=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?-=20basic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/entities/ProductCard/index.tsx | 11 +-- src/basic/pages/AdminPage.tsx | 5 +- src/basic/utils/couponHelpers.ts | 77 +++++++++++++++++++ src/basic/utils/productHelpers.ts | 77 +++++++++++++++++++ 4 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 src/basic/utils/couponHelpers.ts create mode 100644 src/basic/utils/productHelpers.ts diff --git a/src/basic/components/entities/ProductCard/index.tsx b/src/basic/components/entities/ProductCard/index.tsx index 64fa81524..7d4627921 100644 --- a/src/basic/components/entities/ProductCard/index.tsx +++ b/src/basic/components/entities/ProductCard/index.tsx @@ -3,6 +3,7 @@ import { Product } from '../../../../types'; import { Button } from '../../ui/Button'; import { Badge } from '../../ui/Badge'; import { formatCustomerPrice, formatPercentage } from '../../../utils/formatters'; +import { getMaxDiscountRate, getFirstDiscount } from '../../../utils/productHelpers'; interface ProductWithUI extends Product { description?: string; @@ -20,9 +21,9 @@ export const ProductCard: React.FC = ({ remainingStock, onAddToCart }) => { - const maxDiscountRate = product.discounts.length > 0 - ? Math.max(...product.discounts.map(d => d.rate)) - : 0; + // Entity 헬퍼 함수 사용 + const maxDiscountRate = getMaxDiscountRate(product); + const firstDiscount = getFirstDiscount(product); return (
@@ -59,9 +60,9 @@ export const ProductCard: React.FC = ({

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

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

- {product.discounts[0].quantity}개 이상 구매시 할인 {formatPercentage(product.discounts[0].rate)} + {firstDiscount.quantity}개 이상 구매시 할인 {formatPercentage(firstDiscount.rate)}

)}
diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx index 6232c43dc..bfded0747 100644 --- a/src/basic/pages/AdminPage.tsx +++ b/src/basic/pages/AdminPage.tsx @@ -1,6 +1,7 @@ import { PlusIcon, TrashIcon } from "../components/icons"; import { Product, Coupon } from "../../types"; import { formatAdminPrice, formatPercentage } from "../utils/formatters"; +import { getCouponDisplayText } from "../utils/couponHelpers"; import { Button } from "../components/ui/Button"; import { ProductForm } from "../components/entities/ProductForm"; import { CouponForm } from "../components/entities/CouponForm"; @@ -169,9 +170,7 @@ const AdminPage = ({

{coupon.code}

- {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${formatPercentage(coupon.discountValue / 100)} 할인`} + {getCouponDisplayText(coupon)}
diff --git a/src/basic/utils/couponHelpers.ts b/src/basic/utils/couponHelpers.ts new file mode 100644 index 000000000..2c9eafce4 --- /dev/null +++ b/src/basic/utils/couponHelpers.ts @@ -0,0 +1,77 @@ +/** + * Coupon 엔티티 관련 헬퍼 함수들 + * + * Coupon 엔티티에만 의존하는 순수 함수들을 모아둡니다. + */ + +import { Coupon } from '../../types'; + +/** + * 쿠폰이 비율 할인인지 확인 + * @param coupon 쿠폰 정보 + * @returns 비율 할인 여부 + */ +export const isPercentageCoupon = (coupon: Coupon): boolean => { + return coupon.discountType === 'percentage'; +}; + +/** + * 쿠폰이 정액 할인인지 확인 + * @param coupon 쿠폰 정보 + * @returns 정액 할인 여부 + */ +export const isAmountCoupon = (coupon: Coupon): boolean => { + return coupon.discountType === 'amount'; +}; + +/** + * 쿠폰의 할인 금액을 계산 + * @param coupon 쿠폰 정보 + * @param subtotal 소계 금액 + * @returns 할인 금액 + */ +export const calculateCouponDiscount = (coupon: Coupon, subtotal: number): number => { + if (isPercentageCoupon(coupon)) { + // 비율 할인은 10,000원 이상일 때만 적용 + if (subtotal < 10000) { + return 0; + } + return Math.round(subtotal * (coupon.discountValue / 100)); + } + + // 정액 할인 + return coupon.discountValue; +}; + +/** + * 쿠폰 적용 가능 여부 확인 + * @param coupon 쿠폰 정보 + * @param subtotal 소계 금액 + * @returns { isApplicable: 적용 가능 여부, reason?: 불가 사유 } + */ +export const checkCouponApplicability = ( + coupon: Coupon, + subtotal: number +): { isApplicable: boolean; reason?: string } => { + if (isPercentageCoupon(coupon) && subtotal < 10000) { + return { + isApplicable: false, + reason: '10,000원 이상 구매시 쿠폰을 사용할 수 있습니다' + }; + } + + return { isApplicable: true }; +}; + +/** + * 쿠폰 표시용 텍스트 생성 + * @param coupon 쿠폰 정보 + * @returns 할인 정보 텍스트 (예: "5,000원 할인", "10% 할인") + */ +export const getCouponDisplayText = (coupon: Coupon): string => { + if (isAmountCoupon(coupon)) { + return `${coupon.discountValue.toLocaleString()}원 할인`; + } + return `${coupon.discountValue}% 할인`; +}; + diff --git a/src/basic/utils/productHelpers.ts b/src/basic/utils/productHelpers.ts new file mode 100644 index 000000000..9c1a9beb4 --- /dev/null +++ b/src/basic/utils/productHelpers.ts @@ -0,0 +1,77 @@ +/** + * Product 엔티티 관련 헬퍼 함수들 + */ + +import { Product, Discount } from '../../types'; + +/** + * 상품의 최대 할인율을 계산 + * @param product 상품 정보 + * @returns 최대 할인율 (0.0 ~ 1.0) + */ +export const getMaxDiscountRate = (product: Product): number => { + if (product.discounts.length === 0) { + return 0; + } + return Math.max(...product.discounts.map(d => d.rate)); +}; + +/** + * 상품의 첫 번째 할인 정보를 가져옴 + * @param product 상품 정보 + * @returns 첫 번째 할인 정보 또는 null + */ +export const getFirstDiscount = (product: Product): Discount | null => { + return product.discounts.length > 0 ? product.discounts[0] : null; +}; + +/** + * 상품에 할인이 있는지 확인 + * @param product 상품 정보 + * @returns 할인 존재 여부 + */ +export const hasDiscounts = (product: Product): boolean => { + return product.discounts.length > 0; +}; + +/** + * 특정 수량에 적용 가능한 할인율을 계산 + * @param product 상품 정보 + * @param quantity 구매 수량 + * @returns 적용 가능한 할인율 (0.0 ~ 1.0) + */ +export const getApplicableDiscountRate = (product: Product, quantity: number): number => { + if (product.discounts.length === 0) { + return 0; + } + + // 수량 조건을 만족하는 할인 중 최대 할인율 찾기 + const applicableDiscounts = product.discounts.filter(d => quantity >= d.quantity); + + if (applicableDiscounts.length === 0) { + return 0; + } + + return Math.max(...applicableDiscounts.map(d => d.rate)); +}; + +/** + * 상품의 할인 정보를 수량 기준으로 정렬 + * @param product 상품 정보 + * @returns 수량 기준 오름차순으로 정렬된 할인 목록 + */ +export const getSortedDiscounts = (product: Product): Discount[] => { + return [...product.discounts].sort((a, b) => a.quantity - b.quantity); +}; + +/** + * 재고 상태를 판단 + * @param stock 재고 수량 + * @returns 'soldout' | 'low' | 'available' + */ +export const getStockStatus = (stock: number): 'soldout' | 'low' | 'available' => { + if (stock <= 0) return 'soldout'; + if (stock <= 5) return 'low'; + return 'available'; +}; + From c3cb3b508522c2303283f33a53d4b698835f8211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Fri, 5 Dec 2025 11:25:37 +0900 Subject: [PATCH 36/38] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20import=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/pages/AdminPage.tsx | 2 +- src/basic/pages/AdminPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx index 26011d19b..9e7d7b7e5 100644 --- a/src/advanced/pages/AdminPage.tsx +++ b/src/advanced/pages/AdminPage.tsx @@ -1,7 +1,7 @@ import { useAtom, useSetAtom } from 'jotai'; import { PlusIcon, TrashIcon } from "../components/icons"; import { Product, Coupon } from "../../types"; -import { formatAdminPrice, formatPercentage } from "../utils/formatters"; +import { formatAdminPrice } from "../utils/formatters"; import { generateProductId } from "../utils/idGenerator"; import { getCouponDisplayText } from "../utils/couponHelpers"; import { Button } from "../components/ui/Button"; diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx index bfded0747..869ef3178 100644 --- a/src/basic/pages/AdminPage.tsx +++ b/src/basic/pages/AdminPage.tsx @@ -1,6 +1,6 @@ import { PlusIcon, TrashIcon } from "../components/icons"; import { Product, Coupon } from "../../types"; -import { formatAdminPrice, formatPercentage } from "../utils/formatters"; +import { formatAdminPrice } from "../utils/formatters"; import { getCouponDisplayText } from "../utils/couponHelpers"; import { Button } from "../components/ui/Button"; import { ProductForm } from "../components/entities/ProductForm"; From 6b03d2a4cf7e1e9237c859649831a9fa84f09097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Fri, 5 Dec 2025 11:26:24 +0900 Subject: [PATCH 37/38] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20import=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/advanced/components/entities/ProductCard/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/advanced/components/entities/ProductCard/index.tsx b/src/advanced/components/entities/ProductCard/index.tsx index 48f73a439..7d4627921 100644 --- a/src/advanced/components/entities/ProductCard/index.tsx +++ b/src/advanced/components/entities/ProductCard/index.tsx @@ -3,7 +3,7 @@ import { Product } from '../../../../types'; import { Button } from '../../ui/Button'; import { Badge } from '../../ui/Badge'; import { formatCustomerPrice, formatPercentage } from '../../../utils/formatters'; -import { getMaxDiscountRate, getFirstDiscount, hasDiscounts } from '../../../utils/productHelpers'; +import { getMaxDiscountRate, getFirstDiscount } from '../../../utils/productHelpers'; interface ProductWithUI extends Product { description?: string; From 6458ea4d4cf14c33743d7fbd9f7cab62542b101f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Fri, 5 Dec 2025 12:49:16 +0900 Subject: [PATCH 38/38] =?UTF-8?q?feat:=20import=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/basic/pages/AdminPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx index 869ef3178..373a12e62 100644 --- a/src/basic/pages/AdminPage.tsx +++ b/src/basic/pages/AdminPage.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { PlusIcon, TrashIcon } from "../components/icons"; import { Product, Coupon } from "../../types"; import { formatAdminPrice } from "../utils/formatters";