- {/* 상품 목록 */}
-
-
-
전체 상품
-
- 총 {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()}원
-
-
-
-
-
-
-
- >
- )}
-
+
+
)}
@@ -1121,4 +209,4 @@ const App = () => {
);
};
-export default App;
\ No newline at end of file
+export default App;
diff --git a/src/advanced/entities/cart/lib/index.ts b/src/advanced/entities/cart/lib/index.ts
new file mode 100644
index 000000000..fe16e7985
--- /dev/null
+++ b/src/advanced/entities/cart/lib/index.ts
@@ -0,0 +1,81 @@
+import { CartItem } from "../../../entities/cart/model/types";
+import { Coupon } from "../../../entities/coupon/model/types";
+
+/**
+ * 장바구니 아이템에 적용 가능한 최대 할인율을 계산합니다.
+ * (대량 구매 로직 포함)
+ */
+export const getMaxApplicableDiscount = (
+ item: CartItem,
+ cart: CartItem[]
+): number => {
+ const { discounts } = item.product;
+ const { quantity } = item;
+
+ // 1. 상품 자체의 수량 할인 확인
+ const baseDiscount = discounts.reduce((maxDiscount, discount) => {
+ return quantity >= discount.quantity && discount.rate > maxDiscount
+ ? discount.rate
+ : maxDiscount;
+ }, 0);
+
+ // 2. 장바구니 전체를 뒤져서 대량 구매 여부 확인 (비즈니스 룰)
+ const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10);
+
+ if (hasBulkPurchase) {
+ return Math.min(baseDiscount + 0.05, 0.5); // 추가 5% 할인, 최대 50%
+ }
+
+ return baseDiscount;
+};
+
+/**
+ * 장바구니 아이템 하나의 최종 가격을 계산합니다.
+ */
+export const calculateItemTotal = (
+ item: CartItem,
+ cart: CartItem[]
+): number => {
+ const { price } = item.product;
+ const { quantity } = item;
+ const discount = getMaxApplicableDiscount(item, cart);
+
+ return Math.round(price * quantity * (1 - discount));
+};
+
+/**
+ * 장바구니 전체 금액(할인 전/후)을 계산합니다.
+ */
+export const calculateCartTotal = (
+ cart: CartItem[],
+ selectedCoupon: Coupon | null
+) => {
+ let totalBeforeDiscount = 0;
+ let totalAfterDiscount = 0;
+
+ cart.forEach((item) => {
+ const itemPrice = item.product.price * item.quantity;
+ totalBeforeDiscount += itemPrice;
+ // calculateItemTotal을 재사용
+ totalAfterDiscount += calculateItemTotal(item, cart);
+ });
+
+ // 쿠폰 적용
+ if (selectedCoupon) {
+ if (selectedCoupon.discountType === "amount") {
+ totalAfterDiscount = Math.max(
+ 0,
+ totalAfterDiscount - selectedCoupon.discountValue
+ );
+ } else {
+ totalAfterDiscount = Math.round(
+ totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)
+ );
+ }
+ }
+
+ return {
+ totalBeforeDiscount: Math.round(totalBeforeDiscount),
+ totalAfterDiscount: Math.round(totalAfterDiscount),
+ };
+};
diff --git a/src/advanced/entities/cart/model/types.ts b/src/advanced/entities/cart/model/types.ts
new file mode 100644
index 000000000..8a6135ce3
--- /dev/null
+++ b/src/advanced/entities/cart/model/types.ts
@@ -0,0 +1,6 @@
+import { ProductWithUI } from "../../product/model/types";
+
+export interface CartItem {
+ product: ProductWithUI;
+ quantity: number;
+}
diff --git a/src/advanced/entities/coupon/lib/index.ts b/src/advanced/entities/coupon/lib/index.ts
new file mode 100644
index 000000000..81e2946e4
--- /dev/null
+++ b/src/advanced/entities/coupon/lib/index.ts
@@ -0,0 +1,17 @@
+import { Coupon } from "../../../entities/coupon/model/types";
+/**
+ * 쿠폰을 적용할 수 있는지 판단하는 순수 함수
+ * @param coupon 적용하려는 쿠폰
+ * @param currentTotalAmount 현재 장바구니 총액 (할인 전)
+ * @returns 적용 가능 여부
+ */
+export const canApplyCoupon = (
+ coupon: Coupon,
+ currentTotalAmount: number
+): boolean => {
+ // 비즈니스 규칙: 정률 할인은 10,000원 이상일 때만 가능
+ if (coupon.discountType === "percentage" && currentTotalAmount < 10000) {
+ return false;
+ }
+ return true;
+};
diff --git a/src/advanced/entities/coupon/model/types.ts b/src/advanced/entities/coupon/model/types.ts
new file mode 100644
index 000000000..5f5750118
--- /dev/null
+++ b/src/advanced/entities/coupon/model/types.ts
@@ -0,0 +1,6 @@
+export interface Coupon {
+ name: string;
+ code: string;
+ discountType: "amount" | "percentage";
+ discountValue: number;
+}
diff --git a/src/advanced/entities/product/lib/index.ts b/src/advanced/entities/product/lib/index.ts
new file mode 100644
index 000000000..f5d225906
--- /dev/null
+++ b/src/advanced/entities/product/lib/index.ts
@@ -0,0 +1,18 @@
+import { CartItem } from "../../../entities/cart/model/types";
+import { Product } from "../../../entities/product/model/types";
+
+
+/**
+ * 상품의 재고가 얼마나 남았는지 계산합니다.
+ * @param product 확인할 상품
+ * @param cart 현재 장바구니 상태 (전체 재고 확인을 위해 필요)
+ */
+export const getRemainingStock = (
+ product: Product,
+ cart: CartItem[]
+): number => {
+ const cartItem = cart.find((item) => item.product.id === product.id);
+ const remaining = product.stock - (cartItem?.quantity || 0);
+
+ return remaining;
+};
diff --git a/src/advanced/entities/product/model/types.ts b/src/advanced/entities/product/model/types.ts
new file mode 100644
index 000000000..00e53cf6e
--- /dev/null
+++ b/src/advanced/entities/product/model/types.ts
@@ -0,0 +1,17 @@
+export interface Discount {
+ quantity: number;
+ rate: number;
+}
+
+export interface Product {
+ id: string;
+ name: string;
+ price: number;
+ stock: number;
+ discounts: Discount[];
+}
+
+export interface ProductWithUI extends Product {
+ description?: string;
+ isRecommended?: boolean;
+}
diff --git a/src/advanced/entities/product/ui/ProductCard.tsx b/src/advanced/entities/product/ui/ProductCard.tsx
new file mode 100644
index 000000000..89ada5368
--- /dev/null
+++ b/src/advanced/entities/product/ui/ProductCard.tsx
@@ -0,0 +1,108 @@
+import { ProductWithUI} from "../../../entities/product/model/types";
+import { CartItem } from "../../../entities/cart/model/types";
+import { formatCurrencyWithSymbol } from "../../../shared/lib/format";
+import { getRemainingStock } from "../lib";
+
+interface Props {
+ product: ProductWithUI;
+ cart: CartItem[];
+ onAddToCart: (product: ProductWithUI) => void;
+}
+
+export const ProductCard = ({ product, cart, onAddToCart }: Props) => {
+ // 도메인 로직: 재고 계산
+ const remainingStock = getRemainingStock(product, cart);
+ const isSoldOut = remainingStock <= 0;
+
+ // UI 로직: 최대 할인율 계산 (배지용)
+ const maxDiscountRate = product.discounts.reduce(
+ (max, d) => Math.max(max, d.rate),
+ 0
+ );
+
+ return (
+
+ {/* 1. 이미지 및 배지 영역 */}
+
+
+
+ {/* BEST 배지 */}
+ {product.isRecommended && (
+
+ BEST
+
+ )}
+
+ {/* 할인율 배지 */}
+ {product.discounts.length > 0 && (
+
+ ~{Math.round(maxDiscountRate * 100)}%
+
+ )}
+
+
+ {/* 2. 상품 정보 영역 */}
+
+
{product.name}
+ {product.description && (
+
+ {product.description}
+
+ )}
+
+ {/* 가격 및 할인 정책 */}
+
+
+ {formatCurrencyWithSymbol(product.price)}
+
+ {product.discounts.length > 0 && (
+
+ {product.discounts[0].quantity}개 이상 구매시 할인{" "}
+ {Math.round(product.discounts[0].rate * 100)}%
+
+ )}
+
+
+ {/* 재고 상태 메시지 */}
+
+ {remainingStock <= 5 && remainingStock > 0 && (
+
+ 품절임박! {remainingStock}개 남음
+
+ )}
+ {remainingStock > 5 && (
+
재고 {remainingStock}개
+ )}
+
+
+ {/* 장바구니 버튼 */}
+
+
+
+ );
+};
diff --git a/src/advanced/features/app/useShop.ts b/src/advanced/features/app/useShop.ts
new file mode 100644
index 000000000..6218ad062
--- /dev/null
+++ b/src/advanced/features/app/useShop.ts
@@ -0,0 +1,23 @@
+import { useProducts } from "../product/model/useProducts";
+import { useCoupons } from "../coupon/model/useCoupons";
+import { useCart } from "../cart/model/useCart";
+import { useNotificationSystem } from "../../shared/lib/useNotificationSystem";
+
+export const useShop = () => {
+ // 1. 알림 시스템
+ const { notifications, addNotification, removeNotification } = useNotificationSystem();
+
+ // 2. 도메인 훅 연결 (의존성 주입 해결)
+ const productLogic = useProducts();
+ const couponLogic = useCoupons();
+ const cartLogic = useCart();
+
+ return {
+ addNotification,
+ notifications,
+ removeNotification,
+ productLogic,
+ couponLogic,
+ cartLogic,
+ };
+};
\ No newline at end of file
diff --git a/src/advanced/features/cart/model/cartStore.ts b/src/advanced/features/cart/model/cartStore.ts
new file mode 100644
index 000000000..f70646b01
--- /dev/null
+++ b/src/advanced/features/cart/model/cartStore.ts
@@ -0,0 +1,150 @@
+import { create } from "zustand";
+
+// Shared (Store)
+import { useNotificationStore } from "../../../shared/lib/notificationStore";
+
+// Entities (Model - types)
+import { CartItem } from "../../../entities/cart/model/types";
+import { ProductWithUI } from "../../../entities/product/model/types";
+import { Coupon } from "../../../entities/coupon/model/types";
+
+// Entities (Lib - Pure Functions)
+import { getRemainingStock } from "../../../entities/product/lib";
+import { calculateCartTotal } from "../../../entities/cart/lib";
+import { canApplyCoupon } from "../../../entities/coupon/lib";
+
+interface CartState {
+ cart: CartItem[];
+ selectedCoupon: Coupon | null;
+
+ // Actions
+ addToCart: (product: ProductWithUI) => void;
+ removeFromCart: (productId: string) => void;
+ updateQuantity: (productId: string, newQuantity: number, products: ProductWithUI[]) => void;
+ applyCoupon: (coupon: Coupon) => void;
+ setSelectedCoupon: (coupon: Coupon | null) => void;
+ completeOrder: () => void;
+}
+
+// ✅ persist 미들웨어 제거: 순수 메모리 저장소로 변경
+export const useCartStore = create
((set, get) => ({
+ cart: [],
+ selectedCoupon: null,
+
+ /**
+ * 상품을 장바구니에 추가합니다.
+ */
+ addToCart: (product: ProductWithUI) => {
+ const { cart } = get();
+ const { addNotification } = useNotificationStore.getState();
+
+ const remaining = getRemainingStock(product, cart);
+ if (remaining <= 0) {
+ addNotification("재고가 부족합니다!", "error");
+ return;
+ }
+
+ const existing = cart.find((item) => item.product.id === product.id);
+ if (existing) {
+ if (existing.quantity + 1 > product.stock) {
+ addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error");
+ return;
+ }
+
+ set({
+ cart: cart.map((item) =>
+ item.product.id === product.id
+ ? { ...item, quantity: item.quantity + 1 }
+ : item
+ ),
+ });
+ } else {
+ set({ cart: [...cart, { product, quantity: 1 }] });
+ }
+
+ addNotification("장바구니에 담았습니다", "success");
+ },
+
+ /**
+ * 장바구니에서 상품을 제거합니다.
+ */
+ removeFromCart: (productId: string) => {
+ set((state) => ({
+ cart: state.cart.filter((item) => item.product.id !== productId),
+ }));
+ },
+
+ /**
+ * 수량을 업데이트합니다.
+ */
+ updateQuantity: (productId: string, newQuantity: number, products: ProductWithUI[]) => {
+ const { removeFromCart } = get();
+ const { addNotification } = useNotificationStore.getState();
+
+ 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;
+ }
+
+ set((state) => ({
+ cart: state.cart.map((item) =>
+ item.product.id === productId
+ ? { ...item, quantity: newQuantity }
+ : item
+ ),
+ }));
+ },
+
+ /**
+ * 쿠폰을 적용합니다.
+ */
+ applyCoupon: (coupon: Coupon) => {
+ const { cart } = get();
+ const { addNotification } = useNotificationStore.getState();
+
+ const { totalAfterDiscount } = calculateCartTotal(cart, null);
+
+ if (!canApplyCoupon(coupon, totalAfterDiscount)) {
+ addNotification(
+ "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.",
+ "error"
+ );
+ return;
+ }
+
+ set({ selectedCoupon: coupon });
+ addNotification("쿠폰이 적용되었습니다.", "success");
+ },
+
+ /**
+ * 쿠폰 선택 상태를 직접 변경합니다. (해제 등)
+ */
+ setSelectedCoupon: (coupon: Coupon | null) => {
+ set({ selectedCoupon: coupon });
+ },
+
+ /**
+ * 주문을 완료하고 장바구니를 비웁니다.
+ */
+ completeOrder: () => {
+ const { addNotification } = useNotificationStore.getState();
+ // crypto.randomUUID()를 사용하여 고유한 주문번호 생성 (키 중복 방지)
+ const orderNumber = `ORD-${crypto.randomUUID().slice(0, 8)}`;
+
+ addNotification(
+ `주문이 완료되었습니다. 주문번호: ${orderNumber}`,
+ "success"
+ );
+
+ set({ cart: [], selectedCoupon: null });
+ },
+}));
\ No newline at end of file
diff --git a/src/advanced/features/cart/model/useCart.ts b/src/advanced/features/cart/model/useCart.ts
new file mode 100644
index 000000000..a6e29a550
--- /dev/null
+++ b/src/advanced/features/cart/model/useCart.ts
@@ -0,0 +1,28 @@
+// Shared (Store)
+import { useCartStore } from "./cartStore";
+import { useProductStore } from "../../product/model/productStore";
+
+/**
+ * 장바구니 관련 상태와 액션을 관리하는 커스텀 훅
+ * 내부적으로 ProductStore를 구독하여 재고 확인에 필요한 데이터를 자동으로 확보합니다.
+ */
+export const useCart = () => {
+ const cartStore = useCartStore();
+ const products = useProductStore((state) => state.products);
+
+ return {
+ cart: cartStore.cart,
+ selectedCoupon: cartStore.selectedCoupon,
+
+ // 단순 전달 액션
+ addToCart: cartStore.addToCart,
+ removeFromCart: cartStore.removeFromCart,
+ applyCoupon: cartStore.applyCoupon,
+ setSelectedCoupon: cartStore.setSelectedCoupon,
+ completeOrder: cartStore.completeOrder,
+
+ // 복합 액션 (Dependency Injection)
+ updateQuantity: (productId: string, newQuantity: number) =>
+ cartStore.updateQuantity(productId, newQuantity, products)
+ };
+};
\ No newline at end of file
diff --git a/src/advanced/features/coupon/model/couponStore.ts b/src/advanced/features/coupon/model/couponStore.ts
new file mode 100644
index 000000000..e457df66e
--- /dev/null
+++ b/src/advanced/features/coupon/model/couponStore.ts
@@ -0,0 +1,44 @@
+import { create } from "zustand";
+import { Coupon } from "../../../entities/coupon/model/types";
+import { useNotificationStore } from "../../../shared/lib/notificationStore";
+
+interface CouponState {
+ coupons: Coupon[];
+ setCoupons: (coupons: Coupon[]) => void;
+ addCoupon: (coupon: Coupon) => void;
+ deleteCoupon: (couponCode: string) => void;
+}
+
+export const useCouponStore = create((set, get) => ({
+ coupons: [],
+
+ setCoupons: (coupons) => set({ coupons }),
+
+ addCoupon: (newCoupon) => {
+ const { coupons } = get();
+ const existing = coupons.find((c) => c.code === newCoupon.code);
+
+ if (existing) {
+ useNotificationStore
+ .getState()
+ .addNotification("이미 존재하는 쿠폰 코드입니다.", "error");
+ return;
+ }
+
+ set((state) => ({
+ coupons: [...state.coupons, newCoupon],
+ }));
+ useNotificationStore
+ .getState()
+ .addNotification("쿠폰이 추가되었습니다.", "success");
+ },
+
+ deleteCoupon: (couponCode) => {
+ set((state) => ({
+ coupons: state.coupons.filter((c) => c.code !== couponCode),
+ }));
+ useNotificationStore
+ .getState()
+ .addNotification("쿠폰이 삭제되었습니다.", "success");
+ },
+}));
\ No newline at end of file
diff --git a/src/advanced/features/coupon/model/useCoupons.ts b/src/advanced/features/coupon/model/useCoupons.ts
new file mode 100644
index 000000000..27606a644
--- /dev/null
+++ b/src/advanced/features/coupon/model/useCoupons.ts
@@ -0,0 +1,14 @@
+import { useCouponStore } from "./couponStore";
+
+/**
+ * 쿠폰 관련 상태와 액션을 제공하는 커스텀 훅
+ */
+export const useCoupons = () => {
+ const couponStore = useCouponStore();
+
+ return {
+ coupons: couponStore.coupons,
+ addCoupon: couponStore.addCoupon,
+ deleteCoupon: couponStore.deleteCoupon,
+ };
+};
\ No newline at end of file
diff --git a/src/advanced/features/coupon/ui/CouponManagementForm.tsx b/src/advanced/features/coupon/ui/CouponManagementForm.tsx
new file mode 100644
index 000000000..20c4b208e
--- /dev/null
+++ b/src/advanced/features/coupon/ui/CouponManagementForm.tsx
@@ -0,0 +1,194 @@
+import { useState, FormEvent } from "react";
+
+// Shared Store
+import { useNotificationStore } from "../../../shared/lib/notificationStore";
+
+// Entities (Model)
+import { Coupon } from "../../../entities/coupon/model/types";
+
+interface Props {
+ /** 쿠폰 추가 확정 시 호출되는 콜백 */
+ onAddCoupon: (coupon: Coupon) => void;
+ /** 취소 버튼 클릭 시 호출되는 콜백 */
+ onCancel: () => void;
+}
+
+/**
+ * 쿠폰 생성을 위한 폼 UI 컴포넌트
+ * 내부적으로 폼 상태를 관리하며, 입력 값에 대한 유효성 검사(Validation)를 수행합니다.
+ */
+export const CouponManagementForm = ({
+ onAddCoupon,
+ onCancel,
+}: Props) => {
+ const addNotification = useNotificationStore((state) => state.addNotification);
+
+ // --------------------------------------------------------------------------
+ // Local State
+ // --------------------------------------------------------------------------
+ const [couponForm, setCouponForm] = useState({
+ name: "",
+ code: "",
+ discountType: "amount",
+ discountValue: 0,
+ });
+
+ // --------------------------------------------------------------------------
+ // Handlers
+ // --------------------------------------------------------------------------
+
+ /**
+ * 폼 제출 핸들러
+ * 데이터를 부모에게 전달하고 폼을 초기화합니다.
+ */
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ onAddCoupon(couponForm);
+ setCouponForm({
+ name: "",
+ code: "",
+ discountType: "amount",
+ discountValue: 0,
+ });
+ };
+
+ /**
+ * 할인 금액/율 입력 필드의 Blur 핸들러
+ * 입력된 값의 유효성을 검사하고, 범위를 벗어난 경우 알림을 띄우고 값을 보정합니다.
+ */
+ const handleDiscountBlur = () => {
+ const value = couponForm.discountValue;
+
+ if (couponForm.discountType === "percentage") {
+ if (value > 100) {
+ addNotification("할인율은 100%를 초과할 수 없습니다", "error");
+ setCouponForm((prev) => ({ ...prev, discountValue: 100 }));
+ } else if (value < 0) {
+ setCouponForm((prev) => ({ ...prev, discountValue: 0 }));
+ }
+ } else {
+ // 정액 할인일 경우
+ if (value > 100000) {
+ addNotification("할인 금액은 100,000원을 초과할 수 없습니다", "error");
+ setCouponForm((prev) => ({ ...prev, discountValue: 100000 }));
+ } else if (value < 0) {
+ setCouponForm((prev) => ({ ...prev, discountValue: 0 }));
+ }
+ }
+ };
+
+ // --------------------------------------------------------------------------
+ // Render
+ // --------------------------------------------------------------------------
+ return (
+
+ );
+};
\ No newline at end of file
diff --git a/src/advanced/features/product/model/productStore.ts b/src/advanced/features/product/model/productStore.ts
new file mode 100644
index 000000000..f257a9b82
--- /dev/null
+++ b/src/advanced/features/product/model/productStore.ts
@@ -0,0 +1,51 @@
+import { create } from "zustand";
+import { ProductWithUI } from "../../../entities/product/model/types";
+import { useNotificationStore } from "../../../shared/lib/notificationStore";
+
+interface ProductState {
+ products: ProductWithUI[];
+ setProducts: (products: ProductWithUI[]) => void;
+
+ // ✅ [수정] 배열([])이 아니라 함수((...)=>void)여야 합니다!
+ addProduct: (product: Omit) => void;
+
+ updateProduct: (productId: string, updates: Partial) => void;
+ deleteProduct: (productId: string) => void;
+}
+
+export const useProductStore = create((set) => ({
+ products: [],
+
+ setProducts: (products) => set({ products }),
+
+ addProduct: (product: Omit) => {
+ const newProduct = { ...product, id: `p-${crypto.randomUUID()}` };
+ set((state) => ({
+ products: [...state.products, newProduct],
+ }));
+
+ useNotificationStore
+ .getState()
+ .addNotification("상품이 추가되었습니다.", "success");
+ },
+
+ updateProduct: (productId, updates) => {
+ set((state) => ({
+ products: state.products.map((p) =>
+ p.id === productId ? { ...p, ...updates } : p
+ ),
+ }));
+ useNotificationStore
+ .getState()
+ .addNotification("상품이 수정되었습니다.", "success");
+ },
+
+ deleteProduct: (productId) => {
+ set((state) => ({
+ products: state.products.filter((p) => p.id !== productId),
+ }));
+ useNotificationStore
+ .getState()
+ .addNotification("상품이 삭제되었습니다.", "success");
+ },
+}));
\ No newline at end of file
diff --git a/src/advanced/features/product/model/ui/ProductManagementForm.tsx b/src/advanced/features/product/model/ui/ProductManagementForm.tsx
new file mode 100644
index 000000000..63ae7652c
--- /dev/null
+++ b/src/advanced/features/product/model/ui/ProductManagementForm.tsx
@@ -0,0 +1,192 @@
+import { FormEvent } from "react";
+
+// Shared
+import { ProductWithUI } from "../../../../entities/product/model/types";
+import { useNotificationStore } from "../../../../shared/lib/notificationStore";
+
+// Features (Model)
+import { useProductForm } from "../useProductForm";
+
+interface Props {
+ initialData?: ProductWithUI | null;
+ onSubmit: (product: ProductWithUI) => void;
+ onCancel: () => void;
+}
+
+/**
+ * 상품 추가/수정을 위한 폼 UI 컴포넌트
+ */
+export const ProductManagementForm = ({
+ initialData,
+ onSubmit,
+ onCancel,
+}: Props) => {
+
+ const addNotification = useNotificationStore((state) => state.addNotification);
+ // Logic을 Hook으로 위임
+ const {
+ productForm,
+ handleChange,
+ handleNumberChange,
+ handleNumberBlur,
+ addDiscount,
+ removeDiscount,
+ updateDiscount,
+ } = useProductForm({ initialData, onNotification: addNotification });
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ onSubmit(productForm);
+ };
+
+ const isEditMode = !!initialData;
+
+ return (
+
+ );
+};
\ No newline at end of file
diff --git a/src/advanced/features/product/model/useProductFilter.ts b/src/advanced/features/product/model/useProductFilter.ts
new file mode 100644
index 000000000..95aee09dd
--- /dev/null
+++ b/src/advanced/features/product/model/useProductFilter.ts
@@ -0,0 +1,23 @@
+import { useState } from "react";
+import { ProductWithUI } from "../../../entities/product/model/types";
+import { useDebounce } from "../../../shared/lib/useDebounce";
+
+export const useProductFilter = (products: ProductWithUI[]) => {
+ const [searchTerm, setSearchTerm] = useState("");
+ const debouncedSearchTerm = useDebounce(searchTerm, 500);
+
+ const filteredProducts = debouncedSearchTerm
+ ? products.filter(
+ (product) =>
+ product.name
+ .toLowerCase()
+ .includes(debouncedSearchTerm.toLowerCase()) ||
+ (product.description &&
+ product.description
+ .toLowerCase()
+ .includes(debouncedSearchTerm.toLowerCase()))
+ )
+ : products;
+
+ return { searchTerm, setSearchTerm, filteredProducts };
+};
\ No newline at end of file
diff --git a/src/advanced/features/product/model/useProductForm.ts b/src/advanced/features/product/model/useProductForm.ts
new file mode 100644
index 000000000..11e424b9b
--- /dev/null
+++ b/src/advanced/features/product/model/useProductForm.ts
@@ -0,0 +1,123 @@
+import { useState, useEffect } from "react";
+
+// Shared
+import { ProductWithUI } from "../../../entities/product/model/types";
+
+// 초기값 상수
+const INITIAL_FORM_STATE: ProductWithUI = {
+ id: "", // 폼에서는 사용 안 함
+ name: "",
+ price: 0,
+ stock: 0,
+ description: "",
+ discounts: [],
+ isRecommended: false,
+};
+
+interface UseProductFormProps {
+ initialData?: ProductWithUI | null;
+ onNotification: (message: string, type?: "error" | "success" | "warning") => void;
+}
+
+/**
+ * 상품 입력 폼의 상태와 로직을 관리하는 커스텀 훅 (Feature Model)
+ * @param initialData 수정 시 채워넣을 초기 데이터
+ * @param onNotification 유효성 검사 실패 시 알림
+ */
+export const useProductForm = ({ initialData, onNotification }: UseProductFormProps) => {
+ const [productForm, setProductForm] = useState(INITIAL_FORM_STATE);
+
+ // 수정 모드일 때 초기 데이터 주입
+ useEffect(() => {
+ if (initialData) {
+ setProductForm(initialData);
+ } else {
+ setProductForm(INITIAL_FORM_STATE);
+ }
+ }, [initialData]);
+
+ const resetForm = () => {
+ setProductForm(INITIAL_FORM_STATE);
+ };
+
+ /**
+ * 텍스트 입력 핸들러
+ */
+ const handleChange = (field: keyof ProductWithUI, value: any) => {
+ setProductForm((prev) => ({ ...prev, [field]: value }));
+ };
+
+ /**
+ * 숫자 입력 핸들러 (유효성 검사 포함)
+ */
+ const handleNumberChange = (field: keyof ProductWithUI, value: string) => {
+ if (value === "" || /^\d+$/.test(value)) {
+ const parsedValue = value === "" ? 0 : parseInt(value);
+ setProductForm((prev) => ({ ...prev, [field]: parsedValue }));
+ }
+ };
+
+ /**
+ * 숫자 필드 Blur 핸들러 (값 보정 및 알림)
+ */
+ const handleNumberBlur = (field: keyof ProductWithUI, value: number, label: string) => {
+ if (Number.isNaN(value) || value === 0) { // 빈 값 처리
+ // 0 허용 여부에 따라 로직이 다를 수 있음. 여기선 0으로 리셋
+ setProductForm((prev) => ({ ...prev, [field]: 0 }));
+ return;
+ }
+
+ if (value < 0) {
+ onNotification(`${label}은 0보다 커야 합니다`, "error");
+ setProductForm((prev) => ({ ...prev, [field]: 0 }));
+ }
+
+ // 재고 9999 제한 (비즈니스 규칙)
+ if (field === "stock" && value > 9999) {
+ onNotification("재고는 9999개를 초과할 수 없습니다", "error");
+ setProductForm((prev) => ({ ...prev, stock: 9999 }));
+ }
+ };
+
+ /**
+ * 할인 추가 핸들러
+ */
+ const addDiscount = () => {
+ setProductForm((prev) => ({
+ ...prev,
+ discounts: [...prev.discounts, { quantity: 10, rate: 0.1 }],
+ }));
+ };
+
+ /**
+ * 할인 제거 핸들러
+ */
+ const removeDiscount = (index: number) => {
+ setProductForm((prev) => ({
+ ...prev,
+ discounts: prev.discounts.filter((_, i) => i !== index),
+ }));
+ };
+
+ /**
+ * 할인 아이템 변경 핸들러
+ */
+ const updateDiscount = (index: number, field: "quantity" | "rate", value: number) => {
+ setProductForm((prev) => {
+ const newDiscounts = [...prev.discounts];
+ newDiscounts[index][field] = value;
+ return { ...prev, discounts: newDiscounts };
+ });
+ };
+
+ return {
+ productForm,
+ handleChange,
+ handleNumberChange,
+ handleNumberBlur,
+ addDiscount,
+ removeDiscount,
+ updateDiscount,
+ resetForm,
+ };
+};
\ No newline at end of file
diff --git a/src/advanced/features/product/model/useProducts.ts b/src/advanced/features/product/model/useProducts.ts
new file mode 100644
index 000000000..1a796cc16
--- /dev/null
+++ b/src/advanced/features/product/model/useProducts.ts
@@ -0,0 +1,17 @@
+import { useProductStore } from "./productStore";
+
+/**
+ * 상품 관련 상태와 액션을 제공하는 커스텀 훅
+ * (Zustand Store를 컴포넌트에서 쉽게 쓰도록 연결하는 Selector 역할)
+ */
+export const useProducts = () => {
+ // 스토어 전체를 반환하거나, 필요한 것만 골라서 반환
+ const productStore = useProductStore();
+
+ return {
+ products: productStore.products,
+ addProduct: productStore.addProduct,
+ updateProduct: productStore.updateProduct,
+ deleteProduct: productStore.deleteProduct,
+ };
+};
\ No newline at end of file
diff --git a/src/advanced/shared/lib/format.ts b/src/advanced/shared/lib/format.ts
new file mode 100644
index 000000000..a86bbaa09
--- /dev/null
+++ b/src/advanced/shared/lib/format.ts
@@ -0,0 +1,20 @@
+/**
+ * 숫자를 한국 통화 형식으로 변환합니다.
+ * 예: 10000 -> "10,000원"
+ * @param value 금액
+ * @returns 포맷팅된 문자열
+ */
+export const formatCurrency = (value: number): string => {
+ // 순수 계산: 입력(number) -> 출력(string)
+ return `${value.toLocaleString()}원`;
+};
+
+/**
+ * 숫자를 ₩ 표시가 있는 통화 형식으로 변환합니다. (기존 코드의 비관리자용)- `src/shared/lib/useLocalStorage.ts` 생성: 로컬 스토리지 읽기/쓰기 로직을 제네릭 훅으로 캡슐화
+- Feature Hooks(`useCart`, `useProducts`, `useCoupons`)에서 중복되는 `useEffect` 및 저장소 접근 로직 제거
+- `JSON.parse` 에러 처리를 공통 훅 내부로 통합하여 안정성 확보
+- 비즈니스 로직에서 저장 매체(Implementation Detail)에 대한 의존성 제거
+ */
+export const formatCurrencyWithSymbol = (value: number): string => {
+ return `₩${value.toLocaleString()}`;
+};
diff --git a/src/advanced/shared/lib/notificationStore.ts b/src/advanced/shared/lib/notificationStore.ts
new file mode 100644
index 000000000..adbc333de
--- /dev/null
+++ b/src/advanced/shared/lib/notificationStore.ts
@@ -0,0 +1,33 @@
+import { create } from "zustand";
+import { Notification } from "../model/types";
+
+interface NotificationState {
+ notifications: Notification[];
+ // 액션도 스토어 안에 함께 정의
+ addNotification: (message: string, type?: "error" | "success" | "warning") => void;
+ removeNotification: (id: string) => void;
+}
+
+export const useNotificationStore = create((set, get) => ({
+ notifications: [],
+
+ addNotification: (message, type = "success") => {
+ const id = crypto.randomUUID();
+
+ // 1. 상태 업데이트 (추가)
+ set((state) => ({
+ notifications: [...state.notifications, { id, message, type }],
+ }));
+
+ // 2. 사이드 이펙트 (타이머).
+ setTimeout(() => {
+ get().removeNotification(id);
+ }, 3000);
+ },
+
+ removeNotification: (id) => {
+ set((state) => ({
+ notifications: state.notifications.filter((n) => n.id !== id),
+ }));
+ },
+}));
\ No newline at end of file
diff --git a/src/advanced/shared/lib/useDebounce.ts b/src/advanced/shared/lib/useDebounce.ts
new file mode 100644
index 000000000..599aee279
--- /dev/null
+++ b/src/advanced/shared/lib/useDebounce.ts
@@ -0,0 +1,25 @@
+import { useState, useEffect } from "react";
+
+/**
+ * 값이 변경되면 지정된 시간(delay)만큼 기다렸다가 업데이트하는 훅
+ * @param value 관찰할 값
+ * @param delay 지연 시간 (ms)
+ * @returns 디바운스된 값
+ */
+export const useDebounce = (value: T, delay: number): T => {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ // 1. 타이머 설정: delay 후에 상태 업데이트
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ // 2. 클린업(Cleanup): 값이 또 바뀌면 이전 타이머 취소 (핵심)
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+};
diff --git a/src/advanced/shared/lib/useLocalStorage.ts b/src/advanced/shared/lib/useLocalStorage.ts
new file mode 100644
index 000000000..85ede0ae2
--- /dev/null
+++ b/src/advanced/shared/lib/useLocalStorage.ts
@@ -0,0 +1,38 @@
+import { useState, useEffect } from "react";
+
+/**
+ * 로컬 스토리지와 동기화되는 상태를 관리하는 커스텀 훅 (Shared Action)
+ * @param key 로컬 스토리지 키
+ * @param initialValue 초기값
+ */
+export const useLocalStorage = (
+ key: string,
+ initialValue: T
+): [T, React.Dispatch>] => {
+ // 1. 초기화 (Read Action): 마운트 시 한 번만 실행
+ const [storedValue, setStoredValue] = useState(() => {
+ try {
+ if (typeof window === "undefined") {
+ return initialValue;
+ }
+ const item = window.localStorage.getItem(key);
+ return item ? JSON.parse(item) : initialValue;
+ } catch (error) {
+ console.error(`Error reading localStorage key "${key}":`, error);
+ return initialValue;
+ }
+ });
+
+ // 2. 동기화 (Write Action): 값이 변경될 때마다 실행
+ useEffect(() => {
+ try {
+ if (typeof window !== "undefined") {
+ window.localStorage.setItem(key, JSON.stringify(storedValue));
+ }
+ } catch (error) {
+ console.error(`Error saving localStorage key "${key}":`, error);
+ }
+ }, [key, storedValue]);
+
+ return [storedValue, setStoredValue];
+};
\ No newline at end of file
diff --git a/src/advanced/shared/lib/useNotificationSystem.ts b/src/advanced/shared/lib/useNotificationSystem.ts
new file mode 100644
index 000000000..8f3c907e6
--- /dev/null
+++ b/src/advanced/shared/lib/useNotificationSystem.ts
@@ -0,0 +1,24 @@
+import { useState, useCallback } from "react";
+import { Notification } from "../model/types";
+
+export const useNotificationSystem = () => {
+ 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 };
+};
\ No newline at end of file
diff --git a/src/advanced/shared/model/types.ts b/src/advanced/shared/model/types.ts
new file mode 100644
index 000000000..9f97fcbb6
--- /dev/null
+++ b/src/advanced/shared/model/types.ts
@@ -0,0 +1,5 @@
+export interface Notification {
+ id: string;
+ message: string;
+ type: "error" | "success" | "warning";
+}
diff --git a/src/advanced/shared/ui/NotificationSystem.tsx b/src/advanced/shared/ui/NotificationSystem.tsx
new file mode 100644
index 000000000..c85d2b90a
--- /dev/null
+++ b/src/advanced/shared/ui/NotificationSystem.tsx
@@ -0,0 +1,59 @@
+// Global State
+import { useNotificationStore } from "../lib/notificationStore";
+
+// Model
+import { Notification } from "../model/types";
+
+// 알림 타입별 배경색 매핑
+const NOTIFICATION_STYLES: Record = {
+ error: "bg-red-600",
+ warning: "bg-yellow-600",
+ success: "bg-green-600",
+};
+
+/**
+ * 전역 알림 메시지를 렌더링하는 순수 UI 컴포넌트 (Smart Component)
+ * Zustand Store를 구독하여 알림 목록을 표시하고 닫기 이벤트를 처리합니다.
+ */
+export const NotificationSystem = () => {
+ // Store 구독
+ const notifications = useNotificationStore((state) => state.notifications);
+ const removeNotification = useNotificationStore((state) => state.removeNotification);
+
+ // 알림이 없으면 렌더링하지 않음
+ if (notifications.length === 0) return null;
+
+ return (
+
+ {notifications.map((notif) => (
+
+
{notif.message}
+
+
+ ))}
+
+ );
+};
\ No newline at end of file
diff --git a/src/advanced/widgets/AdminDashboard/ui/CouponListGrid.tsx b/src/advanced/widgets/AdminDashboard/ui/CouponListGrid.tsx
new file mode 100644
index 000000000..4f99d3bd6
--- /dev/null
+++ b/src/advanced/widgets/AdminDashboard/ui/CouponListGrid.tsx
@@ -0,0 +1,46 @@
+import { Coupon } from "../../../entities/coupon/model/types";
+
+interface Props {
+ coupons: Coupon[];
+ onDelete: (code: string) => void;
+ onAddClick: () => void;
+}
+
+export const CouponListGrid = ({ coupons, onDelete, onAddClick }: Props) => {
+ return (
+
+ {coupons.map((coupon) => (
+
+
+
+
{coupon.name}
+
{coupon.code}
+
+
+ {coupon.discountType === "amount"
+ ? `${coupon.discountValue.toLocaleString()}원 할인`
+ : `${coupon.discountValue}% 할인`}
+
+
+
+
+
+
+ ))}
+
+ {/* 추가 버튼 카드 */}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/advanced/widgets/AdminDashboard/ui/ProductListTable.tsx b/src/advanced/widgets/AdminDashboard/ui/ProductListTable.tsx
new file mode 100644
index 000000000..cccb459fd
--- /dev/null
+++ b/src/advanced/widgets/AdminDashboard/ui/ProductListTable.tsx
@@ -0,0 +1,57 @@
+import { ProductWithUI } from "../../../entities/product/model/types";
+import { formatCurrency } from "../../../shared/lib/format";
+
+interface Props {
+ products: ProductWithUI[];
+ onEdit: (product: ProductWithUI) => void;
+ onDelete: (id: string) => void;
+}
+
+export const ProductListTable = ({ products, onEdit, onDelete }: Props) => {
+ return (
+
+
+
+
+ {["상품명", "가격", "재고", "설명", "작업"].map((header) => (
+ |
+ {header}
+ |
+ ))}
+
+
+
+ {products.map((product) => (
+
+ |
+ {product.name}
+ {product.isRecommended && (BEST)}
+ |
+
+ {formatCurrency(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 || "-"}
+ |
+
+
+
+ |
+
+ ))}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/advanced/widgets/AdminDashboard/ui/index.tsx b/src/advanced/widgets/AdminDashboard/ui/index.tsx
new file mode 100644
index 000000000..d6baf5060
--- /dev/null
+++ b/src/advanced/widgets/AdminDashboard/ui/index.tsx
@@ -0,0 +1,262 @@
+import { useState } from "react";
+
+// Shared Store & Lib
+import { formatCurrency } from "../../../shared/lib/format";
+
+// Entities (Model)
+import { ProductWithUI } from "../../../entities/product/model/types";
+import { Coupon } from "../../../entities/coupon/model/types";
+
+// Features (Hooks - Selectors)
+// ✅ [핵심] 부모가 안 주니까, 내가 직접 훅을 불러서 씁니다.
+import { useProducts } from "../../../features/product/model/useProducts";
+import { useCoupons } from "../../../features/coupon/model/useCoupons";
+
+// Features (UI Forms)
+import { ProductManagementForm } from "../../../features/product/model/ui/ProductManagementForm";
+import { CouponManagementForm } from "../../../features/coupon/ui/CouponManagementForm";
+
+
+export const AdminDashboard = () => {
+ // --------------------------------------------------------------------------
+ // 1. Global State Connection (Hooks)
+ // --------------------------------------------------------------------------
+ const { products, addProduct, updateProduct, deleteProduct } = useProducts();
+ const { coupons, addCoupon, deleteCoupon } = useCoupons();
+
+ // --------------------------------------------------------------------------
+ // 2. Local UI State
+ // --------------------------------------------------------------------------
+ const [activeTab, setActiveTab] = useState<"products" | "coupons">("products");
+
+ // 상품 폼 상태
+ const [showProductForm, setShowProductForm] = useState(false);
+ const [editingProduct, setEditingProduct] = useState(null);
+
+ // 쿠폰 폼 상태
+ const [showCouponForm, setShowCouponForm] = useState(false);
+
+ // --------------------------------------------------------------------------
+ // 3. Handlers
+ // --------------------------------------------------------------------------
+ const handleProductSubmit = (productData: Omit) => {
+ if (editingProduct) {
+ updateProduct(editingProduct.id, productData);
+ } else {
+ addProduct(productData);
+ }
+ setEditingProduct(null);
+ setShowProductForm(false);
+ };
+
+ const handleEditClick = (product: ProductWithUI) => {
+ setEditingProduct(product);
+ setShowProductForm(true);
+ };
+
+ const handleCouponSubmit = (newCoupon: Coupon) => {
+ addCoupon(newCoupon);
+ setShowCouponForm(false);
+ };
+
+ // --------------------------------------------------------------------------
+ // 4. Render
+ // --------------------------------------------------------------------------
+ return (
+
+
+
관리자 대시보드
+
상품과 쿠폰을 관리할 수 있습니다
+
+
+
+
+
+
+ {activeTab === "products" ? (
+
+
+
+
상품 목록
+
+
+
+
+
+
+
+
+ | 상품명 |
+ 가격 |
+ 재고 |
+ 설명 |
+ 작업 |
+
+
+
+ {products.map((product) => (
+
+ |
+ {product.name}
+ {product.isRecommended && (BEST)}
+ |
+
+ {formatCurrency(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 && (
+ {
+ setShowProductForm(false);
+ setEditingProduct(null);
+ }}
+ />
+ )}
+
+ ) : (
+
+
+
쿠폰 관리
+
+
+
+ {coupons.map((coupon) => (
+
+
+
+
{coupon.name}
+
{coupon.code}
+
+
+ {coupon.discountType === "amount"
+ ? `${coupon.discountValue.toLocaleString()}원 할인`
+ : `${coupon.discountValue}% 할인`}
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ {showCouponForm && (
+
setShowCouponForm(false)}
+ />
+ )}
+
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/src/advanced/widgets/CartSidebar/ui/index.tsx b/src/advanced/widgets/CartSidebar/ui/index.tsx
new file mode 100644
index 000000000..92cc18037
--- /dev/null
+++ b/src/advanced/widgets/CartSidebar/ui/index.tsx
@@ -0,0 +1,233 @@
+import { CartItem } from "../../../entities/cart/model/types";
+import { Coupon } from "../../../entities/coupon/model/types";
+import {
+ calculateItemTotal,
+ calculateCartTotal,
+} from "../../../entities/cart/lib";
+import { formatCurrency } from "../../../shared/lib/format";
+
+interface Props {
+ cart: CartItem[];
+ coupons: Coupon[];
+ selectedCoupon: Coupon | null;
+
+ // Actions
+ onUpdateQuantity: (productId: string, newQuantity: number) => void;
+ onRemoveFromCart: (productId: string) => void;
+ onApplyCoupon: (coupon: Coupon) => void;
+ onCouponSelected: (coupon: Coupon | null) => void;
+ onCompleteOrder: () => void;
+}
+
+export const CartSidebar = ({
+ cart,
+ coupons,
+ selectedCoupon,
+ onUpdateQuantity,
+ onRemoveFromCart,
+ onApplyCoupon,
+ onCouponSelected,
+ onCompleteOrder,
+}: Props) => {
+ const { totalBeforeDiscount, totalAfterDiscount } = calculateCartTotal(
+ cart,
+ selectedCoupon
+ );
+
+ return (
+
+
+
+
+ 장바구니
+
+
+ {cart.length === 0 ? (
+
+ ) : (
+
+ {cart.map((item) => {
+ const itemTotal = calculateItemTotal(item, cart);
+ const originalPrice = item.product.price * item.quantity;
+ const hasDiscount = itemTotal < originalPrice;
+ const discountRate = hasDiscount
+ ? Math.round((1 - itemTotal / originalPrice) * 100)
+ : 0;
+
+ return (
+
+
+
+ {item.product.name}
+
+
+
+
+
+
+
+ {item.quantity}
+
+
+
+
+ {hasDiscount && (
+
+ -{discountRate}%
+
+ )}
+
+ {formatCurrency(Math.round(itemTotal))}
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {cart.length > 0 && (
+ <>
+
+
+
쿠폰 할인
+
+
+ {coupons.length > 0 && (
+
+ )}
+
+
+
+ 결제 정보
+
+
+ 상품 금액
+
+ {formatCurrency(totalBeforeDiscount)}
+
+
+
+ {totalBeforeDiscount - totalAfterDiscount > 0 && (
+
+ 할인 금액
+
+ -{formatCurrency(totalBeforeDiscount - totalAfterDiscount)}
+
+
+ )}
+
+
+ 결제 예정 금액
+
+ {formatCurrency(totalAfterDiscount)}
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+};
diff --git a/src/advanced/widgets/Header/ui/index.tsx b/src/advanced/widgets/Header/ui/index.tsx
new file mode 100644
index 000000000..88d2ba62d
--- /dev/null
+++ b/src/advanced/widgets/Header/ui/index.tsx
@@ -0,0 +1,90 @@
+import { CartItem } from "../../../entities/cart/model/types";
+
+interface Props {
+ // 1. 데이터 (Data)
+ cart: CartItem[];
+ isAdmin: boolean;
+ searchTerm: string;
+
+ // 2. 액션 (Event Handlers) -> 부모에게 위임
+ onToggleAdmin: () => void;
+ onSearchChange: (value: string) => void;
+}
+
+export const Header = ({
+ cart,
+ isAdmin,
+ onToggleAdmin,
+ searchTerm,
+ onSearchChange,
+}: Props) => {
+ // UI 로직: 장바구니 총 수량 계산
+ // (이 로직은 '장바구니' 도메인에 가깝지만, 배지 표시용 UI 로직이므로 여기서 계산해도 무방합니다.)
+ // 추후 features/cart/lib 등으로 이동할 수도 있습니다.
+ const totalItemCount = cart.reduce((acc, item) => acc + item.quantity, 0);
+
+ return (
+
+
+
+
+
SHOP
+
+ {/* 검색창 영역 */}
+ {!isAdmin && (
+
+ onSearchChange(e.target.value)}
+ placeholder="상품 검색..."
+ className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500"
+ />
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/src/advanced/widgets/ProductList/ui/index.tsx b/src/advanced/widgets/ProductList/ui/index.tsx
new file mode 100644
index 000000000..21c03c67f
--- /dev/null
+++ b/src/advanced/widgets/ProductList/ui/index.tsx
@@ -0,0 +1,59 @@
+import { ProductWithUI} from "../../../entities/product/model/types";
+import { CartItem } from "../../../entities/cart/model/types";
+import { ProductCard } from "../../../entities/product/ui/ProductCard";
+
+interface Props {
+ // 화면에 보여줄 목록 (검색 필터링된 결과)
+ products: ProductWithUI[];
+
+ // 전체 상품 개수 (헤더 표시용: '총 5개 상품')
+ totalCount: number;
+
+ // 재고 확인용
+ cart: CartItem[];
+
+ // 액션
+ onAddToCart: (product: ProductWithUI) => void;
+
+ // 검색어 (결과 없음 메시지용)
+ searchTerm: string;
+}
+
+export const ProductList = ({
+ products,
+ totalCount,
+ cart,
+ onAddToCart,
+ searchTerm,
+}: Props) => {
+ return (
+
+ {/* 위젯 헤더 */}
+
+
전체 상품
+
총 {totalCount}개 상품
+
+
+ {/* 검색 결과 없음 처리 */}
+ {products.length === 0 ? (
+
+
+ "{searchTerm}"에 대한 검색 결과가 없습니다.
+
+
+ ) : (
+ // 상품 목록
+
+ {products.map((product) => (
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/src/basic/App.tsx b/src/basic/App.tsx
index a4369fe1d..eb65056d2 100644
--- a/src/basic/App.tsx
+++ b/src/basic/App.tsx
@@ -1,1118 +1,161 @@
-import { useState, useCallback, useEffect } from 'react';
-import { CartItem, Coupon, Product } from '../types';
+import { useState, useCallback } from "react";
+import { Notification } from "./shared/model/types";
-interface ProductWithUI extends Product {
- description?: string;
- isRecommended?: boolean;
-}
+// Widgets (UI 조립)
+import { Header } from "./widgets/Header/ui";
+import { ProductList } from "./widgets/ProductList/ui";
+import { CartSidebar } from "./widgets/CartSidebar/ui";
+import { AdminDashboard } from "./widgets/AdminDashboard/ui";
-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
- }
-];
+// Feature Hooks (비즈니스 로직)
+import { useProducts } from "./features/product/model/useProducts";
+import { useCoupons } from "./features/coupon/model/useCoupons";
+import { useCart } from "./features/cart/model/useCart";
+import { useDebounce } from "./shared/lib/useDebounce";
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);
+ // 1. Shared Logic: 알림 시스템
+ // (테스트가 '알림 메시지 자동 사라짐'을 체크하므로 레거시 로직 유지)
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 addNotification = useCallback(
+ (message: string, type: "error" | "success" | "warning" = "success") => {
+ const id = Date.now().toString();
+ setNotifications((prev) => [...prev, { id, message, type }]);
- 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]);
+ setTimeout(() => {
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
+ }, 3000);
+ },
+ []
+ );
- 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);
- };
+ // 2. Feature Hooks: 도메인 로직 주입
+ const { products, addProduct, updateProduct, deleteProduct } =
+ useProducts(addNotification);
- const handleCouponSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- addCoupon(couponForm);
- setCouponForm({
- name: '',
- code: '',
- discountType: 'amount',
- discountValue: 0
- });
- setShowCouponForm(false);
- };
+ const { coupons, addCoupon, deleteCoupon } = useCoupons(addNotification);
- 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 {
+ cart,
+ selectedCoupon,
+ setSelectedCoupon,
+ addToCart,
+ removeFromCart,
+ updateQuantity,
+ applyCoupon,
+ completeOrder,
+ } = useCart(products, addNotification);
- const totals = calculateCartTotal();
+ // 3. UI State: 화면 제어
+ const [isAdmin, setIsAdmin] = useState(false);
+ const [searchTerm, setSearchTerm] = useState("");
+ const debouncedSearchTerm = useDebounce(searchTerm, 500);
+ // 검색 필터링 로직
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;
return (
+ {/* 알림 메시지 영역 (Shared UI) */}
{notifications.length > 0 && (
- {notifications.map(notif => (
+ {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"
- />
-
- )}
-
-
-
-
-
+
+ {/* 헤더 위젯 */}
+
setIsAdmin(!isAdmin)}
+ searchTerm={searchTerm}
+ onSearchChange={setSearchTerm}
+ />
{isAdmin ? (
-
-
-
관리자 대시보드
-
상품과 쿠폰을 관리할 수 있습니다
-
-
-
-
-
- {activeTab === 'products' ? (
-
-
-
-
상품 목록
- {
- setEditingProduct('new');
- setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] });
- setShowProductForm(true);
- }}
- className="px-4 py-2 bg-gray-900 text-white text-sm rounded-md hover:bg-gray-800"
- >
- 새 상품 추가
-
-
-
-
-
-
-
-
- | 상품명 |
- 가격 |
- 재고 |
- 설명 |
- 작업 |
-
-
-
- {(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 || '-'} |
-
- startEditProduct(product)}
- className="text-indigo-600 hover:text-indigo-900 mr-3"
- >
- 수정
-
- deleteProduct(product.id)}
- className="text-red-600 hover:text-red-900"
- >
- 삭제
-
- |
-
- ))}
-
-
-
- {showProductForm && (
-
- )}
-
- ) : (
-
-
-
쿠폰 관리
-
-
-
- {coupons.map(coupon => (
-
-
-
-
{coupon.name}
-
{coupon.code}
-
-
- {coupon.discountType === 'amount'
- ? `${coupon.discountValue.toLocaleString()}원 할인`
- : `${coupon.discountValue}% 할인`}
-
-
-
-
deleteCoupon(coupon.code)}
- className="text-gray-400 hover:text-red-600 transition-colors"
- >
-
-
-
-
-
-
- ))}
-
-
-
setShowCouponForm(!showCouponForm)}
- className="text-gray-400 hover:text-gray-600 flex flex-col items-center"
- >
-
-
-
- 새 쿠폰 추가
-
-
-
-
- {showCouponForm && (
-
- )}
-
-
- )}
-
+ // 관리자 대시보드 위젯
+
) : (
+ // 쇼핑몰 화면 위젯 조합
- {/* 상품 목록 */}
-
-
-
전체 상품
-
- 총 {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}개
- )}
-
-
- {/* 장바구니 버튼 */}
-
addToCart(product)}
- disabled={remainingStock <= 0}
- className={`w-full py-2 px-4 rounded-md font-medium transition-colors ${
- remainingStock <= 0
- ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
- : 'bg-gray-900 text-white hover:bg-gray-800'
- }`}
- >
- {remainingStock <= 0 ? '품절' : '장바구니 담기'}
-
-
-
- );
- })}
-
- )}
-
+
-
-
-
-
-
-
-
-
- 장바구니
-
- {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}
-
removeFromCart(item.product.id)}
- className="text-gray-400 hover:text-red-500 ml-2"
- >
-
-
-
-
-
-
-
- updateQuantity(item.product.id, item.quantity - 1)}
- className="w-6 h-6 rounded border border-gray-300 flex items-center justify-center hover:bg-gray-100"
- >
- −
-
- {item.quantity}
- updateQuantity(item.product.id, item.quantity + 1)}
- className="w-6 h-6 rounded border border-gray-300 flex items-center justify-center hover:bg-gray-100"
- >
- +
-
-
-
- {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()}원
-
-
-
-
- {totals.totalAfterDiscount.toLocaleString()}원 결제하기
-
-
-
-
- >
- )}
-
+
+
)}
@@ -1121,4 +164,4 @@ const App = () => {
);
};
-export default App;
\ No newline at end of file
+export default App;
diff --git a/src/basic/entities/cart/lib/index.ts b/src/basic/entities/cart/lib/index.ts
new file mode 100644
index 000000000..fe16e7985
--- /dev/null
+++ b/src/basic/entities/cart/lib/index.ts
@@ -0,0 +1,81 @@
+import { CartItem } from "../../../entities/cart/model/types";
+import { Coupon } from "../../../entities/coupon/model/types";
+
+/**
+ * 장바구니 아이템에 적용 가능한 최대 할인율을 계산합니다.
+ * (대량 구매 로직 포함)
+ */
+export const getMaxApplicableDiscount = (
+ item: CartItem,
+ cart: CartItem[]
+): number => {
+ const { discounts } = item.product;
+ const { quantity } = item;
+
+ // 1. 상품 자체의 수량 할인 확인
+ const baseDiscount = discounts.reduce((maxDiscount, discount) => {
+ return quantity >= discount.quantity && discount.rate > maxDiscount
+ ? discount.rate
+ : maxDiscount;
+ }, 0);
+
+ // 2. 장바구니 전체를 뒤져서 대량 구매 여부 확인 (비즈니스 룰)
+ const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10);
+
+ if (hasBulkPurchase) {
+ return Math.min(baseDiscount + 0.05, 0.5); // 추가 5% 할인, 최대 50%
+ }
+
+ return baseDiscount;
+};
+
+/**
+ * 장바구니 아이템 하나의 최종 가격을 계산합니다.
+ */
+export const calculateItemTotal = (
+ item: CartItem,
+ cart: CartItem[]
+): number => {
+ const { price } = item.product;
+ const { quantity } = item;
+ const discount = getMaxApplicableDiscount(item, cart);
+
+ return Math.round(price * quantity * (1 - discount));
+};
+
+/**
+ * 장바구니 전체 금액(할인 전/후)을 계산합니다.
+ */
+export const calculateCartTotal = (
+ cart: CartItem[],
+ selectedCoupon: Coupon | null
+) => {
+ let totalBeforeDiscount = 0;
+ let totalAfterDiscount = 0;
+
+ cart.forEach((item) => {
+ const itemPrice = item.product.price * item.quantity;
+ totalBeforeDiscount += itemPrice;
+ // calculateItemTotal을 재사용
+ totalAfterDiscount += calculateItemTotal(item, cart);
+ });
+
+ // 쿠폰 적용
+ if (selectedCoupon) {
+ if (selectedCoupon.discountType === "amount") {
+ totalAfterDiscount = Math.max(
+ 0,
+ totalAfterDiscount - selectedCoupon.discountValue
+ );
+ } else {
+ totalAfterDiscount = Math.round(
+ totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)
+ );
+ }
+ }
+
+ return {
+ totalBeforeDiscount: Math.round(totalBeforeDiscount),
+ totalAfterDiscount: Math.round(totalAfterDiscount),
+ };
+};
diff --git a/src/basic/entities/cart/model/types.ts b/src/basic/entities/cart/model/types.ts
new file mode 100644
index 000000000..8a6135ce3
--- /dev/null
+++ b/src/basic/entities/cart/model/types.ts
@@ -0,0 +1,6 @@
+import { ProductWithUI } from "../../product/model/types";
+
+export interface CartItem {
+ product: ProductWithUI;
+ quantity: number;
+}
diff --git a/src/basic/entities/coupon/lib/index.ts b/src/basic/entities/coupon/lib/index.ts
new file mode 100644
index 000000000..81e2946e4
--- /dev/null
+++ b/src/basic/entities/coupon/lib/index.ts
@@ -0,0 +1,17 @@
+import { Coupon } from "../../../entities/coupon/model/types";
+/**
+ * 쿠폰을 적용할 수 있는지 판단하는 순수 함수
+ * @param coupon 적용하려는 쿠폰
+ * @param currentTotalAmount 현재 장바구니 총액 (할인 전)
+ * @returns 적용 가능 여부
+ */
+export const canApplyCoupon = (
+ coupon: Coupon,
+ currentTotalAmount: number
+): boolean => {
+ // 비즈니스 규칙: 정률 할인은 10,000원 이상일 때만 가능
+ if (coupon.discountType === "percentage" && currentTotalAmount < 10000) {
+ return false;
+ }
+ return true;
+};
diff --git a/src/basic/entities/coupon/model/types.ts b/src/basic/entities/coupon/model/types.ts
new file mode 100644
index 000000000..5f5750118
--- /dev/null
+++ b/src/basic/entities/coupon/model/types.ts
@@ -0,0 +1,6 @@
+export interface Coupon {
+ name: string;
+ code: string;
+ discountType: "amount" | "percentage";
+ discountValue: number;
+}
diff --git a/src/basic/entities/product/lib/index.ts b/src/basic/entities/product/lib/index.ts
new file mode 100644
index 000000000..f5d225906
--- /dev/null
+++ b/src/basic/entities/product/lib/index.ts
@@ -0,0 +1,18 @@
+import { CartItem } from "../../../entities/cart/model/types";
+import { Product } from "../../../entities/product/model/types";
+
+
+/**
+ * 상품의 재고가 얼마나 남았는지 계산합니다.
+ * @param product 확인할 상품
+ * @param cart 현재 장바구니 상태 (전체 재고 확인을 위해 필요)
+ */
+export const getRemainingStock = (
+ product: Product,
+ cart: CartItem[]
+): 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/entities/product/model/types.ts b/src/basic/entities/product/model/types.ts
new file mode 100644
index 000000000..00e53cf6e
--- /dev/null
+++ b/src/basic/entities/product/model/types.ts
@@ -0,0 +1,17 @@
+export interface Discount {
+ quantity: number;
+ rate: number;
+}
+
+export interface Product {
+ id: string;
+ name: string;
+ price: number;
+ stock: number;
+ discounts: Discount[];
+}
+
+export interface ProductWithUI extends Product {
+ description?: string;
+ isRecommended?: boolean;
+}
diff --git a/src/basic/entities/product/ui/ProductCard.tsx b/src/basic/entities/product/ui/ProductCard.tsx
new file mode 100644
index 000000000..14731f851
--- /dev/null
+++ b/src/basic/entities/product/ui/ProductCard.tsx
@@ -0,0 +1,107 @@
+import { ProductWithUI} from "../../../entities/product/model/types";
+import { CartItem } from "../../../entities/cart/model/types";
+import { formatCurrencyWithSymbol } from "../../../shared/lib/format";
+import { getRemainingStock } from "../lib";
+
+interface Props {
+ product: ProductWithUI;
+ cart: CartItem[];
+ onAddToCart: (product: ProductWithUI) => void;
+}
+
+export const ProductCard = ({ product, cart, onAddToCart }: Props) => {
+ // 도메인 로직: 재고 계산
+ const remainingStock = getRemainingStock(product, cart);
+ const isSoldOut = remainingStock <= 0;
+
+ // UI 로직: 최대 할인율 계산 (배지용)
+ const maxDiscountRate = product.discounts.reduce(
+ (max, d) => Math.max(max, d.rate),
+ 0
+ );
+
+ return (
+
+ {/* 1. 이미지 및 배지 영역 */}
+
+
+
+ {/* BEST 배지 */}
+ {product.isRecommended && (
+
+ BEST
+
+ )}
+
+ {/* 할인율 배지 */}
+ {product.discounts.length > 0 && (
+
+ ~{Math.round(maxDiscountRate * 100)}%
+
+ )}
+
+
+ {/* 2. 상품 정보 영역 */}
+
+
{product.name}
+ {product.description && (
+
+ {product.description}
+
+ )}
+
+ {/* 가격 및 할인 정책 */}
+
+
+ {formatCurrencyWithSymbol(product.price)}
+
+ {product.discounts.length > 0 && (
+
+ {product.discounts[0].quantity}개 이상 구매시 할인{" "}
+ {Math.round(product.discounts[0].rate * 100)}%
+
+ )}
+
+
+ {/* 재고 상태 메시지 */}
+
+ {remainingStock <= 5 && remainingStock > 0 && (
+
+ 품절임박! {remainingStock}개 남음
+
+ )}
+ {remainingStock > 5 && (
+
재고 {remainingStock}개
+ )}
+
+
+ {/* 장바구니 버튼 */}
+
onAddToCart(product)}
+ disabled={isSoldOut}
+ className={`w-full py-2 px-4 rounded-md font-medium transition-colors ${
+ isSoldOut
+ ? "bg-gray-100 text-gray-400 cursor-not-allowed"
+ : "bg-gray-900 text-white hover:bg-gray-800"
+ }`}
+ >
+ {isSoldOut ? "품절" : "장바구니 담기"}
+
+
+
+ );
+};
diff --git a/src/basic/features/cart/model/useCart.ts b/src/basic/features/cart/model/useCart.ts
new file mode 100644
index 000000000..e9ac02b42
--- /dev/null
+++ b/src/basic/features/cart/model/useCart.ts
@@ -0,0 +1,116 @@
+import { useState, useCallback } from "react";
+import { CartItem } from "../../../entities/cart/model/types";
+import { ProductWithUI } from "../../../entities/product/model/types";
+import { Coupon } from "../../../entities/coupon/model/types";
+import { getRemainingStock } from "../../../entities/product/lib";
+import { calculateCartTotal } from "../../../entities/cart/lib";
+import { useLocalStorage } from "../../../shared/lib/useLocalStorage";
+import { canApplyCoupon } from "../../../entities/coupon/lib";
+
+export const useCart = (
+ products: ProductWithUI[],
+ addNotification: (msg: string, type?: "error" | "success" | "warning") => void
+) => {
+ const [cart, setCart] = useLocalStorage
("cart", []);
+ const [selectedCoupon, setSelectedCoupon] = useState(null);
+
+ const addToCart = useCallback(
+ (product: ProductWithUI) => {
+ const remaining = getRemainingStock(product, cart);
+ if (remaining <= 0) {
+ addNotification("재고가 부족합니다!", "error");
+ return;
+ }
+
+ setCart((prev) => {
+ const existing = prev.find((item) => item.product.id === product.id);
+ if (existing) {
+ if (existing.quantity + 1 > product.stock) {
+ addNotification(
+ `재고는 ${product.stock}개까지만 있습니다.`,
+ "error"
+ );
+ return prev;
+ }
+ return prev.map((item) =>
+ item.product.id === product.id
+ ? { ...item, quantity: item.quantity + 1 }
+ : item
+ );
+ }
+ return [...prev, { product, quantity: 1 }];
+ });
+ addNotification("장바구니에 담았습니다", "success");
+ },
+ [cart, addNotification]
+ );
+
+ const removeFromCart = useCallback((productId: string) => {
+ setCart((prev) => prev.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((prev) =>
+ prev.map((item) =>
+ item.product.id === productId
+ ? { ...item, quantity: newQuantity }
+ : item
+ )
+ );
+ },
+ [products, removeFromCart, addNotification]
+ );
+
+ const applyCoupon = useCallback(
+ (coupon: Coupon) => {
+ const { totalAfterDiscount } = calculateCartTotal(cart, null);
+ if (!canApplyCoupon(coupon, totalAfterDiscount)) {
+ addNotification(
+ "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.",
+ "error"
+ );
+ return;
+ }
+
+ setSelectedCoupon(coupon);
+ addNotification("쿠폰이 적용되었습니다.", "success");
+ },
+ [cart, addNotification]
+ );
+
+ const completeOrder = useCallback(() => {
+ const orderNumber = `ORD-${Date.now()}`;
+ addNotification(
+ `주문이 완료되었습니다. 주문번호: ${orderNumber}`,
+ "success"
+ );
+ setCart([]);
+ setSelectedCoupon(null);
+ }, [addNotification]);
+
+ return {
+ cart,
+ selectedCoupon,
+ setSelectedCoupon,
+ addToCart,
+ removeFromCart,
+ updateQuantity,
+ applyCoupon,
+ completeOrder,
+ };
+};
diff --git a/src/basic/features/coupon/model/useCoupons.ts b/src/basic/features/coupon/model/useCoupons.ts
new file mode 100644
index 000000000..659d98ed6
--- /dev/null
+++ b/src/basic/features/coupon/model/useCoupons.ts
@@ -0,0 +1,50 @@
+import { useCallback } from "react";
+import { Coupon } from "../../../entities/coupon/model/types";
+import { useLocalStorage } from "../../../shared/lib/useLocalStorage";
+
+const initialCoupons: Coupon[] = [
+ {
+ name: "5000원 할인",
+ code: "AMOUNT5000",
+ discountType: "amount",
+ discountValue: 5000,
+ },
+ {
+ name: "10% 할인",
+ code: "PERCENT10",
+ discountType: "percentage",
+ discountValue: 10,
+ },
+];
+
+export const useCoupons = (
+ addNotification: (msg: string, type?: "error" | "success") => void
+) => {
+ const [coupons, setCoupons] = useLocalStorage(
+ "coupons",
+ initialCoupons
+ );
+
+ 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));
+ addNotification("쿠폰이 삭제되었습니다.", "success");
+ },
+ [addNotification]
+ );
+
+ return { coupons, addCoupon, deleteCoupon };
+};
diff --git a/src/basic/features/product/model/useProducts.ts b/src/basic/features/product/model/useProducts.ts
new file mode 100644
index 000000000..916ab30c4
--- /dev/null
+++ b/src/basic/features/product/model/useProducts.ts
@@ -0,0 +1,81 @@
+import {useCallback } from "react";
+import { ProductWithUI} from "../../../entities/product/model/types";
+import { useLocalStorage } from "../../../shared/lib/useLocalStorage"
+
+// 초기 데이터 (실제로는 API에서 가져오거나 상수로 관리)
+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 useProducts = (
+ addNotification: (msg: string, type?: "error" | "success") => void
+) => {
+ const [products, setProducts] = useLocalStorage(
+ "products",
+ initialProducts
+ );
+
+ 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]
+ );
+
+ return { products, addProduct, updateProduct, deleteProduct };
+};
\ No newline at end of file
diff --git a/src/basic/shared/lib/format.ts b/src/basic/shared/lib/format.ts
new file mode 100644
index 000000000..a86bbaa09
--- /dev/null
+++ b/src/basic/shared/lib/format.ts
@@ -0,0 +1,20 @@
+/**
+ * 숫자를 한국 통화 형식으로 변환합니다.
+ * 예: 10000 -> "10,000원"
+ * @param value 금액
+ * @returns 포맷팅된 문자열
+ */
+export const formatCurrency = (value: number): string => {
+ // 순수 계산: 입력(number) -> 출력(string)
+ return `${value.toLocaleString()}원`;
+};
+
+/**
+ * 숫자를 ₩ 표시가 있는 통화 형식으로 변환합니다. (기존 코드의 비관리자용)- `src/shared/lib/useLocalStorage.ts` 생성: 로컬 스토리지 읽기/쓰기 로직을 제네릭 훅으로 캡슐화
+- Feature Hooks(`useCart`, `useProducts`, `useCoupons`)에서 중복되는 `useEffect` 및 저장소 접근 로직 제거
+- `JSON.parse` 에러 처리를 공통 훅 내부로 통합하여 안정성 확보
+- 비즈니스 로직에서 저장 매체(Implementation Detail)에 대한 의존성 제거
+ */
+export const formatCurrencyWithSymbol = (value: number): string => {
+ return `₩${value.toLocaleString()}`;
+};
diff --git a/src/basic/shared/lib/useDebounce.ts b/src/basic/shared/lib/useDebounce.ts
new file mode 100644
index 000000000..599aee279
--- /dev/null
+++ b/src/basic/shared/lib/useDebounce.ts
@@ -0,0 +1,25 @@
+import { useState, useEffect } from "react";
+
+/**
+ * 값이 변경되면 지정된 시간(delay)만큼 기다렸다가 업데이트하는 훅
+ * @param value 관찰할 값
+ * @param delay 지연 시간 (ms)
+ * @returns 디바운스된 값
+ */
+export const useDebounce = (value: T, delay: number): T => {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ // 1. 타이머 설정: delay 후에 상태 업데이트
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ // 2. 클린업(Cleanup): 값이 또 바뀌면 이전 타이머 취소 (핵심)
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+};
diff --git a/src/basic/shared/lib/useLocalStorage.ts b/src/basic/shared/lib/useLocalStorage.ts
new file mode 100644
index 000000000..85ede0ae2
--- /dev/null
+++ b/src/basic/shared/lib/useLocalStorage.ts
@@ -0,0 +1,38 @@
+import { useState, useEffect } from "react";
+
+/**
+ * 로컬 스토리지와 동기화되는 상태를 관리하는 커스텀 훅 (Shared Action)
+ * @param key 로컬 스토리지 키
+ * @param initialValue 초기값
+ */
+export const useLocalStorage = (
+ key: string,
+ initialValue: T
+): [T, React.Dispatch>] => {
+ // 1. 초기화 (Read Action): 마운트 시 한 번만 실행
+ const [storedValue, setStoredValue] = useState(() => {
+ try {
+ if (typeof window === "undefined") {
+ return initialValue;
+ }
+ const item = window.localStorage.getItem(key);
+ return item ? JSON.parse(item) : initialValue;
+ } catch (error) {
+ console.error(`Error reading localStorage key "${key}":`, error);
+ return initialValue;
+ }
+ });
+
+ // 2. 동기화 (Write Action): 값이 변경될 때마다 실행
+ useEffect(() => {
+ try {
+ if (typeof window !== "undefined") {
+ window.localStorage.setItem(key, JSON.stringify(storedValue));
+ }
+ } catch (error) {
+ console.error(`Error saving localStorage key "${key}":`, error);
+ }
+ }, [key, storedValue]);
+
+ return [storedValue, setStoredValue];
+};
\ No newline at end of file
diff --git a/src/basic/shared/lib/useNotification.ts b/src/basic/shared/lib/useNotification.ts
new file mode 100644
index 000000000..d2cadf375
--- /dev/null
+++ b/src/basic/shared/lib/useNotification.ts
@@ -0,0 +1,24 @@
+import { useState, useCallback } from "react";
+import { Notification } from "../model/types";
+
+export const useNotification = () => {
+ const [notifications, setNotifications] = useState([]);
+
+ const addNotification = useCallback(
+ (message: string, type: "error" | "success" | "warning" = "success") => {
+ const id = Date.now().toString();
+ setNotifications((prev) => [...prev, { id, message, type }]);
+
+ setTimeout(() => {
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
+ }, 3000);
+ },
+ []
+ );
+
+ const removeNotification = useCallback((id: string) => {
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
+ }, []);
+
+ return { notifications, addNotification, removeNotification };
+};
\ No newline at end of file
diff --git a/src/basic/shared/model/types.ts b/src/basic/shared/model/types.ts
new file mode 100644
index 000000000..9f97fcbb6
--- /dev/null
+++ b/src/basic/shared/model/types.ts
@@ -0,0 +1,5 @@
+export interface Notification {
+ id: string;
+ message: string;
+ type: "error" | "success" | "warning";
+}
diff --git a/src/basic/widgets/AdminDashboard/ui/index.tsx b/src/basic/widgets/AdminDashboard/ui/index.tsx
new file mode 100644
index 000000000..a573fb722
--- /dev/null
+++ b/src/basic/widgets/AdminDashboard/ui/index.tsx
@@ -0,0 +1,653 @@
+import { useState } from "react";
+import { ProductWithUI} from "../../../entities/product/model/types";
+import { Coupon } from "../../../entities/coupon/model/types";
+import { formatCurrency } from "../../../shared/lib/format";
+
+interface Props {
+ products: ProductWithUI[];
+ coupons: Coupon[];
+ onAddProduct: (product: Omit) => void;
+ onUpdateProduct: (id: string, product: Partial) => void;
+ onDeleteProduct: (id: string) => void;
+ onAddCoupon: (coupon: Coupon) => void;
+ onDeleteCoupon: (id: string) => void;
+ // 알림 기능 주입 추가
+ onNotification: (message: string, type?: "error" | "success" | "warning") => void;
+}
+
+export const AdminDashboard = ({
+ products,
+ coupons,
+ onAddProduct,
+ onUpdateProduct,
+ onDeleteProduct,
+ onAddCoupon,
+ onDeleteCoupon,
+ onNotification, // Props로 받기
+}: Props) => {
+ const [activeTab, setActiveTab] = useState<"products" | "coupons">("products");
+ const [showProductForm, setShowProductForm] = useState(false);
+ const [editingProduct, setEditingProduct] = useState(null);
+
+ const [productForm, setProductForm] = useState({
+ name: "",
+ price: 0,
+ stock: 0,
+ description: "",
+ discounts: [] as Array<{ quantity: number; rate: number }>,
+ isRecommended: false,
+ });
+
+ const [showCouponForm, setShowCouponForm] = useState(false);
+ const [couponForm, setCouponForm] = useState({
+ name: "",
+ code: "",
+ discountType: "amount" as "amount" | "percentage",
+ discountValue: 0,
+ });
+
+ const handleProductSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (editingProduct && editingProduct !== "new") {
+ onUpdateProduct(editingProduct, productForm);
+ } else {
+ onAddProduct(productForm);
+ }
+ setProductForm({
+ name: "",
+ price: 0,
+ stock: 0,
+ description: "",
+ discounts: [],
+ isRecommended: false,
+ });
+ setEditingProduct(null);
+ setShowProductForm(false);
+ };
+
+ const handleCouponSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onAddCoupon(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 || [],
+ isRecommended: product.isRecommended || false,
+ });
+ setShowProductForm(true);
+ };
+
+ return (
+
+
+
관리자 대시보드
+
상품과 쿠폰을 관리할 수 있습니다
+
+
+
+
+
+
+ {activeTab === "products" ? (
+
+
+
+
상품 목록
+ {
+ setEditingProduct("new");
+ setProductForm({
+ name: "",
+ price: 0,
+ stock: 0,
+ description: "",
+ discounts: [],
+ isRecommended: false,
+ });
+ setShowProductForm(true);
+ }}
+ className="px-4 py-2 bg-gray-900 text-white text-sm rounded-md hover:bg-gray-800"
+ >
+ 새 상품 추가
+
+
+
+
+
+
+
+
+ |
+ 상품명
+ |
+
+ 가격
+ |
+
+ 재고
+ |
+
+ 설명
+ |
+
+ 작업
+ |
+
+
+
+ {products.map((product) => (
+
+ |
+ {product.name}
+ {product.isRecommended && (BEST)}
+ |
+
+ {formatCurrency(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 || "-"}
+ |
+
+ startEditProduct(product)}
+ className="text-indigo-600 hover:text-indigo-900 mr-3"
+ >
+ 수정
+
+ onDeleteProduct(product.id)}
+ className="text-red-600 hover:text-red-900"
+ >
+ 삭제
+
+ |
+
+ ))}
+
+
+
+
+ {showProductForm && (
+
+ )}
+
+ ) : (
+
+
+
쿠폰 관리
+
+
+
+ {coupons.map((coupon) => (
+
+
+
+
+ {coupon.name}
+
+
+ {coupon.code}
+
+
+
+ {coupon.discountType === "amount"
+ ? `${coupon.discountValue.toLocaleString()}원 할인`
+ : `${coupon.discountValue}% 할인`}
+
+
+
+
onDeleteCoupon(coupon.code)}
+ className="text-gray-400 hover:text-red-600 transition-colors"
+ >
+
+
+
+
+
+
+ ))}
+
+
+
setShowCouponForm(!showCouponForm)}
+ className="text-gray-400 hover:text-gray-600 flex flex-col items-center"
+ >
+
+
+
+ 새 쿠폰 추가
+
+
+
+
+ {showCouponForm && (
+
+ )}
+
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/src/basic/widgets/CartSidebar/ui/index.tsx b/src/basic/widgets/CartSidebar/ui/index.tsx
new file mode 100644
index 000000000..89e96b842
--- /dev/null
+++ b/src/basic/widgets/CartSidebar/ui/index.tsx
@@ -0,0 +1,230 @@
+import { CartItem } from "../../../entities/cart/model/types";
+import { Coupon } from "../../../entities/coupon/model/types";
+import {
+ calculateItemTotal,
+ calculateCartTotal,
+} from "../../../entities/cart/lib";
+import { formatCurrency } from "../../../shared/lib/format";
+
+interface Props {
+ cart: CartItem[];
+ coupons: Coupon[];
+ selectedCoupon: Coupon | null;
+
+ // Actions
+ onUpdateQuantity: (productId: string, newQuantity: number) => void;
+ onRemoveFromCart: (productId: string) => void;
+ onApplyCoupon: (coupon: Coupon) => void;
+ onCouponSelected: (coupon: Coupon | null) => void;
+ onCompleteOrder: () => void;
+}
+
+export const CartSidebar = ({
+ cart,
+ coupons,
+ selectedCoupon,
+ onUpdateQuantity,
+ onRemoveFromCart,
+ onApplyCoupon,
+ onCouponSelected,
+ onCompleteOrder,
+}: Props) => {
+ const { totalBeforeDiscount, totalAfterDiscount } = calculateCartTotal(
+ cart,
+ selectedCoupon
+ );
+
+ return (
+
+
+
+
+
+
+ 장바구니
+
+
+ {cart.length === 0 ? (
+
+ ) : (
+
+ {cart.map((item) => {
+ const itemTotal = calculateItemTotal(item, cart);
+ const originalPrice = item.product.price * item.quantity;
+ const hasDiscount = itemTotal < originalPrice;
+ const discountRate = hasDiscount
+ ? Math.round((1 - itemTotal / originalPrice) * 100)
+ : 0;
+
+ return (
+
+
+
+ {item.product.name}
+
+
onRemoveFromCart(item.product.id)}
+ className="text-gray-400 hover:text-red-500 ml-2"
+ >
+
+
+
+
+
+
+
+
+ onUpdateQuantity(item.product.id, item.quantity - 1)
+ }
+ className="w-6 h-6 rounded border border-gray-300 flex items-center justify-center hover:bg-gray-100"
+ >
+ −
+
+
+ {item.quantity}
+
+
+ onUpdateQuantity(item.product.id, item.quantity + 1)
+ }
+ className="w-6 h-6 rounded border border-gray-300 flex items-center justify-center hover:bg-gray-100"
+ >
+ +
+
+
+
+ {hasDiscount && (
+
+ -{discountRate}%
+
+ )}
+
+ {formatCurrency(Math.round(itemTotal))}
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {cart.length > 0 && (
+ <>
+
+
+
쿠폰 할인
+
+ 쿠폰 등록
+
+
+ {coupons.length > 0 && (
+
+ )}
+
+
+
+ 결제 정보
+
+
+ 상품 금액
+
+ {formatCurrency(totalBeforeDiscount)}
+
+
+
+ {totalBeforeDiscount - totalAfterDiscount > 0 && (
+
+ 할인 금액
+
+ -{formatCurrency(totalBeforeDiscount - totalAfterDiscount)}
+
+
+ )}
+
+
+ 결제 예정 금액
+
+ {formatCurrency(totalAfterDiscount)}
+
+
+
+
+
+ {formatCurrency(totalAfterDiscount)} 결제하기
+
+
+
+
+ >
+ )}
+
+ );
+};
diff --git a/src/basic/widgets/Header/ui/index.tsx b/src/basic/widgets/Header/ui/index.tsx
new file mode 100644
index 000000000..7e44a7d1f
--- /dev/null
+++ b/src/basic/widgets/Header/ui/index.tsx
@@ -0,0 +1,89 @@
+import { CartItem } from "../../../entities/cart/model/types";
+interface Props {
+ // 1. 데이터 (Data)
+ cart: CartItem[];
+ isAdmin: boolean;
+ searchTerm: string;
+
+ // 2. 액션 (Event Handlers) -> 부모에게 위임
+ onToggleAdmin: () => void;
+ onSearchChange: (value: string) => void;
+}
+
+export const Header = ({
+ cart,
+ isAdmin,
+ onToggleAdmin,
+ searchTerm,
+ onSearchChange,
+}: Props) => {
+ // UI 로직: 장바구니 총 수량 계산
+ // (이 로직은 '장바구니' 도메인에 가깝지만, 배지 표시용 UI 로직이므로 여기서 계산해도 무방합니다.)
+ // 추후 features/cart/lib 등으로 이동할 수도 있습니다.
+ const totalItemCount = cart.reduce((acc, item) => acc + item.quantity, 0);
+
+ return (
+
+
+
+
+
SHOP
+
+ {/* 검색창 영역 */}
+ {!isAdmin && (
+
+ onSearchChange(e.target.value)}
+ placeholder="상품 검색..."
+ className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500"
+ />
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/src/basic/widgets/ProductList/ui/index.tsx b/src/basic/widgets/ProductList/ui/index.tsx
new file mode 100644
index 000000000..21c03c67f
--- /dev/null
+++ b/src/basic/widgets/ProductList/ui/index.tsx
@@ -0,0 +1,59 @@
+import { ProductWithUI} from "../../../entities/product/model/types";
+import { CartItem } from "../../../entities/cart/model/types";
+import { ProductCard } from "../../../entities/product/ui/ProductCard";
+
+interface Props {
+ // 화면에 보여줄 목록 (검색 필터링된 결과)
+ products: ProductWithUI[];
+
+ // 전체 상품 개수 (헤더 표시용: '총 5개 상품')
+ totalCount: number;
+
+ // 재고 확인용
+ cart: CartItem[];
+
+ // 액션
+ onAddToCart: (product: ProductWithUI) => void;
+
+ // 검색어 (결과 없음 메시지용)
+ searchTerm: string;
+}
+
+export const ProductList = ({
+ products,
+ totalCount,
+ cart,
+ onAddToCart,
+ searchTerm,
+}: Props) => {
+ return (
+
+ {/* 위젯 헤더 */}
+
+
전체 상품
+
총 {totalCount}개 상품
+
+
+ {/* 검색 결과 없음 처리 */}
+ {products.length === 0 ? (
+
+
+ "{searchTerm}"에 대한 검색 결과가 없습니다.
+
+
+ ) : (
+ // 상품 목록
+
+ {products.map((product) => (
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/src/origin/App.tsx b/src/origin/App.tsx
index a4369fe1d..4ef0350e8 100644
--- a/src/origin/App.tsx
+++ b/src/origin/App.tsx
@@ -1,5 +1,5 @@
-import { useState, useCallback, useEffect } from 'react';
-import { CartItem, Coupon, Product } from '../types';
+import { useState, useCallback, useEffect } from "react";
+import { CartItem, Coupon, Product } from "../types";
interface ProductWithUI extends Product {
description?: string;
@@ -9,65 +9,62 @@ interface ProductWithUI extends Product {
interface Notification {
id: string;
message: string;
- type: 'error' | 'success' | 'warning';
+ type: "error" | "success" | "warning";
}
// 초기 데이터
const initialProducts: ProductWithUI[] = [
{
- id: 'p1',
- name: '상품1',
+ id: "p1",
+ name: "상품1",
price: 10000,
stock: 20,
discounts: [
{ quantity: 10, rate: 0.1 },
- { quantity: 20, rate: 0.2 }
+ { quantity: 20, rate: 0.2 },
],
- description: '최고급 품질의 프리미엄 상품입니다.'
+ description: "최고급 품질의 프리미엄 상품입니다.",
},
{
- id: 'p2',
- name: '상품2',
+ id: "p2",
+ name: "상품2",
price: 20000,
stock: 20,
- discounts: [
- { quantity: 10, rate: 0.15 }
- ],
- description: '다양한 기능을 갖춘 실용적인 상품입니다.',
- isRecommended: true
+ discounts: [{ quantity: 10, rate: 0.15 }],
+ description: "다양한 기능을 갖춘 실용적인 상품입니다.",
+ isRecommended: true,
},
{
- id: 'p3',
- name: '상품3',
+ id: "p3",
+ name: "상품3",
price: 30000,
stock: 20,
discounts: [
{ quantity: 10, rate: 0.2 },
- { quantity: 30, rate: 0.25 }
+ { quantity: 30, rate: 0.25 },
],
- description: '대용량과 고성능을 자랑하는 상품입니다.'
- }
+ description: "대용량과 고성능을 자랑하는 상품입니다.",
+ },
];
const initialCoupons: Coupon[] = [
{
- name: '5000원 할인',
- code: 'AMOUNT5000',
- discountType: 'amount',
- discountValue: 5000
+ name: "5000원 할인",
+ code: "AMOUNT5000",
+ discountType: "amount",
+ discountValue: 5000,
},
{
- name: '10% 할인',
- code: 'PERCENT10',
- discountType: 'percentage',
- discountValue: 10
- }
+ name: "10% 할인",
+ code: "PERCENT10",
+ discountType: "percentage",
+ discountValue: 10,
+ },
];
const App = () => {
-
const [products, setProducts] = useState(() => {
- const saved = localStorage.getItem('products');
+ const saved = localStorage.getItem("products");
if (saved) {
try {
return JSON.parse(saved);
@@ -79,7 +76,7 @@ const App = () => {
});
const [cart, setCart] = useState(() => {
- const saved = localStorage.getItem('cart');
+ const saved = localStorage.getItem("cart");
if (saved) {
try {
return JSON.parse(saved);
@@ -91,7 +88,7 @@ const App = () => {
});
const [coupons, setCoupons] = useState(() => {
- const saved = localStorage.getItem('coupons');
+ const saved = localStorage.getItem("coupons");
if (saved) {
try {
return JSON.parse(saved);
@@ -106,59 +103,60 @@ const App = () => {
const [isAdmin, setIsAdmin] = useState(false);
const [notifications, setNotifications] = useState([]);
const [showCouponForm, setShowCouponForm] = useState(false);
- const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products');
+ const [activeTab, setActiveTab] = useState<"products" | "coupons">(
+ "products"
+ );
const [showProductForm, setShowProductForm] = useState(false);
- const [searchTerm, setSearchTerm] = useState('');
- const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
+ const [searchTerm, setSearchTerm] = useState("");
+ const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
// Admin
const [editingProduct, setEditingProduct] = useState(null);
const [productForm, setProductForm] = useState({
- name: '',
+ name: "",
price: 0,
stock: 0,
- description: '',
- discounts: [] as Array<{ quantity: number; rate: number }>
+ description: "",
+ discounts: [] as Array<{ quantity: number; rate: number }>,
});
const [couponForm, setCouponForm] = useState({
- name: '',
- code: '',
- discountType: 'amount' as 'amount' | 'percentage',
- discountValue: 0
+ name: "",
+ code: "",
+ discountType: "amount" as "amount" | "percentage",
+ discountValue: 0,
});
-
const formatPrice = (price: number, productId?: string): string => {
if (productId) {
- const product = products.find(p => p.id === productId);
+ const product = products.find((p) => p.id === productId);
if (product && getRemainingStock(product) <= 0) {
- return 'SOLD OUT';
+ return "SOLD OUT";
}
}
if (isAdmin) {
return `${price.toLocaleString()}원`;
}
-
+
return `₩${price.toLocaleString()}`;
};
const getMaxApplicableDiscount = (item: CartItem): number => {
const { discounts } = item.product;
const { quantity } = item;
-
+
const baseDiscount = discounts.reduce((maxDiscount, discount) => {
- return quantity >= discount.quantity && discount.rate > maxDiscount
- ? discount.rate
+ return quantity >= discount.quantity && discount.rate > maxDiscount
+ ? discount.rate
: maxDiscount;
}, 0);
-
- const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10);
+
+ const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10);
if (hasBulkPurchase) {
return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인
}
-
+
return baseDiscount;
};
@@ -166,7 +164,7 @@ const App = () => {
const { price } = item.product;
const { quantity } = item;
const discount = getMaxApplicableDiscount(item);
-
+
return Math.round(price * quantity * (1 - discount));
};
@@ -177,44 +175,51 @@ const App = () => {
let totalBeforeDiscount = 0;
let totalAfterDiscount = 0;
- cart.forEach(item => {
+ cart.forEach((item) => {
const itemPrice = item.product.price * item.quantity;
totalBeforeDiscount += itemPrice;
totalAfterDiscount += calculateItemTotal(item);
});
if (selectedCoupon) {
- if (selectedCoupon.discountType === 'amount') {
- totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue);
+ if (selectedCoupon.discountType === "amount") {
+ totalAfterDiscount = Math.max(
+ 0,
+ totalAfterDiscount - selectedCoupon.discountValue
+ );
} else {
- totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100));
+ totalAfterDiscount = Math.round(
+ totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)
+ );
}
}
return {
totalBeforeDiscount: Math.round(totalBeforeDiscount),
- totalAfterDiscount: Math.round(totalAfterDiscount)
+ totalAfterDiscount: Math.round(totalAfterDiscount),
};
};
const getRemainingStock = (product: Product): number => {
- const cartItem = cart.find(item => item.product.id === product.id);
+ const cartItem = cart.find((item) => item.product.id === product.id);
const remaining = product.stock - (cartItem?.quantity || 0);
-
+
return remaining;
};
- const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => {
- const id = Date.now().toString();
- setNotifications(prev => [...prev, { id, message, type }]);
-
- setTimeout(() => {
- setNotifications(prev => prev.filter(n => n.id !== id));
- }, 3000);
- }, []);
+ const addNotification = useCallback(
+ (message: string, type: "error" | "success" | "warning" = "success") => {
+ const id = Date.now().toString();
+ setNotifications((prev) => [...prev, { id, message, type }]);
+
+ setTimeout(() => {
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
+ }, 3000);
+ },
+ []
+ );
const [totalItemCount, setTotalItemCount] = useState(0);
-
useEffect(() => {
const count = cart.reduce((sum, item) => sum + item.quantity, 0);
@@ -222,18 +227,18 @@ const App = () => {
}, [cart]);
useEffect(() => {
- localStorage.setItem('products', JSON.stringify(products));
+ localStorage.setItem("products", JSON.stringify(products));
}, [products]);
useEffect(() => {
- localStorage.setItem('coupons', JSON.stringify(coupons));
+ localStorage.setItem("coupons", JSON.stringify(coupons));
}, [coupons]);
useEffect(() => {
if (cart.length > 0) {
- localStorage.setItem('cart', JSON.stringify(cart));
+ localStorage.setItem("cart", JSON.stringify(cart));
} else {
- localStorage.removeItem('cart');
+ localStorage.removeItem("cart");
}
}, [cart]);
@@ -244,139 +249,180 @@ const App = () => {
return () => clearTimeout(timer);
}, [searchTerm]);
- const addToCart = useCallback((product: ProductWithUI) => {
- const remainingStock = getRemainingStock(product);
- if (remainingStock <= 0) {
- addNotification('재고가 부족합니다!', 'error');
- return;
- }
+ const addToCart = useCallback(
+ (product: ProductWithUI) => {
+ const remainingStock = getRemainingStock(product);
+ if (remainingStock <= 0) {
+ addNotification("재고가 부족합니다!", "error");
+ return;
+ }
+
+ setCart((prevCart) => {
+ const existingItem = prevCart.find(
+ (item) => item.product.id === product.id
+ );
+
+ if (existingItem) {
+ const newQuantity = existingItem.quantity + 1;
- setCart(prevCart => {
- const existingItem = prevCart.find(item => item.product.id === product.id);
-
- if (existingItem) {
- const newQuantity = existingItem.quantity + 1;
-
- if (newQuantity > product.stock) {
- addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error');
- return prevCart;
+ if (newQuantity > product.stock) {
+ addNotification(
+ `재고는 ${product.stock}개까지만 있습니다.`,
+ "error"
+ );
+ return prevCart;
+ }
+
+ return prevCart.map((item) =>
+ item.product.id === product.id
+ ? { ...item, quantity: newQuantity }
+ : item
+ );
}
- return prevCart.map(item =>
- item.product.id === product.id
- ? { ...item, quantity: newQuantity }
- : item
- );
- }
-
- return [...prevCart, { product, quantity: 1 }];
- });
-
- addNotification('장바구니에 담았습니다', 'success');
- }, [cart, addNotification, getRemainingStock]);
+ return [...prevCart, { product, quantity: 1 }];
+ });
+
+ addNotification("장바구니에 담았습니다", "success");
+ },
+ [cart, addNotification, getRemainingStock]
+ );
const removeFromCart = useCallback((productId: string) => {
- setCart(prevCart => prevCart.filter(item => item.product.id !== productId));
+ setCart((prevCart) =>
+ prevCart.filter((item) => item.product.id !== productId)
+ );
}, []);
- const updateQuantity = useCallback((productId: string, newQuantity: number) => {
- if (newQuantity <= 0) {
- removeFromCart(productId);
- return;
- }
+ const updateQuantity = useCallback(
+ (productId: string, newQuantity: number) => {
+ if (newQuantity <= 0) {
+ removeFromCart(productId);
+ return;
+ }
- const product = products.find(p => p.id === productId);
- if (!product) return;
+ const product = products.find((p) => p.id === productId);
+ if (!product) return;
- const maxStock = product.stock;
- if (newQuantity > maxStock) {
- addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error');
- return;
- }
+ const maxStock = product.stock;
+ if (newQuantity > maxStock) {
+ addNotification(`재고는 ${maxStock}개까지만 있습니다.`, "error");
+ return;
+ }
- setCart(prevCart =>
- prevCart.map(item =>
- item.product.id === productId
- ? { ...item, quantity: newQuantity }
- : item
- )
- );
- }, [products, removeFromCart, addNotification, getRemainingStock]);
-
- const applyCoupon = useCallback((coupon: Coupon) => {
- const currentTotal = calculateCartTotal().totalAfterDiscount;
-
- if (currentTotal < 10000 && coupon.discountType === 'percentage') {
- addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error');
- return;
- }
+ setCart((prevCart) =>
+ prevCart.map((item) =>
+ item.product.id === productId
+ ? { ...item, quantity: newQuantity }
+ : item
+ )
+ );
+ },
+ [products, removeFromCart, addNotification, getRemainingStock]
+ );
+
+ const applyCoupon = useCallback(
+ (coupon: Coupon) => {
+ const currentTotal = calculateCartTotal().totalAfterDiscount;
- setSelectedCoupon(coupon);
- addNotification('쿠폰이 적용되었습니다.', 'success');
- }, [addNotification, calculateCartTotal]);
+ if (currentTotal < 10000 && coupon.discountType === "percentage") {
+ addNotification(
+ "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.",
+ "error"
+ );
+ return;
+ }
+
+ setSelectedCoupon(coupon);
+ addNotification("쿠폰이 적용되었습니다.", "success");
+ },
+ [addNotification, calculateCartTotal]
+ );
const completeOrder = useCallback(() => {
const orderNumber = `ORD-${Date.now()}`;
- addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success');
+ addNotification(
+ `주문이 완료되었습니다. 주문번호: ${orderNumber}`,
+ "success"
+ );
setCart([]);
setSelectedCoupon(null);
}, [addNotification]);
- const addProduct = useCallback((newProduct: Omit) => {
- const product: ProductWithUI = {
- ...newProduct,
- id: `p${Date.now()}`
- };
- setProducts(prev => [...prev, product]);
- addNotification('상품이 추가되었습니다.', 'success');
- }, [addNotification]);
+ const addProduct = useCallback(
+ (newProduct: Omit) => {
+ const product: ProductWithUI = {
+ ...newProduct,
+ id: `p${Date.now()}`,
+ };
+ setProducts((prev) => [...prev, product]);
+ addNotification("상품이 추가되었습니다.", "success");
+ },
+ [addNotification]
+ );
- const updateProduct = useCallback((productId: string, updates: Partial) => {
- setProducts(prev =>
- prev.map(product =>
- product.id === productId
- ? { ...product, ...updates }
- : product
- )
- );
- addNotification('상품이 수정되었습니다.', 'success');
- }, [addNotification]);
+ const updateProduct = useCallback(
+ (productId: string, updates: Partial) => {
+ setProducts((prev) =>
+ prev.map((product) =>
+ product.id === productId ? { ...product, ...updates } : product
+ )
+ );
+ addNotification("상품이 수정되었습니다.", "success");
+ },
+ [addNotification]
+ );
- const deleteProduct = useCallback((productId: string) => {
- setProducts(prev => prev.filter(p => p.id !== productId));
- addNotification('상품이 삭제되었습니다.', 'success');
- }, [addNotification]);
+ const deleteProduct = useCallback(
+ (productId: string) => {
+ setProducts((prev) => prev.filter((p) => p.id !== productId));
+ addNotification("상품이 삭제되었습니다.", "success");
+ },
+ [addNotification]
+ );
- const addCoupon = useCallback((newCoupon: Coupon) => {
- const existingCoupon = coupons.find(c => c.code === newCoupon.code);
- if (existingCoupon) {
- addNotification('이미 존재하는 쿠폰 코드입니다.', 'error');
- return;
- }
- setCoupons(prev => [...prev, newCoupon]);
- addNotification('쿠폰이 추가되었습니다.', 'success');
- }, [coupons, addNotification]);
-
- const deleteCoupon = useCallback((couponCode: string) => {
- setCoupons(prev => prev.filter(c => c.code !== couponCode));
- if (selectedCoupon?.code === couponCode) {
- setSelectedCoupon(null);
- }
- addNotification('쿠폰이 삭제되었습니다.', 'success');
- }, [selectedCoupon, addNotification]);
+ const addCoupon = useCallback(
+ (newCoupon: Coupon) => {
+ const existingCoupon = coupons.find((c) => c.code === newCoupon.code);
+ if (existingCoupon) {
+ addNotification("이미 존재하는 쿠폰 코드입니다.", "error");
+ return;
+ }
+ setCoupons((prev) => [...prev, newCoupon]);
+ addNotification("쿠폰이 추가되었습니다.", "success");
+ },
+ [coupons, addNotification]
+ );
+
+ const deleteCoupon = useCallback(
+ (couponCode: string) => {
+ setCoupons((prev) => prev.filter((c) => c.code !== couponCode));
+ if (selectedCoupon?.code === couponCode) {
+ setSelectedCoupon(null);
+ }
+ addNotification("쿠폰이 삭제되었습니다.", "success");
+ },
+ [selectedCoupon, addNotification]
+ );
const handleProductSubmit = (e: React.FormEvent) => {
e.preventDefault();
- if (editingProduct && editingProduct !== 'new') {
+ if (editingProduct && editingProduct !== "new") {
updateProduct(editingProduct, productForm);
setEditingProduct(null);
} else {
addProduct({
...productForm,
- discounts: productForm.discounts
+ discounts: productForm.discounts,
});
}
- setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] });
+ setProductForm({
+ name: "",
+ price: 0,
+ stock: 0,
+ description: "",
+ discounts: [],
+ });
setEditingProduct(null);
setShowProductForm(false);
};
@@ -385,10 +431,10 @@ const App = () => {
e.preventDefault();
addCoupon(couponForm);
setCouponForm({
- name: '',
- code: '',
- discountType: 'amount',
- discountValue: 0
+ name: "",
+ code: "",
+ discountType: "amount",
+ discountValue: 0,
});
setShowCouponForm(false);
};
@@ -399,8 +445,8 @@ const App = () => {
name: product.name,
price: product.price,
stock: product.stock,
- description: product.description || '',
- discounts: product.discounts || []
+ description: product.description || "",
+ discounts: product.discounts || [],
});
setShowProductForm(true);
};
@@ -408,9 +454,15 @@ const App = () => {
const totals = calculateCartTotal();
const filteredProducts = debouncedSearchTerm
- ? products.filter(product =>
- product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
- (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase()))
+ ? products.filter(
+ (product) =>
+ product.name
+ .toLowerCase()
+ .includes(debouncedSearchTerm.toLowerCase()) ||
+ (product.description &&
+ product.description
+ .toLowerCase()
+ .includes(debouncedSearchTerm.toLowerCase()))
)
: products;
@@ -418,22 +470,38 @@ const App = () => {
{notifications.length > 0 && (
- {notifications.map(notif => (
+ {notifications.map((notif) => (
{notif.message}
-
setNotifications(prev => prev.filter(n => n.id !== notif.id))}
+
+ setNotifications((prev) =>
+ prev.filter((n) => n.id !== notif.id)
+ )
+ }
className="text-white hover:text-gray-200"
>
-
-
+
+
@@ -462,17 +530,27 @@ const App = () => {
setIsAdmin(!isAdmin)}
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
+ ? "bg-gray-800 text-white"
+ : "text-gray-600 hover:text-gray-900"
}`}
>
- {isAdmin ? '쇼핑몰로 돌아가기' : '관리자 페이지로'}
+ {isAdmin ? "쇼핑몰로 돌아가기" : "관리자 페이지로"}
{!isAdmin && (
-
-
+
+
{cart.length > 0 && (
@@ -490,27 +568,31 @@ const App = () => {
{isAdmin ? (
-
관리자 대시보드
-
상품과 쿠폰을 관리할 수 있습니다
+
+ 관리자 대시보드
+
+
+ 상품과 쿠폰을 관리할 수 있습니다
+
- {activeTab === 'products' ? (
+ {activeTab === "products" ? (
-
-
-
상품 목록
-
{
- setEditingProduct('new');
- setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] });
- setShowProductForm(true);
- }}
- className="px-4 py-2 bg-gray-900 text-white text-sm rounded-md hover:bg-gray-800"
- >
- 새 상품 추가
-
+
+
+
상품 목록
+ {
+ setEditingProduct("new");
+ setProductForm({
+ name: "",
+ price: 0,
+ stock: 0,
+ description: "",
+ discounts: [],
+ });
+ setShowProductForm(true);
+ }}
+ className="px-4 py-2 bg-gray-900 text-white text-sm rounded-md hover:bg-gray-800"
+ >
+ 새 상품 추가
+
+
-
-
-
-
-
- | 상품명 |
- 가격 |
- 재고 |
- 설명 |
- 작업 |
-
-
-
- {(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 || '-'} |
-
- startEditProduct(product)}
- className="text-indigo-600 hover:text-indigo-900 mr-3"
- >
- 수정
-
- deleteProduct(product.id)}
- className="text-red-600 hover:text-red-900"
- >
- 삭제
-
- |
+
+
+
+
+ |
+ 상품명
+ |
+
+ 가격
+ |
+
+ 재고
+ |
+
+ 설명
+ |
+
+ 작업
+ |
- ))}
-
-
-
- {showProductForm && (
-
+
+ {showProductForm && (
+
-
-
-
- {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.filter((_, i) => i !== index);
- setProductForm({ ...productForm, discounts: newDiscounts });
- }}
- className="text-red-600 hover:text-red-800"
+
+
+
+ {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.filter(
+ (_, i) => i !== index
+ );
+ setProductForm({
+ ...productForm,
+ discounts: newDiscounts,
+ });
+ }}
+ className="text-red-600 hover:text-red-800"
+ >
+
+
+
+
+
+ ))}
+
{
+ setProductForm({
+ ...productForm,
+ discounts: [
+ ...productForm.discounts,
+ { quantity: 10, rate: 0.1 },
+ ],
+ });
+ }}
+ className="text-sm text-indigo-600 hover:text-indigo-800"
+ >
+ + 할인 추가
+
+
+
+
+
{
+ setEditingProduct(null);
setProductForm({
- ...productForm,
- discounts: [...productForm.discounts, { quantity: 10, rate: 0.1 }]
+ name: "",
+ price: 0,
+ stock: 0,
+ description: "",
+ discounts: [],
});
+ setShowProductForm(false);
}}
- className="text-sm text-indigo-600 hover:text-indigo-800"
+ className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
- + 할인 추가
+ 취소
+
+
+ {editingProduct === "new" ? "추가" : "수정"}
-
-
-
- {
- setEditingProduct(null);
- setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] });
- setShowProductForm(false);
- }}
- className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
- >
- 취소
-
-
- {editingProduct === 'new' ? '추가' : '수정'}
-
-
-
-
- )}
+
+
+ )}
) : (
-
-
쿠폰 관리
-
-
-
- {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}% 할인`}
+
+
+
deleteCoupon(coupon.code)}
+ className="text-gray-400 hover:text-red-600 transition-colors"
+ >
+
+
+
+
-
deleteCoupon(coupon.code)}
- className="text-gray-400 hover:text-red-600 transition-colors"
- >
-
-
-
-
-
- ))}
-
-
-
setShowCouponForm(!showCouponForm)}
- className="text-gray-400 hover:text-gray-600 flex flex-col items-center"
- >
-
-
-
- 새 쿠폰 추가
-
-
-
+ ))}
- {showCouponForm && (
-
+
+ {showCouponForm && (
+
+ )}
+
)}
@@ -897,137 +1169,221 @@ const App = () => {
{/* 상품 목록 */}
-
전체 상품
+
+ 전체 상품
+
총 {products.length}개 상품
{filteredProducts.length === 0 ? (
-
"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.
+
+ "{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.
+
) : (
- {filteredProducts.map(product => {
- const remainingStock = getRemainingStock(product);
-
- return (
-
- {/* 상품 이미지 영역 (placeholder) */}
-
-
- {product.isRecommended && (
-
- BEST
-
- )}
- {product.discounts.length > 0 && (
-
- ~{Math.max(...product.discounts.map(d => d.rate)) * 100}%
-
- )}
-
-
- {/* 상품 정보 */}
-
-
{product.name}
- {product.description && (
-
{product.description}
- )}
-
- {/* 가격 정보 */}
-
-
{formatPrice(product.price, product.id)}
+ {filteredProducts.map((product) => {
+ const remainingStock = getRemainingStock(product);
+
+ return (
+
+ {/* 상품 이미지 영역 (placeholder) */}
+
+
+ {product.isRecommended && (
+
+ BEST
+
+ )}
{product.discounts.length > 0 && (
-
- {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}%
-
+
+ ~
+ {Math.max(
+ ...product.discounts.map((d) => d.rate)
+ ) * 100}
+ %
+
)}
-
- {/* 재고 상태 */}
-
- {remainingStock <= 5 && remainingStock > 0 && (
-
품절임박! {remainingStock}개 남음
- )}
- {remainingStock > 5 && (
-
재고 {remainingStock}개
+
+ {/* 상품 정보 */}
+
+
+ {product.name}
+
+ {product.description && (
+
+ {product.description}
+
)}
+
+ {/* 가격 정보 */}
+
+
+ {formatPrice(product.price, product.id)}
+
+ {product.discounts.length > 0 && (
+
+ {product.discounts[0].quantity}개 이상 구매시
+ 할인 {product.discounts[0].rate * 100}%
+
+ )}
+
+
+ {/* 재고 상태 */}
+
+ {remainingStock <= 5 && remainingStock > 0 && (
+
+ 품절임박! {remainingStock}개 남음
+
+ )}
+ {remainingStock > 5 && (
+
+ 재고 {remainingStock}개
+
+ )}
+
+
+ {/* 장바구니 버튼 */}
+
addToCart(product)}
+ disabled={remainingStock <= 0}
+ className={`w-full py-2 px-4 rounded-md font-medium transition-colors ${
+ remainingStock <= 0
+ ? "bg-gray-100 text-gray-400 cursor-not-allowed"
+ : "bg-gray-900 text-white hover:bg-gray-800"
+ }`}
+ >
+ {remainingStock <= 0 ? "품절" : "장바구니 담기"}
+
-
- {/* 장바구니 버튼 */}
-
addToCart(product)}
- disabled={remainingStock <= 0}
- className={`w-full py-2 px-4 rounded-md font-medium transition-colors ${
- remainingStock <= 0
- ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
- : 'bg-gray-900 text-white hover:bg-gray-800'
- }`}
- >
- {remainingStock <= 0 ? '품절' : '장바구니 담기'}
-
-
- );
+ );
})}
)}
-
+
-
-
+
+
장바구니
{cart.length === 0 ? (
-
-
+
+
- 장바구니가 비어있습니다
+
+ 장바구니가 비어있습니다
+
) : (
- {cart.map(item => {
+ {cart.map((item) => {
const itemTotal = calculateItemTotal(item);
- const originalPrice = item.product.price * item.quantity;
+ const originalPrice =
+ item.product.price * item.quantity;
const hasDiscount = itemTotal < originalPrice;
- const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0;
-
+ const discountRate = hasDiscount
+ ? Math.round((1 - itemTotal / originalPrice) * 100)
+ : 0;
+
return (
-
+
-
{item.product.name}
-
removeFromCart(item.product.id)}
+
+ {item.product.name}
+
+ removeFromCart(item.product.id)}
className="text-gray-400 hover:text-red-500 ml-2"
>
-
-
+
+
- updateQuantity(item.product.id, item.quantity - 1)}
+
+ updateQuantity(
+ item.product.id,
+ item.quantity - 1
+ )
+ }
className="w-6 h-6 rounded border border-gray-300 flex items-center justify-center hover:bg-gray-100"
>
−
- {item.quantity}
- updateQuantity(item.product.id, item.quantity + 1)}
+
+ {item.quantity}
+
+
+ updateQuantity(
+ item.product.id,
+ item.quantity + 1
+ )
+ }
className="w-6 h-6 rounded border border-gray-300 flex items-center justify-center hover:bg-gray-100"
>
+
@@ -1035,7 +1391,9 @@ const App = () => {
{hasDiscount && (
-
-{discountRate}%
+
+ -{discountRate}%
+
)}
{Math.round(itemTotal).toLocaleString()}원
@@ -1053,27 +1411,33 @@ const App = () => {
<>
-
쿠폰 할인
+
+ 쿠폰 할인
+
쿠폰 등록
{coupons.length > 0 && (
-
@@ -1085,27 +1449,40 @@ const App = () => {
상품 금액
- {totals.totalBeforeDiscount.toLocaleString()}원
+
+ {totals.totalBeforeDiscount.toLocaleString()}원
+
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && (
+ {totals.totalBeforeDiscount -
+ totals.totalAfterDiscount >
+ 0 && (
할인 금액
- -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원
+
+ -
+ {(
+ totals.totalBeforeDiscount -
+ totals.totalAfterDiscount
+ ).toLocaleString()}
+ 원
+
)}
결제 예정 금액
- {totals.totalAfterDiscount.toLocaleString()}원
+
+ {totals.totalAfterDiscount.toLocaleString()}원
+
-
+
{totals.totalAfterDiscount.toLocaleString()}원 결제하기
-
+
@@ -1121,4 +1498,4 @@ const App = () => {
);
};
-export default App;
\ No newline at end of file
+export default App;
diff --git a/src/refactoring(hint)/utils/hooks/useDebounce.ts b/src/refactoring(hint)/utils/hooks/useDebounce.ts
index 53c8a3746..22b560647 100644
--- a/src/refactoring(hint)/utils/hooks/useDebounce.ts
+++ b/src/refactoring(hint)/utils/hooks/useDebounce.ts
@@ -1,4 +1,4 @@
-// TODO: 디바운스 Hook
+/* TODO: 디바운스 Hook
// 힌트:
// 1. 값이 변경되어도 지정된 시간 동안 대기
// 2. 대기 시간 동안 값이 다시 변경되면 타이머 리셋
@@ -8,4 +8,5 @@
export function useDebounce(value: T, delay: number): T {
// TODO: 구현
-}
\ No newline at end of file
+}
+ */
\ No newline at end of file
diff --git a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts
index 5dc72c501..2cc5003db 100644
--- a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts
+++ b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts
@@ -1,4 +1,4 @@
-// TODO: LocalStorage Hook
+/** TODO: LocalStorage Hook
// 힌트:
// 1. localStorage와 React state 동기화
// 2. 초기값 로드 시 에러 처리
@@ -12,4 +12,5 @@ export function useLocalStorage(
initialValue: T
): [T, (value: T | ((val: T) => T)) => void] {
// TODO: 구현
-}
\ No newline at end of file
+}
+ */
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
index 5489e296e..720c0bef6 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -19,6 +19,17 @@ export interface CartItem {
export interface Coupon {
name: string;
code: string;
- discountType: 'amount' | 'percentage';
+ discountType: "amount" | "percentage";
discountValue: number;
}
+
+export interface ProductWithUI extends Product {
+ description?: string;
+ isRecommended?: boolean;
+}
+
+export interface Notification {
+ id: string;
+ message: string;
+ type: "error" | "success" | "warning";
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
index d739292ae..59d52892b 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -7,6 +7,7 @@
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
+
/* Bundler mode */
"moduleResolution": "bundler",
@@ -23,5 +24,6 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
- "include": ["src"]
-}
+ "include": ["src"],
+ "exclude": ["src/refactoring(hint)"]
+}
\ No newline at end of file
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..11923f8ca 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,16 +1,30 @@
-import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config';
-import { defineConfig } from 'vite';
-import react from '@vitejs/plugin-react-swc';
+import { defineConfig as defineTestConfig, mergeConfig } from "vitest/config";
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react-swc";
+import { resolve } from 'path';
export default mergeConfig(
defineConfig({
plugins: [react()],
+ build: {
+ rollupOptions: {
+ input: {
+ main: resolve(__dirname, 'index.advanced.html'),
+ },
+ },
+ },
+ base: process.env.VITE_BASE_PATH || "/",
}),
defineTestConfig({
test: {
globals: true,
- environment: 'jsdom',
- setupFiles: './src/setupTests.ts'
+ environment: "jsdom",
+ setupFiles: "./src/setupTests.ts",
+ environmentOptions: {
+ jsdom: {
+ resources: "usable",
+ },
+ },
},
})
-)
+);
\ No newline at end of file