diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..31eb6768d --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,62 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: 'pages' + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + env: + GITHUB_REPOSITORY: ${{ github.repository }} + run: pnpm run build + + - name: Rename HTML file for GitHub Pages + run: mv dist/index.basic.html dist/index.html + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './dist' + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/package.json b/package.json index 17b18de25..d946bc812 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "version": "0.0.0", "type": "module", + "packageManager": "pnpm@9.0.0", "scripts": { "dev:origin": "vite --open ./index.origin.html", "dev:basic": "vite --open ./index.basic.html", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0ba406491..a817d1c60 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,6 @@ +packages: + - '.' + ignoredBuiltDependencies: - esbuild diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..897ff22aa 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,112 +1,47 @@ import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { CartItem, ProductWithUI } from '../types'; +import { useCart } from './hooks/useCart'; +import { useNotification } from './hooks/useNotification'; +import { cartModel } from './models/cart'; +import { initialProducts } from './constants'; +import { useCoupons } from './hooks/useCoupons'; -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: '대용량과 고성능을 자랑하는 상품입니다.' +// localStorage 안전 접근 헬퍼 +const getLocalStorageItem = (key: string, defaultValue: T): T => { + try { + if (typeof window === 'undefined' || typeof localStorage === 'undefined') { + return defaultValue; + } + const saved = localStorage.getItem(key); + if (saved) { + return JSON.parse(saved); + } + } catch (error) { + console.error(`Error loading localStorage key "${key}":`, error); } -]; + return defaultValue; +}; -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 +const setLocalStorageItem = (key: string, value: T): void => { + try { + if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') { + localStorage.setItem(key, JSON.stringify(value)); + } + } catch (error) { + console.error(`Error saving localStorage key "${key}":`, error); } -]; +}; const App = () => { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; + return getLocalStorageItem('products', 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 [activeTab, setActiveTab] = useState<'products' | 'coupons'>( + 'products' + ); const [showProductForm, setShowProductForm] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); @@ -118,20 +53,44 @@ const App = () => { price: 0, stock: 0, description: '', - discounts: [] as Array<{ quantity: number; rate: number }> + discounts: [] as Array<{ quantity: number; rate: number }>, }); const [couponForm, setCouponForm] = useState({ name: '', code: '', discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 + discountValue: 0, }); + const { notifications, addNotification, removeNotification } = + useNotification(); + + const { + cart, + selectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + calculateTotal, + getRemainingStock, + clearCart, + clearCoupon, + } = useCart(addNotification); + + const { coupons, addCoupon, deleteCoupon } = useCoupons( + addNotification, + (couponCode: string) => { + if (selectedCoupon?.code === couponCode) { + clearCoupon(); + } + } + ); const formatPrice = (price: number, productId?: string): string => { if (productId) { - const product = products.find(p => p.id === productId); + const product = products.find((p) => p.id === productId); if (product && getRemainingStock(product) <= 0) { return 'SOLD OUT'; } @@ -140,103 +99,24 @@ const App = () => { if (isAdmin) { return `${price.toLocaleString()}원`; } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; + return `₩${price.toLocaleString()}`; }; - 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); + const count = cart.reduce( + (sum: number, item: CartItem) => sum + item.quantity, + 0 + ); setTotalItemCount(count); }, [cart]); useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); + setLocalStorageItem('products', 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); @@ -244,126 +124,46 @@ const App = () => { return () => clearTimeout(timer); }, [searchTerm]); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - 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( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + 'success' ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); + clearCart(); + }, [addNotification, clearCart]); - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [addNotification] + ); - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => + product.id === productId ? { ...product, ...updates } : product + ) + ); + addNotification('상품이 수정되었습니다.', 'success'); + }, + [addNotification] + ); - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification('상품이 삭제되었습니다.', 'success'); + }, + [addNotification] + ); const handleProductSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -373,10 +173,16 @@ const App = () => { } else { addProduct({ ...productForm, - discounts: productForm.discounts + discounts: productForm.discounts, }); } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setProductForm({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], + }); setEditingProduct(null); setShowProductForm(false); }; @@ -388,7 +194,7 @@ const App = () => { name: '', code: '', discountType: 'amount', - discountValue: 0 + discountValue: 0, }); setShowCouponForm(false); }; @@ -400,17 +206,23 @@ const App = () => { price: product.price, stock: product.stock, description: product.description || '', - discounts: product.discounts || [] + discounts: product.discounts || [], }); setShowProductForm(true); }; - const totals = calculateCartTotal(); + const totals = calculateTotal(); const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) ) : products; @@ -418,22 +230,34 @@ const App = () => {
{notifications.length > 0 && (
- {notifications.map(notif => ( + {notifications.map((notif) => (
{notif.message} -
@@ -462,8 +286,8 @@ const App = () => { {!isAdmin && (
- - + + {cart.length > 0 && ( @@ -490,26 +324,30 @@ const App = () => { {isAdmin ? (
-

관리자 대시보드

-

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

+

+ 관리자 대시보드 +

+

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

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

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

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); + + + {(activeTab === 'products' ? products : products).map( + (product) => ( + + + {product.name} + + + {formatPrice(product.price, product.id)} + + + 10 + ? 'bg-green-100 text-green-800' + : product.stock > 0 + ? 'bg-yellow-100 text-yellow-800' + : 'bg-red-100 text-red-800' + }`} + > + {product.stock}개 + + + + {product.description || '-'} + + + + + + + ) + )} + + +
+ {showProductForm && ( +
+ +

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

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

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

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

쿠폰 관리

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

+ {coupon.name} +

+

+ {coupon.code} +

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

새 쿠폰 생성

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

+ 새 쿠폰 생성 +

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

전체 상품

+

+ 전체 상품 +

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

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

+

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

+ {product.name} +

+ {product.description && ( +

+ {product.description} +

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

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

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

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

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

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

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

+ 재고 {remainingStock}개 +

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

- - + + 장바구니

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

장바구니가 비어있습니다

+

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

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

{item.product.name}

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

{Math.round(itemTotal).toLocaleString()}원 @@ -1053,27 +1170,33 @@ const App = () => { <>

-

쿠폰 할인

+

+ 쿠폰 할인 +

{coupons.length > 0 && ( - @@ -1085,27 +1208,40 @@ const App = () => {
상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 + + {totals.totalBeforeDiscount.toLocaleString()}원 +
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( + {totals.totalBeforeDiscount - + totals.totalAfterDiscount > + 0 && (
할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 + + - + {( + totals.totalBeforeDiscount - + totals.totalAfterDiscount + ).toLocaleString()} + 원 +
)}
결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 + + {totals.totalAfterDiscount.toLocaleString()}원 +
- + - +

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

@@ -1121,4 +1257,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/App_origin.tsx b/src/basic/App_origin.tsx new file mode 100644 index 000000000..a4369fe1d --- /dev/null +++ b/src/basic/App_origin.tsx @@ -0,0 +1,1124 @@ +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 + } +]; + +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'; + } + } + + if (isAdmin) { + return `${price.toLocaleString()}원`; + } + + return `₩${price.toLocaleString()}`; + }; + + const getMaxApplicableDiscount = (item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; + }; + + const calculateItemTotal = (item: CartItem): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item); + + return Math.round(price * quantity * (1 - discount)); + }; + + const calculateCartTotal = (): { + totalBeforeDiscount: number; + totalAfterDiscount: number; + } => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach(item => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === 'amount') { + totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); + } else { + totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount) + }; + }; + + const getRemainingStock = (product: Product): number => { + const cartItem = cart.find(item => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; + }; + + const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { + const id = Date.now().toString(); + setNotifications(prev => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, 3000); + }, []); + + const [totalItemCount, setTotalItemCount] = useState(0); + + + useEffect(() => { + const count = cart.reduce((sum, item) => sum + item.quantity, 0); + setTotalItemCount(count); + }, [cart]); + + useEffect(() => { + localStorage.setItem('products', JSON.stringify(products)); + }, [products]); + + useEffect(() => { + localStorage.setItem('coupons', JSON.stringify(coupons)); + }, [coupons]); + + useEffect(() => { + if (cart.length > 0) { + localStorage.setItem('cart', JSON.stringify(cart)); + } else { + localStorage.removeItem('cart'); + } + }, [cart]); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + return () => clearTimeout(timer); + }, [searchTerm]); + + const addToCart = useCallback((product: ProductWithUI) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + addNotification('재고가 부족합니다!', 'error'); + return; + } + + setCart(prevCart => { + const existingItem = prevCart.find(item => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + return prevCart; + } + + return prevCart.map(item => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + addNotification('장바구니에 담았습니다', 'success'); + }, [cart, addNotification, getRemainingStock]); + + const removeFromCart = useCallback((productId: string) => { + setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + }, []); + + const updateQuantity = useCallback((productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } + + const product = products.find(p => p.id === productId); + if (!product) return; + + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); + return; + } + + setCart(prevCart => + prevCart.map(item => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ) + ); + }, [products, removeFromCart, addNotification, getRemainingStock]); + + const applyCoupon = useCallback((coupon: Coupon) => { + const currentTotal = calculateCartTotal().totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); + return; + } + + setSelectedCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, [addNotification, calculateCartTotal]); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + setCart([]); + setSelectedCoupon(null); + }, [addNotification]); + + const addProduct = useCallback((newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + setProducts(prev => [...prev, product]); + addNotification('상품이 추가되었습니다.', 'success'); + }, [addNotification]); + + const updateProduct = useCallback((productId: string, updates: Partial) => { + setProducts(prev => + prev.map(product => + product.id === productId + ? { ...product, ...updates } + : product + ) + ); + addNotification('상품이 수정되었습니다.', 'success'); + }, [addNotification]); + + const deleteProduct = useCallback((productId: string) => { + setProducts(prev => prev.filter(p => p.id !== productId)); + addNotification('상품이 삭제되었습니다.', 'success'); + }, [addNotification]); + + const addCoupon = useCallback((newCoupon: Coupon) => { + const existingCoupon = coupons.find(c => c.code === newCoupon.code); + if (existingCoupon) { + addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); + return; + } + setCoupons(prev => [...prev, newCoupon]); + addNotification('쿠폰이 추가되었습니다.', 'success'); + }, [coupons, addNotification]); + + const deleteCoupon = useCallback((couponCode: string) => { + setCoupons(prev => prev.filter(c => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }, [selectedCoupon, addNotification]); + + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== 'new') { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProduct({ + ...productForm, + discounts: productForm.discounts + }); + } + setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm({ + name: '', + code: '', + discountType: 'amount', + discountValue: 0 + }); + setShowCouponForm(false); + }; + + const startEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [] + }); + setShowProductForm(true); + }; + + const totals = calculateCartTotal(); + + const filteredProducts = debouncedSearchTerm + ? products.filter(product => + product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ) + : products; + + return ( +
+ {notifications.length > 0 && ( +
+ {notifications.map(notif => ( +
+ {notif.message} + +
+ ))} +
+ )} +
+
+
+
+

SHOP

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

관리자 대시보드

+

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

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

상품 목록

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

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

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

쿠폰 관리

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

{coupon.name}

+

{coupon.code}

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

새 쿠폰 생성

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

전체 상품

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

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

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

{product.name}

+ {product.description && ( +

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

+ + + + 장바구니 +

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

+
+
+ + )} +
+
+
+ )} +
+
+ ); +}; + +export default App; \ No newline at end of file diff --git a/src/basic/components/icons/index.tsx b/src/basic/components/icons/index.tsx new file mode 100644 index 000000000..1609d7749 --- /dev/null +++ b/src/basic/components/icons/index.tsx @@ -0,0 +1,12 @@ +// TODO: SVG 아이콘 컴포넌트들 +// 구현할 아이콘: +// - CartIcon: 장바구니 아이콘 +// - AdminIcon: 관리자 아이콘 +// - PlusIcon: 플러스 아이콘 +// - MinusIcon: 마이너스 아이콘 +// - TrashIcon: 삭제 아이콘 +// - ChevronDownIcon: 아래 화살표 +// - ChevronUpIcon: 위 화살표 +// - CheckIcon: 체크 아이콘 + +// TODO: 구현 \ No newline at end of file diff --git a/src/basic/components/ui/UIToast.ts b/src/basic/components/ui/UIToast.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/constants/index.ts b/src/basic/constants/index.ts new file mode 100644 index 000000000..89e64c545 --- /dev/null +++ b/src/basic/constants/index.ts @@ -0,0 +1,55 @@ +// TODO: 초기 데이터 상수 +// 참고: origin/App.tsx의 초기 데이터 구조를 참조 + +import { ProductWithUI, Coupon } from '../../types'; + +// - initialProducts: 초기 상품 목록 (상품1, 상품2, 상품3 + 설명 필드 포함) +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: '대용량과 고성능을 자랑하는 상품입니다.', + }, +]; + +// - initialCoupons: 초기 쿠폰 목록 (5000원 할인, 10% 할인) +export const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..5da658907 --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,141 @@ +// TODO: 장바구니 관리 Hook + +// 사용할 모델 함수: +// - cartModel.addItemToCart +// - cartModel.removeItemFromCart +// - cartModel.updateCartItemQuantity +// - cartModel.calculateCartTotal +// - cartModel.getRemainingStock + +import { useState, useCallback } from 'react'; +import { CartItem, Coupon, NotificationFunction, Product } from '../../types'; +import { cartModel } from '../models/cart'; +import { useLocalStorage } from '../utils/hooks/useLocalStorage'; + +export function useCart(addNotification: NotificationFunction) { + // 1. 장바구니 상태 관리 (localStorage 연동) + const [cart, setCart] = useLocalStorage('cart', []); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + // 2. 상품 추가/삭제/수량 변경 + const addToCart = useCallback( + (product: Product) => { + const remainingStock = cartModel.getRemainingStock(cart, 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 cartModel.updateCartItemQuantity( + prevCart, + product, + newQuantity + ); + } + + return cartModel.addItemToCart(prevCart, product); + }); + + addNotification('장바구니에 담았습니다', 'success'); + }, + [cart, addNotification] + ); + + const removeFromCart = useCallback( + (productId: string) => { + setCart((prevCart) => cartModel.removeItemFromCart(prevCart, productId)); + }, + [setCart] + ); + + const updateQuantity = useCallback( + (product: Product, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(product.id); + return; + } + + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); + return; + } + + setCart((prevCart) => + cartModel.updateCartItemQuantity(prevCart, product, newQuantity) + ); + }, + [removeFromCart, addNotification] + ); + + // 3. 쿠폰 적용 + const applyCoupon = useCallback( + (coupon: Coupon) => { + const totals = cartModel.calculateCartTotal(cart, selectedCoupon); + const currentTotal = totals.totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + addNotification( + 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + 'error' + ); + return; + } + + setSelectedCoupon(coupon); + addNotification('쿠폰이 적용되었습니다.', 'success'); + }, + [cart, selectedCoupon, addNotification] + ); + + // 4. 총액 계산 + const calculateTotal = useCallback(() => { + return cartModel.calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + // 5. 재고 확인 + const getRemainingStock = useCallback( + (product: Product) => { + return cartModel.getRemainingStock(cart, product); + }, + [cart] + ); + + const clearCart = useCallback(() => { + setCart([]); + setSelectedCoupon(null); + }, [setCart]); + + const clearCoupon = useCallback(() => { + setSelectedCoupon(null); + }, []); + + return { + cart, + selectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + calculateTotal, + getRemainingStock, + clearCart, + clearCoupon, + }; +} diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 000000000..2484b813c --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,51 @@ +// TODO: 쿠폰 관리 Hook + +import { useCallback } from 'react'; +import { Coupon, NotificationFunction } from '../../types'; +import { initialCoupons } from '../constants'; +import { useLocalStorage } from '../utils/hooks/useLocalStorage'; +import { couponModel } from '../models/coupon'; + +export function useCoupons( + addNotification: NotificationFunction, + onCouponDeleted?: (couponCode: string) => void +) { + // 1. 쿠폰 목록 상태 관리 (localStorage 연동 고려) + const [coupons, setCoupons] = useLocalStorage( + 'coupons', + initialCoupons + ); + + // 2. 쿠폰 추가/삭제 + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); + return; + } + setCoupons(couponModel.addCoupon(coupons, newCoupon)); + addNotification('쿠폰이 추가되었습니다.', 'success'); + }, + [coupons, addNotification] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons(couponModel.removeCoupon(couponCode, coupons)); + addNotification('쿠폰이 삭제되었습니다.', 'success'); + onCouponDeleted?.(couponCode); + }, + [coupons, addNotification, onCouponDeleted, setCoupons] + ); + + // 반환할 값: + // - coupons: 쿠폰 배열 + // - addCoupon: 새 쿠폰 추가 + // - removeCoupon: 쿠폰 삭제 + return { + coupons, + addCoupon, + deleteCoupon, + }; +} diff --git a/src/basic/hooks/useNotification.ts b/src/basic/hooks/useNotification.ts new file mode 100644 index 000000000..4084a9d68 --- /dev/null +++ b/src/basic/hooks/useNotification.ts @@ -0,0 +1,33 @@ +import { useState, useCallback } from 'react'; + +interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export function useNotification() { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: 'error' | 'success' | 'warning' = 'success') => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); + + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return { + notifications, + addNotification, + removeNotification, + }; +} diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 000000000..f4bef103f --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,18 @@ +// TODO: 상품 관리 Hook +// 힌트: +// 1. 상품 목록 상태 관리 (localStorage 연동 고려) +// 2. 상품 CRUD 작업 +// 3. 재고 업데이트 +// 4. 할인 규칙 추가/삭제 +// +// 반환할 값: +// - products: 상품 배열 +// - updateProduct: 상품 정보 수정 +// - addProduct: 새 상품 추가 +// - updateProductStock: 재고 수정 +// - addProductDiscount: 할인 규칙 추가 +// - removeProductDiscount: 할인 규칙 삭제 + +export function useProducts() { + // TODO: 구현 +} \ No newline at end of file diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts new file mode 100644 index 000000000..665fa9499 --- /dev/null +++ b/src/basic/models/cart.ts @@ -0,0 +1,95 @@ +// TODO: 장바구니 비즈니스 로직 (순수 함수) +// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) +// 원칙: +// - UI와 관련된 로직 없음 +// - 외부 상태에 의존하지 않음 +// - 모든 필요한 데이터는 파라미터로 전달받음 + +import { CartItem, Coupon, Product } from '../../types'; + +export const cartModel = { + // 1. calculateItemTotal(item): 개별 아이템의 할인 적용 후 총액 계산 + calculateItemTotal: (item: CartItem, cart: CartItem[]): number => { + const { price } = item.product; + const { quantity } = item; + const discount = cartModel.getMaxApplicableDiscount(item, cart); + return Math.round(price * quantity * (1 - discount)); + }, + // 2. getMaxApplicableDiscount(item): 적용 가능한 최대 할인율 계산 + getMaxApplicableDiscount: (item: CartItem, cart: CartItem[]): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); + } + + return baseDiscount; + }, + // 3. calculateCartTotal(cart, coupon): 장바구니 총액 계산 (할인 전/후, 할인액) + calculateCartTotal: ( + cart: CartItem[], + selectedCoupon: Coupon | null + ): { + totalBeforeDiscount: number; + totalAfterDiscount: number; + } => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += cartModel.calculateItemTotal(item, cart); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === 'amount') { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue + ); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; + }, + // 4. updateCartItemQuantity(cart, productId, quantity): 수량 변경 + updateCartItemQuantity: ( + cart: CartItem[], + product: Product, + quantity: number + ): CartItem[] => { + return cart.map((item) => + item.product.id === product.id ? { ...item, quantity } : item + ); + }, + // 5. addItemToCart(cart, product): 상품 추가 + addItemToCart: (cart: CartItem[], product: Product): CartItem[] => { + return [...cart, { product, quantity: 1 }]; + }, + // 6. removeItemFromCart(cart, productId): 상품 제거 + removeItemFromCart: (cart: CartItem[], productId: string): CartItem[] => { + return cart.filter((item) => item.product.id !== productId); + }, + // 7. getRemainingStock(product, cart): 남은 재고 계산 + getRemainingStock: (cart: CartItem[], product: Product): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + return remaining; + }, +}; diff --git a/src/basic/models/coupon.ts b/src/basic/models/coupon.ts new file mode 100644 index 000000000..7b89db86a --- /dev/null +++ b/src/basic/models/coupon.ts @@ -0,0 +1,10 @@ +import { Coupon } from '../../types'; + +export const couponModel = { + addCoupon: (coupons: Coupon[], coupon: Coupon) => { + return [...coupons, coupon]; + }, + removeCoupon: (code: string, coupons: Coupon[]) => { + return coupons.filter((coupon) => coupon.code !== code); + }, +}; diff --git a/src/basic/models/discount.ts b/src/basic/models/discount.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/models/product.ts b/src/basic/models/product.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx new file mode 100644 index 000000000..afb5b1aee --- /dev/null +++ b/src/basic/pages/AdminPage.tsx @@ -0,0 +1,20 @@ +// TODO: 관리자 페이지 컴포넌트 +// 힌트: +// 1. 탭 UI로 상품 관리와 쿠폰 관리 분리 +// 2. 상품 추가/수정/삭제 기능 +// 3. 쿠폰 생성 기능 +// 4. 할인 규칙 설정 +// +// 필요한 hooks: +// - useProducts: 상품 CRUD +// - useCoupons: 쿠폰 CRUD +// +// 하위 컴포넌트: +// - ProductForm: 새 상품 추가 폼 +// - ProductAccordion: 상품 정보 표시 및 수정 +// - CouponForm: 새 쿠폰 추가 폼 +// - CouponList: 쿠폰 목록 표시 + +export function AdminPage() { + // TODO: 구현 +} \ No newline at end of file diff --git a/src/basic/pages/CartPage.tsx b/src/basic/pages/CartPage.tsx new file mode 100644 index 000000000..e6ff46a30 --- /dev/null +++ b/src/basic/pages/CartPage.tsx @@ -0,0 +1,21 @@ +// TODO: 장바구니 페이지 컴포넌트 +// 힌트: +// 1. 상품 목록 표시 (검색 기능 포함) +// 2. 장바구니 관리 +// 3. 쿠폰 적용 +// 4. 주문 처리 +// +// 필요한 hooks: +// - useProducts: 상품 목록 관리 +// - useCart: 장바구니 상태 관리 +// - useCoupons: 쿠폰 목록 관리 +// - useDebounce: 검색어 디바운싱 +// +// 하위 컴포넌트: +// - SearchBar: 검색 입력 +// - ProductList: 상품 목록 표시 +// - Cart: 장바구니 표시 및 결제 + +export function CartPage() { + // TODO: 구현 +} diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts new file mode 100644 index 000000000..ff157f5cd --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,7 @@ +// TODO: 포맷팅 유틸리티 함수들 +// 구현할 함수: +// - formatPrice(price: number): string - 가격을 한국 원화 형식으로 포맷 +// - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷 +// - formatPercentage(rate: number): string - 소수를 퍼센트로 변환 (0.1 → 10%) + +// TODO: 구현 \ No newline at end of file diff --git a/src/basic/utils/hooks/useDebounce.ts b/src/basic/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..39bb5a26c --- /dev/null +++ b/src/basic/utils/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} \ 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..df328b62f --- /dev/null +++ b/src/basic/utils/hooks/useLocalStorage.ts @@ -0,0 +1,73 @@ +// TODO: LocalStorage Hook +import { useState, useEffect, useCallback } from 'react'; + +// localStorage 접근 가능 여부 확인 +const isLocalStorageAvailable = (): boolean => { + try { + if (typeof window === 'undefined' || typeof localStorage === 'undefined') { + return false; + } + // localStorage 접근 테스트 + const test = '__localStorage_test__'; + localStorage.setItem(test, test); + localStorage.removeItem(test); + return true; + } catch { + return false; + } +}; + +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + // 2. 초기값 로드 시 에러 처리 + const [storedValue, setStoredValue] = useState(() => { + if (!isLocalStorageAvailable()) { + return initialValue; + } + try { + const item = localStorage.getItem(key); + if (item) { + // 3. 저장 시 JSON 역직렬화 + return JSON.parse(item); + } + } catch (error) { + console.error(`Error loading localStorage key "${key}":`, error); + } + return initialValue; + }); + + // 1. localStorage와 React state 동기화 + useEffect(() => { + if (!isLocalStorageAvailable()) { + return; + } + try { + // 4. 빈 배열이나 undefined는 삭제 + if ( + storedValue === undefined || + (Array.isArray(storedValue) && storedValue.length === 0) + ) { + localStorage.removeItem(key); + } else { + // 3. 저장 시 JSON 직렬화 + localStorage.setItem(key, JSON.stringify(storedValue)); + } + } catch (error) { + console.error(`Error saving localStorage key "${key}":`, error); + } + }, [key, storedValue]); + + const setValue = useCallback( + (value: T | ((val: T) => T)) => { + const valueToStore = + value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + }, + [storedValue] + ); + + // 반환값: [저장된 값, 값 설정 함수] + return [storedValue, setValue]; +} diff --git a/src/basic/utils/hooks/useValidate.ts b/src/basic/utils/hooks/useValidate.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/utils/validators.ts b/src/basic/utils/validators.ts new file mode 100644 index 000000000..7d2dda444 --- /dev/null +++ b/src/basic/utils/validators.ts @@ -0,0 +1,8 @@ +// TODO: 검증 유틸리티 함수들 +// 구현할 함수: +// - isValidCouponCode(code: string): boolean - 쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자) +// - isValidStock(stock: number): boolean - 재고 수량 검증 (0 이상) +// - isValidPrice(price: number): boolean - 가격 검증 (양수) +// - extractNumbers(value: string): string - 문자열에서 숫자만 추출 + +// TODO: 구현 \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 5489e296e..8c543aa6d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,3 +22,15 @@ export interface Coupon { discountType: 'amount' | 'percentage'; discountValue: number; } + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export type NotificationType = 'error' | 'success' | 'warning'; + +export type NotificationFunction = ( + message: string, + type: NotificationType +) => void; diff --git a/tsconfig.app.json b/tsconfig.app.json index d739292ae..c6a8a3246 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,5 +23,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src/basic", "src/types.ts", "src/vite-env.d.ts"] } diff --git a/vite.config.ts b/vite.config.ts index e6c4016bc..4996e37db 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,15 +2,43 @@ import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; +// GitHub Pages base path 설정 +// 사용자 페이지: '/' (grappe96.github.io) +// 프로젝트 페이지: '/repository-name/' (grappe96.github.io/repository-name) +const getBasePath = (): string => { + // @ts-ignore - process.env는 Node.js 환경에서 사용 가능 + const repo = process.env.GITHUB_REPOSITORY || ''; + if (repo) { + const repoName = repo.split('/')[1]; + // 사용자 페이지인지 확인 (repository 이름이 username.github.io인 경우) + if (repoName === 'grappe96.github.io' || !repoName) { + return '/'; + } + return `/${repoName}/`; + } + return '/'; +}; + export default mergeConfig( defineConfig({ + base: getBasePath(), plugins: [react()], + build: { + rollupOptions: { + input: './index.basic.html', + output: { + entryFileNames: 'assets/[name]-[hash].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash].[ext]', + }, + }, + }, }), defineTestConfig({ test: { globals: true, environment: 'jsdom', - setupFiles: './src/setupTests.ts' + setupFiles: './src/setupTests.ts', }, }) -) +);