Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Large diffs are not rendered by default.

77 changes: 77 additions & 0 deletions src/basic/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import React from "react";

type Props = {
isAdmin: boolean;
searchTerm: string;
totalItemCount: number;
togglePage: () => void;
setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
};

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

export default Header;
51 changes: 51 additions & 0 deletions src/basic/components/Notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
interface Notification {
id: string;
message: string;
type: "error" | "success" | "warning";
}

type Props = {
notifications: Notification[];
removeNotification: (notificationId: string) => void;
};
const Notification = ({ notifications = [], removeNotification }: Props) => {
return (
notifications.length > 0 && (
<div className="fixed top-20 right-4 z-50 space-y-2 max-w-sm">
{notifications.map((notif) => (
<div
key={notif.id}
className={`p-4 rounded-md shadow-md text-white flex justify-between items-center ${
notif.type === "error"
? "bg-red-600"
: notif.type === "warning"
? "bg-yellow-600"
: "bg-green-600"
}`}
>
<span className="mr-2">{notif.message}</span>
<button
onClick={() => removeNotification(notif.id)}
className="text-white hover:text-gray-200"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
))}
</div>
)
);
};
export default Notification;
51 changes: 51 additions & 0 deletions src/basic/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Coupon } from "../types";

// 초기 데이터
export const initialProducts = [
{
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,
},
];
62 changes: 62 additions & 0 deletions src/basic/hooks/useCart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useCallback } from "react";
import { CartItem, Product } from "../../types";
import { useLocalStorage } from "../utils/hooks/useLocalStorage";

export const useCart = () => {
const [cart, setCart] = useLocalStorage<CartItem[]>({
key: "cart",
initialValue: [],
});

const addToCart = useCallback(
(product: Product) => {
setCart((prevCart) => {
const existingItem = prevCart.find(
(item) => item.product.id === product.id
);
if (existingItem) {
return prevCart.map((item) =>
item.product.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...prevCart, { product, quantity: 1 }];
});
},
[setCart]
);

const removeFromCart = useCallback(
(productId: string) => {
setCart((prevCart) =>
prevCart.filter((item) => item.product.id !== productId)
);
},
[setCart]
);

const updateQuantity = useCallback(
(productId: string, newQuantity: number) => {
if (newQuantity <= 0) {
removeFromCart(productId);
return;
}

setCart((prevCart) =>
prevCart.map((item) =>
item.product.id === productId
? { ...item, quantity: newQuantity }
: item
)
);
},
[setCart, removeFromCart]
);

const clearCart = useCallback(() => {
setCart([]);
}, [setCart]);

return { cart, addToCart, removeFromCart, updateQuantity, clearCart };
};
30 changes: 30 additions & 0 deletions src/basic/hooks/useCoupons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useState } from "react";
import { Coupon } from "../../types";
import { initialCoupons } from "../data";
import { useLocalStorage } from "../utils/hooks/useLocalStorage";

export const useCoupons = () => {
const [coupons, setCoupons] = useLocalStorage<Coupon[]>({
key: "coupons",
initialValue: initialCoupons,
});

const addCoupon = (newCoupon: Coupon) => {
const existingCoupon = coupons.find((c) => c.code === newCoupon.code);
if (existingCoupon) {
throw Error("이미 존재하는 쿠폰 코드입니다.");
}

setCoupons((prev) => [...prev, newCoupon]);
};

const deleteCoupon = (couponCode: string) => {
setCoupons((prev) => prev.filter((c) => c.code !== couponCode));
};

return {
coupons,
addCoupon,
deleteCoupon,
};
};
24 changes: 24 additions & 0 deletions src/basic/hooks/useNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useCallback, useState } from "react";
import Notification from "../components/Notification";

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

const addNotification = useCallback(
(message: string, type: "error" | "success" | "warning" = "success") => {
const id = Date.now().toString();
setNotifications((prev) => [...prev, { id, message, type }]);

setTimeout(() => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, 3000);
},
[]
);

const removeNotification = useCallback((notificationId: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== notificationId));
}, []);

return { notifications, addNotification, removeNotification };
};
59 changes: 59 additions & 0 deletions src/basic/hooks/useProducts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Product } from "../../types";
import { initialProducts } from "../data";
import { useLocalStorage } from "../utils/hooks/useLocalStorage";
// TODO: 상품 관리 Hook
// 힌트:
// 1. 상품 목록 상태 관리 (localStorage 연동 고려) ㅇ
// 2. 상품 CRUD 작업 ㅇ
// 3. 재고 업데이트
// 4. 할인 규칙 추가/삭제
//
// 반환할 값:
// - products: 상품 배열 ㅇ
// - updateProduct: 상품 정보 수정 ㅇ
// - addProduct: 새 상품 추가 ㅇ
// - updateProductStock: 재고 수정
// - addProductDiscount: 할인 규칙 추가
// - removeProductDiscount: 할인 규칙 삭제

export interface ProductWithUI extends Product {
description?: string;
isRecommended?: boolean;
}

export function useProducts() {
const [products, setProducts] = useLocalStorage<ProductWithUI[]>({
key: "products",
initialValue: initialProducts,
});

const addProduct = (newProduct: Omit<ProductWithUI, "id">) => {
const product: ProductWithUI = {
...newProduct,
id: `p${Date.now()}`,
};
setProducts((prev) => [...prev, product]);
};

const updateProduct = (
productId: string,
updates: Partial<ProductWithUI>
) => {
setProducts((prev) =>
prev.map((product) =>
product.id === productId ? { ...product, ...updates } : product
)
);
};

const deleteProduct = (productId: string) => {
setProducts((prev) => prev.filter((p) => p.id !== productId));
};

return {
products,
addProduct,
updateProduct,
deleteProduct,
};
}
32 changes: 32 additions & 0 deletions src/basic/hooks/useSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useEffect, useMemo, useState } from "react";
import { ProductWithUI } from "./useProducts";

export const useSearch = (searchTerm: string, products: ProductWithUI[]) => {
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");

const filteredProduct = useMemo(
() =>
debouncedSearchTerm
? products.filter(
(product) =>
product.name
.toLowerCase()
.includes(debouncedSearchTerm.toLowerCase()) ||
(product.description &&
product.description
.toLowerCase()
.includes(debouncedSearchTerm.toLowerCase()))
)
: products,
[products, debouncedSearchTerm]
);

useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm]);

return { debouncedSearchTerm, searchResult: filteredProduct };
};
Loading