tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ |
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/src/advanced/components/ui/feature/CartButton.tsx b/src/advanced/components/ui/feature/CartButton.tsx
new file mode 100644
index 000000000..3db3a752a
--- /dev/null
+++ b/src/advanced/components/ui/feature/CartButton.tsx
@@ -0,0 +1,29 @@
+interface CartButtonProps {
+ itemCount: number;
+}
+
+export function CartButton({ itemCount }: CartButtonProps) {
+ return (
+
+
+ {itemCount > 0 && (
+
+ {itemCount}
+
+ )}
+
+ );
+}
+
diff --git a/src/advanced/components/ui/feature/ModeSwitchButton.tsx b/src/advanced/components/ui/feature/ModeSwitchButton.tsx
new file mode 100644
index 000000000..5816b0372
--- /dev/null
+++ b/src/advanced/components/ui/feature/ModeSwitchButton.tsx
@@ -0,0 +1,19 @@
+import { Button } from "../common/button";
+
+interface ModeSwitchButtonProps {
+ isAdmin: boolean;
+ onToggle: () => void;
+}
+
+export function ModeSwitchButton({ isAdmin, onToggle }: ModeSwitchButtonProps) {
+ return (
+
+ );
+}
+
diff --git a/src/advanced/components/ui/feature/SearchInput.tsx b/src/advanced/components/ui/feature/SearchInput.tsx
new file mode 100644
index 000000000..cb5e26324
--- /dev/null
+++ b/src/advanced/components/ui/feature/SearchInput.tsx
@@ -0,0 +1,22 @@
+interface SearchInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+}
+
+export function SearchInput({
+ value,
+ onChange,
+ placeholder = '상품 검색...',
+}: SearchInputProps) {
+ return (
+ onChange(e.target.value)}
+ placeholder={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/components/ui/layout/AdminLayout.tsx b/src/advanced/components/ui/layout/AdminLayout.tsx
new file mode 100644
index 000000000..e2d7144b9
--- /dev/null
+++ b/src/advanced/components/ui/layout/AdminLayout.tsx
@@ -0,0 +1,19 @@
+import { ModeSwitchButton } from '../feature/ModeSwitchButton';
+import { GlobalHeader } from './GlobalHeader';
+
+interface AdminLayoutProps {
+ children: React.ReactNode;
+ onToggleAdmin: () => void;
+}
+
+export function AdminLayout({ children, onToggleAdmin }: AdminLayoutProps) {
+ return (
+ <>
+ }
+ />
+ {children}
+ >
+ );
+}
+
diff --git a/src/advanced/components/ui/layout/CartLayout.tsx b/src/advanced/components/ui/layout/CartLayout.tsx
new file mode 100644
index 000000000..5a8b9371a
--- /dev/null
+++ b/src/advanced/components/ui/layout/CartLayout.tsx
@@ -0,0 +1,39 @@
+// components/ui/layout/CartLayout.tsx
+import { useState } from "react";
+import { useAtomValue } from "jotai";
+
+import { CartButton } from "../feature/CartButton";
+import { ModeSwitchButton } from "../feature/ModeSwitchButton";
+import { SearchInput } from "../feature/SearchInput";
+import { GlobalHeader } from "./GlobalHeader";
+import { useDebounce } from "../../../utils/hooks/useDebounce";
+import { totalItemCountAtom } from "../../../store";
+import { CartPage } from "../cart/CartPage";
+
+interface CartLayoutProps {
+ onToggleAdmin: () => void;
+}
+
+export function CartLayout({ onToggleAdmin }: CartLayoutProps) {
+ // 검색어는 이 페이지에서만 사용 → 로컬 상태
+ const [searchTerm, setSearchTerm] = useState("");
+ const debouncedSearchTerm = useDebounce(searchTerm, 500);
+
+ // 장바구니 개수는 전역 상태
+ const cartItemCount = useAtomValue(totalItemCountAtom);
+
+ return (
+ <>
+ }
+ right={
+ <>
+
+
+ >
+ }
+ />
+
+ >
+ );
+}
diff --git a/src/advanced/components/ui/layout/GlobalHeader.tsx b/src/advanced/components/ui/layout/GlobalHeader.tsx
new file mode 100644
index 000000000..85f1bde7f
--- /dev/null
+++ b/src/advanced/components/ui/layout/GlobalHeader.tsx
@@ -0,0 +1,26 @@
+interface GlobalHeaderProps {
+ center?: React.ReactNode;
+ right?: React.ReactNode;
+}
+
+export function GlobalHeader({ center, right }: GlobalHeaderProps) {
+ return (
+
+
+
+ {/* 1. Logo Section: 항상 고정 */}
+
+ SHOP
+
+ {/* 2. Center Slot: 검색창 등 */}
+ {center && {center} }
+
+
+ {/* 3. Right Slot: 버튼, 카트 등 */}
+
+
+
+
+ );
+}
+
diff --git a/src/advanced/constants/index.ts b/src/advanced/constants/index.ts
new file mode 100644
index 000000000..def94fd11
--- /dev/null
+++ b/src/advanced/constants/index.ts
@@ -0,0 +1,56 @@
+
+// 초기 데이터 상수
+
+
+import { Coupon, ProductWithUI } from "../types";
+
+// 초기 상품 데이터
+export const initialProducts: ProductWithUI[] = [
+ {
+ id: "p1",
+ name: "상품1",
+ price: 10000,
+ stock: 20,
+ discounts: [
+ { quantity: 10, rate: 0.1 },
+ { quantity: 20, rate: 0.2 },
+ ],
+ description: "최고급 품질의 프리미엄 상품입니다.",
+ },
+ {
+ id: "p2",
+ name: "상품2",
+ price: 20000,
+ stock: 20,
+ discounts: [{ quantity: 10, rate: 0.15 }],
+ description: "다양한 기능을 갖춘 실용적인 상품입니다.",
+ isRecommended: true,
+ },
+ {
+ id: "p3",
+ name: "상품3",
+ price: 30000,
+ stock: 20,
+ discounts: [
+ { quantity: 10, rate: 0.2 },
+ { quantity: 30, rate: 0.25 },
+ ],
+ description: "대용량과 고성능을 자랑하는 상품입니다.",
+ },
+];
+
+// 초기 쿠폰 데이터
+export const initialCoupons: Coupon[] = [
+ {
+ name: "5000원 할인",
+ code: "AMOUNT5000",
+ discountType: "amount",
+ discountValue: 5000,
+ },
+ {
+ name: "10% 할인",
+ code: "PERCENT10",
+ discountType: "percentage",
+ discountValue: 10,
+ },
+];
diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts
new file mode 100644
index 000000000..6dc7c0511
--- /dev/null
+++ b/src/advanced/hooks/useCart.ts
@@ -0,0 +1,142 @@
+// useCart Hook (Jotai 기반)
+
+import { useCallback, useMemo } from "react";
+import { useAtom, useAtomValue } from "jotai";
+import { CartItem, Coupon, Product } from "../types";
+import { cartAtom, selectedCouponAtom, totalItemCountAtom } from "../store";
+import * as cartModel from "../models/cart";
+import { calculateCartTotal } from "../models/discount";
+
+type NotifyFn = (message: string, type: "error" | "success" | "warning") => void;
+
+interface UseCartOptions {
+ onNotify?: NotifyFn;
+}
+
+interface UseCartReturn {
+ cart: CartItem[];
+ selectedCoupon: Coupon | null;
+ totals: {
+ totalBeforeDiscount: number;
+ totalAfterDiscount: number;
+ totalDiscount: number;
+ };
+ totalItemCount: number;
+ addToCart: (product: Product) => void;
+ removeFromCart: (productId: string) => void;
+ updateQuantity: (productId: string, quantity: number) => void;
+ applyCoupon: (coupon: Coupon) => void;
+ removeCoupon: () => void;
+ clearCart: () => void;
+ completeOrder: () => string;
+ getRemainingStock: (product: Product) => number;
+}
+
+export function useCart(options: UseCartOptions = {}): UseCartReturn {
+ const { onNotify } = options;
+
+ // store에서 상태 읽기
+ const [cart, setCart] = useAtom(cartAtom);
+ const [selectedCoupon, setSelectedCoupon] = useAtom(selectedCouponAtom);
+ const totalItemCount = useAtomValue(totalItemCountAtom);
+
+ // 직접 계산 (이 hook에서만 사용)
+ const totals = useMemo(
+ () => calculateCartTotal(cart, selectedCoupon),
+ [cart, selectedCoupon]
+ );
+
+ const getRemainingStock = useCallback(
+ (product: Product) => cartModel.getRemainingStock(product, cart),
+ [cart]
+ );
+
+ // 헬퍼
+ const notify = useCallback(
+ (message: string, type: "error" | "success" | "warning") => {
+ onNotify?.(message, type);
+ },
+ [onNotify]
+ );
+
+ // 장바구니 액션
+ const addToCart = useCallback(
+ (product: Product) => {
+ const result = cartModel.addItemToCart(product, cart);
+ if (!result.success) {
+ notify(result.error, "error");
+ return;
+ }
+ setCart(result.data);
+ notify("장바구니에 담았습니다", "success");
+ },
+ [cart, setCart, notify]
+ );
+
+ const removeFromCart = useCallback(
+ (productId: string) => {
+ const newCart = cartModel.removeItemFromCart(productId, cart);
+ setCart(newCart);
+ },
+ [cart, setCart]
+ );
+
+ const updateQuantity = useCallback(
+ (productId: string, newQuantity: number) => {
+ const result = cartModel.updateCartItemQuantity(productId, newQuantity, cart);
+ if (!result.success) {
+ notify(result.error, "error");
+ return;
+ }
+ setCart(result.data);
+ },
+ [cart, setCart, notify]
+ );
+
+ // 쿠폰 액션
+ const applyCoupon = useCallback(
+ (coupon: Coupon) => {
+ const validationResult = cartModel.validateCouponApplication(coupon, cart);
+ if (!validationResult.success) {
+ notify(validationResult.error, "error");
+ return;
+ }
+ setSelectedCoupon(coupon);
+ notify("쿠폰이 적용되었습니다.", "success");
+ },
+ [cart, setSelectedCoupon, notify]
+ );
+
+ const removeCoupon = useCallback(() => {
+ setSelectedCoupon(null);
+ }, [setSelectedCoupon]);
+
+ // 주문 액션
+ const clearCart = useCallback(() => {
+ setCart([]);
+ setSelectedCoupon(null);
+ }, [setCart, setSelectedCoupon]);
+
+ const completeOrder = useCallback(() => {
+ const orderNumber = `ORD-${Date.now()}`;
+ notify(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success");
+ setCart([]);
+ setSelectedCoupon(null);
+ return orderNumber;
+ }, [setCart, setSelectedCoupon, notify]);
+
+ return {
+ cart,
+ selectedCoupon,
+ totals,
+ totalItemCount,
+ addToCart,
+ removeFromCart,
+ updateQuantity,
+ applyCoupon,
+ removeCoupon,
+ clearCart,
+ completeOrder,
+ getRemainingStock,
+ };
+}
diff --git a/src/advanced/hooks/useCouponForm.ts b/src/advanced/hooks/useCouponForm.ts
new file mode 100644
index 000000000..c9c70b57c
--- /dev/null
+++ b/src/advanced/hooks/useCouponForm.ts
@@ -0,0 +1,69 @@
+// hooks/useCouponForm.ts
+// 쿠폰 폼 관리 Hook
+
+import { useState, useCallback } from "react";
+import { Coupon } from "@/types";
+
+// 타입 & 상수
+export interface CouponFormData {
+ name: string;
+ code: string;
+ discountType: "amount" | "percentage";
+ discountValue: number;
+}
+
+const INITIAL_FORM: CouponFormData = {
+ name: "",
+ code: "",
+ discountType: "amount",
+ discountValue: 0,
+};
+
+interface UseCouponFormOptions {
+ addCoupon: (coupon: Coupon) => boolean;
+}
+
+export interface CouponFormHook {
+ form: CouponFormData;
+ setForm: React.Dispatch>;
+ isOpen: boolean;
+ open: () => void;
+ close: () => void;
+ submit: (e: React.FormEvent) => void;
+}
+
+// Hook
+export function useCouponForm({
+ addCoupon,
+}: UseCouponFormOptions): CouponFormHook {
+ const [form, setForm] = useState(INITIAL_FORM);
+ const [isOpen, setIsOpen] = useState(false);
+
+ const open = useCallback(() => {
+ setForm(INITIAL_FORM);
+ setIsOpen(true);
+ }, []);
+
+ const close = useCallback(() => {
+ setIsOpen(false);
+ setForm(INITIAL_FORM);
+ }, []);
+
+ const submit = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault();
+ const success = addCoupon(form);
+ if (success) close();
+ },
+ [form, addCoupon, close]
+ );
+
+ return {
+ form,
+ setForm,
+ isOpen,
+ open,
+ close,
+ submit,
+ };
+}
diff --git a/src/advanced/hooks/useCoupons.ts b/src/advanced/hooks/useCoupons.ts
new file mode 100644
index 000000000..2152ead2d
--- /dev/null
+++ b/src/advanced/hooks/useCoupons.ts
@@ -0,0 +1,75 @@
+// useCoupons Hook (Jotai 기반)
+
+import { useCallback } from "react";
+import { useAtom, useSetAtom } from "jotai";
+import { Coupon } from "../types";
+import { couponsAtom, selectedCouponAtom } from "../store";
+import * as couponModel from "../models/coupon";
+
+type NotifyFn = (message: string, type: "error" | "success" | "warning") => void;
+
+interface UseCouponsOptions {
+ onNotify?: NotifyFn;
+ onCouponRemoved?: (couponCode: string) => void;
+}
+
+export interface UseCouponsReturn {
+ coupons: Coupon[];
+ couponCount: number;
+ addCoupon: (coupon: Coupon) => boolean;
+ removeCoupon: (couponCode: string) => boolean;
+}
+
+export function useCoupons(options: UseCouponsOptions = {}): UseCouponsReturn {
+ const { onNotify, onCouponRemoved } = options;
+
+ const [coupons, setCoupons] = useAtom(couponsAtom);
+ const setSelectedCoupon = useSetAtom(selectedCouponAtom);
+
+ // 직접 계산
+ const couponCount = coupons.length;
+
+ const notify = useCallback(
+ (message: string, type: "error" | "success" | "warning") => {
+ onNotify?.(message, type);
+ },
+ [onNotify]
+ );
+
+ const addCoupon = useCallback(
+ (newCoupon: Coupon): boolean => {
+ const result = couponModel.addCoupon(newCoupon, coupons);
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+ setCoupons(result.data);
+ notify("쿠폰이 추가되었습니다.", "success");
+ return true;
+ },
+ [coupons, setCoupons, notify]
+ );
+
+ const removeCoupon = useCallback(
+ (couponCode: string): boolean => {
+ const result = couponModel.removeCoupon(couponCode, coupons);
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+ setCoupons(result.data);
+ setSelectedCoupon((prev) => (prev?.code === couponCode ? null : prev));
+ onCouponRemoved?.(couponCode);
+ notify("쿠폰이 삭제되었습니다.", "success");
+ return true;
+ },
+ [coupons, setCoupons, setSelectedCoupon, notify, onCouponRemoved]
+ );
+
+ return {
+ coupons,
+ couponCount,
+ addCoupon,
+ removeCoupon,
+ };
+}
diff --git a/src/advanced/hooks/useNotifications.ts b/src/advanced/hooks/useNotifications.ts
new file mode 100644
index 000000000..2b2679a45
--- /dev/null
+++ b/src/advanced/hooks/useNotifications.ts
@@ -0,0 +1,67 @@
+// useNotifications Hook (Jotai 기반)
+
+import { useCallback } from "react";
+import { useAtom } from "jotai";
+import { Notification } from "../types";
+import { notificationsAtom } from "../store";
+
+export type NotifyFn = (
+ message: string,
+ type?: "error" | "success" | "warning"
+) => void;
+
+interface UseNotificationsOptions {
+ autoRemoveDelay?: number;
+}
+
+interface UseNotificationsReturn {
+ notifications: Notification[];
+ notificationCount: number;
+ addNotification: NotifyFn;
+ removeNotification: (id: string) => void;
+ clearNotifications: () => void;
+}
+
+export function useNotifications(
+ options: UseNotificationsOptions = {}
+): UseNotificationsReturn {
+ const { autoRemoveDelay = 3000 } = options;
+
+ const [notifications, setNotifications] = useAtom(notificationsAtom);
+
+ // 직접 계산
+ const notificationCount = notifications.length;
+
+ const addNotification: NotifyFn = useCallback(
+ (message, type = "success") => {
+ const id = Date.now().toString();
+ setNotifications((prev) => [...prev, { id, message, type }]);
+
+ if (autoRemoveDelay > 0) {
+ setTimeout(() => {
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
+ }, autoRemoveDelay);
+ }
+ },
+ [setNotifications, autoRemoveDelay]
+ );
+
+ const removeNotification = useCallback(
+ (id: string) => {
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
+ },
+ [setNotifications]
+ );
+
+ const clearNotifications = useCallback(() => {
+ setNotifications([]);
+ }, [setNotifications]);
+
+ return {
+ notifications,
+ notificationCount,
+ addNotification,
+ removeNotification,
+ clearNotifications,
+ };
+}
diff --git a/src/advanced/hooks/useProductForm.ts b/src/advanced/hooks/useProductForm.ts
new file mode 100644
index 000000000..13e0893ee
--- /dev/null
+++ b/src/advanced/hooks/useProductForm.ts
@@ -0,0 +1,102 @@
+// hooks/useProductForm.ts
+// 상품 폼 관리 Hook
+
+import { useState, useCallback } from "react";
+import { ProductWithUI } from "@/types";
+
+
+// 타입 & 상수
+
+export interface ProductFormData {
+ name: string;
+ price: number;
+ stock: number;
+ description: string;
+ discounts: Array<{ quantity: number; rate: number }>;
+}
+
+const INITIAL_FORM: ProductFormData = {
+ name: "",
+ price: 0,
+ stock: 0,
+ description: "",
+ discounts: [],
+};
+
+interface UseProductFormOptions {
+ addProduct: (product: Omit) => boolean;
+ updateProduct: (
+ productId: string,
+ updates: Partial>
+ ) => boolean;
+}
+
+export interface ProductFormHook {
+ form: ProductFormData;
+ setForm: React.Dispatch>;
+ isOpen: boolean;
+ editingId: string | null;
+ open: () => void;
+ openEdit: (product: ProductWithUI) => void;
+ close: () => void;
+ submit: (e: React.FormEvent) => void;
+}
+
+
+// Hook
+
+export function useProductForm({
+ addProduct,
+ updateProduct,
+}: UseProductFormOptions): ProductFormHook {
+ const [form, setForm] = useState(INITIAL_FORM);
+ const [isOpen, setIsOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+
+ const open = useCallback(() => {
+ setForm(INITIAL_FORM);
+ setEditingId(null);
+ setIsOpen(true);
+ }, []);
+
+ const openEdit = useCallback((product: ProductWithUI) => {
+ setForm({
+ name: product.name,
+ price: product.price,
+ stock: product.stock,
+ description: product.description || "",
+ discounts: product.discounts || [],
+ });
+ setEditingId(product.id);
+ setIsOpen(true);
+ }, []);
+
+ const close = useCallback(() => {
+ setIsOpen(false);
+ setEditingId(null);
+ setForm(INITIAL_FORM);
+ }, []);
+
+ const submit = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault();
+ const success = editingId
+ ? updateProduct(editingId, form)
+ : addProduct(form);
+
+ if (success) close();
+ },
+ [form, editingId, addProduct, updateProduct, close]
+ );
+
+ return {
+ form,
+ setForm,
+ isOpen,
+ editingId,
+ open,
+ openEdit,
+ close,
+ submit,
+ };
+}
diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts
new file mode 100644
index 000000000..e5b3d7865
--- /dev/null
+++ b/src/advanced/hooks/useProducts.ts
@@ -0,0 +1,144 @@
+// useProducts Hook (Jotai 기반)
+
+import { useCallback } from "react";
+import { useAtom } from "jotai";
+import { ProductWithUI } from "../types";
+import { productsAtom } from "../store";
+import * as productModel from "../models/product";
+
+export type NotifyFn = (
+ message: string,
+ type: "error" | "success" | "warning"
+) => void;
+
+interface UseProductsOptions {
+ onNotify?: NotifyFn;
+}
+
+export interface UseProductsReturn {
+ products: ProductWithUI[];
+ productCount: number;
+ addProduct: (product: Omit) => boolean;
+ updateProduct: (
+ productId: string,
+ updates: Partial>
+ ) => boolean;
+ removeProduct: (productId: string) => boolean;
+ updateProductStock: (productId: string, newStock: number) => boolean;
+ addProductDiscount: (
+ productId: string,
+ discount: productModel.Discount
+ ) => boolean;
+ removeProductDiscount: (productId: string, discountIndex: number) => boolean;
+}
+
+export function useProducts(options: UseProductsOptions = {}): UseProductsReturn {
+ const { onNotify } = options;
+
+ const [products, setProducts] = useAtom(productsAtom);
+
+ // 직접 계산
+ const productCount = products.length;
+
+ const notify = useCallback(
+ (message: string, type: "error" | "success" | "warning") => {
+ onNotify?.(message, type);
+ },
+ [onNotify]
+ );
+
+ const addProduct = useCallback(
+ (newProduct: Omit): boolean => {
+ const result = productModel.addProduct(newProduct, products);
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+ setProducts(result.data);
+ notify("상품이 추가되었습니다.", "success");
+ return true;
+ },
+ [products, setProducts, notify]
+ );
+
+ const updateProduct = useCallback(
+ (productId: string, updates: Partial>): boolean => {
+ const result = productModel.updateProduct(productId, updates, products);
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+ setProducts(result.data);
+ notify("상품이 수정되었습니다.", "success");
+ return true;
+ },
+ [products, setProducts, notify]
+ );
+
+ const removeProduct = useCallback(
+ (productId: string): boolean => {
+ const result = productModel.removeProduct(productId, products);
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+ setProducts(result.data);
+ notify("상품이 삭제되었습니다.", "success");
+ return true;
+ },
+ [products, setProducts, notify]
+ );
+
+ const updateProductStock = useCallback(
+ (productId: string, newStock: number): boolean => {
+ const result = productModel.updateProductStock(productId, newStock, products);
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+ setProducts(result.data);
+ notify("재고가 수정되었습니다.", "success");
+ return true;
+ },
+ [products, setProducts, notify]
+ );
+
+ const addProductDiscount = useCallback(
+ (productId: string, discount: productModel.Discount): boolean => {
+ const result = productModel.addProductDiscount(productId, discount, products);
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+ setProducts(result.data);
+ notify("할인 규칙이 추가되었습니다.", "success");
+ return true;
+ },
+ [products, setProducts, notify]
+ );
+
+ const removeProductDiscount = useCallback(
+ (productId: string, discountIndex: number): boolean => {
+ const result = productModel.removeProductDiscount(productId, discountIndex, products);
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+ setProducts(result.data);
+ notify("할인 규칙이 삭제되었습니다.", "success");
+ return true;
+ },
+ [products, setProducts, notify]
+ );
+
+ return {
+ products,
+ productCount,
+ addProduct,
+ updateProduct,
+ removeProduct,
+ updateProductStock,
+ addProductDiscount,
+ removeProductDiscount,
+ };
+}
diff --git a/src/advanced/index.css b/src/advanced/index.css
new file mode 100644
index 000000000..7550e245b
--- /dev/null
+++ b/src/advanced/index.css
@@ -0,0 +1,120 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
\ No newline at end of file
diff --git a/src/advanced/lib/utils.ts b/src/advanced/lib/utils.ts
new file mode 100644
index 000000000..a5ef19350
--- /dev/null
+++ b/src/advanced/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/advanced/models/cart.ts b/src/advanced/models/cart.ts
new file mode 100644
index 000000000..e656836bb
--- /dev/null
+++ b/src/advanced/models/cart.ts
@@ -0,0 +1,131 @@
+// Cart Model (순수 함수)
+// 역할: 장바구니 조작 (추가, 삭제, 수량 변경)
+
+import { CartItem, Coupon, Product } from "../types";
+import {
+ calculateItemTotal,
+ calculateCartTotal,
+ validateDiscount,
+ Discount,
+} from "./discount";
+
+// Re-export
+export { calculateItemTotal, validateDiscount, calculateCartTotal };
+export type { Discount };
+
+// Result 패턴
+
+export type CartResult =
+ | { success: true; data: T }
+ | { success: false; error: string };
+
+// 재고 관련 함수
+
+/** 남은 재고 수량 계산 */
+export const getRemainingStock = (product: Product, cart: CartItem[]): number => {
+ const cartItem = cart.find((item) => item.product.id === product.id);
+ return product.stock - (cartItem?.quantity ?? 0);
+};
+
+
+// 장바구니 아이템 수 계산
+
+
+/** 장바구니 아이템 총 개수 계산 */
+export const calculateTotalItemCount = (cart: CartItem[]): number => {
+ return cart.reduce((sum, item) => sum + item.quantity, 0);
+};
+
+
+// 장바구니 조작 함수 (불변성 유지)
+
+
+/** 장바구니에 상품 추가 */
+export const addItemToCart = (
+ product: Product,
+ cart: CartItem[]
+): CartResult => {
+ const remainingStock = getRemainingStock(product, cart);
+
+ if (remainingStock <= 0) {
+ return { success: false, error: "재고가 부족합니다!" };
+ }
+
+ const existingItem = cart.find((item) => item.product.id === product.id);
+
+ if (existingItem) {
+ const newQuantity = existingItem.quantity + 1;
+ if (newQuantity > product.stock) {
+ return {
+ success: false,
+ error: `재고는 ${product.stock}개까지만 있습니다.`,
+ };
+ }
+
+ const newCart = cart.map((item) =>
+ item.product.id === product.id ? { ...item, quantity: newQuantity } : item
+ );
+ return { success: true, data: newCart };
+ }
+
+ return { success: true, data: [...cart, { product, quantity: 1 }] };
+};
+
+/** 장바구니에서 상품 제거 */
+export const removeItemFromCart = (
+ productId: string,
+ cart: CartItem[]
+): CartItem[] => {
+ return cart.filter((item) => item.product.id !== productId);
+};
+
+/** 장바구니 아이템 수량 변경 */
+export const updateCartItemQuantity = (
+ productId: string,
+ newQuantity: number,
+ cart: CartItem[]
+): CartResult => {
+ if (newQuantity <= 0) {
+ return { success: true, data: removeItemFromCart(productId, cart) };
+ }
+
+ const item = cart.find((i) => i.product.id === productId);
+
+ if (!item) {
+ return { success: false, error: "장바구니에 해당 상품이 없습니다." };
+ }
+
+ if (newQuantity > item.product.stock) {
+ return {
+ success: false,
+ error: `재고는 ${item.product.stock}개까지만 있습니다.`,
+ };
+ }
+
+ const newCart = cart.map((i) =>
+ i.product.id === productId ? { ...i, quantity: newQuantity } : i
+ );
+
+ return { success: true, data: newCart };
+};
+
+
+// 쿠폰 적용 검증
+
+
+/** 쿠폰 적용 가능 여부 검증 */
+export const validateCouponApplication = (
+ coupon: Coupon,
+ cart: CartItem[]
+): CartResult => {
+ const { totalAfterDiscount } = calculateCartTotal(cart, null);
+
+ if (totalAfterDiscount < 10000 && coupon.discountType === "percentage") {
+ return {
+ success: false,
+ error: "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.",
+ };
+ }
+
+ return { success: true, data: true };
+};
diff --git a/src/advanced/models/coupon.ts b/src/advanced/models/coupon.ts
new file mode 100644
index 000000000..0bd9ec867
--- /dev/null
+++ b/src/advanced/models/coupon.ts
@@ -0,0 +1,51 @@
+// Coupon Model (순수 함수)
+// 역할: 쿠폰 CRUD
+
+import { Coupon } from "../types";
+
+// Result 패턴
+export type CouponResult =
+ | { success: true; data: T }
+ | { success: false; error: string };
+
+// 쿠폰 조회 함수
+
+/** 코드로 쿠폰 찾기 */
+export const findCouponByCode = (
+ code: string,
+ coupons: Coupon[]
+): Coupon | undefined => {
+ return coupons.find((coupon) => coupon.code === code);
+};
+
+/** 쿠폰 존재 여부 확인 */
+export const isCouponCodeExists = (code: string, coupons: Coupon[]): boolean => {
+ return coupons.some((coupon) => coupon.code === code);
+};
+
+// 쿠폰 CRUD 함수 (불변성 유지)
+
+/** 쿠폰 추가 */
+export const addCoupon = (
+ newCoupon: Coupon,
+ coupons: Coupon[]
+): CouponResult => {
+ if (isCouponCodeExists(newCoupon.code, coupons)) {
+ return { success: false, error: "이미 존재하는 쿠폰 코드입니다." };
+ }
+
+ return { success: true, data: [...coupons, newCoupon] };
+};
+
+/** 쿠폰 삭제 */
+export const removeCoupon = (
+ couponCode: string,
+ coupons: Coupon[]
+): CouponResult => {
+ if (!isCouponCodeExists(couponCode, coupons)) {
+ return { success: false, error: "존재하지 않는 쿠폰입니다." };
+ }
+
+ const newCoupons = coupons.filter((coupon) => coupon.code !== couponCode);
+ return { success: true, data: newCoupons };
+};
diff --git a/src/advanced/models/discount.ts b/src/advanced/models/discount.ts
new file mode 100644
index 000000000..f0bd7e76a
--- /dev/null
+++ b/src/advanced/models/discount.ts
@@ -0,0 +1,143 @@
+// Discount Model (순수 함수)
+// 할인 계산 관련 비즈니스 로직
+
+import { CartItem, Coupon } from "../types";
+
+// 타입 정의
+
+export interface Discount {
+ quantity: number;
+ rate: number;
+}
+
+// 상수
+
+const BULK_PURCHASE_THRESHOLD = 10;
+const BULK_PURCHASE_BONUS = 0.05;
+const MAX_DISCOUNT_RATE = 0.5;
+
+// 수량별 할인 계산 함수
+
+/** 수량에 따른 기본 할인율 계산 */
+export const getQuantityDiscount = (
+ discounts: Discount[],
+ quantity: number
+): number => {
+ return discounts.reduce((maxDiscount, discount) => {
+ return quantity >= discount.quantity && discount.rate > maxDiscount
+ ? discount.rate
+ : maxDiscount;
+ }, 0);
+};
+
+/** 대량 구매 여부 확인 */
+export const hasBulkPurchase = (cart: CartItem[]): boolean => {
+ return cart.some((item) => item.quantity >= BULK_PURCHASE_THRESHOLD);
+};
+
+/** 대량 구매 보너스 할인율 계산 */
+export const applyBulkPurchaseBonus = (
+ baseDiscount: number,
+ isBulkPurchase: boolean
+): number => {
+ if (isBulkPurchase) {
+ return Math.min(baseDiscount + BULK_PURCHASE_BONUS, MAX_DISCOUNT_RATE);
+ }
+ return baseDiscount;
+};
+
+/** 개별 아이템에 적용 가능한 최대 할인율 계산 */
+export const getMaxApplicableDiscount = (
+ item: CartItem,
+ cart: CartItem[]
+): number => {
+ const { discounts } = item.product;
+ const { quantity } = item;
+
+ const baseDiscount = getQuantityDiscount(discounts, quantity);
+ const isBulkPurchase = hasBulkPurchase(cart);
+
+ return applyBulkPurchaseBonus(baseDiscount, isBulkPurchase);
+};
+
+
+// 금액 계산 함수
+
+
+/** 할인 적용 후 금액 계산 */
+export const applyDiscount = (price: number, discountRate: number): number => {
+ return Math.round(price * (1 - discountRate));
+};
+
+/** 개별 아이템의 할인 적용 후 총액 계산 */
+export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => {
+ const { price } = item.product;
+ const { quantity } = item;
+ const discount = getMaxApplicableDiscount(item, cart);
+ return applyDiscount(price * quantity, discount);
+};
+
+/** 장바구니 아이템들의 할인 전 총액 계산 */
+export const calculateTotalBeforeDiscount = (cart: CartItem[]): number => {
+ return cart.reduce((sum, item) => sum + item.product.price * item.quantity, 0);
+};
+
+/** 장바구니 아이템들의 할인 후 총액 계산 (쿠폰 제외) */
+export const calculateTotalAfterItemDiscount = (cart: CartItem[]): number => {
+ return cart.reduce((sum, item) => sum + calculateItemTotal(item, cart), 0);
+};
+
+// 쿠폰 할인 함수
+
+/** 쿠폰 할인 적용 */
+export const applyCouponDiscount = (
+ total: number,
+ coupon: Coupon | null
+): number => {
+ if (!coupon) {
+ return total;
+ }
+
+ if (coupon.discountType === "amount") {
+ return Math.max(0, total - coupon.discountValue);
+ }
+
+ return Math.round(total * (1 - coupon.discountValue / 100));
+};
+
+/** 장바구니 총액 계산 (할인 전/후, 할인액) */
+export const calculateCartTotal = (
+ cart: CartItem[],
+ coupon: Coupon | null
+): {
+ totalBeforeDiscount: number;
+ totalAfterDiscount: number;
+ totalDiscount: number;
+} => {
+ const totalBeforeDiscount = calculateTotalBeforeDiscount(cart);
+ const totalAfterItemDiscount = calculateTotalAfterItemDiscount(cart);
+ const totalAfterDiscount = applyCouponDiscount(totalAfterItemDiscount, coupon);
+
+ return {
+ totalBeforeDiscount: Math.round(totalBeforeDiscount),
+ totalAfterDiscount: Math.round(totalAfterDiscount),
+ totalDiscount: Math.round(totalBeforeDiscount - totalAfterDiscount),
+ };
+};
+
+// 할인 유효성 검증 함수
+
+/** 할인 규칙 유효성 검증 */
+export const validateDiscount = (
+ discount: Discount
+): { valid: boolean; error?: string } => {
+ if (discount.quantity <= 0) {
+ return { valid: false, error: "할인 적용 수량은 1 이상이어야 합니다." };
+ }
+
+ if (discount.rate <= 0 || discount.rate > 1) {
+ return { valid: false, error: "할인율은 0~1 사이여야 합니다." };
+ }
+
+ return { valid: true };
+};
diff --git a/src/advanced/models/product.ts b/src/advanced/models/product.ts
new file mode 100644
index 000000000..f33a1f616
--- /dev/null
+++ b/src/advanced/models/product.ts
@@ -0,0 +1,179 @@
+// Product Model (순수 함수)
+
+import { ProductWithUI } from "../types";
+
+// 타입 정의
+export interface Discount {
+ quantity: number;
+ rate: number;
+}
+
+export type ProductResult =
+ | { success: true; data: T }
+ | { success: false; error: string };
+
+// 내부 헬퍼 함수 (export 불필요)
+const findProductById = (
+ productId: string,
+ products: ProductWithUI[]
+): ProductWithUI | undefined => {
+ return products.find((product) => product.id === productId);
+};
+
+const isProductExists = (
+ productId: string,
+ products: ProductWithUI[]
+): boolean => {
+ return products.some((product) => product.id === productId);
+};
+
+// 상품 CRUD 함수
+/** 상품 추가 */
+export const addProduct = (
+ newProduct: Omit,
+ products: ProductWithUI[]
+): ProductResult => {
+ if (!newProduct.name.trim()) {
+ return { success: false, error: "상품 이름을 입력해주세요." };
+ }
+ if (newProduct.price < 0) {
+ return { success: false, error: "가격은 0 이상이어야 합니다." };
+ }
+ if (newProduct.stock < 0) {
+ return { success: false, error: "재고는 0 이상이어야 합니다." };
+ }
+
+ const product: ProductWithUI = {
+ ...newProduct,
+ id: `p${Date.now()}`,
+ };
+
+ return { success: true, data: [...products, product] };
+};
+
+/** 상품 수정 */
+export const updateProduct = (
+ productId: string,
+ updates: Partial>,
+ products: ProductWithUI[]
+): ProductResult => {
+ if (!isProductExists(productId, products)) {
+ return { success: false, error: "존재하지 않는 상품입니다." };
+ }
+
+ if (updates.name !== undefined && !updates.name.trim()) {
+ return { success: false, error: "상품 이름을 입력해주세요." };
+ }
+ if (updates.price !== undefined && updates.price < 0) {
+ return { success: false, error: "가격은 0 이상이어야 합니다." };
+ }
+ if (updates.stock !== undefined && updates.stock < 0) {
+ return { success: false, error: "재고는 0 이상이어야 합니다." };
+ }
+
+ const newProducts = products.map((product) =>
+ product.id === productId ? { ...product, ...updates } : product
+ );
+
+ return { success: true, data: newProducts };
+};
+
+/** 상품 삭제 */
+export const removeProduct = (
+ productId: string,
+ products: ProductWithUI[]
+): ProductResult => {
+ if (!isProductExists(productId, products)) {
+ return { success: false, error: "존재하지 않는 상품입니다." };
+ }
+
+ const newProducts = products.filter((product) => product.id !== productId);
+ return { success: true, data: newProducts };
+};
+
+/** 재고 수정 */
+export const updateProductStock = (
+ productId: string,
+ newStock: number,
+ products: ProductWithUI[]
+): ProductResult => {
+ if (newStock < 0) {
+ return { success: false, error: "재고는 0 이상이어야 합니다." };
+ }
+ return updateProduct(productId, { stock: newStock }, products);
+};
+
+// 할인 규칙 함수
+/** 할인 규칙 추가 */
+export const addProductDiscount = (
+ productId: string,
+ discount: Discount,
+ products: ProductWithUI[]
+): ProductResult => {
+ const product = findProductById(productId, products);
+
+ if (!product) {
+ return { success: false, error: "존재하지 않는 상품입니다." };
+ }
+
+ if (discount.quantity <= 0) {
+ return { success: false, error: "할인 적용 수량은 1 이상이어야 합니다." };
+ }
+ if (discount.rate <= 0 || discount.rate > 1) {
+ return { success: false, error: "할인율은 0~1 사이여야 합니다." };
+ }
+
+ const existingDiscount = product.discounts.find(
+ (d) => d.quantity === discount.quantity
+ );
+ if (existingDiscount) {
+ return {
+ success: false,
+ error: `${discount.quantity}개 수량에 대한 할인이 이미 존재합니다.`,
+ };
+ }
+
+ const newDiscounts = [...product.discounts, discount].sort(
+ (a, b) => a.quantity - b.quantity
+ );
+
+ return updateProduct(productId, { discounts: newDiscounts }, products);
+};
+
+/** 할인 규칙 삭제 */
+export const removeProductDiscount = (
+ productId: string,
+ discountIndex: number,
+ products: ProductWithUI[]
+): ProductResult => {
+ const product = findProductById(productId, products);
+
+ if (!product) {
+ return { success: false, error: "존재하지 않는 상품입니다." };
+ }
+
+ if (discountIndex < 0 || discountIndex >= product.discounts.length) {
+ return { success: false, error: "존재하지 않는 할인 규칙입니다." };
+ }
+
+ const newDiscounts = product.discounts.filter((_, index) => index !== discountIndex);
+ return updateProduct(productId, { discounts: newDiscounts }, products);
+};
+
+// 검색 함수
+/** 상품 검색 (이름, 설명 기준) */
+export const filterProductsBySearch = (
+ products: T[],
+ searchTerm: string
+): T[] => {
+ if (!searchTerm.trim()) {
+ return products;
+ }
+
+ const lowerSearchTerm = searchTerm.toLowerCase();
+ return products.filter(
+ (product) =>
+ product.name.toLowerCase().includes(lowerSearchTerm) ||
+ (product.description?.toLowerCase().includes(lowerSearchTerm) ?? false)
+ );
+};
diff --git a/src/advanced/store/cartAtom.ts b/src/advanced/store/cartAtom.ts
new file mode 100644
index 000000000..60fc598d4
--- /dev/null
+++ b/src/advanced/store/cartAtom.ts
@@ -0,0 +1,20 @@
+// Cart Store (Jotai Atom)
+// 장바구니 전역 상태
+
+import { atom } from "jotai";
+import { atomWithStorage } from "jotai/utils";
+import { CartItem, Coupon } from "../types";
+
+// Base Atoms
+/** 장바구니 아이템 (localStorage 동기화) */
+export const cartAtom = atomWithStorage("cart", []);
+
+/** 선택된 쿠폰 */
+export const selectedCouponAtom = atom(null);
+
+// Derived Atom (여러 컴포넌트에서 사용)
+/** 총 아이템 개수 - CartLayout, CartPage에서 사용 */
+export const totalItemCountAtom = atom((get) => {
+ const cart = get(cartAtom);
+ return cart.reduce((sum, item) => sum + item.quantity, 0);
+});
diff --git a/src/advanced/store/couponAtom.ts b/src/advanced/store/couponAtom.ts
new file mode 100644
index 000000000..b0ac43923
--- /dev/null
+++ b/src/advanced/store/couponAtom.ts
@@ -0,0 +1,10 @@
+// Coupon Store (Jotai Atom)
+// 쿠폰 전역 상태
+
+import { atomWithStorage } from "jotai/utils";
+import { Coupon } from "../types";
+import { initialCoupons } from "../constants";
+
+// Base Atom
+/** 쿠폰 목록 (localStorage 동기화) */
+export const couponsAtom = atomWithStorage("coupons", initialCoupons);
diff --git a/src/advanced/store/index.ts b/src/advanced/store/index.ts
new file mode 100644
index 000000000..ad4365370
--- /dev/null
+++ b/src/advanced/store/index.ts
@@ -0,0 +1,13 @@
+// Store Index - 모든 Atom export
+
+// Product Store
+export { productsAtom } from "./productAtom";
+
+// Cart Store
+export { cartAtom, selectedCouponAtom, totalItemCountAtom } from "./cartAtom";
+
+// Coupon Store
+export { couponsAtom } from "./couponAtom";
+
+// Notification Store
+export { notificationsAtom } from "./notificationAtom";
diff --git a/src/advanced/store/notificationAtom.ts b/src/advanced/store/notificationAtom.ts
new file mode 100644
index 000000000..0446c0916
--- /dev/null
+++ b/src/advanced/store/notificationAtom.ts
@@ -0,0 +1,9 @@
+// Notification Store (Jotai Atom)
+// 알림 전역 상태
+
+import { atom } from "jotai";
+import { Notification } from "../types";
+
+// Base Atom
+/** 알림 목록 */
+export const notificationsAtom = atom([]);
diff --git a/src/advanced/store/productAtom.ts b/src/advanced/store/productAtom.ts
new file mode 100644
index 000000000..0794fd57a
--- /dev/null
+++ b/src/advanced/store/productAtom.ts
@@ -0,0 +1,13 @@
+// Product Store (Jotai Atom)
+// 상품 전역 상태
+
+import { atomWithStorage } from "jotai/utils";
+import { ProductWithUI } from "../types";
+import { initialProducts } from "../constants";
+
+// Base Atom
+/** 상품 목록 (localStorage 동기화) */
+export const productsAtom = atomWithStorage(
+ "products",
+ initialProducts
+);
diff --git a/src/advanced/types.ts b/src/advanced/types.ts
new file mode 100644
index 000000000..4806cad42
--- /dev/null
+++ b/src/advanced/types.ts
@@ -0,0 +1,47 @@
+
+// 기본 타입
+
+
+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;
+}
+
+export interface CartItem {
+ product: Product;
+ quantity: number;
+}
+
+export interface Coupon {
+ name: string;
+ code: string;
+ discountType: "amount" | "percentage";
+ discountValue: number;
+}
+
+export interface Notification {
+ id: string;
+ message: string;
+ type: "error" | "success" | "warning";
+}
+
+
+// 결과 타입 (Result 패턴)
+
+
+export type Result =
+ | { success: true; data: T }
+ | { success: false; error: string };
diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts
new file mode 100644
index 000000000..9de9e7cda
--- /dev/null
+++ b/src/advanced/utils/formatters.ts
@@ -0,0 +1,23 @@
+// TODO: 포맷팅 유틸리티 함수들
+// 구현할 함수:
+// - formatPrice(price: number): string - 가격을 한국 원화 형식으로 포맷
+// - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷
+// - formatPercentage(rate: number): string - 소수를 퍼센트로 변환 (0.1 → 10%)
+
+type PriceNotation = "text" | "symbol";
+
+/** 가격 포맷팅 */
+export const formatPrice = (price: number, type?: PriceNotation): string => {
+ if (type === "text") return `${price.toLocaleString()}원`;
+ return `₩${price.toLocaleString()}`;
+};
+
+/** 할인율 포맷팅 (0.1 -> "10%") */
+export const formatPercentage = (rate: number): string => {
+ return `${Math.round(rate * 100)}%`;
+};
+
+/** 날짜 포맷팅 (YYYY-MM-DD) */
+export const formatDate = (date: Date): string => {
+ return date.toISOString().split("T")[0];
+};
diff --git a/src/advanced/utils/hooks/useDebounce.ts b/src/advanced/utils/hooks/useDebounce.ts
new file mode 100644
index 000000000..62ee7af93
--- /dev/null
+++ b/src/advanced/utils/hooks/useDebounce.ts
@@ -0,0 +1,23 @@
+import { useState, useEffect } from 'react';
+
+/**
+ * 값이 변경되어도 지정된 시간 동안 대기 후 최종 값만 반환하는 훅
+ * @param value - 디바운스할 값
+ * @param delay - 대기 시간 (ms)
+ * @returns 디바운스된 값
+ */
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ // 지정된 시간 후에 값 업데이트
+ const timer = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ // 값이 변경되면 이전 타이머 취소
+ return () => clearTimeout(timer);
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/src/advanced/utils/hooks/useLocalStorage.ts b/src/advanced/utils/hooks/useLocalStorage.ts
new file mode 100644
index 000000000..867264972
--- /dev/null
+++ b/src/advanced/utils/hooks/useLocalStorage.ts
@@ -0,0 +1,57 @@
+// TODO: LocalStorage Hook
+// 힌트:
+// 1. localStorage와 React state 동기화
+// 2. 초기값 로드 시 에러 처리
+// 3. 저장 시 JSON 직렬화/역직렬화
+// 4. 빈 배열이나 undefined는 삭제
+//
+// 반환값: [저장된 값, 값 설정 함수]
+import { useState, useEffect, useCallback } from "react";
+
+interface UseLocalStorageOptions {
+ removeWhenEmpty?: boolean; // 빈 배열일 때 localStorage에서 삭제할지 여부
+}
+
+export function useLocalStorage(
+ key: string,
+ initialValue: T,
+ options: UseLocalStorageOptions = {}
+): [T, (value: T | ((val: T) => T)) => void] {
+ const { removeWhenEmpty = false } = options;
+
+ // 1. 초기값 로드 (lazy initialization)
+ const [storedValue, setStoredValue] = useState(() => {
+ try {
+ const item = localStorage.getItem(key);
+ return item ? JSON.parse(item) : initialValue;
+ } catch (error) {
+ console.error(`Error reading localStorage key "${key}":`, error);
+ return initialValue;
+ }
+ });
+
+ // 2. 값 설정 함수 (함수형 업데이트 지원)
+ const setValue = useCallback((value: T | ((val: T) => T)) => {
+ setStoredValue((prev) => {
+ const valueToStore = value instanceof Function ? value(prev) : value;
+ return valueToStore;
+ });
+ }, []);
+
+ // 3. 상태 변경 시 localStorage 동기화
+ useEffect(() => {
+ try {
+ const isEmpty = Array.isArray(storedValue) && storedValue.length === 0;
+
+ if (removeWhenEmpty && isEmpty) {
+ localStorage.removeItem(key);
+ } else {
+ localStorage.setItem(key, JSON.stringify(storedValue));
+ }
+ } catch (error) {
+ console.error(`Error setting localStorage key "${key}":`, error);
+ }
+ }, [key, storedValue, removeWhenEmpty]);
+
+ return [storedValue, setValue];
+}
\ No newline at end of file
diff --git a/src/advanced/utils/hooks/useValidate.ts b/src/advanced/utils/hooks/useValidate.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/advanced/utils/validators.ts b/src/advanced/utils/validators.ts
new file mode 100644
index 000000000..7d2dda444
--- /dev/null
+++ b/src/advanced/utils/validators.ts
@@ -0,0 +1,8 @@
+// TODO: 검증 유틸리티 함수들
+// 구현할 함수:
+// - isValidCouponCode(code: string): boolean - 쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자)
+// - isValidStock(stock: number): boolean - 재고 수량 검증 (0 이상)
+// - isValidPrice(price: number): boolean - 가격 검증 (양수)
+// - extractNumbers(value: string): string - 문자열에서 숫자만 추출
+
+// TODO: 구현
\ No newline at end of file
diff --git a/src/basic/App.tsx b/src/basic/App.tsx
index a4369fe1d..5a8284fe8 100644
--- a/src/basic/App.tsx
+++ b/src/basic/App.tsx
@@ -1,1122 +1,57 @@
-import { useState, useCallback, useEffect } from 'react';
-import { CartItem, Coupon, Product } from '../types';
-
-interface ProductWithUI extends Product {
- description?: string;
- isRecommended?: boolean;
-}
-
-interface Notification {
- id: string;
- message: string;
- type: 'error' | 'success' | 'warning';
-}
-
-// 초기 데이터
-const initialProducts: ProductWithUI[] = [
- {
- id: 'p1',
- name: '상품1',
- price: 10000,
- stock: 20,
- discounts: [
- { quantity: 10, rate: 0.1 },
- { quantity: 20, rate: 0.2 }
- ],
- description: '최고급 품질의 프리미엄 상품입니다.'
- },
- {
- id: 'p2',
- name: '상품2',
- price: 20000,
- stock: 20,
- discounts: [
- { quantity: 10, rate: 0.15 }
- ],
- description: '다양한 기능을 갖춘 실용적인 상품입니다.',
- isRecommended: true
- },
- {
- id: 'p3',
- name: '상품3',
- price: 30000,
- stock: 20,
- discounts: [
- { quantity: 10, rate: 0.2 },
- { quantity: 30, rate: 0.25 }
- ],
- description: '대용량과 고성능을 자랑하는 상품입니다.'
- }
-];
-
-const initialCoupons: Coupon[] = [
- {
- name: '5000원 할인',
- code: 'AMOUNT5000',
- discountType: 'amount',
- discountValue: 5000
- },
- {
- name: '10% 할인',
- code: 'PERCENT10',
- discountType: 'percentage',
- discountValue: 10
- }
-];
+import { useState } from 'react';
+import { CartLayout } from './components/ui/layout/CartLayout';
+import { AdminPage } from './components/ui/admin';
+import { AdminLayout } from './components/ui/layout/AdminLayout';
+import { CartPage } from './components/ui/cart/CartPage';
+
+// 커스텀 훅
+import { useCart } from './hooks/useCart';
+import { useCoupons } from './hooks/useCoupons';
+import { useProducts } from './hooks/useProducts';
+import { useNotifications } from './hooks/useNotifications';
+import { NotificationList } from './components/ui/common/NotificationList';
const App = () => {
+ // === Notifications ===
+ const { notifications, addNotification, removeNotification } = useNotifications();
- const [products, setProducts] = useState(() => {
- const saved = localStorage.getItem('products');
- if (saved) {
- try {
- return JSON.parse(saved);
- } catch {
- return initialProducts;
- }
- }
- return initialProducts;
- });
+ // === 도메인 훅 (actions로 그룹화) ===
+ const productActions = useProducts({ onNotify: addNotification });
+ const couponActions = useCoupons({ onNotify: addNotification });
+ const cartActions = useCart({ onNotify: addNotification });
- 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);
+ // === UI State ===
const [isAdmin, setIsAdmin] = useState(false);
- const [notifications, setNotifications] = useState([]);
- const [showCouponForm, setShowCouponForm] = useState(false);
- const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products');
- const [showProductForm, setShowProductForm] = useState(false);
- const [searchTerm, setSearchTerm] = useState('');
- const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
-
- // Admin
- const [editingProduct, setEditingProduct] = useState(null);
- const [productForm, setProductForm] = useState({
- name: '',
- price: 0,
- stock: 0,
- description: '',
- discounts: [] as Array<{ quantity: number; rate: number }>
- });
-
- const [couponForm, setCouponForm] = useState({
- name: '',
- code: '',
- discountType: 'amount' as 'amount' | 'percentage',
- discountValue: 0
- });
-
-
- const formatPrice = (price: number, productId?: string): string => {
- if (productId) {
- const product = products.find(p => p.id === productId);
- if (product && getRemainingStock(product) <= 0) {
- return 'SOLD OUT';
- }
- }
-
- if (isAdmin) {
- return `${price.toLocaleString()}원`;
- }
-
- return `₩${price.toLocaleString()}`;
- };
-
- const getMaxApplicableDiscount = (item: CartItem): number => {
- const { discounts } = item.product;
- const { quantity } = item;
-
- const baseDiscount = discounts.reduce((maxDiscount, discount) => {
- return quantity >= discount.quantity && discount.rate > maxDiscount
- ? discount.rate
- : maxDiscount;
- }, 0);
-
- const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10);
- if (hasBulkPurchase) {
- return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인
- }
-
- return baseDiscount;
- };
-
- const calculateItemTotal = (item: CartItem): number => {
- const { price } = item.product;
- const { quantity } = item;
- const discount = getMaxApplicableDiscount(item);
-
- return Math.round(price * quantity * (1 - discount));
- };
-
- const calculateCartTotal = (): {
- totalBeforeDiscount: number;
- totalAfterDiscount: number;
- } => {
- let totalBeforeDiscount = 0;
- let totalAfterDiscount = 0;
-
- cart.forEach(item => {
- const itemPrice = item.product.price * item.quantity;
- totalBeforeDiscount += itemPrice;
- totalAfterDiscount += calculateItemTotal(item);
- });
-
- if (selectedCoupon) {
- if (selectedCoupon.discountType === 'amount') {
- totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue);
- } else {
- totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100));
- }
- }
-
- return {
- totalBeforeDiscount: Math.round(totalBeforeDiscount),
- totalAfterDiscount: Math.round(totalAfterDiscount)
- };
- };
-
- const getRemainingStock = (product: Product): number => {
- const cartItem = cart.find(item => item.product.id === product.id);
- const remaining = product.stock - (cartItem?.quantity || 0);
-
- return remaining;
- };
-
- const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => {
- const id = Date.now().toString();
- setNotifications(prev => [...prev, { id, message, type }]);
-
- setTimeout(() => {
- setNotifications(prev => prev.filter(n => n.id !== id));
- }, 3000);
- }, []);
-
- const [totalItemCount, setTotalItemCount] = useState(0);
-
-
- useEffect(() => {
- const count = cart.reduce((sum, item) => sum + item.quantity, 0);
- setTotalItemCount(count);
- }, [cart]);
-
- useEffect(() => {
- localStorage.setItem('products', JSON.stringify(products));
- }, [products]);
-
- useEffect(() => {
- localStorage.setItem('coupons', JSON.stringify(coupons));
- }, [coupons]);
-
- useEffect(() => {
- if (cart.length > 0) {
- localStorage.setItem('cart', JSON.stringify(cart));
- } else {
- localStorage.removeItem('cart');
- }
- }, [cart]);
-
- useEffect(() => {
- const timer = setTimeout(() => {
- setDebouncedSearchTerm(searchTerm);
- }, 500);
- return () => clearTimeout(timer);
- }, [searchTerm]);
-
- const addToCart = useCallback((product: ProductWithUI) => {
- const remainingStock = getRemainingStock(product);
- if (remainingStock <= 0) {
- addNotification('재고가 부족합니다!', 'error');
- return;
- }
-
- setCart(prevCart => {
- const existingItem = prevCart.find(item => item.product.id === product.id);
-
- if (existingItem) {
- const newQuantity = existingItem.quantity + 1;
-
- if (newQuantity > product.stock) {
- addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error');
- return prevCart;
- }
-
- return prevCart.map(item =>
- item.product.id === product.id
- ? { ...item, quantity: newQuantity }
- : item
- );
- }
-
- return [...prevCart, { product, quantity: 1 }];
- });
-
- addNotification('장바구니에 담았습니다', 'success');
- }, [cart, addNotification, getRemainingStock]);
-
- const removeFromCart = useCallback((productId: string) => {
- setCart(prevCart => prevCart.filter(item => item.product.id !== productId));
- }, []);
-
- const updateQuantity = useCallback((productId: string, newQuantity: number) => {
- if (newQuantity <= 0) {
- removeFromCart(productId);
- return;
- }
-
- const product = products.find(p => p.id === productId);
- if (!product) return;
-
- const maxStock = product.stock;
- if (newQuantity > maxStock) {
- addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error');
- return;
- }
-
- setCart(prevCart =>
- prevCart.map(item =>
- item.product.id === productId
- ? { ...item, quantity: newQuantity }
- : item
- )
- );
- }, [products, removeFromCart, addNotification, getRemainingStock]);
-
- const applyCoupon = useCallback((coupon: Coupon) => {
- const currentTotal = calculateCartTotal().totalAfterDiscount;
-
- if (currentTotal < 10000 && coupon.discountType === 'percentage') {
- addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error');
- return;
- }
-
- setSelectedCoupon(coupon);
- addNotification('쿠폰이 적용되었습니다.', 'success');
- }, [addNotification, calculateCartTotal]);
-
- const completeOrder = useCallback(() => {
- const orderNumber = `ORD-${Date.now()}`;
- addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success');
- setCart([]);
- setSelectedCoupon(null);
- }, [addNotification]);
-
- const addProduct = useCallback((newProduct: Omit) => {
- const product: ProductWithUI = {
- ...newProduct,
- id: `p${Date.now()}`
- };
- setProducts(prev => [...prev, product]);
- addNotification('상품이 추가되었습니다.', 'success');
- }, [addNotification]);
-
- const updateProduct = useCallback((productId: string, updates: Partial) => {
- setProducts(prev =>
- prev.map(product =>
- product.id === productId
- ? { ...product, ...updates }
- : product
- )
- );
- addNotification('상품이 수정되었습니다.', 'success');
- }, [addNotification]);
-
- const deleteProduct = useCallback((productId: string) => {
- setProducts(prev => prev.filter(p => p.id !== productId));
- addNotification('상품이 삭제되었습니다.', 'success');
- }, [addNotification]);
-
- const addCoupon = useCallback((newCoupon: Coupon) => {
- const existingCoupon = coupons.find(c => c.code === newCoupon.code);
- if (existingCoupon) {
- addNotification('이미 존재하는 쿠폰 코드입니다.', 'error');
- return;
- }
- setCoupons(prev => [...prev, newCoupon]);
- addNotification('쿠폰이 추가되었습니다.', 'success');
- }, [coupons, addNotification]);
-
- const deleteCoupon = useCallback((couponCode: string) => {
- setCoupons(prev => prev.filter(c => c.code !== couponCode));
- if (selectedCoupon?.code === couponCode) {
- setSelectedCoupon(null);
- }
- addNotification('쿠폰이 삭제되었습니다.', 'success');
- }, [selectedCoupon, addNotification]);
-
- const handleProductSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (editingProduct && editingProduct !== 'new') {
- updateProduct(editingProduct, productForm);
- setEditingProduct(null);
- } else {
- addProduct({
- ...productForm,
- discounts: productForm.discounts
- });
- }
- setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] });
- setEditingProduct(null);
- setShowProductForm(false);
- };
-
- const handleCouponSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- addCoupon(couponForm);
- setCouponForm({
- name: '',
- code: '',
- discountType: 'amount',
- discountValue: 0
- });
- setShowCouponForm(false);
- };
-
- const startEditProduct = (product: ProductWithUI) => {
- setEditingProduct(product.id);
- setProductForm({
- name: product.name,
- price: product.price,
- stock: product.stock,
- description: product.description || '',
- discounts: product.discounts || []
- });
- setShowProductForm(true);
- };
-
- const totals = calculateCartTotal();
-
- const filteredProducts = debouncedSearchTerm
- ? products.filter(product =>
- product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
- (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase()))
- )
- : products;
+ // === Render ===
return (
- {notifications.length > 0 && (
-
- {notifications.map(notif => (
-
- {notif.message}
-
-
- ))}
-
+
+
+ {/* Page Content */}
+ {isAdmin ? (
+ setIsAdmin(false)}>
+
+
+ ) : (
+ setIsAdmin(true)}
+ >
+ {({ debouncedSearchTerm }) => (
+
+ )}
+
)}
-
-
-
-
- SHOP
- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */}
- {!isAdmin && (
-
- setSearchTerm(e.target.value)}
- placeholder="상품 검색..."
- className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500"
- />
-
- )}
-
-
-
-
-
-
-
- {isAdmin ? (
-
-
- 관리자 대시보드
- 상품과 쿠폰을 관리할 수 있습니다
-
-
-
-
-
- {activeTab === 'products' ? (
-
-
-
- 상품 목록
-
-
-
-
-
-
-
-
- | 상품명 |
- 가격 |
- 재고 |
- 설명 |
- 작업 |
-
-
-
- {(activeTab === 'products' ? products : products).map(product => (
-
- | {product.name} |
- {formatPrice(product.price, product.id)} |
-
- 10 ? 'bg-green-100 text-green-800' :
- product.stock > 0 ? 'bg-yellow-100 text-yellow-800' :
- 'bg-red-100 text-red-800'
- }`}>
- {product.stock}개
-
- |
- {product.description || '-'} |
-
-
-
- |
-
- ))}
-
-
-
- {showProductForm && (
-
- )}
-
- ) : (
-
-
- 쿠폰 관리
-
-
-
- {coupons.map(coupon => (
-
-
-
- {coupon.name}
- {coupon.code}
-
-
- {coupon.discountType === 'amount'
- ? `${coupon.discountValue.toLocaleString()}원 할인`
- : `${coupon.discountValue}% 할인`}
-
-
-
-
-
-
- ))}
-
-
-
-
-
-
- {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}개
- )}
-
-
- {/* 장바구니 버튼 */}
-
-
-
- );
- })}
-
- )}
-
-
-
-
-
-
-
-
- 장바구니
-
- {cart.length === 0 ? (
-
- ) : (
-
- {cart.map(item => {
- const itemTotal = calculateItemTotal(item);
- const originalPrice = item.product.price * item.quantity;
- const hasDiscount = itemTotal < originalPrice;
- const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0;
-
- return (
-
-
- {item.product.name}
-
-
-
-
-
- {item.quantity}
-
-
-
- {hasDiscount && (
- -{discountRate}%
- )}
-
- {Math.round(itemTotal).toLocaleString()}원
-
-
-
-
- );
- })}
-
- )}
-
-
- {cart.length > 0 && (
- <>
-
-
- 쿠폰 할인
-
-
- {coupons.length > 0 && (
-
- )}
-
-
-
- 결제 정보
-
-
- 상품 금액
- {totals.totalBeforeDiscount.toLocaleString()}원
-
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && (
-
- 할인 금액
- -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원
-
- )}
-
- 결제 예정 금액
- {totals.totalAfterDiscount.toLocaleString()}원
-
-
-
-
-
-
-
- >
- )}
-
-
-
- )}
-
);
};
diff --git a/src/basic/components/icons/index.tsx b/src/basic/components/icons/index.tsx
new file mode 100644
index 000000000..1609d7749
--- /dev/null
+++ b/src/basic/components/icons/index.tsx
@@ -0,0 +1,12 @@
+// TODO: SVG 아이콘 컴포넌트들
+// 구현할 아이콘:
+// - CartIcon: 장바구니 아이콘
+// - AdminIcon: 관리자 아이콘
+// - PlusIcon: 플러스 아이콘
+// - MinusIcon: 마이너스 아이콘
+// - TrashIcon: 삭제 아이콘
+// - ChevronDownIcon: 아래 화살표
+// - ChevronUpIcon: 위 화살표
+// - CheckIcon: 체크 아이콘
+
+// TODO: 구현
\ No newline at end of file
diff --git a/src/basic/components/ui/admin/AdminHeader.tsx b/src/basic/components/ui/admin/AdminHeader.tsx
new file mode 100644
index 000000000..410d9111c
--- /dev/null
+++ b/src/basic/components/ui/admin/AdminHeader.tsx
@@ -0,0 +1,15 @@
+// components/admin/AdminHeader.tsx
+
+interface AdminHeaderProps {
+ title: string;
+ description: string;
+}
+
+export function AdminHeader({ title, description }: AdminHeaderProps) {
+ return (
+
+ {title}
+ {description}
+
+ );
+}
\ No newline at end of file
diff --git a/src/basic/components/ui/admin/AdminPage.tsx b/src/basic/components/ui/admin/AdminPage.tsx
new file mode 100644
index 000000000..36a539a99
--- /dev/null
+++ b/src/basic/components/ui/admin/AdminPage.tsx
@@ -0,0 +1,50 @@
+// components/admin/AdminPage.tsx
+import { useState } from "react";
+import { AdminHeader } from "./AdminHeader";
+import { AdminTabs, AdminTabType } from "./AdminTabs";
+import { ProductSection } from "./product/ProductSection";
+import { CouponSection } from "./coupon/CouponSection";
+import { NotifyFn, UseProductsReturn } from "@/basic/hooks/useProducts";
+import { UseCouponsReturn } from "@/basic/hooks/useCoupons";
+
+// 타입 import
+
+interface AdminPageProps {
+ productActions: UseProductsReturn;
+ couponActions: UseCouponsReturn;
+ addNotification: NotifyFn;
+}
+
+export function AdminPage({
+ productActions,
+ couponActions,
+ addNotification,
+}: AdminPageProps) {
+ // 🔹 탭 상태 추가
+ const [activeTab, setActiveTab] = useState("products");
+
+ return (
+
+
+
+
+
+
+ {activeTab === "products" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/basic/components/ui/admin/AdminTabs.tsx b/src/basic/components/ui/admin/AdminTabs.tsx
new file mode 100644
index 000000000..d90c0db40
--- /dev/null
+++ b/src/basic/components/ui/admin/AdminTabs.tsx
@@ -0,0 +1,39 @@
+// components/admin/AdminTabs.tsx
+
+export type AdminTabType = 'products' | 'coupons';
+
+interface Tab {
+ key: AdminTabType;
+ label: string;
+}
+
+const TABS: Tab[] = [
+ { key: 'products', label: '상품 관리' },
+ { key: 'coupons', label: '쿠폰 관리' },
+];
+
+interface AdminTabsProps {
+ activeTab: AdminTabType;
+ onTabChange: (tab: AdminTabType) => void;
+}
+
+export function AdminTabs({ activeTab, onTabChange }: AdminTabsProps) {
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/basic/components/ui/admin/coupon/AddCouponCard.tsx b/src/basic/components/ui/admin/coupon/AddCouponCard.tsx
new file mode 100644
index 000000000..3c47051a8
--- /dev/null
+++ b/src/basic/components/ui/admin/coupon/AddCouponCard.tsx
@@ -0,0 +1,35 @@
+import { Button } from "../../common/button";
+import { Card } from "../../common/card";
+
+
+interface AddCouponCardProps {
+ onClick: () => void;
+}
+
+export function AddCouponCard({ onClick }: AddCouponCardProps) {
+ return (
+
+
+
+ );
+}
+
diff --git a/src/basic/components/ui/admin/coupon/CouponCard.tsx b/src/basic/components/ui/admin/coupon/CouponCard.tsx
new file mode 100644
index 000000000..7022681b6
--- /dev/null
+++ b/src/basic/components/ui/admin/coupon/CouponCard.tsx
@@ -0,0 +1,52 @@
+import { Coupon } from '@/types';
+import { Card, CardContent } from '../../common/card';
+import { Button } from '../../common/button';
+
+
+interface CouponCardProps {
+ coupon: Coupon;
+ onDelete: () => void;
+}
+
+export function CouponCard({ coupon, onDelete }: CouponCardProps) {
+ return (
+
+
+
+
+ {coupon.name}
+ {coupon.code}
+
+
+ {coupon.discountType === 'amount'
+ ? `${coupon.discountValue.toLocaleString()}원 할인`
+ : `${coupon.discountValue}% 할인`}
+
+
+
+
+
+
+
+ );
+}
+
diff --git a/src/basic/components/ui/admin/coupon/CouponForm.tsx b/src/basic/components/ui/admin/coupon/CouponForm.tsx
new file mode 100644
index 000000000..5e24019c0
--- /dev/null
+++ b/src/basic/components/ui/admin/coupon/CouponForm.tsx
@@ -0,0 +1,146 @@
+// components/admin/CouponForm.tsx
+
+import { CouponFormHook } from "@/basic/hooks/useCouponForm";
+import { Button } from "../../common/button";
+
+interface CouponFormProps {
+ couponForm: CouponFormHook;
+ onNotify: (message: string, type: 'error' | 'success' | 'warning') => void;
+}
+
+export function CouponForm({ couponForm, onNotify }: CouponFormProps) {
+ if (!couponForm.isOpen) return null;
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/basic/components/ui/admin/coupon/CouponSection.tsx b/src/basic/components/ui/admin/coupon/CouponSection.tsx
new file mode 100644
index 000000000..7dd11d2ae
--- /dev/null
+++ b/src/basic/components/ui/admin/coupon/CouponSection.tsx
@@ -0,0 +1,51 @@
+import { useCouponForm } from "@/basic/hooks/useCouponForm";
+import { UseCouponsReturn } from "@/basic/hooks/useCoupons";
+import { CouponCard } from "./CouponCard";
+import { AddCouponCard } from "./AddCouponCard";
+import { CouponForm } from "./CouponForm";
+
+
+type NotifyFn = (message: string, type: 'error' | 'success' | 'warning') => void;
+
+interface CouponSectionProps {
+ couponActions: UseCouponsReturn;
+ onNotify: NotifyFn;
+}
+
+export function CouponSection({
+ couponActions,
+ onNotify,
+}: CouponSectionProps) {
+ const { coupons, addCoupon, removeCoupon } = couponActions;
+ const couponForm = useCouponForm({ addCoupon });
+
+ return (
+
+ {/* 섹션 헤더 */}
+
+ 쿠폰 관리
+
+
+ {/* 쿠폰 목록 */}
+
+
+ {coupons.map((coupon) => (
+ removeCoupon(coupon.code)}
+ />
+ ))}
+
+ couponForm.isOpen ? couponForm.close() : couponForm.open()
+ }
+ />
+
+
+ {/* 쿠폰 추가 폼 */}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/basic/components/ui/admin/coupon/CouponTable.tsx b/src/basic/components/ui/admin/coupon/CouponTable.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/basic/components/ui/admin/index.ts b/src/basic/components/ui/admin/index.ts
new file mode 100644
index 000000000..acaf7e182
--- /dev/null
+++ b/src/basic/components/ui/admin/index.ts
@@ -0,0 +1,10 @@
+// components/admin/index.ts
+export { CouponForm } from "./coupon/CouponForm";
+export { ProductForm } from "./product/ProductForm";
+export { ProductSection } from "./product/ProductSection";
+export { CouponSection } from "./coupon/CouponSection";
+export { AdminPage } from "./AdminPage";
+export { AdminHeader } from "./AdminHeader";
+export { AdminTabs } from "./AdminTabs";
+
+export type { AdminTabType } from "./AdminTabs";
diff --git a/src/basic/components/ui/admin/product/ProductForm.tsx b/src/basic/components/ui/admin/product/ProductForm.tsx
new file mode 100644
index 000000000..84e49fa13
--- /dev/null
+++ b/src/basic/components/ui/admin/product/ProductForm.tsx
@@ -0,0 +1,222 @@
+// components/admin/ProductForm.tsx
+
+import { ProductFormHook } from "@/basic/hooks/useProductForm";
+import { Button } from "../../common/button";
+
+interface ProductFormProps {
+ productForm: ProductFormHook;
+ onNotify: (message: string, type: 'error' | 'success' | 'warning') => void;
+}
+
+export function ProductForm({ productForm, onNotify }: ProductFormProps) {
+ if (!productForm.isOpen) return null;
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/basic/components/ui/admin/product/ProductSection.tsx b/src/basic/components/ui/admin/product/ProductSection.tsx
new file mode 100644
index 000000000..089a9d3a7
--- /dev/null
+++ b/src/basic/components/ui/admin/product/ProductSection.tsx
@@ -0,0 +1,70 @@
+// components/admin/product/ProductSection.tsx
+import { useCallback } from "react";
+import { Button } from "../../common/button";
+import { ProductTable } from "./ProductTable";
+import { ProductForm } from "./ProductForm";
+import { useProductForm } from "@/basic/hooks/useProductForm";
+import { NotifyFn, UseProductsReturn } from "@/basic/hooks/useProducts";
+
+interface ProductSectionProps {
+ productActions: UseProductsReturn;
+ onNotify: NotifyFn; // (message, type) => void
+}
+
+export function ProductSection({
+ productActions,
+ onNotify,
+}: ProductSectionProps) {
+ const {
+ products,
+ addProduct,
+ updateProduct,
+ removeProduct,
+ } = productActions;
+
+ const productForm = useProductForm({
+ addProduct,
+ updateProduct,
+ });
+
+ const onDeleteProduct = useCallback(
+ (id: string) => {
+ const success = removeProduct(id);
+ // useProducts에서 이미 notify를 하고 있다면 이 부분은 빼도 되지만,
+ // UI용으로 한 번 더 알리고 싶다면 남겨도 괜찮습니다.
+ if (success) {
+ onNotify("상품이 삭제되었습니다.", "success");
+ }
+ },
+ [removeProduct, onNotify]
+ );
+
+ // 🔹 가격 포맷터
+ const formatPrice = (price: number) =>
+ `${price.toLocaleString("ko-KR")}원`;
+
+ return (
+
+ {/* 섹션 헤더 */}
+
+
+ {/* 상품 테이블 */}
+
+
+ {/* 상품 추가/수정 폼 */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/basic/components/ui/admin/product/ProductTable.tsx b/src/basic/components/ui/admin/product/ProductTable.tsx
new file mode 100644
index 000000000..aa7eea7c5
--- /dev/null
+++ b/src/basic/components/ui/admin/product/ProductTable.tsx
@@ -0,0 +1,94 @@
+import { Product, ProductWithUI } from "@/types";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../common/table";
+import { Badge } from "../../common/badge";
+import { Button } from "../../common/button";
+
+// Props 타입 정의 (필요시)
+interface ProductTableProps {
+ products: ProductWithUI[]; // Product 타입은 별도 정의되었다고 가정
+ onStartEditProduct: (product: Product) => void;
+ onDeleteProduct: (id: string) => void;
+ formatPrice: (price: number, id: string) => string;
+}
+
+export function ProductTable({
+ products,
+ onStartEditProduct,
+ onDeleteProduct,
+ formatPrice
+}: ProductTableProps) {
+ return (
+
+
+
+
+ 상품명
+ 가격
+ 재고
+ 설명
+ 작업
+
+
+
+ {products.map((product: ProductWithUI) => (
+
+
+ {product.name}
+
+
+
+ {formatPrice(product.price, product.id)}
+
+
+
+ 10
+ ? 'stockHigh'
+ : product.stock > 0
+ ? 'stockLow'
+ : 'stockHigh'
+ }
+ >
+ {product.stock}개
+
+
+
+
+ {product.description || '-'}
+
+
+
+
+
+
+
+ ))}
+
+ {/* 데이터가 없을 때 처리 */}
+ {products.length === 0 && (
+
+
+ 등록된 상품이 없습니다.
+
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/basic/components/ui/cart/CartPage.tsx b/src/basic/components/ui/cart/CartPage.tsx
new file mode 100644
index 000000000..7fc4a22c7
--- /dev/null
+++ b/src/basic/components/ui/cart/CartPage.tsx
@@ -0,0 +1,289 @@
+// components/CartPage.tsx
+import { useMemo } from 'react';
+import { Product, Coupon } from '@/types';
+import { ProductCard } from './ProductCard';
+import { Button } from '../common/button';
+import { useCart } from '../../../hooks/useCart';
+import { calculateItemTotal, filterProductsBySearch } from '../../../models/cart';
+// TODO: UI 분리 필요
+interface ProductWithUI extends Product {
+ description?: string;
+ isRecommended?: boolean;
+}
+
+interface CartPageProps {
+ products: ProductWithUI[];
+ coupons: Coupon[];
+ cartActions: ReturnType;
+ debouncedSearchTerm: string;
+}
+
+export function CartPage({
+ products,
+ coupons,
+ cartActions,
+ debouncedSearchTerm,
+}: CartPageProps) {
+ const {
+ cart,
+ selectedCoupon,
+ totals,
+ addToCart,
+ removeFromCart,
+ updateQuantity,
+ applyCoupon,
+ removeCoupon,
+ completeOrder,
+ getRemainingStock,
+ } = cartActions;
+
+ // 필터링된 상품 목록
+ const filteredProducts = useMemo(
+ () => filterProductsBySearch(products, debouncedSearchTerm),
+ [products, debouncedSearchTerm]
+ );
+
+ // 아이템 총액 계산
+ const getItemTotal = (item: { product: Product; quantity: number }) =>
+ calculateItemTotal(item, cart);
+
+ return (
+
+
+
+ {/* 상품 목록 */}
+
+
+ 전체 상품
+
+ 총 {products.length}개 상품
+
+
+ {filteredProducts.length === 0 ? (
+
+
+ "{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.
+
+
+ ) : (
+
+ {filteredProducts.map((product) => {
+ const remainingStock = getRemainingStock(product);
+ return (
+ addToCart(product)}
+ />
+ );
+ })}
+
+ )}
+
+
+
+
+
+ {/* 장바구니 섹션 */}
+
+
+
+ 장바구니
+
+ {cart.length === 0 ? (
+
+ ) : (
+
+ {cart.map((item) => {
+ const itemTotal = getItemTotal(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.totalDiscount > 0 && (
+
+ 할인 금액
+ -{totals.totalDiscount.toLocaleString()}원
+
+ )}
+
+ 결제 예정 금액
+
+ {totals.totalAfterDiscount.toLocaleString()}원
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/basic/components/ui/cart/ProductCard.tsx b/src/basic/components/ui/cart/ProductCard.tsx
new file mode 100644
index 000000000..83b8401e9
--- /dev/null
+++ b/src/basic/components/ui/cart/ProductCard.tsx
@@ -0,0 +1,97 @@
+import { Product } from '@/types';
+import { Card, CardContent } from '../common/card';
+import { Button } from '../common/button';
+import { formatPrice } from '@/basic/utils/formatters';
+
+interface ProductWithUI extends Product {
+ description?: string;
+ isRecommended?: boolean;
+}
+
+interface ProductCardProps {
+ product: ProductWithUI;
+ remainingStock: number;
+ onAddToCart: () => void;
+}
+
+export function ProductCard({
+ product,
+ remainingStock,
+ onAddToCart,
+}: ProductCardProps) {
+ return (
+
+ {/* 상품 이미지 영역 */}
+
+
+ {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.discounts.length > 0 && (
+
+ {product.discounts[0].quantity}개 이상 구매시 할인{' '}
+ {product.discounts[0].rate * 100}%
+
+ )}
+
+
+ {/* 재고 상태 */}
+
+ {remainingStock <= 5 && remainingStock > 0 && (
+
+ 품절임박! {remainingStock}개 남음
+
+ )}
+ {remainingStock > 5 && (
+ 재고 {remainingStock}개
+ )}
+
+
+ {/* 장바구니 버튼 */}
+
+
+
+ );
+}
+
diff --git a/src/basic/components/ui/common/NotificationList.tsx b/src/basic/components/ui/common/NotificationList.tsx
new file mode 100644
index 000000000..4afefbb8c
--- /dev/null
+++ b/src/basic/components/ui/common/NotificationList.tsx
@@ -0,0 +1,35 @@
+// components/ui/NotificationList.tsx
+
+import { Notification } from "@/basic/hooks/useNotifications";
+
+interface NotificationListProps {
+ notifications: Notification[];
+ onRemove: (id: string) => void;
+}
+
+export function NotificationList({ notifications, onRemove }: NotificationListProps) {
+ if (notifications.length === 0) return null;
+
+ return (
+
+ {notifications.map((notif) => (
+
+ {notif.message}
+
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/src/basic/components/ui/common/badge.tsx b/src/basic/components/ui/common/badge.tsx
new file mode 100644
index 000000000..e8110bb70
--- /dev/null
+++ b/src/basic/components/ui/common/badge.tsx
@@ -0,0 +1,29 @@
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "@/basic/lib/utils"
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-full border px-2.5 py-0.5 text-xs font-medium border-transparent",
+ {
+ variants: {
+ variant: {
+ stockHigh: "bg-green-100 text-green-800",
+ stockLow: "bg-yellow-100 text-yellow-800",
+ stockOut: "bg-red-100 text-red-800",
+ },
+ },
+ defaultVariants: {
+ variant: "stockHigh",
+ },
+ }
+)
+
+interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/src/basic/components/ui/common/button.tsx b/src/basic/components/ui/common/button.tsx
new file mode 100644
index 000000000..2558fb510
--- /dev/null
+++ b/src/basic/components/ui/common/button.tsx
@@ -0,0 +1,56 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "@/basic/lib/utils"
+
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ // 기본 검정 버튼 - 주요 액션 (새 상품 추가, 장바구니 담기)
+ default: "bg-gray-900 text-white hover:bg-gray-800",
+
+ // 인디고 강조 버튼 - 폼 제출 (추가/수정/생성)
+ primary: "bg-indigo-600 text-white hover:bg-indigo-700",
+
+ // 테두리 버튼 - 보조 액션 (취소)
+ outline: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50",
+
+ // 텍스트 버튼 - 최소 강조 (토글, 네비게이션)
+ ghost: "text-gray-600 hover:text-gray-900 hover:bg-gray-100",
+
+ // 삭제/위험 액션
+ destructive: "text-red-600 hover:text-red-900",
+
+ // 링크 스타일 - 인라인 액션 (수정)
+ link: "text-indigo-600 hover:text-indigo-900 underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 px-3 py-1.5 text-sm",
+ lg: "h-11 px-6 py-3",
+ icon: "h-6 w-6",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {}
+
+function Button({ className, variant, size, ...props }: ButtonProps) {
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/src/basic/components/ui/common/card.tsx b/src/basic/components/ui/common/card.tsx
new file mode 100644
index 000000000..f9cc08c83
--- /dev/null
+++ b/src/basic/components/ui/common/card.tsx
@@ -0,0 +1,94 @@
+import * as React from "react"
+import { cn } from "@/basic/lib/utils"
+
+
+
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/src/basic/components/ui/common/input.tsx b/src/basic/components/ui/common/input.tsx
new file mode 100644
index 000000000..e18701dce
--- /dev/null
+++ b/src/basic/components/ui/common/input.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+import { cn } from "@/basic/lib/utils"
+
+
+
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/src/basic/components/ui/common/label.tsx b/src/basic/components/ui/common/label.tsx
new file mode 100644
index 000000000..740218f22
--- /dev/null
+++ b/src/basic/components/ui/common/label.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+
+import { cn } from "@/basic/lib/utils"
+
+
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/src/basic/components/ui/common/select.tsx b/src/basic/components/ui/common/select.tsx
new file mode 100644
index 000000000..455ec0739
--- /dev/null
+++ b/src/basic/components/ui/common/select.tsx
@@ -0,0 +1,186 @@
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+import { cn } from "@/basic/lib/utils"
+
+
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "popper",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/src/basic/components/ui/common/table.tsx b/src/basic/components/ui/common/table.tsx
new file mode 100644
index 000000000..80cf911ee
--- /dev/null
+++ b/src/basic/components/ui/common/table.tsx
@@ -0,0 +1,115 @@
+import { cn } from "@/basic/lib/utils"
+
+import * as React from "react"
+
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ |
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/src/basic/components/ui/feature/CartButton.tsx b/src/basic/components/ui/feature/CartButton.tsx
new file mode 100644
index 000000000..3db3a752a
--- /dev/null
+++ b/src/basic/components/ui/feature/CartButton.tsx
@@ -0,0 +1,29 @@
+interface CartButtonProps {
+ itemCount: number;
+}
+
+export function CartButton({ itemCount }: CartButtonProps) {
+ return (
+
+
+ {itemCount > 0 && (
+
+ {itemCount}
+
+ )}
+
+ );
+}
+
diff --git a/src/basic/components/ui/feature/ModeSwitchButton.tsx b/src/basic/components/ui/feature/ModeSwitchButton.tsx
new file mode 100644
index 000000000..5816b0372
--- /dev/null
+++ b/src/basic/components/ui/feature/ModeSwitchButton.tsx
@@ -0,0 +1,19 @@
+import { Button } from "../common/button";
+
+interface ModeSwitchButtonProps {
+ isAdmin: boolean;
+ onToggle: () => void;
+}
+
+export function ModeSwitchButton({ isAdmin, onToggle }: ModeSwitchButtonProps) {
+ return (
+
+ );
+}
+
diff --git a/src/basic/components/ui/feature/SearchInput.tsx b/src/basic/components/ui/feature/SearchInput.tsx
new file mode 100644
index 000000000..cb5e26324
--- /dev/null
+++ b/src/basic/components/ui/feature/SearchInput.tsx
@@ -0,0 +1,22 @@
+interface SearchInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+}
+
+export function SearchInput({
+ value,
+ onChange,
+ placeholder = '상품 검색...',
+}: SearchInputProps) {
+ return (
+ onChange(e.target.value)}
+ placeholder={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/components/ui/layout/AdminLayout.tsx b/src/basic/components/ui/layout/AdminLayout.tsx
new file mode 100644
index 000000000..e2d7144b9
--- /dev/null
+++ b/src/basic/components/ui/layout/AdminLayout.tsx
@@ -0,0 +1,19 @@
+import { ModeSwitchButton } from '../feature/ModeSwitchButton';
+import { GlobalHeader } from './GlobalHeader';
+
+interface AdminLayoutProps {
+ children: React.ReactNode;
+ onToggleAdmin: () => void;
+}
+
+export function AdminLayout({ children, onToggleAdmin }: AdminLayoutProps) {
+ return (
+ <>
+ }
+ />
+ {children}
+ >
+ );
+}
+
diff --git a/src/basic/components/ui/layout/CartLayout.tsx b/src/basic/components/ui/layout/CartLayout.tsx
new file mode 100644
index 000000000..6212e13e3
--- /dev/null
+++ b/src/basic/components/ui/layout/CartLayout.tsx
@@ -0,0 +1,47 @@
+// components/ui/layout/CartLayout.tsx
+import { useState } from 'react';
+
+import { CartButton } from '../feature/CartButton';
+import { ModeSwitchButton } from '../feature/ModeSwitchButton';
+import { SearchInput } from '../feature/SearchInput';
+import { GlobalHeader } from './GlobalHeader';
+import { useDebounce } from '@/basic/utils/hooks/useDebounce';
+
+interface CartLayoutRenderProps {
+ debouncedSearchTerm: string;
+}
+
+interface CartLayoutProps {
+ children: (props: CartLayoutRenderProps) => React.ReactNode;
+ cartItemCount: number;
+ onToggleAdmin: () => void;
+}
+
+export function CartLayout({
+ children,
+ cartItemCount,
+ onToggleAdmin,
+}: CartLayoutProps) {
+ const [searchTerm, setSearchTerm] = useState('');
+ const debouncedSearchTerm = useDebounce(searchTerm, 500);
+
+ return (
+ <>
+
+ }
+ right={
+ <>
+
+
+ >
+ }
+ />
+ {children({ debouncedSearchTerm })}
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/basic/components/ui/layout/GlobalHeader.tsx b/src/basic/components/ui/layout/GlobalHeader.tsx
new file mode 100644
index 000000000..85f1bde7f
--- /dev/null
+++ b/src/basic/components/ui/layout/GlobalHeader.tsx
@@ -0,0 +1,26 @@
+interface GlobalHeaderProps {
+ center?: React.ReactNode;
+ right?: React.ReactNode;
+}
+
+export function GlobalHeader({ center, right }: GlobalHeaderProps) {
+ return (
+
+
+
+ {/* 1. Logo Section: 항상 고정 */}
+
+ SHOP
+
+ {/* 2. Center Slot: 검색창 등 */}
+ {center && {center} }
+
+
+ {/* 3. Right Slot: 버튼, 카트 등 */}
+
+
+
+
+ );
+}
+
diff --git a/src/basic/constants/index.ts b/src/basic/constants/index.ts
new file mode 100644
index 000000000..9263bacea
--- /dev/null
+++ b/src/basic/constants/index.ts
@@ -0,0 +1,60 @@
+// TODO: 초기 데이터 상수
+// 정의할 상수들:
+// - initialProducts: 초기 상품 목록 (상품1, 상품2, 상품3 + 설명 필드 포함)
+// - initialCoupons: 초기 쿠폰 목록 (5000원 할인, 10% 할인)
+//
+// 참고: origin/App.tsx의 초기 데이터 구조를 참조
+
+import { Coupon, ProductWithUI } from "@/types";
+
+// TODO: 구현
+
+// 초기 데이터
+export const initialProducts: ProductWithUI[] = [
+ {
+ id: "p1",
+ name: "상품1",
+ price: 10000,
+ stock: 20,
+ discounts: [
+ { quantity: 10, rate: 0.1 },
+ { quantity: 20, rate: 0.2 },
+ ],
+ description: "최고급 품질의 프리미엄 상품입니다.",
+ },
+ {
+ id: "p2",
+ name: "상품2",
+ price: 20000,
+ stock: 20,
+ discounts: [{ quantity: 10, rate: 0.15 }],
+ description: "다양한 기능을 갖춘 실용적인 상품입니다.",
+ isRecommended: true,
+ },
+ {
+ id: "p3",
+ name: "상품3",
+ price: 30000,
+ stock: 20,
+ discounts: [
+ { quantity: 10, rate: 0.2 },
+ { quantity: 30, rate: 0.25 },
+ ],
+ description: "대용량과 고성능을 자랑하는 상품입니다.",
+ },
+];
+
+export const initialCoupons: Coupon[] = [
+ {
+ name: "5000원 할인",
+ code: "AMOUNT5000",
+ discountType: "amount",
+ discountValue: 5000,
+ },
+ {
+ name: "10% 할인",
+ code: "PERCENT10",
+ discountType: "percentage",
+ discountValue: 10,
+ },
+];
\ No newline at end of file
diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts
new file mode 100644
index 000000000..d0eabc7f7
--- /dev/null
+++ b/src/basic/hooks/useCart.ts
@@ -0,0 +1,224 @@
+// TODO: 장바구니 관리 Hook
+// 힌트:
+// 1. 장바구니 상태 관리 (localStorage 연동)
+// 2. 상품 추가/삭제/수량 변경
+// 3. 쿠폰 적용
+// 4. 총액 계산
+// 5. 재고 확인
+//
+// 사용할 모델 함수:
+// - cartModel.addItemToCart
+// - cartModel.removeItemFromCart
+// - cartModel.updateCartItemQuantity
+// - cartModel.calculateCartTotal
+// - cartModel.getRemainingStock
+//
+// 반환할 값:
+// - cart: 장바구니 아이템 배열
+// - selectedCoupon: 선택된 쿠폰
+// - addToCart: 상품 추가 함수
+// - removeFromCart: 상품 제거 함수
+// - updateQuantity: 수량 변경 함수
+// - applyCoupon: 쿠폰 적용 함수
+// - calculateTotal: 총액 계산 함수
+// - getRemainingStock: 재고 확인 함수
+// - clearCart: 장바구니 비우기 함수
+
+
+// TODO: 구현
+import { useState, useCallback, useMemo } from "react";
+import { CartItem, Coupon, Product } from "@/types";
+import { useLocalStorage } from "../utils/hooks/useLocalStorage";
+import * as cartModel from "../models/cart";
+
+// ============================================
+// 타입 정의
+// ============================================
+type NotifyFn = (
+ message: string,
+ type: "error" | "success" | "warning"
+) => void;
+
+interface UseCartOptions {
+ /** 알림 함수 (선택적) */
+ onNotify?: NotifyFn;
+ /** localStorage 키 */
+ storageKey?: string;
+}
+
+interface UseCartReturn {
+ // 상태
+ cart: CartItem[];
+ selectedCoupon: Coupon | null;
+
+ // 계산된 값
+ totals: {
+ totalBeforeDiscount: number;
+ totalAfterDiscount: number;
+ totalDiscount: number;
+ };
+ totalItemCount: number;
+
+ // 장바구니 액션
+ addToCart: (product: Product) => void;
+ removeFromCart: (productId: string) => void;
+ updateQuantity: (productId: string, quantity: number) => void;
+
+ // 쿠폰 액션
+ applyCoupon: (coupon: Coupon) => void;
+ removeCoupon: () => void;
+
+ // 주문 액션
+ clearCart: () => void;
+ completeOrder: () => string;
+
+ // 조회
+ getRemainingStock: (product: Product) => number;
+}
+
+// ============================================
+// useCart Hook
+// ============================================
+export function useCart(options: UseCartOptions = {}): UseCartReturn {
+ const { onNotify, storageKey = "cart" } = options;
+
+ // === 상태 ===
+ const [cart, setCart] = useLocalStorage(storageKey, [], {
+ removeWhenEmpty: true,
+ });
+ const [selectedCoupon, setSelectedCoupon] = useState(null);
+
+ // === 헬퍼: 안전한 알림 호출 ===
+ const notify = useCallback(
+ (message: string, type: "error" | "success" | "warning") => {
+ onNotify?.(message, type);
+ },
+ [onNotify]
+ );
+
+ // === 장바구니 액션: 추가 ===
+ const addToCart = useCallback(
+ (product: Product) => {
+ const result = cartModel.addItemToCart(product, cart);
+
+ if (!result.success) {
+ notify(result.error, "error");
+ return;
+ }
+
+ setCart(result.data);
+ notify("장바구니에 담았습니다", "success");
+ },
+ [cart, setCart, notify]
+ );
+
+ // === 장바구니 액션: 제거 ===
+ const removeFromCart = useCallback(
+ (productId: string) => {
+ const newCart = cartModel.removeItemFromCart(productId, cart);
+ setCart(newCart);
+ },
+ [cart, setCart]
+ );
+
+ // === 장바구니 액션: 수량 변경 ===
+ const updateQuantity = useCallback(
+ (productId: string, newQuantity: number) => {
+ const result = cartModel.updateCartItemQuantity(
+ productId,
+ newQuantity,
+ cart
+ );
+
+ if (!result.success) {
+ notify(result.error, "error");
+ return;
+ }
+
+ setCart(result.data);
+ },
+ [cart, setCart, notify]
+ );
+
+ // === 쿠폰 액션: 적용 ===
+const applyCoupon: (coupon: Coupon) => void = useCallback(
+ (coupon: Coupon) => {
+ const validationResult = cartModel.validateCouponApplication(coupon, cart);
+
+ if (!validationResult.success) {
+ notify(validationResult.error, "error");
+ return;
+ }
+
+ setSelectedCoupon(coupon);
+ notify("쿠폰이 적용되었습니다.", "success");
+ },
+ [cart, notify]
+);
+
+ // === 쿠폰 액션: 제거 ===
+ const removeCoupon = useCallback(() => {
+ setSelectedCoupon(null);
+ }, []);
+
+ // === 주문 액션: 장바구니 비우기 ===
+ const clearCart = useCallback(() => {
+ setCart([]);
+ setSelectedCoupon(null);
+ }, [setCart]);
+
+ // === 주문 액션: 주문 완료 ===
+ const completeOrder = useCallback(() => {
+ const orderNumber = `ORD-${Date.now()}`;
+ notify(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success");
+ setCart([]);
+ setSelectedCoupon(null);
+ return orderNumber;
+ }, [setCart, notify]);
+
+ // === 조회: 남은 재고 ===
+ const getRemainingStock = useCallback(
+ (product: Product) => {
+ return cartModel.getRemainingStock(product, cart);
+ },
+ [cart]
+ );
+
+ // === 파생 상태: 총액 ===
+ const totals = useMemo(
+ () => cartModel.calculateCartTotal(cart, selectedCoupon),
+ [cart, selectedCoupon]
+ );
+
+ // === 파생 상태: 총 아이템 수 ===
+ const totalItemCount = useMemo(
+ () => cartModel.calculateTotalItemCount(cart),
+ [cart]
+ );
+
+ return {
+ // 상태
+ cart,
+ selectedCoupon,
+
+ // 계산된 값
+ totals,
+ totalItemCount,
+
+ // 장바구니 액션
+ addToCart,
+ removeFromCart,
+ updateQuantity,
+
+ // 쿠폰 액션
+ applyCoupon,
+ removeCoupon,
+
+ // 주문 액션
+ clearCart,
+ completeOrder,
+
+ // 조회
+ getRemainingStock,
+ };
+}
\ No newline at end of file
diff --git a/src/basic/hooks/useCouponForm.ts b/src/basic/hooks/useCouponForm.ts
new file mode 100644
index 000000000..52e77707a
--- /dev/null
+++ b/src/basic/hooks/useCouponForm.ts
@@ -0,0 +1,73 @@
+// hooks/useCouponForm.ts
+// 쿠폰 폼 관리 Hook
+
+import { useState, useCallback } from "react";
+import { Coupon } from "@/types";
+
+// ============================================
+// 타입 & 상수
+// ============================================
+export interface CouponFormData {
+ name: string;
+ code: string;
+ discountType: "amount" | "percentage";
+ discountValue: number;
+}
+
+const INITIAL_FORM: CouponFormData = {
+ name: "",
+ code: "",
+ discountType: "amount",
+ discountValue: 0,
+};
+
+interface UseCouponFormOptions {
+ addCoupon: (coupon: Coupon) => boolean;
+}
+
+export interface CouponFormHook {
+ form: CouponFormData;
+ setForm: React.Dispatch>;
+ isOpen: boolean;
+ open: () => void;
+ close: () => void;
+ submit: (e: React.FormEvent) => void;
+}
+
+// ============================================
+// Hook
+// ============================================
+export function useCouponForm({
+ addCoupon,
+}: UseCouponFormOptions): CouponFormHook {
+ const [form, setForm] = useState(INITIAL_FORM);
+ const [isOpen, setIsOpen] = useState(false);
+
+ const open = useCallback(() => {
+ setForm(INITIAL_FORM);
+ setIsOpen(true);
+ }, []);
+
+ const close = useCallback(() => {
+ setIsOpen(false);
+ setForm(INITIAL_FORM);
+ }, []);
+
+ const submit = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault();
+ const success = addCoupon(form);
+ if (success) close();
+ },
+ [form, addCoupon, close]
+ );
+
+ return {
+ form,
+ setForm,
+ isOpen,
+ open,
+ close,
+ submit,
+ };
+}
diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts
new file mode 100644
index 000000000..7463d5823
--- /dev/null
+++ b/src/basic/hooks/useCoupons.ts
@@ -0,0 +1,110 @@
+// TODO: 쿠폰 관리 Hook
+
+
+// hooks/useCoupons.ts
+// 쿠폰 관리 Hook
+//
+// 역할: 순수 함수(couponModel)와 컴포넌트 사이의 중간 계층
+// - 상태 관리 (coupons)
+// - 부작용 처리 (알림, localStorage)
+// - couponModel 함수 호출 및 결과 처리
+//
+// 반환할 값:
+// - coupons: 쿠폰 배열
+// - addCoupon: 새 쿠폰 추가
+// - removeCoupon: 쿠폰 삭제
+
+import { useCallback } from "react";
+import { Coupon } from "@/types";
+import { useLocalStorage } from "../utils/hooks/useLocalStorage";
+import { initialCoupons } from "../constants";
+import * as couponModel from "../models/coupon";
+
+// ============================================
+// 타입 정의
+// ============================================
+type NotifyFn = (message: string, type: "error" | "success" | "warning") => void;
+
+interface UseCouponsOptions {
+ /** 알림 함수 (선택적) */
+ onNotify?: NotifyFn;
+ /** localStorage 키 */
+ storageKey?: string;
+ /** 쿠폰 삭제 시 호출되는 콜백 */
+ onCouponRemoved?: (couponCode: string) => void;
+}
+
+export interface UseCouponsReturn {
+ // 상태
+ coupons: Coupon[];
+
+ // 액션
+ addCoupon: (coupon: Coupon) => boolean;
+ removeCoupon: (couponCode: string) => boolean;
+}
+
+// ============================================
+// useCoupons Hook
+// ============================================
+export function useCoupons(options: UseCouponsOptions = {}): UseCouponsReturn {
+ const { onNotify, storageKey = "coupons", onCouponRemoved } = options;
+
+ // === 상태 (localStorage 연동) ===
+ const [coupons, setCoupons] = useLocalStorage(
+ storageKey,
+ initialCoupons
+ );
+
+ // === 헬퍼: 안전한 알림 호출 ===
+ const notify = useCallback(
+ (message: string, type: "error" | "success" | "warning") => {
+ onNotify?.(message, type);
+ },
+ [onNotify]
+ );
+
+ // === 액션: 쿠폰 추가 ===
+ const addCoupon = useCallback(
+ (newCoupon: Coupon): boolean => {
+ // 순수 함수 호출
+ const result = couponModel.addCoupon(newCoupon, coupons);
+
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+
+ // 상태 업데이트 + 알림
+ setCoupons(result.data);
+ notify("쿠폰이 추가되었습니다.", "success");
+ return true;
+ },
+ [coupons, setCoupons, notify]
+ );
+
+ // === 액션: 쿠폰 삭제 ===
+ const removeCoupon = useCallback(
+ (couponCode: string): boolean => {
+ // 순수 함수 호출
+ const result = couponModel.removeCoupon(couponCode, coupons);
+
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+
+ // 상태 업데이트 + 콜백 + 알림
+ setCoupons(result.data);
+ onCouponRemoved?.(couponCode);
+ notify("쿠폰이 삭제되었습니다.", "success");
+ return true;
+ },
+ [coupons, setCoupons, notify, onCouponRemoved]
+ );
+
+ return {
+ coupons,
+ addCoupon,
+ removeCoupon,
+ };
+}
\ No newline at end of file
diff --git a/src/basic/hooks/useNotifications.ts b/src/basic/hooks/useNotifications.ts
new file mode 100644
index 000000000..dcd4c4330
--- /dev/null
+++ b/src/basic/hooks/useNotifications.ts
@@ -0,0 +1,85 @@
+// hooks/useNotifications.ts
+// 알림(Toast) 관리 Hook
+//
+// 역할:
+// - 알림 상태 관리
+// - 알림 추가/제거
+// - 자동 제거 타이머
+//
+// 반환할 값:
+// - notifications: 알림 배열
+// - addNotification: 알림 추가
+// - removeNotification: 알림 제거
+
+import { useState, useCallback } from "react";
+
+// ============================================
+// 타입 정의
+// ============================================
+export interface Notification {
+ id: string;
+ message: string;
+ type: "error" | "success" | "warning";
+}
+
+export type NotifyFn = (
+ message: string,
+ type?: "error" | "success" | "warning"
+) => void;
+
+interface UseNotificationsOptions {
+ /** 자동 제거 시간 (ms), 0이면 자동 제거 안함 */
+ autoRemoveDelay?: number;
+}
+
+interface UseNotificationsReturn {
+ notifications: Notification[];
+ addNotification: NotifyFn;
+ removeNotification: (id: string) => void;
+ clearNotifications: () => void;
+}
+
+// ============================================
+// useNotifications Hook
+// ============================================
+export function useNotifications(
+ options: UseNotificationsOptions = {}
+): UseNotificationsReturn {
+ const { autoRemoveDelay = 3000 } = options;
+
+ const [notifications, setNotifications] = useState([]);
+
+ // === 알림 추가 ===
+ const addNotification: NotifyFn = useCallback(
+ (message, type = "success") => {
+ const id = Date.now().toString();
+
+ setNotifications((prev) => [...prev, { id, message, type }]);
+
+ // 자동 제거
+ if (autoRemoveDelay > 0) {
+ setTimeout(() => {
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
+ }, autoRemoveDelay);
+ }
+ },
+ [autoRemoveDelay]
+ );
+
+ // === 알림 제거 ===
+ const removeNotification = useCallback((id: string) => {
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
+ }, []);
+
+ // === 모든 알림 제거 ===
+ const clearNotifications = useCallback(() => {
+ setNotifications([]);
+ }, []);
+
+ return {
+ notifications,
+ addNotification,
+ removeNotification,
+ clearNotifications,
+ };
+}
diff --git a/src/basic/hooks/useProductForm.ts b/src/basic/hooks/useProductForm.ts
new file mode 100644
index 000000000..516e8b627
--- /dev/null
+++ b/src/basic/hooks/useProductForm.ts
@@ -0,0 +1,102 @@
+// hooks/useProductForm.ts
+// 상품 폼 관리 Hook
+
+import { useState, useCallback } from "react";
+import { ProductWithUI } from "@/types";
+
+// ============================================
+// 타입 & 상수
+// ============================================
+export interface ProductFormData {
+ name: string;
+ price: number;
+ stock: number;
+ description: string;
+ discounts: Array<{ quantity: number; rate: number }>;
+}
+
+const INITIAL_FORM: ProductFormData = {
+ name: "",
+ price: 0,
+ stock: 0,
+ description: "",
+ discounts: [],
+};
+
+interface UseProductFormOptions {
+ addProduct: (product: Omit) => boolean;
+ updateProduct: (
+ productId: string,
+ updates: Partial>
+ ) => boolean;
+}
+
+export interface ProductFormHook {
+ form: ProductFormData;
+ setForm: React.Dispatch>;
+ isOpen: boolean;
+ editingId: string | null;
+ open: () => void;
+ openEdit: (product: ProductWithUI) => void;
+ close: () => void;
+ submit: (e: React.FormEvent) => void;
+}
+
+// ============================================
+// Hook
+// ============================================
+export function useProductForm({
+ addProduct,
+ updateProduct,
+}: UseProductFormOptions): ProductFormHook {
+ const [form, setForm] = useState(INITIAL_FORM);
+ const [isOpen, setIsOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+
+ const open = useCallback(() => {
+ setForm(INITIAL_FORM);
+ setEditingId(null);
+ setIsOpen(true);
+ }, []);
+
+ const openEdit = useCallback((product: ProductWithUI) => {
+ setForm({
+ name: product.name,
+ price: product.price,
+ stock: product.stock,
+ description: product.description || "",
+ discounts: product.discounts || [],
+ });
+ setEditingId(product.id);
+ setIsOpen(true);
+ }, []);
+
+ const close = useCallback(() => {
+ setIsOpen(false);
+ setEditingId(null);
+ setForm(INITIAL_FORM);
+ }, []);
+
+ const submit = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault();
+ const success = editingId
+ ? updateProduct(editingId, form)
+ : addProduct(form);
+
+ if (success) close();
+ },
+ [form, editingId, addProduct, updateProduct, close]
+ );
+
+ return {
+ form,
+ setForm,
+ isOpen,
+ editingId,
+ open,
+ openEdit,
+ close,
+ submit,
+ };
+}
diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts
new file mode 100644
index 000000000..d25cb0390
--- /dev/null
+++ b/src/basic/hooks/useProducts.ts
@@ -0,0 +1,203 @@
+// TODO: 상품 관리 Hook
+// 힌트:
+// 1. 상품 목록 상태 관리 (localStorage 연동 고려)
+// 2. 상품 CRUD 작업
+// 3. 재고 업데이트
+// 4. 할인 규칙 추가/삭제
+//
+// 반환할 값:
+// - products: 상품 배열
+// - updateProduct: 상품 정보 수정
+// - addProduct: 새 상품 추가
+// - updateProductStock: 재고 수정
+// - addProductDiscount: 할인 규칙 추가
+// - removeProductDiscount: 할인 규칙 삭제
+
+import { useCallback } from "react";
+import { ProductWithUI } from "@/types";
+import { useLocalStorage } from "../utils/hooks/useLocalStorage";
+import { initialProducts } from "../constants";
+import * as productModel from "../models/product";
+
+// 타입 정의
+export type NotifyFn = (
+ message: string,
+ type: "error" | "success" | "warning"
+) => void;
+
+interface UseProductsOptions {
+ /** 알림 함수 (선택적) */
+ onNotify?: NotifyFn;
+ /** localStorage 키 */
+ storageKey?: string;
+}
+
+export interface UseProductsReturn {
+ // 상태
+ products: ProductWithUI[];
+
+ // CRUD 액션
+ addProduct: (product: Omit) => boolean;
+ updateProduct: (
+ productId: string,
+ updates: Partial>
+ ) => boolean;
+ removeProduct: (productId: string) => boolean;
+
+ // 재고 액션
+ updateProductStock: (productId: string, newStock: number) => boolean;
+
+ // 할인 액션
+ addProductDiscount: (
+ productId: string,
+ discount: productModel.Discount
+ ) => boolean;
+ removeProductDiscount: (productId: string, discountIndex: number) => boolean;
+}
+// useProducts Hook
+export function useProducts(
+ options: UseProductsOptions = {}
+): UseProductsReturn {
+ const { onNotify, storageKey = "products" } = options;
+
+ // === 상태 (localStorage 연동) ===
+ const [products, setProducts] = useLocalStorage(
+ storageKey,
+ initialProducts
+ );
+
+ // === 헬퍼: 안전한 알림 호출 ===
+ const notify = useCallback(
+ (message: string, type: "error" | "success" | "warning") => {
+ onNotify?.(message, type);
+ },
+ [onNotify]
+ );
+
+ // === 액션: 상품 추가 ===
+ const addProduct = useCallback(
+ (newProduct: Omit): boolean => {
+ const result = productModel.addProduct(newProduct, products);
+
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+
+ setProducts(result.data);
+ notify("상품이 추가되었습니다.", "success");
+ return true;
+ },
+ [products, setProducts, notify]
+ );
+
+ // === 액션: 상품 수정 ===
+ const updateProduct = useCallback(
+ (
+ productId: string,
+ updates: Partial>
+ ): boolean => {
+ const result = productModel.updateProduct(productId, updates, products);
+
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+
+ setProducts(result.data);
+ notify("상품이 수정되었습니다.", "success");
+ return true;
+ },
+ [products, setProducts, notify]
+ );
+
+ // === 액션: 상품 삭제 ===
+ const removeProduct = useCallback(
+ (productId: string): boolean => {
+ const result = productModel.removeProduct(productId, products);
+
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+
+ setProducts(result.data);
+ notify("상품이 삭제되었습니다.", "success");
+ return true;
+ },
+ [products, setProducts, notify]
+ );
+
+ // === 액션: 재고 수정 ===
+ const updateProductStock = useCallback(
+ (productId: string, newStock: number): boolean => {
+ const result = productModel.updateProductStock(
+ productId,
+ newStock,
+ products
+ );
+
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+
+ setProducts(result.data);
+ notify("재고가 수정되었습니다.", "success");
+ return true;
+ },
+ [products, setProducts, notify]
+ );
+
+ // === 액션: 할인 규칙 추가 ===
+ const addProductDiscount = useCallback(
+ (productId: string, discount: productModel.Discount): boolean => {
+ const result = productModel.addProductDiscount(
+ productId,
+ discount,
+ products
+ );
+
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+
+ setProducts(result.data);
+ notify("할인 규칙이 추가되었습니다.", "success");
+ return true;
+ },
+ [products, setProducts, notify]
+ );
+
+ // === 액션: 할인 규칙 삭제 ===
+ const removeProductDiscount = useCallback(
+ (productId: string, discountIndex: number): boolean => {
+ const result = productModel.removeProductDiscount(
+ productId,
+ discountIndex,
+ products
+ );
+
+ if (!result.success) {
+ notify(result.error, "error");
+ return false;
+ }
+
+ setProducts(result.data);
+ notify("할인 규칙이 삭제되었습니다.", "success");
+ return true;
+ },
+ [products, setProducts, notify]
+ );
+
+ return {
+ products,
+ addProduct,
+ updateProduct,
+ removeProduct,
+ updateProductStock,
+ addProductDiscount,
+ removeProductDiscount,
+ };
+}
diff --git a/src/basic/index.css b/src/basic/index.css
new file mode 100644
index 000000000..7550e245b
--- /dev/null
+++ b/src/basic/index.css
@@ -0,0 +1,120 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
\ No newline at end of file
diff --git a/src/basic/lib/utils.ts b/src/basic/lib/utils.ts
new file mode 100644
index 000000000..a5ef19350
--- /dev/null
+++ b/src/basic/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts
new file mode 100644
index 000000000..0dcfacf36
--- /dev/null
+++ b/src/basic/models/cart.ts
@@ -0,0 +1,186 @@
+// models/cart.ts
+// 장바구니 비즈니스 로직 (순수 함수)
+//
+// 아키텍처:
+// cartModel (순수 함수) → useCart (훅) → App (컴포넌트)
+//
+// 원칙:
+// - UI와 관련된 로직 없음
+// - 외부 상태에 의존하지 않음
+// - 모든 필요한 데이터는 파라미터로 전달받음
+
+import { CartItem, Coupon, Product, ProductWithUI } from "@/types";
+
+// discount 모듈에서 할인 관련 함수 import
+import {
+ getMaxApplicableDiscount,
+ calculateItemTotal,
+ calculateCartTotal,
+ validateDiscount,
+ formatDiscountRate,
+ formatDiscountAmount,
+ Discount,
+} from "./discount";
+
+// discount 모듈 함수들 re-export
+export {
+ getMaxApplicableDiscount,
+ calculateItemTotal,
+ calculateCartTotal,
+ validateDiscount,
+ formatDiscountRate,
+ formatDiscountAmount,
+};
+export type { Discount };
+
+// 결과 타입 정의 (에러 처리를 위한 Result 패턴)
+export type CartResult =
+ | { success: true; data: T }
+ | { success: false; error: string };
+
+// 재고 관련 함수
+
+/**
+ * 남은 재고 수량 계산
+ */
+export const getRemainingStock = (
+ product: Product,
+ cart: CartItem[]
+): number => {
+ const cartItem = cart.find((item) => item.product.id === product.id);
+ return product.stock - (cartItem?.quantity ?? 0);
+};
+
+// 장바구니 아이템 수 계산
+
+/**
+ * 장바구니 아이템 총 개수 계산
+ */
+export const calculateTotalItemCount = (cart: CartItem[]): number => {
+ return cart.reduce((sum, item) => sum + item.quantity, 0);
+};
+
+// 장바구니 조작 함수 (불변성 유지, 새 배열 반환)
+
+/**
+ * 장바구니에 상품 추가
+ * - 이미 있으면 수량 +1
+ * - 없으면 새로 추가
+ */
+export const addItemToCart = (
+ product: Product,
+ cart: CartItem[]
+): CartResult => {
+ const remainingStock = getRemainingStock(product, cart);
+
+ if (remainingStock <= 0) {
+ return { success: false, error: "재고가 부족합니다!" };
+ }
+
+ const existingItem = cart.find((item) => item.product.id === product.id);
+
+ if (existingItem) {
+ const newQuantity = existingItem.quantity + 1;
+ if (newQuantity > product.stock) {
+ return {
+ success: false,
+ error: `재고는 ${product.stock}개까지만 있습니다.`,
+ };
+ }
+
+ const newCart = cart.map((item) =>
+ item.product.id === product.id ? { ...item, quantity: newQuantity } : item
+ );
+ return { success: true, data: newCart };
+ }
+
+ return { success: true, data: [...cart, { product, quantity: 1 }] };
+};
+
+/**
+ * 장바구니에서 상품 제거
+ */
+export const removeItemFromCart = (
+ productId: string,
+ cart: CartItem[]
+): CartItem[] => {
+ return cart.filter((item) => item.product.id !== productId);
+};
+
+/**
+ * 장바구니 아이템 수량 변경
+ * - 0 이하면 제거
+ * - 재고 초과 시 에러
+ */
+export const updateCartItemQuantity = (
+ productId: string,
+ newQuantity: number,
+ cart: CartItem[]
+): CartResult => {
+ if (newQuantity <= 0) {
+ return { success: true, data: removeItemFromCart(productId, cart) };
+ }
+
+ const item = cart.find((i) => i.product.id === productId);
+
+ if (!item) {
+ return { success: false, error: "장바구니에 해당 상품이 없습니다." };
+ }
+
+ if (newQuantity > item.product.stock) {
+ return {
+ success: false,
+ error: `재고는 ${item.product.stock}개까지만 있습니다.`,
+ };
+ }
+
+ const newCart = cart.map((i) =>
+ i.product.id === productId ? { ...i, quantity: newQuantity } : i
+ );
+
+ return { success: true, data: newCart };
+};
+
+// 쿠폰 관련 함수
+
+/**
+ * 쿠폰 적용 가능 여부 검증
+ */
+export const validateCouponApplication = (
+ coupon: Coupon,
+ cart: CartItem[]
+): CartResult => {
+ const { totalAfterDiscount } = calculateCartTotal(cart, null);
+
+ if (totalAfterDiscount < 10000 && coupon.discountType === "percentage") {
+ return {
+ success: false,
+ error: "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.",
+ };
+ }
+
+ return { success: true, data: true };
+};
+
+// 검색/필터 함수
+
+/**
+ * 상품 검색 (이름, 설명 기준)
+ */
+export const filterProductsBySearch = (
+ products: T[],
+ searchTerm: string
+): T[] => {
+ if (!searchTerm.trim()) {
+ return products;
+ }
+
+ const lowerSearchTerm = searchTerm.toLowerCase();
+ return products.filter(
+ (product) =>
+ product.name.toLowerCase().includes(lowerSearchTerm) ||
+ (product.description?.toLowerCase().includes(lowerSearchTerm) ?? false)
+ );
+};
+
+
diff --git a/src/basic/models/coupon.ts b/src/basic/models/coupon.ts
new file mode 100644
index 000000000..013dd28dd
--- /dev/null
+++ b/src/basic/models/coupon.ts
@@ -0,0 +1,72 @@
+// models/coupon.ts
+// 쿠폰 비즈니스 로직 (순수 함수)
+//
+// 아키텍처:
+// couponModel (순수 함수) → useCoupons (훅) → App (컴포넌트)
+//
+// 원칙:
+// - UI와 관련된 로직 없음 (toast, notification 등 없음)
+// - 외부 상태에 의존하지 않음
+// - 모든 필요한 데이터는 파라미터로 전달받음
+
+import { Coupon } from "@/types";
+
+// 결과 타입 정의 (에러 처리를 위한 Result 패턴)
+export type CouponResult =
+ | { success: true; data: T }
+ | { success: false; error: string };
+
+// 쿠폰 조회 함수
+/**
+ * 코드로 쿠폰 찾기
+ */
+export const findCouponByCode = (
+ code: string,
+ coupons: Coupon[]
+): Coupon | undefined => {
+ return coupons.find((coupon) => coupon.code === code);
+};
+
+/**
+ * 쿠폰 존재 여부 확인
+ */
+export const isCouponCodeExists = (
+ code: string,
+ coupons: Coupon[]
+): boolean => {
+ return coupons.some((coupon) => coupon.code === code);
+};
+
+// ============================================
+// 쿠폰 CRUD 함수 (불변성 유지)
+// ============================================
+
+/**
+ * 쿠폰 추가
+ */
+export const addCoupon = (
+ newCoupon: Coupon,
+ coupons: Coupon[]
+): CouponResult => {
+ // 중복 코드 검사
+ if (isCouponCodeExists(newCoupon.code, coupons)) {
+ return { success: false, error: "이미 존재하는 쿠폰 코드입니다." };
+ }
+
+ return { success: true, data: [...coupons, newCoupon] };
+};
+
+/**
+ * 쿠폰 삭제
+ */
+export const removeCoupon = (
+ couponCode: string,
+ coupons: Coupon[]
+): CouponResult => {
+ if (!isCouponCodeExists(couponCode, coupons)) {
+ return { success: false, error: "존재하지 않는 쿠폰입니다." };
+ }
+
+ const newCoupons = coupons.filter((coupon) => coupon.code !== couponCode);
+ return { success: true, data: newCoupons };
+};
diff --git a/src/basic/models/discount.ts b/src/basic/models/discount.ts
new file mode 100644
index 000000000..8ed5cbe9c
--- /dev/null
+++ b/src/basic/models/discount.ts
@@ -0,0 +1,211 @@
+// models/discount.ts
+// 할인 비즈니스 로직 (순수 함수)
+//
+// 원칙:
+// - UI와 관련된 로직 없음
+// - 외부 상태에 의존하지 않음
+// - 모든 필요한 데이터는 파라미터로 전달받음
+
+import { CartItem, Coupon } from "@/types";
+
+// 타입 정의
+export interface Discount {
+ quantity: number;
+ rate: number;
+}
+
+// 상수
+const BULK_PURCHASE_THRESHOLD = 10; // 대량 구매 기준 수량
+const BULK_PURCHASE_BONUS = 0.05; // 대량 구매 보너스 할인율 (5%)
+const MAX_DISCOUNT_RATE = 0.5; // 최대 할인율 (50%)
+
+// 수량별 할인 계산 함수
+
+/**
+ * 수량에 따른 기본 할인율 계산
+ * - 상품에 설정된 할인 규칙 중 수량 조건을 만족하는 최대 할인율 반환
+ */
+export const getQuantityDiscount = (
+ discounts: Discount[],
+ quantity: number
+): number => {
+ return discounts.reduce((maxDiscount, discount) => {
+ return quantity >= discount.quantity && discount.rate > maxDiscount
+ ? discount.rate
+ : maxDiscount;
+ }, 0);
+};
+
+/**
+ * 대량 구매 여부 확인
+ * - 장바구니에 10개 이상인 아이템이 있는지 확인
+ */
+export const hasBulkPurchase = (cart: CartItem[]): boolean => {
+ return cart.some((item) => item.quantity >= BULK_PURCHASE_THRESHOLD);
+};
+
+/**
+ * 대량 구매 보너스 할인율 계산
+ * - 대량 구매 시 기본 할인율에 5% 추가 (최대 50%)
+ */
+export const applyBulkPurchaseBonus = (
+ baseDiscount: number,
+ isBulkPurchase: boolean
+): number => {
+ if (isBulkPurchase) {
+ return Math.min(baseDiscount + BULK_PURCHASE_BONUS, MAX_DISCOUNT_RATE);
+ }
+ return baseDiscount;
+};
+
+/**
+ * 개별 아이템에 적용 가능한 최대 할인율 계산
+ * - 수량별 할인 + 대량 구매 보너스 적용
+ */
+export const getMaxApplicableDiscount = (
+ item: CartItem,
+ cart: CartItem[]
+): number => {
+ const { discounts } = item.product;
+ const { quantity } = item;
+
+ const baseDiscount = getQuantityDiscount(discounts, quantity);
+ const isBulkPurchase = hasBulkPurchase(cart);
+
+ return applyBulkPurchaseBonus(baseDiscount, isBulkPurchase);
+};
+
+// 금액 계산 함수
+
+/**
+ * 할인 적용 후 금액 계산
+ */
+export const applyDiscount = (price: number, discountRate: number): number => {
+ return Math.round(price * (1 - discountRate));
+};
+
+/**
+ * 개별 아이템의 할인 적용 후 총액 계산
+ */
+export const calculateItemTotal = (
+ item: CartItem,
+ cart: CartItem[]
+): number => {
+ const { price } = item.product;
+ const { quantity } = item;
+ const discount = getMaxApplicableDiscount(item, cart);
+ return applyDiscount(price * quantity, discount);
+};
+
+/**
+ * 장바구니 아이템들의 할인 전 총액 계산
+ */
+export const calculateTotalBeforeDiscount = (cart: CartItem[]): number => {
+ return cart.reduce(
+ (sum, item) => sum + item.product.price * item.quantity,
+ 0
+ );
+};
+
+/**
+ * 장바구니 아이템들의 할인 후 총액 계산 (쿠폰 제외)
+ */
+export const calculateTotalAfterItemDiscount = (cart: CartItem[]): number => {
+ return cart.reduce((sum, item) => sum + calculateItemTotal(item, cart), 0);
+};
+
+// 쿠폰 할인 함수
+
+/**
+ * 쿠폰 할인 적용
+ */
+export const applyCouponDiscount = (
+ total: number,
+ coupon: Coupon | null
+): number => {
+ if (!coupon) {
+ return total;
+ }
+
+ if (coupon.discountType === "amount") {
+ return Math.max(0, total - coupon.discountValue);
+ }
+
+ // percentage
+ return Math.round(total * (1 - coupon.discountValue / 100));
+};
+
+/**
+ * 장바구니 총액 계산 (할인 전/후, 할인액)
+ */
+export const calculateCartTotal = (
+ cart: CartItem[],
+ coupon: Coupon | null
+): {
+ totalBeforeDiscount: number;
+ totalAfterDiscount: number;
+ totalDiscount: number;
+} => {
+ const totalBeforeDiscount = calculateTotalBeforeDiscount(cart);
+ const totalAfterItemDiscount = calculateTotalAfterItemDiscount(cart);
+ const totalAfterDiscount = applyCouponDiscount(
+ totalAfterItemDiscount,
+ coupon
+ );
+
+ return {
+ totalBeforeDiscount: Math.round(totalBeforeDiscount),
+ totalAfterDiscount: Math.round(totalAfterDiscount),
+ totalDiscount: Math.round(totalBeforeDiscount - totalAfterDiscount),
+ };
+};
+
+// 할인 유효성 검증 함수
+
+/**
+ * 할인 규칙 유효성 검증
+ */
+export const validateDiscount = (
+ discount: Discount
+): {
+ valid: boolean;
+ error?: string;
+} => {
+ if (discount.quantity <= 0) {
+ return { valid: false, error: "할인 적용 수량은 1 이상이어야 합니다." };
+ }
+
+ if (discount.rate <= 0 || discount.rate > 1) {
+ return {
+ valid: false,
+ error: "할인율은 0~1 사이여야 합니다. (예: 0.1 = 10%)",
+ };
+ }
+
+ return { valid: true };
+};
+
+/**
+ * 할인 규칙 중복 체크
+ */
+export const isDuplicateDiscount = (
+ discounts: Discount[],
+ newQuantity: number
+): boolean => {
+ return discounts.some((d) => d.quantity === newQuantity);
+};
+
+
+/**
+ * 할인율 포맷팅 (0.1 -> "10%")
+ */
+export const formatDiscountRate = (rate: number): string => {
+ return `${Math.round(rate * 100)}%`;
+};
+
+/**
+ * 할인 금액 포맷팅
+ */
+export const formatDiscountAmount = (amount: number): string => {
+ return `${amount.toLocaleString()}원`;
+};
diff --git a/src/basic/models/product.ts b/src/basic/models/product.ts
new file mode 100644
index 000000000..03bc0154b
--- /dev/null
+++ b/src/basic/models/product.ts
@@ -0,0 +1,301 @@
+// models/product.ts
+// 상품 비즈니스 로직 (순수 함수)
+//
+// 아키텍처:
+// productModel (순수 함수) → useProducts (훅) → App (컴포넌트)
+//
+// 원칙:
+// - UI와 관련된 로직 없음 (toast, notification 등 없음)
+// - 외부 상태에 의존하지 않음
+// - 모든 필요한 데이터는 파라미터로 전달받음
+
+import { ProductWithUI } from "@/types";
+
+// ============================================
+// 타입 정의
+// ============================================
+export interface Discount {
+ quantity: number;
+ rate: number;
+}
+
+export type ProductResult =
+ | { success: true; data: T }
+ | { success: false; error: string };
+
+// ============================================
+// 상품 조회 함수
+// ============================================
+
+/**
+ * ID로 상품 찾기
+ */
+export const findProductById = (
+ productId: string,
+ products: ProductWithUI[]
+): ProductWithUI | undefined => {
+ return products.find((product) => product.id === productId);
+};
+
+/**
+ * 상품 존재 여부 확인
+ */
+export const isProductExists = (
+ productId: string,
+ products: ProductWithUI[]
+): boolean => {
+ return products.some((product) => product.id === productId);
+};
+
+// ============================================
+// 상품 CRUD 함수 (불변성 유지)
+// ============================================
+
+/**
+ * 상품 추가
+ */
+export const addProduct = (
+ newProduct: Omit,
+ products: ProductWithUI[]
+): ProductResult => {
+ // 기본 유효성 검사
+ if (!newProduct.name.trim()) {
+ return { success: false, error: "상품 이름을 입력해주세요." };
+ }
+
+ if (newProduct.price < 0) {
+ return { success: false, error: "가격은 0 이상이어야 합니다." };
+ }
+
+ if (newProduct.stock < 0) {
+ return { success: false, error: "재고는 0 이상이어야 합니다." };
+ }
+
+ const product: ProductWithUI = {
+ ...newProduct,
+ id: `p${Date.now()}`,
+ };
+
+ return { success: true, data: [...products, product] };
+};
+
+/**
+ * 상품 수정
+ */
+export const updateProduct = (
+ productId: string,
+ updates: Partial>,
+ products: ProductWithUI[]
+): ProductResult => {
+ if (!isProductExists(productId, products)) {
+ return { success: false, error: "존재하지 않는 상품입니다." };
+ }
+
+ // 유효성 검사
+ if (updates.name !== undefined && !updates.name.trim()) {
+ return { success: false, error: "상품 이름을 입력해주세요." };
+ }
+
+ if (updates.price !== undefined && updates.price < 0) {
+ return { success: false, error: "가격은 0 이상이어야 합니다." };
+ }
+
+ if (updates.stock !== undefined && updates.stock < 0) {
+ return { success: false, error: "재고는 0 이상이어야 합니다." };
+ }
+
+ const newProducts = products.map((product) =>
+ product.id === productId ? { ...product, ...updates } : product
+ );
+
+ return { success: true, data: newProducts };
+};
+
+/**
+ * 상품 삭제
+ */
+export const removeProduct = (
+ productId: string,
+ products: ProductWithUI[]
+): ProductResult => {
+ if (!isProductExists(productId, products)) {
+ return { success: false, error: "존재하지 않는 상품입니다." };
+ }
+
+ const newProducts = products.filter((product) => product.id !== productId);
+ return { success: true, data: newProducts };
+};
+
+// ============================================
+// 재고 관련 함수
+// ============================================
+
+/**
+ * 재고 수정
+ */
+export const updateProductStock = (
+ productId: string,
+ newStock: number,
+ products: ProductWithUI[]
+): ProductResult => {
+ if (newStock < 0) {
+ return { success: false, error: "재고는 0 이상이어야 합니다." };
+ }
+
+ return updateProduct(productId, { stock: newStock }, products);
+};
+
+/**
+ * 재고 증가
+ */
+export const increaseStock = (
+ productId: string,
+ amount: number,
+ products: ProductWithUI[]
+): ProductResult => {
+ const product = findProductById(productId, products);
+
+ if (!product) {
+ return { success: false, error: "존재하지 않는 상품입니다." };
+ }
+
+ if (amount < 0) {
+ return { success: false, error: "증가량은 0 이상이어야 합니다." };
+ }
+
+ return updateProductStock(productId, product.stock + amount, products);
+};
+
+/**
+ * 재고 감소
+ */
+export const decreaseStock = (
+ productId: string,
+ amount: number,
+ products: ProductWithUI[]
+): ProductResult => {
+ const product = findProductById(productId, products);
+
+ if (!product) {
+ return { success: false, error: "존재하지 않는 상품입니다." };
+ }
+
+ if (amount < 0) {
+ return { success: false, error: "감소량은 0 이상이어야 합니다." };
+ }
+
+ const newStock = product.stock - amount;
+ if (newStock < 0) {
+ return { success: false, error: "재고가 부족합니다." };
+ }
+
+ return updateProductStock(productId, newStock, products);
+};
+
+// ============================================
+// 할인 규칙 관련 함수
+// ============================================
+
+/**
+ * 할인 규칙 추가
+ */
+export const addProductDiscount = (
+ productId: string,
+ discount: Discount,
+ products: ProductWithUI[]
+): ProductResult => {
+ const product = findProductById(productId, products);
+
+ if (!product) {
+ return { success: false, error: "존재하지 않는 상품입니다." };
+ }
+
+ // 유효성 검사
+ if (discount.quantity <= 0) {
+ return { success: false, error: "할인 적용 수량은 1 이상이어야 합니다." };
+ }
+
+ if (discount.rate <= 0 || discount.rate > 1) {
+ return {
+ success: false,
+ error: "할인율은 0~1 사이여야 합니다. (예: 0.1 = 10%)",
+ };
+ }
+
+ // 중복 수량 체크
+ const existingDiscount = product.discounts.find(
+ (d) => d.quantity === discount.quantity
+ );
+ if (existingDiscount) {
+ return {
+ success: false,
+ error: `${discount.quantity}개 수량에 대한 할인이 이미 존재합니다.`,
+ };
+ }
+
+ const newDiscounts = [...product.discounts, discount].sort(
+ (a, b) => a.quantity - b.quantity
+ );
+
+ return updateProduct(productId, { discounts: newDiscounts }, products);
+};
+
+/**
+ * 할인 규칙 삭제
+ */
+export const removeProductDiscount = (
+ productId: string,
+ discountIndex: number,
+ products: ProductWithUI[]
+): ProductResult => {
+ const product = findProductById(productId, products);
+
+ if (!product) {
+ return { success: false, error: "존재하지 않는 상품입니다." };
+ }
+
+ if (discountIndex < 0 || discountIndex >= product.discounts.length) {
+ return { success: false, error: "존재하지 않는 할인 규칙입니다." };
+ }
+
+ const newDiscounts = product.discounts.filter(
+ (_, index) => index !== discountIndex
+ );
+
+ return updateProduct(productId, { discounts: newDiscounts }, products);
+};
+
+/**
+ * 할인 규칙 수정
+ */
+export const updateProductDiscount = (
+ productId: string,
+ discountIndex: number,
+ newDiscount: Discount,
+ products: ProductWithUI[]
+): ProductResult => {
+ const product = findProductById(productId, products);
+
+ if (!product) {
+ return { success: false, error: "존재하지 않는 상품입니다." };
+ }
+
+ if (discountIndex < 0 || discountIndex >= product.discounts.length) {
+ return { success: false, error: "존재하지 않는 할인 규칙입니다." };
+ }
+
+ // 유효성 검사
+ if (newDiscount.quantity <= 0) {
+ return { success: false, error: "할인 적용 수량은 1 이상이어야 합니다." };
+ }
+
+ if (newDiscount.rate <= 0 || newDiscount.rate > 1) {
+ return { success: false, error: "할인율은 0~1 사이여야 합니다." };
+ }
+
+ const newDiscounts = product.discounts.map((d, index) =>
+ index === discountIndex ? newDiscount : d
+ );
+
+ return updateProduct(productId, { discounts: newDiscounts }, products);
+};
diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts
new file mode 100644
index 000000000..4712ddc93
--- /dev/null
+++ b/src/basic/utils/formatters.ts
@@ -0,0 +1,13 @@
+// TODO: 포맷팅 유틸리티 함수들
+// 구현할 함수:
+// - formatPrice(price: number): string - 가격을 한국 원화 형식으로 포맷
+// - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷
+// - formatPercentage(rate: number): string - 소수를 퍼센트로 변환 (0.1 → 10%)
+
+// TODO: 구현
+type PriceNotation = "text" | "symbol";
+
+export const formatPrice = (price: number, type?: PriceNotation) => {
+ if (type === "text") return `${price.toLocaleString()}원`;
+ return `₩${price.toLocaleString()}`;
+};
\ No newline at end of file
diff --git a/src/basic/utils/hooks/useDebounce.ts b/src/basic/utils/hooks/useDebounce.ts
new file mode 100644
index 000000000..62ee7af93
--- /dev/null
+++ b/src/basic/utils/hooks/useDebounce.ts
@@ -0,0 +1,23 @@
+import { useState, useEffect } from 'react';
+
+/**
+ * 값이 변경되어도 지정된 시간 동안 대기 후 최종 값만 반환하는 훅
+ * @param value - 디바운스할 값
+ * @param delay - 대기 시간 (ms)
+ * @returns 디바운스된 값
+ */
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ // 지정된 시간 후에 값 업데이트
+ const timer = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ // 값이 변경되면 이전 타이머 취소
+ return () => clearTimeout(timer);
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/src/basic/utils/hooks/useLocalStorage.ts b/src/basic/utils/hooks/useLocalStorage.ts
new file mode 100644
index 000000000..867264972
--- /dev/null
+++ b/src/basic/utils/hooks/useLocalStorage.ts
@@ -0,0 +1,57 @@
+// TODO: LocalStorage Hook
+// 힌트:
+// 1. localStorage와 React state 동기화
+// 2. 초기값 로드 시 에러 처리
+// 3. 저장 시 JSON 직렬화/역직렬화
+// 4. 빈 배열이나 undefined는 삭제
+//
+// 반환값: [저장된 값, 값 설정 함수]
+import { useState, useEffect, useCallback } from "react";
+
+interface UseLocalStorageOptions {
+ removeWhenEmpty?: boolean; // 빈 배열일 때 localStorage에서 삭제할지 여부
+}
+
+export function useLocalStorage(
+ key: string,
+ initialValue: T,
+ options: UseLocalStorageOptions = {}
+): [T, (value: T | ((val: T) => T)) => void] {
+ const { removeWhenEmpty = false } = options;
+
+ // 1. 초기값 로드 (lazy initialization)
+ const [storedValue, setStoredValue] = useState(() => {
+ try {
+ const item = localStorage.getItem(key);
+ return item ? JSON.parse(item) : initialValue;
+ } catch (error) {
+ console.error(`Error reading localStorage key "${key}":`, error);
+ return initialValue;
+ }
+ });
+
+ // 2. 값 설정 함수 (함수형 업데이트 지원)
+ const setValue = useCallback((value: T | ((val: T) => T)) => {
+ setStoredValue((prev) => {
+ const valueToStore = value instanceof Function ? value(prev) : value;
+ return valueToStore;
+ });
+ }, []);
+
+ // 3. 상태 변경 시 localStorage 동기화
+ useEffect(() => {
+ try {
+ const isEmpty = Array.isArray(storedValue) && storedValue.length === 0;
+
+ if (removeWhenEmpty && isEmpty) {
+ localStorage.removeItem(key);
+ } else {
+ localStorage.setItem(key, JSON.stringify(storedValue));
+ }
+ } catch (error) {
+ console.error(`Error setting localStorage key "${key}":`, error);
+ }
+ }, [key, storedValue, removeWhenEmpty]);
+
+ return [storedValue, setValue];
+}
\ No newline at end of file
diff --git a/src/basic/utils/hooks/useValidate.ts b/src/basic/utils/hooks/useValidate.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/basic/utils/validators.ts b/src/basic/utils/validators.ts
new file mode 100644
index 000000000..7d2dda444
--- /dev/null
+++ b/src/basic/utils/validators.ts
@@ -0,0 +1,8 @@
+// TODO: 검증 유틸리티 함수들
+// 구현할 함수:
+// - isValidCouponCode(code: string): boolean - 쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자)
+// - isValidStock(stock: number): boolean - 재고 수량 검증 (0 이상)
+// - isValidPrice(price: number): boolean - 가격 검증 (양수)
+// - extractNumbers(value: string): string - 문자열에서 숫자만 추출
+
+// TODO: 구현
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
index 5489e296e..161135ab9 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -5,6 +5,10 @@ export interface Product {
stock: number;
discounts: Discount[];
}
+export interface ProductWithUI extends Product {
+ description?: string;
+ isRecommended?: boolean;
+}
export interface Discount {
quantity: number;
diff --git a/tsconfig.app.json b/tsconfig.app.json
index d739292ae..9d18e1dcb 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -1,5 +1,9 @@
{
"compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
@@ -23,5 +27,6 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
- "include": ["src"]
+ "include": ["src"],
+ "exclude": ["src/refactoring(hint)"]
}
diff --git a/tsconfig.json b/tsconfig.json
index ea9d0cd82..1c7bcfcd1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,5 +1,11 @@
{
"files": [],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
"references": [
{
"path": "./tsconfig.app.json"
diff --git a/vite.config.ts b/vite.config.ts
index e6c4016bc..965017285 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,16 +1,42 @@
-import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config';
-import { defineConfig } from 'vite';
-import react from '@vitejs/plugin-react-swc';
+import path from "path";
+import tailwindcss from "@tailwindcss/vite";
+import { defineConfig, loadEnv } from "vite";
+import react from "@vitejs/plugin-react-swc";
-export default mergeConfig(
- defineConfig({
- plugins: [react()],
- }),
- defineTestConfig({
+export default defineConfig(({ mode }) => {
+ // .env 파일에서 환경변수 로드
+ const env = loadEnv(mode, process.cwd(), "");
+
+ // process.env 우선, 없으면 .env 파일, 없으면 기본값
+ const buildTarget = process.env.BUILD_TARGET || env.BUILD_TARGET || "basic";
+ const baseUrl = process.env.BASE_URL || env.BASE_URL || "/";
+
+ console.log(`🎯 Build Target: ${buildTarget}`);
+ console.log(`🌐 Base URL: ${baseUrl}`);
+
+ return {
+ base: baseUrl,
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ build: {
+ rollupOptions: {
+ input: {
+ index: path.resolve(__dirname, `index.${buildTarget}.html`),
+ },
+ output: {
+ // 출력 파일명을 index.html로 변경
+ entryFileNames: "assets/[name]-[hash].js",
+ },
+ },
+ },
test: {
globals: true,
- environment: 'jsdom',
- setupFiles: './src/setupTests.ts'
+ environment: "jsdom",
+ setupFiles: "./src/setupTests.ts",
},
- })
-)
+ };
+});
| |