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
671 changes: 531 additions & 140 deletions .github/pull_request_template.md

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Deploy to GitHub Pages

on:
push: # push trigger
branches:
- main
- release-* # release 브랜치도 배포

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: "pages"
cancel-in-progress: true

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.0.0

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build
run: pnpm run build

- name: Create SPA fallback
run: cp dist/index.html dist/404.html

- name: Setup Pages
uses: actions/configure-pages@v4

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: "./dist"

- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
12 changes: 12 additions & 0 deletions jsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext",
"jsx": "preserve",
"checkJs": true,
"allowImportingTsExtensions": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
{
"name": "front-chapter1-1",
"name": "front-chapter2-1",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"msw:init": "msw init public/",
"dev": "vite",
"dev:hash": "vite --open ./index.hash.html",
"build": "vite build",
Expand Down
18 changes: 18 additions & 0 deletions src/components/cart/CartHeader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const CartHeader = () => {
return /*html*/ `
<!-- 헤더 -->
<div class="sticky top-0 bg-white border-b border-gray-200 p-4 flex items-center justify-between">
<h2 class="text-lg font-bold text-gray-900 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4m2.6 8L6 2H3m4 11v6a1 1 0 001 1h1a1 1 0 001-1v-6M13 13v6a1 1 0 001 1h1a1 1 0 001-1v-6"></path>
</svg>
장바구니
</h2>

<button data-link="/" id="cart-modal-close-btn" class="text-gray-400 hover:text-gray-600 p-1">
<svg class="w-6 h-6" 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>`;
};
30 changes: 30 additions & 0 deletions src/components/cart/CartIcon.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { cartStore } from "../../store/cartStore";

export const CartIcon = () => {
return /*html*/ `
<button id="cart-icon-btn" class="relative p-2 text-gray-700 hover:text-gray-900 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4m2.6 8L6 2H3m4 11v6a1 1 0 001 1h1a1 1 0 001-1v-6M13 13v6a1 1 0 001 1h1a1 1 0 001-1v-6"></path>
</svg>
${
cartStore.getTotalCount() === 0
? ""
: `
<span class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">
${cartStore.getTotalCount()}
</span>`
}

</button>`;
};

Copy link
Contributor

Choose a reason for hiding this comment

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

문제 상황

cartStore.subscribe 안에서 cartIconBtn.innerHTML = CartIcon().trim()으로 버튼 전체를 다시 그립니다. 이 동작은 토스트나 접근성 속성을 버튼에 추가하려고 할 때마다 다시 덮어쓰게 되어 확장이 어렵습니다.

현재 코드의 한계

  • innerHTML 전체 재생성으로 aria-*, title, data-* 속성을 추가해도 다음 notify에서 없어짐
  • 향후 툴팁, 애니메이션, focus 유지가 필요할 때 이를 유지하기 어렵고, 재사용성도 떨어짐

개선 구조

변경이 필요한 부분(숫자를 표시하는 스팬)만 선택해서 텍스트만 업데이트하면 됩니다.
예를 들어:

const updateBadge = () => {
  const badge = document.querySelector("#cart-icon-btn .cart-count-badge");
  if (badge) {
    badge.textContent = cartStore.getTotalCount();
  }
};

이렇게 하면 버튼 본체는 최초 렌더링대로 두고, 상태만 붙이는 방식으로 기능을 확장할 때마다 전체 마크업을 덮어쓸 필요가 없어집니다.

const subscribeCartIcon = () => {
cartStore.subscribe(() => {
const cartIconBtn = document.getElementById("cart-icon-btn");
if (!cartIconBtn) return false;

cartIconBtn.innerHTML = CartIcon().trim();
return true;
});
};
subscribeCartIcon();
223 changes: 223 additions & 0 deletions src/components/cart/CartList.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { cartStore } from "../../store/cartStore.js";
import { EmptyCart } from "./EmptyCart.js";

const CART_LIST_CONTAINER_ID = "cart-list-container";
let cleanupCartList = null;
const selectedProductIds = new Set();

const normalizeProductId = (productId) => {
if (productId === undefined || productId === null) return "";
return String(productId);
Copy link
Contributor

Choose a reason for hiding this comment

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

문제 상황

심화 요구사항에서 언급한 것처럼 장바구니 선택 상태(선택 삭제, 전체 체크)는 새로고침 이후에도 유지되어야 합니다. 그러나 selectedProductIds는 모듈 스코프의 Set으로만 관리되고 있으며, 새로고침하면 빈 집합으로 초기화됩니다.

현재 코드의 한계

  • selectedProductIdslocalStorage 등의 영속 계층과 연결되어 있지 않음
  • 브라우저 새로고침 → 장바구니 데이터를 불러온 뒤에도 체크 상태가 복원되지 않음
  • 선택 기반 액션이 많아지면 사용자가 매번 다시 체크해야 해서 UX가 깬다

개선 구조

선택 상태도 cartStore나 별도 스토어(LocalStorage)로 옮기면 상태 영속성이 생깁니다.

  • cartStoreselection 상태를 추가
  • cartStore.notify() 시 함께 저장
  • 초기화 시 localStorage에서 복원하고 subscribe로 컴포넌트까지 전달

이렇게 하면 “장바구니의 선택 상태 유지”라는 요구사항을 스토어 중심으로 일관되게 해결할 수 있습니다.

};

const syncSelectionWithCart = (products = []) => {
const validIds = new Set(products.map((product) => normalizeProductId(product.productId)));
Array.from(selectedProductIds).forEach((id) => {
if (!validIds.has(id)) {
selectedProductIds.delete(id);
}
});
};

const getSelectionSummary = (products = []) => {
let totalAmount = 0;
let selectedCount = 0;

products.forEach((product) => {
const normalizedId = normalizeProductId(product.productId);
if (selectedProductIds.has(normalizedId)) {
selectedCount += 1;
totalAmount += Number(product.lprice) * Number(product.quantity);
}
});

const allSelected = products.length > 0 && selectedCount === products.length;

return {
count: selectedCount,
amount: totalAmount,
allSelected,
};
};

const cartItem = ({ productId, title, image, lprice, quantity = 1 }, isChecked = false) => /*html*/ `
<div class="flex items-center py-3 border-b border-gray-100 cart-item" data-product-id="${productId}">
<label class="flex items-center mr-3">
<input type="checkbox" class="cart-item-checkbox w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" data-product-id="${productId}" ${
isChecked ? "checked" : ""
}>
</label>

<div class="w-16 h-16 bg-gray-100 rounded-lg overflow-hidden mr-3 flex-shrink-0">
<img src="${image}" alt="${title}" class="w-full h-full object-cover cursor-pointer cart-item-image" data-product-id="${productId}">
</div>

<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 truncate cursor-pointer cart-item-title" data-product-id="${productId}">
${title}
</h4>
<p class="text-sm text-gray-600 mt-1">${Number(lprice).toLocaleString()}원</p>

<div class="flex items-center mt-2">
<button class="quantity-decrease-btn w-7 h-7 flex items-center justify-center border border-gray-300 rounded-l-md bg-gray-50 hover:bg-gray-100" data-product-id="${productId}">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4"></path>
</svg>
</button>
<input type="number" value="${quantity}" min="1" class="quantity-input w-12 h-7 text-center text-sm border-t border-b border-gray-300 focus:ring-1 focus:ring-blue-500 focus:border-blue-500" disabled data-product-id="${productId}">
<button class="quantity-increase-btn w-7 h-7 flex items-center justify-center border border-gray-300 rounded-r-md bg-gray-50 hover:bg-gray-100" data-product-id="${productId}">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
</button>
</div>
</div>
</div>`;

const renderCartLayout = (cartProducts = []) => {
const products = Array.isArray(cartProducts) ? cartProducts : [];
if (products.length === 0) {
selectedProductIds.clear();
return EmptyCart;
}

syncSelectionWithCart(products);
const { count, amount, allSelected } = getSelectionSummary(products);

const selectAllCheckedAttr = allSelected ? "checked" : "";
const showSelectedSummary = count > 0 ? "flex" : "none";
const removeBtnDisplay = count > 0 ? "block" : "none";

return /*html*/ `
<div class="p-4 border-b border-gray-200 bg-gray-50">
<label class="flex items-center text-sm text-gray-700">
<input type="checkbox" id="cart-modal-select-all-checkbox" class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 mr-2" ${selectAllCheckedAttr}>
전체선택 (${products.length}개)
</label>
</div>

<div class="flex-1 overflow-y-auto">
<div class="p-4 space-y-4">
${products
.map((product) => cartItem(product, selectedProductIds.has(normalizeProductId(product.productId))))
.join("")}
</div>
</div>

<div class="sticky bottom-0 bg-white border-t border-gray-200 p-4">
<div id="cart-selected-amount" class="flex justify-between items-center mb-3 text-sm" style="display:${showSelectedSummary}">
<span class="text-gray-600">선택한 상품 (${count}개)</span>
<span class="font-medium">${amount.toLocaleString()}원</span>
</div>


<div class="flex justify-between items-center mb-4">
<span class="text-lg font-bold text-gray-900">총 금액</span>
<span class="text-xl font-bold text-blue-600">${Number(cartStore.getTotalAmount()).toLocaleString()}원</span>
</div>

Copy link
Contributor

Choose a reason for hiding this comment

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

문제 상황

updateCartListView에서 매번 container.innerHTML = renderCartLayout(...)으로 전체 DOM을 대체합니다. 제품 수가 많거나 선택을 조금씩 바꾸는 경우에도 전체 리스트가 다시 그려지면서 스크롤 위치가 위로 튀고, 체크박스 상태가 잠깐 깜빡이는 등 부정적인 UX가 나타납니다.

현재 코드의 한계

  • 선택/수량만 바뀌었는데도 cart-item DOM 전체를 새로 만듦
  • 스크롤 위치, 포커스, 이미지 로딩이 매번 초기화됨
  • 장바구니가 커졌다가 다시 작아지는 시나리오에서 눈에 띄게 깜빡임

개선 구조

후속 기능(예: 체크한 항목만 사라지는 애니메이션)이 요구된다면 전체 innerHTML 교체 방식은 한계가 있습니다. 선택 상태만 바꾸는 dataset/classList 업데이트나 DOM 패칭 라이브러리(예: morphdom)를 적용하거나, 최소한 cart-item 단위로 DOM을 찾은 뒤에 필요한 부분만 바꾸는 방향으로 전환하면 다음 요구사항에도 유연하게 대응할 수 있습니다.

<div class="space-y-2">
<button id="cart-modal-remove-selected-btn" style="display:${removeBtnDisplay}" class="w-full bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors text-sm">
선택한 상품 삭제 (${count}개)
</button>
<div class="flex gap-2">
<button id="cart-modal-clear-cart-btn" class="flex-1 bg-gray-600 text-white py-2 px-4 rounded-md hover:bg-gray-700 transition-colors text-sm">
전체 비우기
</button>
<button id="cart-modal-checkout-btn" class="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors text-sm">
구매하기
</button>
</div>
</div>
</div>
`;
};

const updateCartListView = (cartProducts = []) => {
const container = document.getElementById(CART_LIST_CONTAINER_ID);
if (!container) return false;

if (!container.isConnected) {
destroyCartList();
return false;
}

container.innerHTML = renderCartLayout(cartProducts);
return true;
};

const mountCartList = () => {
destroyCartList();
const mounted = updateCartListView(cartStore.state.cart);
if (!mounted) return false;

const unsubscribe = cartStore.subscribe((cartProducts) => {
updateCartListView(cartProducts);
});

cleanupCartList = () => {
unsubscribe();
cleanupCartList = null;
};
};

const scheduleCartListMount = () => {
if (typeof window === "undefined") return;

window.requestAnimationFrame(() => {
if (!mountCartList()) {
window.setTimeout(mountCartList, 0);
}
});
};

export const destroyCartList = () => {
if (cleanupCartList) {
cleanupCartList();
}
};

const rerenderCartSelection = () => {
updateCartListView(cartStore.state.cart);
};

export const toggleCartItemSelection = (productId, isSelected) => {
const normalizedId = normalizeProductId(productId);
if (!normalizedId) return;

if (isSelected) {
selectedProductIds.add(normalizedId);
} else {
selectedProductIds.delete(normalizedId);
}

rerenderCartSelection();
};

export const selectAllCartItems = (isSelected) => {
if (isSelected) {
cartStore.state.cart.forEach((product) => selectedProductIds.add(normalizeProductId(product.productId)));
} else {
selectedProductIds.clear();
}

rerenderCartSelection();
};

export const getSelectedCartProductIds = () => Array.from(selectedProductIds);

export const clearCartSelection = () => {
if (selectedProductIds.size === 0) return;
selectedProductIds.clear();
rerenderCartSelection();
};

export const CartList = () => {
scheduleCartListMount();

return /*html*/ `
<div id="${CART_LIST_CONTAINER_ID}" class="flex flex-col max-h-[calc(90vh-120px)] overflow-hidden">
${renderCartLayout(cartStore.state.cart)}
</div>
`;
};
12 changes: 12 additions & 0 deletions src/components/cart/EmptyCart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const EmptyCart = `
<div class="flex-1 flex items-center justify-center p-8">
<div class="text-center">
<div class="text-gray-400 mb-4">
<svg class="mx-auto h-12 w-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4m2.6 8L6 2H3m4 11v6a1 1 0 001 1h1a1 1 0 001-1v-6M13 13v6a1 1 0 001 1h1a1 1 0 001-1v-6"></path>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">장바구니가 비어있습니다</h3>
<p class="text-gray-600">원하는 상품을 담아보세요!</p>
</div>
</div>`;
3 changes: 3 additions & 0 deletions src/components/cart/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./CartHeader.js";
export * from "./CartList.js";
export * from "./CartIcon.js";
1 change: 1 addition & 0 deletions src/components/category/BreadCrumb.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const BreadCrumb = () => {};
Loading