Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
d23e34c
chore: first commit
jym0421 Aug 4, 2025
3ec89c5
feat: 빈 값을 의미하는지 판단하는 함수 추가
jym0421 Aug 5, 2025
80bc050
feat: 스토리지 get, set 함수 추가
jym0421 Aug 5, 2025
4647c9c
feat: 로컬스토리지 hook 추가
jym0421 Aug 5, 2025
dafc354
refactor: 로컬스토리지 hook 적용
jym0421 Aug 5, 2025
3d2252e
feat: debounce hook 추가
jym0421 Aug 6, 2025
1dbbcbe
refactor: searchTerm debounce hook 적용
jym0421 Aug 6, 2025
9169ad8
comment: cart model 요구사항 정리
jym0421 Aug 6, 2025
21defaf
refactor: getRemainingStock 분리
jym0421 Aug 6, 2025
f374cad
refactor: 장바구니 추가
jym0421 Aug 6, 2025
a81c1c7
refactor: 장바구니에서 상품 제거
jym0421 Aug 6, 2025
39f29eb
refactor: 장바구니 상품 업데이트
jym0421 Aug 6, 2025
2ec45eb
refactor: 장바구니 상품 총합 계산
jym0421 Aug 6, 2025
40c8462
refactor: discount 분리
jym0421 Aug 7, 2025
27d9c6c
refactor: product 분리
jym0421 Aug 7, 2025
d880a68
refactor: coupon 분리
jym0421 Aug 7, 2025
4f0c679
refactor: 사용하지 않는 함수 제거
jym0421 Aug 7, 2025
e5b82b8
refactor: notification 분리
jym0421 Aug 7, 2025
e8a8442
refactor: 헤더 컴포넌트 분리
jym0421 Aug 7, 2025
93c1f27
refactor: Admin page 컴포넌트 분리
jym0421 Aug 8, 2025
82030ca
refactor: Shop page 컴포넌트 분리
jym0421 Aug 8, 2025
017adec
Merge pull request #1 from ciel-youngmin/main
0miiii Aug 8, 2025
2cd4a10
refactor: 쇼핑몰 상품 컴포넌트 분리
jym0421 Aug 8, 2025
bf4e1fc
refactor: 쇼핑몰 결제정보 컴포넌트 분리
jym0421 Aug 8, 2025
d4790fd
refactor: 쿠폰할인 컴포넌트 분리
jym0421 Aug 8, 2025
9ffada0
refactor: 장바구니 분리
jym0421 Aug 8, 2025
947913a
refactor: 장바구니 분리
jym0421 Aug 8, 2025
0a75630
refactor: Cart 컴포넌트 인터페이스 개선
jym0421 Aug 8, 2025
1f86d5f
refactor: 화살표함수 스타일 변경
jym0421 Aug 8, 2025
610beee
feat: 가격 포맷함수 매개변수명 변경
jym0421 Aug 8, 2025
f24f436
feat: 가격 포맷함수 매개변수를 객체로 변경
jym0421 Aug 8, 2025
2cbe5a1
refactor: 가격 포맷함수 매개변수 변경 적용
jym0421 Aug 8, 2025
7b8f804
refactor: formatPrice props 제거
jym0421 Aug 8, 2025
c9b8d8c
refactor: 함수명 명확하게 개선
jym0421 Aug 8, 2025
a5ad5fb
feat: 불필요한 조건문 제거
jym0421 Aug 8, 2025
f4d69be
refactor: 상품관리 페이지 분리
jym0421 Aug 8, 2025
3323ce3
refactor: formatPrice 함수 props 제거
jym0421 Aug 8, 2025
f5f92e6
refactor: 불필요한 import 제거
jym0421 Aug 8, 2025
cb558c4
refactor: 상품form 분리
jym0421 Aug 8, 2025
d4754ff
refactor: 컴포넌트명 수정
jym0421 Aug 8, 2025
687b6dc
feat: coupon manage 분리
jym0421 Aug 8, 2025
fe24343
feat: basic 파일 advanced로 복사
jym0421 Aug 8, 2025
22f2ee4
feat: basic 파일 advanced로 복사
jym0421 Aug 8, 2025
20c9c5b
refactor: notification context로 관리
jym0421 Aug 8, 2025
3419523
refactor: 폴더구조변경
jym0421 Aug 8, 2025
6cc3346
refactor: product context 관리
jym0421 Aug 8, 2025
d440b40
refactor: coupon context로 변경
jym0421 Aug 9, 2025
3a2ee00
feat: gh pages 설정
jym0421 Aug 9, 2025
88c6757
fix: 배포경로 수정
jym0421 Aug 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,159 changes: 95 additions & 1,064 deletions src/basic/App.tsx

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions src/basic/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
interface Props {
isAdmin: boolean;
onToggleAdmin: () => void;
searchTerm: string;
onSearchTerms: (value: string) => void;
cartCount: number;
}

const Header = ({
isAdmin,
searchTerm,
cartCount,
onSearchTerms,
onToggleAdmin,
}: Props) => {
return (
<header className="bg-white shadow-sm sticky top-0 z-40 border-b">
<div className="max-w-7xl mx-auto px-4">
<div className="flex justify-between items-center h-16">
<div className="flex items-center flex-1">
<h1 className="text-xl font-semibold text-gray-800">SHOP</h1>
{/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */}
{!isAdmin && (
<div className="ml-8 flex-1 max-w-md">
<input
type="text"
value={searchTerm}
onChange={(e) => onSearchTerms(e.target.value)}
placeholder="상품 검색..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500"
/>
</div>
)}
</div>
<nav className="flex items-center space-x-4">
<button
onClick={onToggleAdmin}
className={`px-3 py-1.5 text-sm rounded transition-colors ${
isAdmin
? "bg-gray-800 text-white"
: "text-gray-600 hover:text-gray-900"
}`}>
{isAdmin ? "쇼핑몰로 돌아가기" : "관리자 페이지로"}
</button>
{!isAdmin && (
<div className="relative">
<svg
className="w-6 h-6 text-gray-700"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
{cartCount > 0 && (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{cartCount}
</span>
)}
</div>
)}
</nav>
</div>
</div>
</header>
);
};

export default Header;
45 changes: 45 additions & 0 deletions src/basic/components/NotificationContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Notification, getNotificationClassName } from "../utils/notification";

interface NotificationContainerProps {
notifications: Notification[];
onRemove: (id: string) => void;
}

export const NotificationContainer = ({
notifications,
onRemove,
}: NotificationContainerProps) => {
if (notifications.length === 0) {
return null;
}

return (
<div className="fixed top-20 right-4 z-50 space-y-2 max-w-sm">
{notifications.map((notif) => (
<div
key={notif.id}
className={`p-4 rounded-md shadow-md text-white flex justify-between items-center ${getNotificationClassName(
notif.type
)}`}>
<span className="mr-2">{notif.message}</span>
<button
onClick={() => onRemove(notif.id)}
className="text-white hover:text-gray-200">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
))}
</div>
);
};
3 changes: 3 additions & 0 deletions src/basic/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./useLocalStorage";
export * from "./useDebounce";
export * from "./useNotifications";
30 changes: 30 additions & 0 deletions src/basic/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useState, useEffect } from "react";

/**
* 입력 값(value)의 변경이 일정 시간(delay) 동안 일어나지 않을 때만 해당 값을 반환하는 디바운스(debounce) 훅입니다.
* 주로 검색어 입력 등 빠르게 변하는 값을 일정 시간 이후에 처리할 때 사용합니다.
*
* @template T - 디바운스할 값의 타입
* @param value - 디바운스를 적용할 값
* @param delay - 디바운스 대기 시간(ms)
* @returns 디바운스 처리된 값
*
* @example
* const debouncedSearch = useDebounce(searchKeyword, 300);
* useEffect(() => {
* fetchData(debouncedSearch);
* }, [debouncedSearch]);
*/
export const useDebounce = <T>(value: T, delay: number): T => {
const [debouncedValue, setDebouncedValue] = useState<T>(value);

useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => clearTimeout(timer);
}, [value, delay]);

return debouncedValue;
};
27 changes: 27 additions & 0 deletions src/basic/hooks/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useState, useEffect } from "react";
import { getStorageItem, setStorageItem } from "../utils/storage";

/**
* React 상태(state)와 localStorage를 동기화하는 커스텀 훅입니다.
* 초기 상태는 localStorage의 값을 우선으로 하며, 이후 상태가 변경될 때마다 저장됩니다.
*
* @param key - localStorage 키
* @param initialValue - 초기값 (localStorage에 값이 없거나 오류 발생 시 사용됨)
* @returns [value, setValue] 튜플 형태의 상태와 상태 변경 함수
*
* @example
* const [theme, setTheme] = useLocalStorage('theme', 'light');
* setTheme('dark'); // localStorage에도 자동 반영됨
*/
export const useLocalStorage = <T>(
key: string,
initialValue: T
): [T, React.Dispatch<React.SetStateAction<T>>] => {
const [value, setValue] = useState<T>(() =>
getStorageItem(key, initialValue)
);

useEffect(() => setStorageItem(key, value), [key, value]);

return [value, setValue];
};
33 changes: 33 additions & 0 deletions src/basic/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useState, useCallback } from "react";
import {
Notification,
createNotification,
addNotification as addNotificationUtil,
removeNotification,
} from "../utils/notification";

export const useNotifications = () => {
const [notifications, setNotifications] = useState<Notification[]>([]);

const addNotification = useCallback(
(message: string, type: "error" | "success" | "warning" = "success") => {
const notification = createNotification(message, type);
setNotifications((prev) => addNotificationUtil(prev, notification));

setTimeout(() => {
setNotifications((prev) => removeNotification(prev, notification.id));
}, 3000);
},
[]
);

const removeNotificationById = useCallback((notificationId: string) => {
setNotifications((prev) => removeNotification(prev, notificationId));
}, []);

return {
notifications,
addNotification,
removeNotificationById,
};
};
166 changes: 166 additions & 0 deletions src/basic/models/cart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Product, CartItem, Coupon } from "../../types";
import { applyCouponToAmount } from "./discount";

type AddToCartResult =
| { success: true; cart: CartItem[] }
| { success: false; cart: CartItem[]; reason: string };

// 요구사항
// - 장바구니
// - 장바구니 내 상품 수량 조절 가능
// - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시
// - 적용된 할인율 표시 (예: "10% 할인 적용")
// - 장바구니 내 모든 상품의 총액을 계산해야

// 1. 장바구니에 추가
// 2. 장바구니에서 제거
// 3. 장바구니에 담긴 상품 개수 증가
// 4. 장바구니에 담긴 상품 개수 감소
// 5. 장바구니 내 모든 상품의 할인율이 적용된 총액 계산

/**
* 특정 상품을 장바구니에 담고 남은 재고 개수를 반환하는 함수
*/
export const 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;
};

/**
* 장바구니에 상품을 추가하는 함수
* 1. 재고가 없는 경우 실패
* 2. 재고가 부족한 경우 실패
*/
export const addToCart = (
cart: CartItem[],
product: Product
): AddToCartResult => {
const remainingStock = getRemainingStock(cart, product);

if (remainingStock <= 0) {
return {
success: false,
cart,
reason: `재고가 부족합니다!`,
};
}

const existingItem = cart.find((item) => item.product.id === product.id);

if (!existingItem) {
return {
success: true,
cart: [...cart, { product, quantity: 1 }],
};
}

const newQuantity = existingItem.quantity + 1;

if (newQuantity <= product.stock) {
return {
success: true,
cart: cart.map((item) =>
item.product.id === product.id
? { ...item, quantity: newQuantity }
: item
),
};
}

return {
success: false,
cart,
reason: `재고는 ${product.stock}개까지만 있습니다.`,
};
};

/**
* 장바구니에서 상품을 제거하는 함수
*/
export const removeFromCart = (cart: CartItem[], productId: string) => {
return cart.filter((item) => item.product.id !== productId);
};

/**
* 장바구니 상품 개수를 업데이트하는 함수
* 업데이트 수량이 재고보다 많은 경우 실패
*/
export const updateQuantity = (
cart: CartItem[],
product: Product,
newQuantity: number
): AddToCartResult => {
if (newQuantity <= 0) {
return {
success: true,
cart: removeFromCart(cart, product.id),
};
}

if (newQuantity > product.stock) {
return {
success: false,
cart: removeFromCart(cart, product.id),
reason: `재고는 ${product.stock}개까지만 있습니다.`,
};
}

return {
success: true,
cart: cart.map((item) =>
item.product.id === product.id ? { ...item, quantity: newQuantity } : item
),
};
};

/**
* 장바구니 상품 금액 총합을 계산하는 함수
*/
export const calculateCartTotal = (cart: CartItem[], coupon: Coupon | null) => {
let totalBeforeDiscount = 0;
let totalAfterDiscount = 0;

cart.forEach((item) => {
const itemPrice = item.product.price * item.quantity;
totalBeforeDiscount += itemPrice;
totalAfterDiscount += calculateItemTotal(item);
});

// 쿠폰 할인 적용
totalAfterDiscount = applyCouponToAmount(totalAfterDiscount, coupon);

return {
totalBeforeDiscount: Math.round(totalBeforeDiscount),
totalAfterDiscount: Math.round(totalAfterDiscount),
};
};

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);

if (item.quantity >= 10) {
return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인
}

return baseDiscount;
};

export const calculateItemTotal = (item: CartItem): number => {
const { price } = item.product;
const { quantity } = item;
const discount = getMaxApplicableDiscount(item);

return Math.round(price * quantity * (1 - discount));
};
Loading