Skip to content
Open
4 changes: 0 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ jobs:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
Expand All @@ -37,8 +35,6 @@ jobs:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
Expand Down
56 changes: 56 additions & 0 deletions deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/bin/bash

# GitHub Pages 배포 스크립트
# main 브랜치의 빌드 결과를 gh-pages 브랜치에 배포합니다

set -e

echo "🚀 배포를 시작합니다..."

# 현재 브랜치 저장
CURRENT_BRANCH=$(git branch --show-current)

# main 브랜치로 전환
echo "📦 main 브랜치로 전환 중..."
git checkout main

# 의존성 설치 (필요한 경우)
if [ ! -d "node_modules" ]; then
echo "📥 의존성 설치 중..."
pnpm install
fi

# 빌드
echo "🔨 프로젝트 빌드 중..."
pnpm run build

# dist 폴더 확인
if [ ! -d "dist" ]; then
echo "❌ dist 폴더를 찾을 수 없습니다. 빌드가 실패했을 수 있습니다."
exit 1
fi

# gh-pages 브랜치로 전환 (없으면 생성)
echo "🌿 gh-pages 브랜치로 전환 중..."
git checkout gh-pages 2>/dev/null || git checkout -b gh-pages

# dist 폴더의 내용을 루트로 복사
echo "📋 빌드 파일 복사 중..."
cp -r dist/* .

# 변경사항 커밋
echo "💾 변경사항 커밋 중..."
git add .
git commit -m "Deploy: $(date +'%Y-%m-%d %H:%M:%S')" || echo "변경사항이 없습니다."

# gh-pages 브랜치 푸시
echo "📤 gh-pages 브랜치 푸시 중..."
git push origin gh-pages

# 원래 브랜치로 돌아가기
echo "↩️ 원래 브랜치($CURRENT_BRANCH)로 돌아가는 중..."
git checkout $CURRENT_BRANCH

echo "✅ 배포가 완료되었습니다!"
echo "🌐 배포 링크: https://taejun0.github.io/front_7th_chapter2-1/"

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"dev": "vite",
"dev:hash": "vite --open ./index.hash.html",
"build": "vite build",
"deploy": "npm run build && npm run deploy:gh-pages",
"deploy:gh-pages": "git subtree push --prefix dist origin gh-pages",
"lint:fix": "eslint --fix",
"prettier:write": "prettier --write ./src",
"preview": "vite preview",
Expand Down
66 changes: 66 additions & 0 deletions src/app/router/router.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
export class Router {
constructor() {
this.routes = new Map();
this.currentPath = window.location.pathname;
this.init();
}

init() {
this.handleRoute();

window.addEventListener("popstate", () => {
this.currentPath = window.location.pathname;
this.handleRoute();
});

document.addEventListener("click", (e) => {
const link = e.target.closest("a[data-link]");
if (link) {
e.preventDefault();
const href = link.getAttribute("href");
if (href) {
this.navigate(href);
}
}
});
}

register(path, handler) {
this.routes.set(path, handler);
}

navigate(path) {
if (this.currentPath !== path) {
this.currentPath = path;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 Router 클래스는 경로에 파라미터가 포함된 경우(예: /product/:id)를 단순히 정규식으로 패턴 매칭하는 방식을 사용하고 있습니다. 그러나 이후 특정 파라미터를 추출하거나 라우팅 핸들러에 파라미터를 전달하는 구조가 없습니다.

개선 제안

  • 라우트 등록 시 핸들러에 파라미터를 인자로 전달하는 구조로 확장할 수 있습니다. 예를 들어 /product/:id에서 id를 추출해 핸들러가 해당 값을 받을 수 있어 동적으로 활용 가능하게 합니다.
  • 이는 상품 상세 페이지 등 동적 라우팅 처리에 필수적이며, 추후 라우트 확장 시 유연성을 크게 향상시킵니다.
// ❌ 현재 방식
if (regex.test(path)) {
  handler = routeHandler;
  break;
}

// ✅ 개선 방식 예시
if (regex.test(path)) {
  const paramValues = [...path.match(regex)].slice(1);
  const paramKeys = (routePath.match(/:([^/]+)/g) || []).map(k => k.substring(1));
  const params = paramKeys.reduce((acc, key, idx) => {
    acc[key] = paramValues[idx];
    return acc;
  }, {});
  handler = () => routeHandler(params);
  break;
}

이렇게 하면 라우터가 더 확장 가능해지고 핸들러가 패스 파라미터를 활용할 수 있습니다.

window.history.pushState({}, "", path);
this.handleRoute();
}
}

handleRoute() {
const path = window.location.pathname;

let handler = this.routes.get(path);

if (!handler) {
for (const [routePath, routeHandler] of this.routes.entries()) {
if (routePath.includes(":")) {
const pattern = routePath.replace(/:[^/]+/g, "([^/]+)");
const regex = new RegExp(`^${pattern}$`);
if (regex.test(path)) {
handler = routeHandler;
break;
}
}
}
}

if (!handler) {
handler = this.routes.get("/");
}

if (handler) {
handler();
}
}
}
93 changes: 93 additions & 0 deletions src/app/services/cartService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
const CART_STORAGE_KEY = "shopping_cart";

export function getCart() {
try {
const cartData = localStorage.getItem(CART_STORAGE_KEY);
return cartData ? JSON.parse(cartData) : {};
} catch (error) {
console.error("장바구니 로드 실패:", error);
return {};
}
}

function saveCart(cart) {
try {
localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cart));
} catch (error) {
console.error("장바구니 저장 실패:", error);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

console.log도 좋지만, 토스트 UI 호출로 사용자에게 장바구니에 상품이 담기지 않았다는 걸 알려 주면 좋을 것 같아요! 그러면 장바구니에 담기지 않은 상품을 고객이 알아서 다시 장바구니에 상품을 담지 않을까~~.. 라는 제 생각!

}
}

export function addToCart(productId, productData = null, quantity = 1) {
const cart = getCart();

if (cart[productId]) {
cart[productId].quantity += quantity;
} else {
cart[productId] = {
productId,
quantity,
...(productData && { productData }),
};
}

saveCart(cart);
updateCartIcon();
return cart;
}

export function removeFromCart(productId) {
const cart = getCart();
delete cart[productId];
saveCart(cart);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

장바구니 서비스의 상태를 localStorage에 저장하고 불러오는 구현은 실용적이고 좋습니다.

다만, cart 상태의 변경 시점마다 updateCartIcon을 호출하도록 되어 있는데, 이로 인해 중복 호출 가능성이 있을 수 있습니다.

개선 제안

  • addToCart, removeFromCart, updateCartQuantity 함수들은 내부적으로 상태 변경과 UI 업데이트를 담당하므로, 호출자가 한꺼번에 처리하도록 역할을 명확히 나눠 고려해볼 수 있습니다.
  • 앞으로 상태가 복잡해질 경우 이벤트 발행/구독 패턴 또는 상태 관리 라이브러리 도입을 고민해보세요.

updateCartIcon();
return cart;
}

export function updateCartQuantity(productId, quantity) {
const cart = getCart();
if (cart[productId]) {
if (quantity <= 0) {
delete cart[productId];
} else {
cart[productId].quantity = quantity;
}
saveCart(cart);
updateCartIcon();
}
return cart;
}

export function clearCart() {
localStorage.removeItem(CART_STORAGE_KEY);
updateCartIcon();
}

export function getCartItemCount() {
const cart = getCart();
return Object.values(cart).reduce((total, item) => total + item.quantity, 0);
}

export function updateCartIcon() {
const cartIconBtn = document.getElementById("cart-icon-btn");
if (!cartIconBtn) return;

const count = getCartItemCount();
const existingBadge = cartIconBtn.querySelector("span");

if (count > 0) {
if (existingBadge) {
existingBadge.textContent = count.toString();
} else {
const badge = document.createElement("span");
badge.className =
"absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center";
badge.textContent = count.toString();
cartIconBtn.appendChild(badge);
}
} else {
if (existingBadge) {
existingBadge.remove();
}
}
}
111 changes: 111 additions & 0 deletions src/components/toast.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
export const toastTemplates = `
<div class="flex flex-col gap-2 items-center justify-center mx-auto" style="width: fit-content;">
<div class="bg-green-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center space-x-2 max-w-sm">
<div class="flex-shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<p class="text-sm font-medium">장바구니에 추가되었습니다</p>
<button id="toast-close-btn" class="flex-shrink-0 ml-2 text-white hover:text-gray-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>

<div class="bg-blue-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center space-x-2 max-w-sm">
<div class="flex-shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

토스트 메시지 컴포넌트가 타입별로 적절하게 스타일과 SVG 아이콘을 분리하여 렌더링하는 점이 좋습니다.

다만, DOM 엘리먼트를 동적으로 생성하고 조작하는 부분이 코드 내 여러 영역에 흩어져 있는데, 컴포넌트 패턴 또는 팩토리 함수로 분리하면 좋겠습니다.

개선 제안

  • 여러 토스트 메시지 유형을 별도의 함수 또는 클래스 메서드로 분리해 코드 가독성을 높이세요.
  • 또한, 토스트 메시지 여러 개가 겹치지 않도록 큐잉을 고려하면 UX 개선에 도움이 됩니다.

</svg>
</div>
<p class="text-sm font-medium">선택된 상품들이 삭제되었습니다</p>
<button id="toast-close-btn" class="flex-shrink-0 ml-2 text-white hover:text-gray-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>

<div class="bg-red-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center space-x-2 max-w-sm">
<div class="flex-shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</div>
<p class="text-sm font-medium">오류가 발생했습니다.</p>
<button id="toast-close-btn" class="flex-shrink-0 ml-2 text-white hover:text-gray-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
`;

export function showToast(type = "success", message = null) {
const existingToast = document.querySelector(".toast-container");
if (existingToast) {
existingToast.remove();
}

let toastHTML = "";
let bgColor = "";
let icon = "";

switch (type) {
case "success":
bgColor = "bg-green-600";
icon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>`;
message = message || "장바구니에 추가되었습니다";
break;
case "info":
bgColor = "bg-blue-600";
icon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>`;
message = message || "선택된 상품들이 삭제되었습니다";
break;
case "error":
bgColor = "bg-red-600";
icon = `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>`;
message = message || "오류가 발생했습니다.";
break;
}

toastHTML = `
<div class="${bgColor} text-white px-4 py-3 rounded-lg shadow-lg flex items-center space-x-2 max-w-sm toast-container" style="position: fixed; bottom: 20px; right: 20px; z-index: 9999;">
<div class="flex-shrink-0">
${icon}
</div>
<p class="text-sm font-medium">${message}</p>
<button id="toast-close-btn" class="toast-close-btn flex-shrink-0 ml-2 text-white hover:text-gray-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
`;

const toastElement = document.createElement("div");
toastElement.innerHTML = toastHTML;
document.body.appendChild(toastElement.firstElementChild);

const toast = document.querySelector(".toast-container");
const closeBtn = toast.querySelector(".toast-close-btn");

closeBtn.addEventListener("click", () => {
toast.remove();
});

setTimeout(() => {
if (toast && toast.parentElement) {
toast.remove();
}
}, 3000);
}
Loading
Loading