-
Notifications
You must be signed in to change notification settings - Fork 50
[4팀 박지영] Chapter2-1. 프레임워크 없이 SPA 만들기 #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d984d54
a033c49
71873a2
4cdb7c5
0012166
eea516d
1663aac
94dc3bd
8fb2422
a3c27d1
d283677
3d29cef
69c6f7d
5b145ff
036d3fd
e716985
9fc7c88
dd2638f
aff22b2
0f539f3
86e1acf
7c3f3b5
c1b7445
45f62d2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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 |
| 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"] | ||
| } |
| 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>`; | ||
| }; |
| 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>`; | ||
| }; | ||
|
|
||
| const subscribeCartIcon = () => { | ||
| cartStore.subscribe(() => { | ||
| const cartIconBtn = document.getElementById("cart-icon-btn"); | ||
| if (!cartIconBtn) return false; | ||
|
|
||
| cartIconBtn.innerHTML = CartIcon().trim(); | ||
| return true; | ||
| }); | ||
| }; | ||
| subscribeCartIcon(); | ||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 문제 상황심화 요구사항에서 언급한 것처럼 장바구니 선택 상태( 현재 코드의 한계
개선 구조선택 상태도
이렇게 하면 “장바구니의 선택 상태 유지”라는 요구사항을 스토어 중심으로 일관되게 해결할 수 있습니다. |
||
| }; | ||
|
|
||
| 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> | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 문제 상황
현재 코드의 한계
개선 구조후속 기능(예: 체크한 항목만 사라지는 애니메이션)이 요구된다면 전체 innerHTML 교체 방식은 한계가 있습니다. 선택 상태만 바꾸는 |
||
| <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> | ||
| `; | ||
| }; | ||
| 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>`; |
| 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"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export const BreadCrumb = () => {}; |
There was a problem hiding this comment.
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유지가 필요할 때 이를 유지하기 어렵고, 재사용성도 떨어짐개선 구조
변경이 필요한 부분(숫자를 표시하는 스팬)만 선택해서 텍스트만 업데이트하면 됩니다.
예를 들어:
이렇게 하면 버튼 본체는 최초 렌더링대로 두고, 상태만 붙이는 방식으로 기능을 확장할 때마다 전체 마크업을 덮어쓸 필요가 없어집니다.