diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 15a3a274..168b3031 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,3 +1,5 @@
+## [4팀 김도현] Chapter2-1. 프레임워크 없이 SPA 만들기
+
## 과제 체크포인트
### 배포 링크
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 00000000..ccaf8ffe
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,48 @@
+name: Deploy to GitHub Pages
+
+on:
+ push: # push trigger
+ branches:
+ - main
+
+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
+
+ - 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: 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
diff --git a/.gitignore b/.gitignore
index 7cff355f..8f28b36f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,6 @@ dist-ssr
/playwright-report/
coverage
.coverage
+
+# Documentation (개인 작업용)
+docs/
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
index 1d2699e4..54391b0d 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -2,5 +2,7 @@
"tabWidth": 2,
"semi": true,
"singleQuote": false,
- "printWidth": 120
+ "printWidth": 120,
+ "embeddedLanguageFormatting": "auto",
+ "htmlWhitespaceSensitivity": "css"
}
diff --git a/COMMIT_GUIDE.md b/COMMIT_GUIDE.md
new file mode 100644
index 00000000..59db9a92
--- /dev/null
+++ b/COMMIT_GUIDE.md
@@ -0,0 +1,275 @@
+# 커밋 가이드
+
+## 과제 목표
+- 프레임워크 없이 SPA(Single Page Application) 구현
+- React가 해결하고자 하는 문제를 이해하고 직접 해결
+- 라우팅, 상태관리, 컴포넌트 구조를 vanilla JS로 구현
+- 테스트 코드 통과 및 GitHub Pages 배포
+
+## 커밋 컨벤션
+
+### 커밋 메시지 형식
+```
+<타입>: <제목>
+
+<본문> (선택사항)
+
+<꼬리말> (선택사항)
+```
+
+### 커밋 타입
+
+| 타입 | 설명 | 예시 |
+|------|------|------|
+| `feat` | 새로운 기능 추가 | feat: 상품 목록 무한 스크롤 구현 |
+| `refactor` | 코드 리팩토링 (기능 변경 없이 구조 개선) | refactor: 컴포넌트 기반 아키텍처로 전환 |
+| `style` | 코드 포매팅, 세미콜론 누락 등 | style: Prettier 설정 추가 |
+| `fix` | 버그 수정 | fix: 장바구니 수량 계산 오류 수정 |
+| `chore` | 빌드 설정, 패키지 매니저 설정 등 | chore: MSW 설정 및 mock 데이터 추가 |
+| `docs` | 문서 수정 | docs: README에 프로젝트 실행 방법 추가 |
+| `test` | 테스트 코드 추가/수정 | test: 상품 목록 e2e 테스트 추가 |
+| `design` | UI/UX 디자인 변경 | design: 상품 카드 레이아웃 개선 |
+
+## 현재 변경사항 커밋 제안
+
+### 방안 1: 상세하게 분리 (권장)
+
+```bash
+# 1. 코드 포매팅 설정
+git add .prettierrc
+git commit -m "style: Prettier 포매팅 설정 추가
+
+- embeddedLanguageFormatting: auto 추가
+- htmlWhitespaceSensitivity: css 추가
+- HTML 템플릿 리터럴 포매팅 개선"
+
+# 2. 템플릿 분리
+git add src/template.js
+git commit -m "refactor: HTML 템플릿을 별도 파일로 분리
+
+- main.js에 있던 대량의 HTML 템플릿 코드를 template.js로 이동
+- 상품목록, 장바구니, 상세페이지 등 모든 템플릿 포함
+- 코드 가독성 및 유지보수성 향상"
+
+# 3. 컴포넌트 구조 추가
+git add src/components/
+git commit -m "refactor: 재사용 가능한 컴포넌트 구조 추가
+
+컴포넌트 목록:
+- Header: 헤더 및 장바구니 아이콘
+- Footer: 푸터 영역
+- SearchForm: 검색 및 필터 폼
+- ProductList: 상품 목록 그리드
+- ProductDetail: 상품 상세 정보
+
+각 컴포넌트는 독립적으로 재사용 가능하도록 구현"
+
+# 4. 페이지 구조 추가
+git add src/pages/
+git commit -m "refactor: 페이지 레벨 컴포넌트 구조 추가
+
+페이지 목록:
+- PageLayout: 공통 레이아웃 (Header + Footer)
+- HomePage: 상품 목록 페이지
+- DetailPage: 상품 상세 페이지
+
+SPA 라우팅을 위한 페이지 단위 분리"
+
+# 5. 메인 파일 리팩토링
+git add src/main.js
+git commit -m "refactor: main.js를 컴포넌트 기반으로 리팩토링
+
+- 템플릿 코드 제거
+- 페이지 및 컴포넌트 import로 대체
+- 코드 라인 수 대폭 감소 (1100+ → 40 lines)
+- 전체 코드 구조 개선"
+```
+
+### 방안 2: 간단하게 분리
+
+```bash
+# 1. 코드 포매팅 설정
+git add .prettierrc
+git commit -m "style: Prettier 포매팅 설정 추가"
+
+# 2. 전체 리팩토링
+git add src/
+git commit -m "refactor: 컴포넌트 기반 아키텍처로 전환
+
+변경사항:
+- HTML 템플릿을 template.js로 분리
+- 재사용 가능한 컴포넌트 구조 추가 (Header, Footer, SearchForm, ProductList, ProductDetail)
+- 페이지 레벨 컴포넌트 추가 (PageLayout, HomePage, DetailPage)
+- main.js 간결화 및 모듈화
+
+목적:
+- 코드 가독성 및 유지보수성 향상
+- SPA 구현을 위한 구조적 기반 마련"
+```
+
+## 이후 개발 시 커밋 가이드
+
+### 기능 개발 단계별 커밋 예시
+
+#### 1단계: 라우팅 시스템
+```bash
+feat: 클라이언트 사이드 라우팅 시스템 구현
+
+- History API를 이용한 SPA 라우팅
+- popstate 이벤트 핸들링
+- 동적 페이지 렌더링
+```
+
+#### 2단계: 상태 관리
+```bash
+feat: 전역 상태 관리 시스템 구현
+
+- Observer 패턴 기반 상태 관리
+- 상태 변경 시 자동 리렌더링
+- 장바구니 상태 관리
+```
+
+#### 3단계: 상품 목록 기능
+```bash
+feat: 상품 목록 조회 및 필터링 기능 구현
+
+- API 연동 및 상품 데이터 로드
+- 검색, 카테고리 필터링
+- 정렬 기능 (가격순, 이름순)
+```
+
+#### 4단계: 무한 스크롤
+```bash
+feat: 상품 목록 무한 스크롤 구현
+
+- Intersection Observer API 활용
+- 페이지네이션 로직
+- 로딩 스켈레톤 UI
+```
+
+#### 5단계: 장바구니 기능
+```bash
+feat: 장바구니 CRUD 기능 구현
+
+- 상품 추가/삭제
+- 수량 조절
+- 전체 선택/삭제
+- LocalStorage 연동
+```
+
+#### 6단계: 상품 상세
+```bash
+feat: 상품 상세 페이지 구현
+
+- 동적 라우팅 (/product/:id)
+- 상품 상세 정보 표시
+- 관련 상품 추천
+```
+
+#### 7단계: URL 상태 동기화
+```bash
+feat: URL 쿼리 파라미터 상태 동기화
+
+- 검색어, 필터, 정렬 조건 URL 반영
+- 새로고침 시 상태 복원
+- 브라우저 히스토리 관리
+```
+
+#### 8단계: 에러 처리
+```bash
+feat: 404 페이지 및 에러 처리 구현
+
+- 존재하지 않는 경로 처리
+- API 에러 핸들링
+- 사용자 피드백 (토스트)
+```
+
+### 리팩토링 커밋 예시
+
+```bash
+refactor: 이벤트 핸들러 로직 분리
+
+- 컴포넌트에서 이벤트 로직 추출
+- 재사용 가능한 이벤트 핸들러 함수 작성
+```
+
+```bash
+refactor: API 호출 로직 유틸리티로 분리
+
+- 중복된 fetch 로직 제거
+- 에러 처리 일관성 확보
+```
+
+```bash
+refactor: 상태 업데이트 로직 최적화
+
+- 불필요한 리렌더링 방지
+- 성능 개선
+```
+
+## 커밋 작성 원칙
+
+### DO ✅
+
+1. **의미 있는 단위로 커밋**
+ - 하나의 커밋은 하나의 목적
+ - 독립적으로 이해 가능한 변경사항
+
+2. **구체적인 제목 작성**
+ ```
+ ❌ feat: 기능 추가
+ ✅ feat: 상품 검색 기능 구현
+ ```
+
+3. **Why를 설명하는 본문 작성**
+ ```
+ feat: Observer 패턴으로 상태 관리 구현
+
+ React의 useState와 유사한 반응형 상태 관리를 위해
+ Observer 패턴을 구현했습니다.
+ 상태 변경 시 자동으로 구독자들에게 알림을 보내
+ UI가 자동으로 업데이트됩니다.
+ ```
+
+4. **테스트와 함께 커밋**
+ ```
+ feat: 무한 스크롤 구현
+
+ - Intersection Observer 활용
+ - 스크롤 위치 감지 및 다음 페이지 로드
+ - e2e 테스트 통과 확인
+ ```
+
+### DON'T ❌
+
+1. **너무 큰 커밋 지양**
+ - 여러 기능을 한 번에 커밋하지 않기
+ - 리팩토링과 기능 추가를 섞지 않기
+
+2. **모호한 커밋 메시지**
+ ```
+ ❌ fix: 수정
+ ❌ update: 업데이트
+ ❌ refactor: 코드 정리
+ ```
+
+3. **WIP(Work In Progress) 커밋 남기지 않기**
+ - 개발 중간에 임시 커밋하지 않기
+ - 완성된 단위로 커밋
+
+4. **포매팅과 로직 변경을 함께 커밋**
+ - 포매팅은 별도 커밋으로 분리
+
+## 과제 제출 시 체크리스트
+
+- [ ] 각 커밋이 독립적으로 의미를 가지는가?
+- [ ] 커밋 메시지가 변경 내용을 명확히 설명하는가?
+- [ ] feat/refactor/fix 등 타입이 올바르게 사용되었는가?
+- [ ] 모든 테스트가 통과하는가?
+- [ ] 배포가 정상적으로 되는가?
+
+## 참고 자료
+
+- [Conventional Commits](https://www.conventionalcommits.org/ko/v1.0.0/)
+- [좋은 git commit 메시지를 위한 영어 사전](https://blog.ull.im/engineering/2019/03/10/logs-on-git.html)
+- [커밋 메시지 가이드](https://github.com/RomuloOliveira/commit-messages-guide/blob/master/README_ko-KR.md)
diff --git a/package.json b/package.json
index 5ec7f3f3..ea2ebed7 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"scripts": {
"dev": "vite",
"dev:hash": "vite --open ./index.hash.html",
- "build": "vite build",
+ "build": "vite build && cp dist/index.html dist/404.html",
"lint:fix": "eslint --fix",
"prettier:write": "prettier --write ./src",
"preview": "vite preview",
diff --git a/src/api/productApi.js b/src/api/productApi.js
index bbdea046..1a4ccc79 100644
--- a/src/api/productApi.js
+++ b/src/api/productApi.js
@@ -1,7 +1,14 @@
// 상품 목록 조회
export async function getProducts(params = {}) {
- const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params;
- const page = params.current ?? params.page ?? 1;
+ const { limit = 20, skip, search = "", category1 = "", category2 = "", sort = "price_asc" } = params;
+
+ // skip이 있으면 page로 변환, 없으면 기존 방식 사용
+ let page;
+ if (skip !== undefined) {
+ page = Math.floor(skip / limit) + 1;
+ } else {
+ page = params.current ?? params.page ?? 1;
+ }
const searchParams = new URLSearchParams({
page: page.toString(),
diff --git a/src/components/CartModal.js b/src/components/CartModal.js
new file mode 100644
index 00000000..bf907670
--- /dev/null
+++ b/src/components/CartModal.js
@@ -0,0 +1,240 @@
+/**
+ * 장바구니 모달 컴포넌트
+ */
+
+// 장바구니 아이템 렌더링
+const CartItem = ({ id, title, image, lprice, quantity, checked = false }) => {
+ const totalPrice = lprice * quantity;
+
+ return /* HTML */ `
+
+
+
+
+
+
+
+
+
+
+
+
+ ${title}
+
+
${Number(lprice).toLocaleString()}원
+
+
+
+
+
+
${totalPrice.toLocaleString()}원
+
+ 삭제
+
+
+
+ `;
+};
+
+export const CartModal = ({ cart = [], selectedIds = [] }) => {
+ const isEmpty = cart.length === 0;
+ const hasSelection = selectedIds.length > 0;
+
+ // 총 금액 계산
+ const totalAmount = cart.reduce((sum, item) => sum + item.lprice * item.quantity, 0);
+
+ // 선택된 아이템 총 금액
+ const selectedAmount = cart
+ .filter((item) => selectedIds.includes(item.id))
+ .reduce((sum, item) => sum + item.lprice * item.quantity, 0);
+
+ // 빈 장바구니
+ if (isEmpty) {
+ return /* HTML */ `
+
+
+
+
+
+
+
+
+ 장바구니
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
장바구니가 비어있습니다
+
원하는 상품을 담아보세요!
+
+
+
+
+
+ `;
+ }
+
+ // 장바구니에 아이템이 있을 때
+ return /* HTML */ `
+
+
+
+
+
+
+
+
+ 장바구니
+ (${cart.length})
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 ? "checked" : ""}
+ />
+ 전체선택 (${cart.length}개)
+
+
+
+
+
+ ${cart.map((item) => CartItem({ ...item, checked: selectedIds.includes(item.id) })).join("")}
+
+
+
+
+
+ ${hasSelection
+ ? /* HTML */ `
+
+
+ 선택한 상품 (${selectedIds.length}개)
+ ${selectedAmount.toLocaleString()}원
+
+ `
+ : ""}
+
+
+ 총 금액
+ ${totalAmount.toLocaleString()}원
+
+
+
+ ${hasSelection
+ ? /* HTML */ `
+
+ 선택한 상품 삭제 (${selectedIds.length}개)
+
+ `
+ : ""}
+
+
+ 전체 비우기
+
+
+ 구매하기
+
+
+
+
+
+
+ `;
+};
diff --git a/src/components/Footer.js b/src/components/Footer.js
new file mode 100644
index 00000000..70ad6b23
--- /dev/null
+++ b/src/components/Footer.js
@@ -0,0 +1,9 @@
+export const Footer = () => {
+ return /* HTML */ `
+
+
+
© 2025 항해플러스 프론트엔드 쇼핑몰
+
+
+ `;
+};
diff --git a/src/components/Header.js b/src/components/Header.js
new file mode 100644
index 00000000..6a4f4496
--- /dev/null
+++ b/src/components/Header.js
@@ -0,0 +1,32 @@
+export const Header = ({ cartCount = 0 }) => {
+ return /* HTML */ `
+
+
+
+
+
+
+
+
+
+
+ ${cartCount > 0
+ ? /* HTML */ `${cartCount} `
+ : ""}
+
+
+
+
+
+ `;
+};
diff --git a/src/components/ProductList.js b/src/components/ProductList.js
new file mode 100644
index 00000000..7870c123
--- /dev/null
+++ b/src/components/ProductList.js
@@ -0,0 +1,126 @@
+const Skeleton = /* HTML */ `
+
+`;
+
+const Loading = /* HTML */ `
+
+
+
+
+
+
+
상품을 불러오는 중...
+
+
+`;
+
+const ProductItem = ({ title, image, lprice, productId, brand }) => {
+ // 장바구니에 추가할 상품 데이터
+ const productData = JSON.stringify({
+ id: productId,
+ title,
+ image,
+ lprice,
+ brand,
+ });
+
+ return /* HTML */ `
+
+
+
+
+
+
+
+
+
${title}
+
${brand || ""}
+
${Number(lprice).toLocaleString()}원
+
+
+
+ 장바구니 담기
+
+
+
+ `;
+};
+
+const ErrorState = () => /* HTML */ `
+
+
+
+
+
상품을 불러올 수 없습니다
+
네트워크 연결을 확인해주세요
+
+ 다시 시도
+
+
+`;
+
+export const ProductList = ({ products, loading, pagination = {}, error }) => {
+ const totalCount = pagination.total || products.length;
+ const isLoadingMore = pagination.isLoadingMore || false;
+ const hasNext = pagination.hasNext !== undefined ? pagination.hasNext : true;
+
+ return /* HTML */ `
+
+
+ ${error
+ ? ErrorState()
+ : loading
+ ? /* HTML */ `
+
${Skeleton.repeat(4)}
+ ${Loading}
+ `
+ : /* HTML */ `
+
+ 총 ${totalCount}개 의 상품
+
+
${products.map(ProductItem).join("")}
+ ${isLoadingMore
+ ? /* HTML */ `
+
${Skeleton.repeat(4)}
+ ${Loading}
+ `
+ : !hasNext && products.length > 0
+ ? /* HTML */ `
모든 상품을 확인했습니다
`
+ : ""}
+ `}
+
+
+ `;
+};
diff --git a/src/components/SearchForm.js b/src/components/SearchForm.js
new file mode 100644
index 00000000..4edada38
--- /dev/null
+++ b/src/components/SearchForm.js
@@ -0,0 +1,135 @@
+export const SearchForm = ({ filters = {}, categories = {} }) => {
+ const searchValue = filters.search || "";
+ const category1Keys = Object.keys(categories);
+ const currentLimit = filters.limit || "20";
+ const currentSort = filters.sort || "price_asc";
+ const selectedCategory1 = filters.category1 || "";
+ const selectedCategory2 = filters.category2 || "";
+
+ // 선택된 category1의 category2 목록
+ const category2Keys =
+ selectedCategory1 && categories[selectedCategory1] ? Object.keys(categories[selectedCategory1]) : [];
+
+ return /* HTML */ `
+
+
+
+
+
+
+
+
+
+ 카테고리:
+ 전체
+ ${selectedCategory1
+ ? /* HTML */ `>
+ ${selectedCategory1}
+ `
+ : ""}
+ ${selectedCategory2
+ ? /* HTML */ `> ${selectedCategory2} `
+ : ""}
+
+
+
+
+
+ ${!selectedCategory1
+ ? // category1 버튼들
+ category1Keys.length > 0
+ ? category1Keys
+ .map(
+ (cat1) => /* HTML */ `
+
+ ${cat1}
+
+ `,
+ )
+ .join("")
+ : /* HTML */ `
카테고리 로딩 중...
`
+ : // category2 버튼들
+ category2Keys
+ .map(
+ (cat2) => /* HTML */ `
+
+ ${cat2}
+
+ `,
+ )
+ .join("")}
+
+
+
+
+
+
+
+ 개수:
+
+ 10개
+ 20개
+ 50개
+ 100개
+
+
+
+
+ 정렬:
+
+ 가격 낮은순
+ 가격 높은순
+ 이름순
+ 이름 역순
+
+
+
+
+
+ `;
+};
diff --git a/src/components/index.js b/src/components/index.js
new file mode 100644
index 00000000..f7bd0c38
--- /dev/null
+++ b/src/components/index.js
@@ -0,0 +1,4 @@
+export * from "./Header";
+export * from "./Footer";
+export * from "./SearchForm";
+export * from "./ProductList";
diff --git a/src/core/lifecycle.js b/src/core/lifecycle.js
new file mode 100644
index 00000000..81b24472
--- /dev/null
+++ b/src/core/lifecycle.js
@@ -0,0 +1,73 @@
+/**
+ * Lifecycle 시스템 - withLifecycle HOC
+ *
+ * React의 useEffect와 유사한 생명주기 관리 시스템
+ * - mount: 컴포넌트가 처음 렌더링될 때 1번만 실행
+ * - watch: 감시하는 값이 변경될 때마다 실행
+ * - unmount: 컴포넌트가 제거될 때 정리 작업
+ *
+ * @param {Object} hooks - { mount, watchs, unmount }
+ * @param {Function} renderFn - 렌더링 함수
+ * @returns {Function} 래핑된 컴포넌트
+ */
+export function withLifecycle(hooks, renderFn) {
+ // 내부 상태 (클로저)
+ let isMounted = false; // mount가 실행되었는지 여부
+ let oldValues = {}; // watch의 이전 값 저장소
+
+ const wrappedComponent = (props) => {
+ let justMounted = false;
+
+ // 1. mount 실행 (처음 1번만)
+ if (!isMounted && hooks.mount) {
+ isMounted = true;
+ justMounted = true;
+
+ // mount 호출 전에 watch의 초기값 저장
+ // (mount 내부에서 dispatch -> render 시 watch가 변경 감지하지 않도록)
+ if (hooks.watchs) {
+ hooks.watchs.forEach(({ target }) => {
+ const initialValue = target();
+ const key = target.toString();
+ oldValues[key] = initialValue;
+ });
+ }
+
+ hooks.mount();
+ }
+
+ // 2. watch 실행 (값 변경 감지)
+ if (hooks.watchs && !justMounted) {
+ hooks.watchs.forEach(({ target, callback }) => {
+ const newValue = target();
+ const key = target.toString();
+ const oldValue = oldValues[key];
+
+ const newStr = JSON.stringify(newValue);
+ const oldStr = JSON.stringify(oldValue);
+
+ // 객체를 JSON.stringify로 비교
+ if (newStr !== oldStr) {
+ // callback 실행 전에 먼저 oldValues 업데이트! (동기 dispatch로 인한 재진입 방지)
+ oldValues[key] = newValue;
+ callback(newValue, oldValue);
+ }
+ });
+ }
+
+ // 3. 렌더링
+ return renderFn(props);
+ };
+
+ // unmount 함수 추가
+ wrappedComponent.unmount = () => {
+ if (hooks.unmount) {
+ hooks.unmount();
+ }
+ // 상태 초기화
+ isMounted = false;
+ oldValues = {};
+ };
+
+ return wrappedComponent;
+}
diff --git a/src/core/observer.js b/src/core/observer.js
new file mode 100644
index 00000000..4c99d221
--- /dev/null
+++ b/src/core/observer.js
@@ -0,0 +1,61 @@
+/**
+ * Observer 패턴 구현
+ *
+ * 실생활 비유: YouTube 구독 시스템
+ * - subscribe(): 채널 구독 버튼 누르기
+ * - notify(): 새 영상 올리면 구독자들에게 알림 보내기
+ * - unsubscribe(): 구독 취소
+ *
+ * 사용 목적:
+ * - Router: URL이 변경되면 → 자동으로 화면 렌더링
+ * - Store: 상태가 변경되면 → 자동으로 UI 업데이트
+ *
+ * @returns {Object} { subscribe, unsubscribe, notify }
+ */
+export const createObserver = () => {
+ const observers = new Set();
+
+ const subscribe = (callback) => {
+ if (typeof callback !== "function") {
+ return;
+ }
+ observers.add(callback);
+ };
+
+ const unsubscribe = (callback) => {
+ observers.delete(callback);
+ };
+ const notify = (data) => {
+ observers.forEach((callback) => {
+ try {
+ callback(data);
+ } catch (error) {
+ console.error("Observer callback error:", error);
+ }
+ });
+ };
+
+ return {
+ subscribe,
+ unsubscribe,
+ notify,
+ };
+};
+
+// ===== 사용 예시 =====
+//
+// import { createObserver } from './core/observer.js';
+//
+// // Observer 생성
+// const routerObserver = createObserver();
+//
+// // 구독자 추가
+// routerObserver.subscribe((route) => {
+// console.log("URL 변경됨:", route);
+// render(); // 자동으로 렌더링!
+// });
+//
+// // 알림 보내기 (URL이 변경되었을 때)
+// routerObserver.notify({ path: "/product/1" });
+// // → "URL 변경됨: { path: "/product/1" }"
+// // → render() 자동 실행!
diff --git a/src/core/router.js b/src/core/router.js
new file mode 100644
index 00000000..5cc019ed
--- /dev/null
+++ b/src/core/router.js
@@ -0,0 +1,165 @@
+import { createObserver } from "./observer.js";
+
+/**
+ * Router 시스템
+ *
+ * Observer 패턴 기반 SPA 라우터
+ * - 선언형 라우트 설정
+ * - URL 파라미터 추출 (/product/:id)
+ * - 쿼리 파라미터 관리 (?search=abc)
+ * - 자동 렌더링 (subscribe)
+ */
+
+// ===== 1. Observer 및 상태 초기화 =====
+const observer = createObserver();
+let routes = {};
+let currentRoute = { name: "", params: {}, query: {}, component: null };
+
+/**
+ * 라우트 설정 (선언형)
+ * @param {Object} routeConfig - { "/": HomePage, "/product/:id": DetailPage, "*": NotFoundPage }
+ *
+ * 사용 예시:
+ * setup({
+ * "/": HomePage,
+ * "/products/:id": DetailPage,
+ * "*": NotFoundPage
+ * });
+ */
+export const setup = (routeConfig) => {
+ routes = routeConfig;
+ updateCurrentRoute();
+};
+
+/**
+ * 구독 - 라우트 변경 시 자동 실행
+ * @param {Function} callback - 실행할 함수
+ *
+ * 사용 예시:
+ * subscribe((route) => {
+ * console.log("라우트 변경:", route);
+ * render();
+ * });
+ */
+export const subscribe = (callback) => {
+ observer.subscribe(callback);
+};
+
+/**
+ * 네비게이션 (페이지 이동)
+ * @param {string} path - 이동할 경로
+ *
+ * 사용 예시:
+ * push("/product/123");
+ */
+export const push = (path) => {
+ const basePath = import.meta.env.BASE_URL;
+ const fullPath = basePath === "/" ? path : basePath.replace(/\/$/, "") + path;
+ window.history.pushState(null, "", fullPath);
+ updateCurrentRoute();
+ observer.notify(currentRoute); // 구독자들에게 알림!
+};
+
+/**
+ * 현재 라우트 정보 반환
+ * @returns {Object} { name, params, query, component }
+ *
+ * 사용 예시:
+ * const route = getCurrentRoute();
+ * console.log(route.params.id); // "123"
+ */
+export const getCurrentRoute = () => currentRoute;
+
+/**
+ * 쿼리 파라미터 업데이트 (replaceState)
+ * @param {Object} updates - 업데이트할 쿼리 객체
+ *
+ * 사용 예시:
+ * updateQuery({ search: "abc", page: "2" });
+ * // → URL이 ?search=abc&page=2 로 변경됨
+ *
+ * updateQuery({ search: "" });
+ * // → search 파라미터 제거됨
+ */
+export const updateQuery = (updates) => {
+ const current = currentRoute.query;
+ const merged = { ...current, ...updates };
+
+ // 빈 값 제거
+ Object.keys(merged).forEach((key) => {
+ if (!merged[key]) delete merged[key];
+ });
+
+ // 쿼리 스트링 생성
+ const queryString = new URLSearchParams(merged).toString();
+ const newPath = `${location.pathname}${queryString ? "?" + queryString : ""}`;
+
+ // replaceState: 히스토리 스택에 추가하지 않고 URL만 변경
+ window.history.replaceState(null, "", newPath);
+ updateCurrentRoute();
+ observer.notify(currentRoute);
+};
+
+// ===== 내부 함수 =====
+
+/**
+ * 현재 URL을 파싱하여 currentRoute 업데이트
+ */
+const updateCurrentRoute = () => {
+ const basePath = import.meta.env.BASE_URL;
+ const pathName = location.pathname;
+ const path = pathName.replace(basePath, "/").replace(/\/$/, "") || "/";
+ const query = Object.fromEntries(new URLSearchParams(location.search));
+
+ // 등록된 라우트와 매칭
+ for (const [pattern, component] of Object.entries(routes)) {
+ const match = matchRoute(path, pattern);
+ if (match) {
+ currentRoute = { name: pattern, params: match.params, query, component };
+ return;
+ }
+ }
+
+ // 매칭 실패 → 404
+ currentRoute = { name: "*", params: {}, query, component: routes["*"] };
+};
+
+/**
+ * URL 패턴 매칭 및 파라미터 추출
+ * @param {string} path - 현재 경로 (예: "/product/123")
+ * @param {string} pattern - 라우트 패턴 (예: "/product/:id")
+ * @returns {Object|null} { params: { id: "123" } } or null
+ */
+const matchRoute = (path, pattern) => {
+ // 404 라우트는 매칭하지 않음 (마지막 fallback)
+ if (pattern === "*") return null;
+
+ // 정확히 일치
+ if (pattern === path) return { params: {} };
+
+ // 동적 라우트 매칭 (예: /product/:id)
+ // 1. :id를 ([^/]+) 정규식으로 변환
+ const regex = new RegExp("^" + pattern.replace(/:(\w+)/g, "([^/]+)") + "$");
+ const match = path.match(regex);
+
+ if (match) {
+ // 2. 파라미터 이름 추출
+ const paramNames = [...pattern.matchAll(/:(\w+)/g)].map((m) => m[1]);
+
+ // 3. 파라미터 값과 이름 매핑
+ const params = {};
+ paramNames.forEach((name, i) => {
+ params[name] = match[i + 1];
+ });
+
+ return { params };
+ }
+
+ return null;
+};
+
+// ===== popstate 이벤트 리스너 (뒤로/앞으로 가기) =====
+window.addEventListener("popstate", () => {
+ updateCurrentRoute();
+ observer.notify(currentRoute);
+});
diff --git a/src/core/storage.js b/src/core/storage.js
new file mode 100644
index 00000000..12ce7479
--- /dev/null
+++ b/src/core/storage.js
@@ -0,0 +1,78 @@
+/**
+ * localStorage 래퍼
+ *
+ * localStorage를 안전하게 사용하기 위한 유틸리티
+ * - JSON 직렬화/역직렬화 자동 처리
+ * - 에러 핸들링
+ */
+
+/**
+ * 데이터 저장
+ * @param {string} key - 저장할 키
+ * @param {any} value - 저장할 값 (객체, 배열 등)
+ *
+ * 사용 예시:
+ * save('cart', [{ id: 1, name: '상품', quantity: 2 }]);
+ */
+export const save = (key, value) => {
+ try {
+ // JSON.stringify: 객체/배열을 문자열로 변환
+ localStorage.setItem(key, JSON.stringify(value));
+ } catch (error) {
+ console.error("Storage save error:", error);
+ }
+};
+
+/**
+ * 데이터 로드
+ * @param {string} key - 불러올 키
+ * @returns {any} 저장된 값 (객체, 배열 등) 또는 null
+ *
+ * 사용 예시:
+ * const cart = load('cart') || [];
+ */
+export const load = (key) => {
+ try {
+ const value = localStorage.getItem(key);
+
+ // 값이 없으면 null 반환
+ if (value === null) {
+ return null;
+ }
+
+ // JSON.parse: 문자열을 객체/배열로 변환
+ return JSON.parse(value);
+ } catch (error) {
+ console.error("Storage load error:", error);
+ return null;
+ }
+};
+
+/**
+ * 데이터 삭제
+ * @param {string} key - 삭제할 키
+ *
+ * 사용 예시:
+ * remove('cart');
+ */
+export const remove = (key) => {
+ try {
+ localStorage.removeItem(key);
+ } catch (error) {
+ console.error("Storage remove error:", error);
+ }
+};
+
+/**
+ * 전체 삭제
+ *
+ * 사용 예시:
+ * clear();
+ */
+export const clear = () => {
+ try {
+ localStorage.clear();
+ } catch (error) {
+ console.error("Storage clear error:", error);
+ }
+};
diff --git a/src/core/store.js b/src/core/store.js
new file mode 100644
index 00000000..75306dee
--- /dev/null
+++ b/src/core/store.js
@@ -0,0 +1,115 @@
+import { createObserver } from "./observer.js";
+
+/**
+ * Store 생성 (상태 관리)
+ *
+ * Observer 패턴 기반 상태 관리
+ * - 상태 변경 시 자동으로 구독자들에게 알림
+ * - Redux의 단순화 버전
+ * - Flux 패턴 적용 (단방향 데이터 흐름)
+ *
+ * @param {Object} config - { state, actions }
+ * @returns {Object} { subscribe, getState, dispatch }
+ *
+ * 사용 예시:
+ * const store = createStore({
+ * state: { count: 0 },
+ * actions: {
+ * increment(setState) {
+ * setState({ count: getState().count + 1 });
+ * }
+ * }
+ * });
+ *
+ * store.subscribe(() => render());
+ * store.dispatch({ type: 'increment' });
+ */
+export const createStore = (config) => {
+ const observer = createObserver();
+ let state = config.state;
+ const actions = config.actions;
+
+ // ===== 1. 구독 (Subscribe) =====
+ // 상태 변경 시 실행할 함수 등록
+ const subscribe = (callback) => {
+ observer.subscribe(callback);
+ };
+
+ // ===== 2. 상태 읽기 (GetState) =====
+ // 현재 상태를 반환
+ const getState = () => state;
+
+ // ===== 3. 상태 업데이트 (SetState) - 내부용 =====
+ // 상태를 업데이트하고 구독자들에게 알림
+ const setState = (updates) => {
+ // 불변성 유지: 기존 state를 변경하지 않고 새로운 객체 생성
+ state = { ...state, ...updates };
+
+ // 구독자들에게 알림 (자동 렌더링!)
+ observer.notify(state);
+ };
+
+ // ===== 4. 액션 디스패치 (Dispatch) =====
+ // 액션을 실행하여 상태 변경
+ // Redux의 dispatch와 유사
+ const dispatch = ({ type, payload }) => {
+ const action = actions[type];
+
+ if (action) {
+ // 액션 함수 실행
+ // - setState: 상태 업데이트 함수
+ // - payload: 액션에 전달된 데이터
+ // - getState: 현재 상태를 읽는 함수
+ action(setState, payload, getState);
+ } else {
+ console.warn(`Unknown action: ${type}`);
+ }
+ };
+
+ // ===== 공개 API =====
+ return {
+ subscribe, // 구독
+ getState, // 상태 읽기
+ dispatch, // 액션 실행
+ };
+};
+
+// ===== 사용 예시 =====
+//
+// // 1. Store 생성
+// const store = createStore({
+// state: {
+// products: [],
+// loading: false,
+// error: null,
+// },
+// actions: {
+// // 로딩 시작
+// pendingProducts(setState) {
+// setState({ loading: true, error: null });
+// },
+//
+// // 상품 로드 성공
+// setProducts(setState, products) {
+// setState({ products, loading: false, error: null });
+// },
+//
+// // 에러 발생
+// errorProducts(setState, error) {
+// setState({ loading: false, error: error.message });
+// },
+// },
+// });
+//
+// // 2. 구독 (상태 변경 시 자동 렌더링)
+// store.subscribe(() => {
+// console.log("상태 변경됨:", store.getState());
+// render();
+// });
+//
+// // 3. 액션 디스패치
+// store.dispatch({ type: 'pendingProducts' });
+// // → loading: true
+//
+// store.dispatch({ type: 'setProducts', payload: [{ id: 1, name: '상품' }] });
+// // → products: [...], loading: false
diff --git a/src/main.js b/src/main.js
index 4b055b89..3d76cda0 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,1150 +1,422 @@
+import * as router from "./core/router.js";
+import { store } from "./state/store.js";
+import { Homepage } from "./pages/HomePage.js";
+import { DetailPage } from "./pages/DetailPage.js";
+import { NotFoundPage } from "./pages/NotFoundPage.js";
+import { showToast } from "./utils/toast.js";
+import { openCartModal, closeCartModal, getSelectedIds, setSelectedIds } from "./utils/cartModal.js";
+
const enableMocking = () =>
import("./mocks/browser.js").then(({ worker }) =>
worker.start({
+ serviceWorker: {
+ url: `${import.meta.env.BASE_URL}mockServiceWorker.js`,
+ },
onUnhandledRequest: "bypass",
}),
);
-function main() {
- const 상품목록_레이아웃_로딩 = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 개수:
-
-
- 10개
-
-
- 20개
-
-
- 50개
-
-
- 100개
-
-
-
-
-
- 정렬:
-
- 가격 낮은순
- 가격 높은순
- 이름순
- 이름 역순
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
상품을 불러오는 중...
-
-
-
-
-
-
-
-
© 2025 항해플러스 프론트엔드 쇼핑몰
-
-
-
- `;
+// 라우트 설정
+router.setup({
+ "/": Homepage,
+ "/product/:id": DetailPage,
+ "*": NotFoundPage,
+});
- const 상품목록_레이아웃_로딩완료 = `
-
-
-
-
-
-
-
-
-
-
-
-
- 카테고리:
- 전체
-
-
-
-
- 생활/건강
-
-
- 디지털/가전
-
-
-
-
-
-
-
-
- 개수:
-
-
- 10개
-
-
- 20개
-
-
- 50개
-
-
- 100개
-
-
-
-
-
- 정렬:
-
- 가격 낮은순
- 가격 높은순
- 이름순
- 이름 역순
-
-
-
-
-
-
-
-
-
-
- 총 340개 의 상품
-
-
-
-
-
-
-
-
-
-
-
-
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
-
-
-
- 220원
-
-
-
-
- 장바구니 담기
-
-
-
-
-
-
-
-
-
-
-
-
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
-
-
이지웨이건축자재
-
- 230원
-
-
-
-
- 장바구니 담기
-
-
-
-
-
-
- 모든 상품을 확인했습니다
-
-
-
-
-
-
-
© 2025 항해플러스 프론트엔드 쇼핑몰
-
-
-
- `;
+// 현재 컴포넌트 저장 (unmount 호출용)
+let currentComponent = null;
- const 상품목록_레이아웃_카테고리_1Depth = `
-
-
-
-
-
-
-
-
+// 렌더링 함수
+const render = () => {
+ const route = router.getCurrentRoute();
+ const $root = document.querySelector("#root");
+ const newComponent = route.component;
-
-
-
- 카테고리:
- 전체 > 생활/건강
-
-
-
-
- 생활용품
-
-
- 주방용품
-
-
- 문구/사무용품
-
-
-
-
-
-
-
-
-
- 개수:
-
-
- 10개
-
-
- 20개
-
-
- 50개
-
-
- 100개
-
-
-
-
-
- 정렬:
-
- 가격 낮은순
- 가격 높은순
- 이름순
- 이름 역순
-
-
-
-
-
-
- `;
+ // 모달이 열려있으면 전체 렌더링 스킵 (모달이 사라지는 것 방지)
+ // 단, 장바구니 개수는 업데이트
+ if (document.getElementById("cart-modal-container")) {
+ const cart = store.getState().cart;
+ const $cartBadge = document.querySelector("#cart-icon-btn span");
+ if ($cartBadge) {
+ if (cart.length > 0) {
+ $cartBadge.textContent = cart.length;
+ } else {
+ // 장바구니가 비었으면 뱃지 제거
+ $cartBadge.remove();
+ }
+ } else if (cart.length > 0) {
+ // 뱃지가 없는데 장바구니에 상품이 있으면 뱃지 추가
+ const $cartBtn = document.getElementById("cart-icon-btn");
+ if ($cartBtn) {
+ 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 = cart.length;
+ $cartBtn.appendChild(badge);
+ }
+ }
+ return;
+ }
- const 상품목록_레이아웃_카테고리_2Depth = `
-
-
-
-
-
-
-
-
+ // 다른 컴포넌트로 전환 시에만 unmount 호출
+ if (currentComponent && currentComponent !== newComponent) {
+ if (currentComponent.unmount) {
+ currentComponent.unmount();
+ }
+ }
-
-
-
- 카테고리:
- 전체 > 생활/건강 > 주방용품
-
-
-
-
- 생활용품
-
-
- 주방용품
-
-
- 문구/사무용품
-
-
-
-
-
-
-
-
-
- 개수:
-
-
- 10개
-
-
- 20개
-
-
- 50개
-
-
- 100개
-
-
-
-
-
- 정렬:
-
- 가격 낮은순
- 가격 높은순
- 이름순
- 이름 역순
-
-
-
-
-
-
- `;
+ currentComponent = newComponent;
+ $root.innerHTML = newComponent();
+};
- const 토스트 = `
-
-
-
-
장바구니에 추가되었습니다
-
-
-
-
-
-
-
-
-
-
선택된 상품들이 삭제되었습니다
-
-
-
-
-
-
-
-
-
-
오류가 발생했습니다.
-
-
-
-
-
-
-
- `;
+// Router, Store 구독
+router.subscribe(render);
+store.subscribe(render);
- const 장바구니_비어있음 = `
-
-
-
-
-
-
-
-
- 장바구니
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
장바구니가 비어있습니다
-
원하는 상품을 담아보세요!
-
-
-
-
-
- `;
+// 전역 이벤트 핸들러
+document.body.addEventListener("click", (e) => {
+ // 링크 클릭
+ const $link = e.target.closest('a[href^="/"]');
+ if ($link) {
+ e.preventDefault();
+ router.push($link.getAttribute("href"));
+ return;
+ }
- const 장바구니_선택없음 = `
-
-
-
-
-
-
-
-
- 장바구니
- (2)
-
-
-
-
-
-
-
-
-
-
-
-
-
- 전체선택 (2개)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
-
-
- 220원
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
-
-
- 230원
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 총 금액
- 670원
-
-
-
-
-
- 전체 비우기
-
-
- 구매하기
-
-
-
-
-
-
- `;
+ // 장바구니 아이콘 클릭 - 모달 열기
+ if (e.target.closest("#cart-icon-btn")) {
+ openCartModal();
+ return;
+ }
- const 장바구니_선택있음 = `
-
-
-
-
-
-
-
-
- 장바구니
- (2)
-
-
-
-
-
-
-
-
-
-
-
-
-
- 전체선택 (2개)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
-
-
- 220원
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
-
-
- 230원
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 선택한 상품 (1개)
- 440원
-
-
-
- 총 금액
- 670원
-
-
-
-
- 선택한 상품 삭제 (1개)
-
-
-
- 전체 비우기
-
-
- 구매하기
-
-
-
-
-
-
- `;
+ // 장바구니 모달 닫기 버튼
+ if (e.target.closest("#cart-modal-close-btn")) {
+ closeCartModal();
+ return;
+ }
- const 상세페이지_로딩 = `
-
-
-
-
-
-
-
-
© 2025 항해플러스 프론트엔드 쇼핑몰
-
-
-
- `;
+ // 장바구니 모달 배경 클릭 - 닫기
+ if (e.target.classList.contains("cart-modal-overlay")) {
+ closeCartModal();
+ return;
+ }
- const 상세페이지_로딩완료 = `
-
-
-
-
-
-
-
홈
-
-
-
-
- 생활/건강
-
-
-
-
-
- 생활용품
-
-
-
-
-
-
-
-
-
-
-
-
-
-
PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
4.0 (749개 리뷰)
-
-
-
- 220원
-
-
-
- 재고 107개
-
-
-
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장에 대한 상세 설명입니다. 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.
-
-
-
-
-
-
-
-
-
- 상품 목록으로 돌아가기
-
-
-
-
-
-
관련 상품
-
같은 카테고리의 다른 상품들
-
-
-
-
-
-
-
© 2025 항해플러스 프론트엔드 쇼핑몰
-
-
-
- `;
+ // 장바구니 모달 내 기능들
+ const $cartModal = document.getElementById("cart-modal-container");
+ if ($cartModal) {
+ // 전체 비우기
+ const $clearCartBtn = e.target.closest("#cart-modal-clear-cart-btn");
+ if ($clearCartBtn) {
+ const cart = store.getState().cart;
+ if (cart.length > 0) {
+ store.dispatch({ type: "clearCart" });
+ setSelectedIds([]); // 선택 상태도 초기화
+ showToast("장바구니가 비워졌습니다", "info");
+ }
+ return;
+ }
- const _404_ = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 404
-
-
-
-
-
-
-
-
- 페이지를 찾을 수 없습니다
-
-
-
-
-
-
홈으로
-
-
- `;
+ // 선택 삭제
+ const $removeSelectedBtn = e.target.closest("#cart-modal-remove-selected-btn");
+ if ($removeSelectedBtn) {
+ const selectedIds = getSelectedIds();
+ if (selectedIds.length > 0) {
+ selectedIds.forEach((productId) => {
+ store.dispatch({ type: "removeFromCart", payload: productId });
+ });
+ setSelectedIds([]);
+ showToast(`${selectedIds.length}개 상품이 삭제되었습니다`, "info");
+ }
+ return;
+ }
- document.body.innerHTML = `
- ${상품목록_레이아웃_로딩}
-
- ${상품목록_레이아웃_로딩완료}
-
- ${상품목록_레이아웃_카테고리_1Depth}
-
- ${상품목록_레이아웃_카테고리_2Depth}
-
- ${토스트}
-
- ${장바구니_비어있음}
-
- ${장바구니_선택없음}
-
- ${장바구니_선택있음}
-
- ${상세페이지_로딩}
-
- ${상세페이지_로딩완료}
-
- ${_404_}
- `;
-}
+ // 수량 증가
+ const $increaseBtn = e.target.closest(".quantity-increase-btn");
+ if ($increaseBtn) {
+ const productId = $increaseBtn.dataset.productId;
+ const cart = store.getState().cart;
+ const item = cart.find((item) => item.id === productId);
+ if (item) {
+ store.dispatch({
+ type: "updateQuantity",
+ payload: { productId, quantity: item.quantity + 1 },
+ });
+ }
+ return;
+ }
+
+ // 수량 감소
+ const $decreaseBtn = e.target.closest(".quantity-decrease-btn");
+ if ($decreaseBtn) {
+ const productId = $decreaseBtn.dataset.productId;
+ const cart = store.getState().cart;
+ const item = cart.find((item) => item.id === productId);
+ if (item && item.quantity > 1) {
+ store.dispatch({
+ type: "updateQuantity",
+ payload: { productId, quantity: item.quantity - 1 },
+ });
+ }
+ return;
+ }
+
+ // 개별 삭제
+ const $removeBtn = e.target.closest(".cart-item-remove-btn");
+ if ($removeBtn) {
+ const productId = $removeBtn.dataset.productId;
+ store.dispatch({ type: "removeFromCart", payload: productId });
+ // 선택 상태에서도 제거
+ const selectedIds = getSelectedIds();
+ setSelectedIds(selectedIds.filter((id) => id !== productId));
+ showToast("상품이 삭제되었습니다", "info");
+ return;
+ }
+
+ // 상품 이미지/제목 클릭 - 상세 페이지로 이동
+ const $cartItemImage = e.target.closest(".cart-item-image");
+ const $cartItemTitle = e.target.closest(".cart-item-title");
+ if ($cartItemImage || $cartItemTitle) {
+ const productId = ($cartItemImage || $cartItemTitle).dataset.productId;
+ closeCartModal();
+ router.push(`/product/${productId}`);
+ return;
+ }
+ }
+
+ // 장바구니 담기 버튼 (상품 카드 클릭보다 먼저 체크)
+ const $addToCartBtn = e.target.closest(".add-to-cart-btn");
+ if ($addToCartBtn) {
+ e.stopPropagation(); // 상품 카드 클릭 이벤트 방지
+ const productData = $addToCartBtn.dataset.product;
+ if (productData) {
+ const product = JSON.parse(productData);
+ store.dispatch({ type: "addToCart", payload: product });
+ showToast("장바구니에 추가되었습니다", "success");
+ }
+ return;
+ }
+
+ // 상품 카드 클릭
+ const $productCard = e.target.closest(".product-card");
+ if ($productCard) {
+ const productId = $productCard.dataset.productId;
+ router.push(`/product/${productId}`);
+ return;
+ }
+
+ // 관련 상품 클릭
+ const $relatedProduct = e.target.closest(".related-product-card");
+ if ($relatedProduct) {
+ const productId = $relatedProduct.dataset.productId;
+ router.push(`/product/${productId}`);
+ return;
+ }
+
+ // 상품 목록으로 돌아가기
+ if (e.target.closest(".go-to-product-list")) {
+ // 히스토리가 있으면 뒤로가기, 없으면 홈으로
+ if (window.history.length > 1) {
+ window.history.back();
+ } else {
+ router.push("/");
+ }
+ return;
+ }
+
+ // 상세 페이지 브레드크럼 - 카테고리 클릭
+ const $breadcrumbLink = e.target.closest(".breadcrumb-link");
+ if ($breadcrumbLink) {
+ const category1 = $breadcrumbLink.dataset.category1;
+ const category2 = $breadcrumbLink.dataset.category2;
+
+ // category2 클릭 시: category1 + category2 필터
+ if (category2) {
+ // category1도 함께 전달해야 하므로 상위 요소에서 찾기
+ const $nav = $breadcrumbLink.closest("nav");
+ const $category1Btn = $nav.querySelector("[data-category1]");
+ const cat1Value = $category1Btn ? $category1Btn.dataset.category1 : "";
+ router.push(`/?category1=${cat1Value}&category2=${category2}`);
+ }
+ // category1 클릭 시: category1만 필터
+ else if (category1) {
+ router.push(`/?category1=${category1}`);
+ }
+ return;
+ }
+
+ // 상세 페이지 - 수량 증가
+ const $quantityIncrease = e.target.closest("#quantity-increase");
+ if ($quantityIncrease) {
+ const $quantityInput = document.getElementById("quantity-input");
+ if ($quantityInput) {
+ const currentValue = parseInt($quantityInput.value);
+ const maxValue = parseInt($quantityInput.max);
+ if (currentValue < maxValue) {
+ $quantityInput.value = currentValue + 1;
+ }
+ }
+ return;
+ }
+
+ // 상세 페이지 - 수량 감소
+ const $quantityDecrease = e.target.closest("#quantity-decrease");
+ if ($quantityDecrease) {
+ const $quantityInput = document.getElementById("quantity-input");
+ if ($quantityInput) {
+ const currentValue = parseInt($quantityInput.value);
+ const minValue = parseInt($quantityInput.min);
+ if (currentValue > minValue) {
+ $quantityInput.value = currentValue - 1;
+ }
+ }
+ return;
+ }
+
+ // 상세 페이지 - 장바구니 담기
+ const $detailAddToCartBtn = e.target.closest("#add-to-cart-btn");
+ if ($detailAddToCartBtn) {
+ const $quantityInput = document.getElementById("quantity-input");
+ const quantity = $quantityInput ? parseInt($quantityInput.value) : 1;
+ const productId = $detailAddToCartBtn.dataset.productId;
+
+ // store에서 현재 상품 정보 가져오기
+ const { product } = store.getState().detail;
+ if (product) {
+ // 장바구니에 추가 (또는 기존 수량 업데이트)
+ const cart = store.getState().cart;
+ const existingItem = cart.find((item) => item.id === productId);
+
+ if (existingItem) {
+ // 이미 있으면 수량 업데이트
+ store.dispatch({
+ type: "updateQuantity",
+ payload: { productId, quantity: existingItem.quantity + quantity },
+ });
+ } else {
+ // 없으면 새로 추가
+ store.dispatch({
+ type: "addToCart",
+ payload: {
+ id: productId,
+ title: product.title,
+ image: product.image,
+ lprice: product.lprice,
+ brand: product.brand,
+ quantity: quantity,
+ },
+ });
+ }
+ showToast(`${quantity}개 상품이 장바구니에 추가되었습니다`, "success");
+ }
+ return;
+ }
+
+ // 카테고리 필터 - 1depth
+ const $category1Btn = e.target.closest(".category1-filter-btn");
+ if ($category1Btn) {
+ const category1 = $category1Btn.dataset.category1;
+ router.updateQuery({ category1, category2: undefined });
+ return;
+ }
+
+ // 카테고리 필터 - 2depth
+ const $category2Btn = e.target.closest(".category2-filter-btn");
+ if ($category2Btn) {
+ const category2 = $category2Btn.dataset.category2;
+ router.updateQuery({ category2 });
+ return;
+ }
+
+ // breadcrumb - 전체 (카테고리 초기화)
+ const $resetBtn = e.target.closest('[data-breadcrumb="reset"]');
+ if ($resetBtn) {
+ router.updateQuery({ category1: undefined, category2: undefined });
+ return;
+ }
+
+ // breadcrumb - category1 클릭 (category2 초기화)
+ const $breadcrumbCat1 = e.target.closest('[data-breadcrumb="category1"]');
+ if ($breadcrumbCat1) {
+ router.updateQuery({ category2: undefined });
+ return;
+ }
+});
+
+// 검색 기능 및 ESC 키 핸들러
+document.body.addEventListener("keydown", (e) => {
+ // ESC 키로 모달 닫기
+ if (e.key === "Escape") {
+ closeCartModal();
+ return;
+ }
+
+ // 검색 기능
+ const $searchInput = e.target.closest("#search-input");
+ if ($searchInput && e.key === "Enter") {
+ const searchValue = $searchInput.value.trim();
+ router.updateQuery({ search: searchValue || undefined });
+ }
+});
+
+// 필터/정렬 및 체크박스 기능
+document.body.addEventListener("change", (e) => {
+ // 장바구니 모달 체크박스
+ const $cartModal = document.getElementById("cart-modal-container");
+ if ($cartModal) {
+ // 전체 선택 체크박스
+ const $selectAllCheckbox = e.target.closest("#cart-modal-select-all-checkbox");
+ if ($selectAllCheckbox) {
+ const cart = store.getState().cart;
+ if ($selectAllCheckbox.checked) {
+ // 전체 선택
+ setSelectedIds(cart.map((item) => item.id));
+ } else {
+ // 전체 해제
+ setSelectedIds([]);
+ }
+ return;
+ }
+
+ // 개별 체크박스
+ const $itemCheckbox = e.target.closest(".cart-item-checkbox");
+ if ($itemCheckbox) {
+ const productId = $itemCheckbox.dataset.productId;
+ const selectedIds = getSelectedIds();
+
+ if ($itemCheckbox.checked) {
+ // 선택 추가
+ setSelectedIds([...selectedIds, productId]);
+ } else {
+ // 선택 제거
+ setSelectedIds(selectedIds.filter((id) => id !== productId));
+ }
+ return;
+ }
+ }
+
+ // 페이지당 상품 수 변경
+ const $limitSelect = e.target.closest("#limit-select");
+ if ($limitSelect) {
+ router.updateQuery({ limit: $limitSelect.value });
+ return;
+ }
+
+ // 정렬 변경
+ const $sortSelect = e.target.closest("#sort-select");
+ if ($sortSelect) {
+ router.updateQuery({ sort: $sortSelect.value });
+ return;
+ }
+});
// 애플리케이션 시작
+const main = async () => {
+ const basePath = import.meta.env.BASE_URL;
+ const pathName = location.pathname;
+ const relativePath = pathName.replace(basePath, "/").replace(/\/$/, "") || "/";
+ router.push(relativePath + location.search);
+};
+
if (import.meta.env.MODE !== "test") {
enableMocking().then(main);
} else {
diff --git a/src/pages/DetailPage.js b/src/pages/DetailPage.js
new file mode 100644
index 00000000..b1621849
--- /dev/null
+++ b/src/pages/DetailPage.js
@@ -0,0 +1,357 @@
+import { withLifecycle } from "../core/lifecycle.js";
+import * as router from "../core/router.js";
+import { store } from "../state/store.js";
+import { getProduct, getProducts } from "../api/productApi.js";
+import { Footer } from "../components/Footer.js";
+import { showToast } from "../utils/toast.js";
+
+/**
+ * 뒤로가기 처리
+ */
+function handleBackButton() {
+ // 히스토리가 2개 이상이면 back() (앱 내부 탐색한 것)
+ if (window.history.length > 1) {
+ window.history.back();
+ } else {
+ // 히스토리가 1개면 직접 접근이므로 홈으로
+ router.push("/");
+ }
+}
+
+/**
+ * 상품 상세 정보 로드
+ */
+async function loadProductDetail(productId) {
+ store.dispatch({ type: "pendingProduct" });
+
+ try {
+ const product = await getProduct(productId);
+ store.dispatch({ type: "setProduct", payload: product });
+
+ // 관련 상품 로드 (같은 category2, 현재 상품 제외)
+ loadRelatedProducts(product.category2, productId);
+ } catch (error) {
+ store.dispatch({ type: "errorProduct", payload: error });
+ showToast("상품을 불러올 수 없습니다", "error");
+ }
+}
+
+/**
+ * 관련 상품 로드
+ */
+async function loadRelatedProducts(category2, excludeProductId) {
+ try {
+ const data = await getProducts({ category2, limit: 10 });
+ // 현재 상품 제외
+ const relatedProducts = data.products.filter((p) => p.productId !== excludeProductId);
+
+ store.dispatch({ type: "setRelatedProducts", payload: relatedProducts });
+ } catch (error) {
+ console.error("관련 상품 로드 실패:", error);
+ }
+}
+
+/**
+ * DetailPage - withLifecycle 적용
+ */
+export const DetailPage = withLifecycle(
+ {
+ // 컴포넌트 초기화 시 1번만 실행
+ mount() {
+ const { id } = router.getCurrentRoute().params;
+ loadProductDetail(id);
+
+ // 이벤트 리스너 등록
+ document.addEventListener("click", (e) => {
+ // 재시도 버튼
+ if (e.target.id === "detail-retry-btn") {
+ const currentId = router.getCurrentRoute().params.id;
+ loadProductDetail(currentId);
+ }
+
+ // 뒤로가기 버튼
+ if (e.target.closest("#detail-back-btn")) {
+ handleBackButton();
+ }
+ });
+ },
+
+ // URL 파라미터 변경 감지 (관련 상품 클릭 시)
+ watchs: [
+ {
+ target() {
+ return router.getCurrentRoute().params.id;
+ },
+ callback(newId) {
+ loadProductDetail(newId);
+ },
+ },
+ ],
+ },
+
+ // 렌더링 함수
+ () => {
+ const { product, loading, relatedProducts, error } = store.getState().detail;
+ const cart = store.getState().cart;
+ const cartCount = cart.length;
+
+ return /* HTML */ `
+
+
+
+
+
+
+
+
+
+
+
+
+ ${cartCount > 0
+ ? /* HTML */ `
+
+ ${cartCount}
+
+ `
+ : ""}
+
+
+
+
+
+
+ ${error
+ ? /* HTML */ `
+
+
+
+
+
+
상품 정보를 불러올 수 없습니다
+
네트워크 연결을 확인해주세요
+
+ 다시 시도
+
+
+
+ `
+ : loading
+ ? /* HTML */ `
+
+
+
+ `
+ : /* HTML */ `
+
+
+
+
홈
+
+
+
+
${product.category1}
+
+
+
+
${product.category2}
+
+
+
+
+
+
+
+
+
+
+
+
+
${product.title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${product.rating} (${product.reviewCount}개 리뷰)
+
+
+
+ ${Number(product.lprice).toLocaleString()}원
+
+
+
재고 ${product.stock}개
+
+
${product.description}
+
+
+
+
+
+
+
+
+ 상품 목록으로 돌아가기
+
+
+
+ ${relatedProducts && relatedProducts.length > 0
+ ? /* HTML */ `
+
+
+
관련 상품
+
같은 카테고리의 다른 상품들
+
+
+
+ ${relatedProducts
+ .map(
+ (relatedProduct) => /* HTML */ `
+
+ `,
+ )
+ .join("")}
+
+
+
+ `
+ : ""}
+ `}
+ ${Footer()}
+
+ `;
+ },
+);
diff --git a/src/pages/HomePage.js b/src/pages/HomePage.js
new file mode 100644
index 00000000..5c11fce2
--- /dev/null
+++ b/src/pages/HomePage.js
@@ -0,0 +1,191 @@
+import { withLifecycle } from "../core/lifecycle.js";
+import * as router from "../core/router.js";
+import { store } from "../state/store.js";
+import { getProducts, getCategories } from "../api/productApi.js";
+import { PageLayout } from "./PageLayout.js";
+import { SearchForm, ProductList } from "../components/index.js";
+import { setupInfiniteScroll, teardownInfiniteScroll } from "../utils/infiniteScroll.js";
+import { showToast } from "../utils/toast.js";
+
+// 무한 스크롤 상태
+let isLoadingMore = false;
+let hasMoreProducts = true;
+let scrollObserver = null;
+
+/**
+ * 상품 목록 로드
+ * 현재 쿼리 파라미터를 읽어서 API 호출
+ */
+async function loadProducts() {
+ const query = router.getCurrentRoute().query;
+
+ // 무한 스크롤 상태 초기화
+ hasMoreProducts = true;
+
+ store.dispatch({ type: "pendingProducts" });
+
+ try {
+ const data = await getProducts(query);
+ store.dispatch({ type: "setProducts", payload: data });
+
+ // 더 불러올 상품이 있는지 확인 (hasNext 사용)
+ const { pagination } = data;
+ hasMoreProducts = pagination.hasNext || false;
+ } catch (error) {
+ store.dispatch({ type: "errorProducts", payload: error });
+ showToast("상품을 불러올 수 없습니다", "error");
+ }
+}
+
+/**
+ * 다음 페이지 상품 로드 (무한 스크롤)
+ */
+async function loadMoreProducts() {
+ // 에러 상태거나 이미 로딩 중이거나 더 이상 상품이 없으면 중단
+ let currentState = store.getState();
+ if (currentState.home.error || currentState.home.loading || isLoadingMore || !hasMoreProducts) {
+ return;
+ }
+
+ isLoadingMore = true;
+
+ // 로딩 상태를 스토어에 반영
+ currentState = store.getState();
+ store.dispatch({
+ type: "setProducts",
+ payload: {
+ products: currentState.home.products,
+ filters: currentState.home.filters,
+ pagination: { ...currentState.home.pagination, isLoadingMore: true },
+ },
+ });
+
+ // 현재 설정된 limit 값 사용 (드롭다운 선택값)
+ const limit = currentState.home.pagination.limit || 20;
+ const skip = currentState.home.products.length; // 현재까지 로드된 상품 개수
+
+ const query = router.getCurrentRoute().query;
+ // query에서 skip과 limit을 제거하고 새로운 값 사용
+ // eslint-disable-next-line no-unused-vars
+ const { skip: _skip, limit: _limit, ...restQuery } = query;
+
+ try {
+ // 계산된 skip과 limit을 사용
+ const data = await getProducts({ ...restQuery, skip, limit });
+ const { products: newProducts, pagination } = data;
+
+ // 기존 상품에 새 상품 추가
+ const allProducts = [...currentState.home.products, ...newProducts];
+
+ store.dispatch({
+ type: "setProducts",
+ payload: {
+ products: allProducts,
+ filters: data.filters,
+ pagination: { ...pagination, isLoadingMore: false },
+ },
+ });
+
+ // 더 불러올 상품이 있는지 확인 (hasNext 사용)
+ hasMoreProducts = pagination.hasNext || false;
+ } catch (error) {
+ console.error("Failed to load more products:", error);
+ // 무한 스크롤 에러는 전체 에러로 처리하지 않고 로딩 상태만 해제
+ // (초기 로딩 실패와 구분)
+ const freshState = store.getState();
+ store.dispatch({
+ type: "setProducts",
+ payload: {
+ products: freshState.home.products,
+ filters: freshState.home.filters,
+ pagination: { ...freshState.home.pagination, isLoadingMore: false },
+ },
+ });
+ } finally {
+ isLoadingMore = false;
+ }
+}
+
+/**
+ * HomePage - withLifecycle 적용
+ */
+export const Homepage = withLifecycle(
+ {
+ // 컴포넌트 초기화 시 1번만 실행
+ async mount() {
+ loadProducts();
+
+ // 카테고리 로드 (1회만)
+ const categories = await getCategories();
+ store.dispatch({ type: "setCategories", payload: categories });
+
+ // 재시도 버튼 이벤트 리스너
+ document.addEventListener("click", (e) => {
+ if (e.target.id === "retry-btn") {
+ loadProducts();
+ }
+ });
+ },
+
+ // 컴포넌트 제거 시
+ unmount() {
+ // 무한 스크롤 해제
+ teardownInfiniteScroll(scrollObserver);
+ scrollObserver = null;
+ },
+
+ // 쿼리 파라미터 변경 감지
+ watchs: [
+ {
+ target() {
+ return router.getCurrentRoute().query;
+ },
+ callback() {
+ loadProducts();
+ },
+ },
+ // 상품 목록이 업데이트될 때마다 observer 재연결
+ {
+ target() {
+ const state = store.getState().home;
+ return { length: state.products.length, error: state.error };
+ },
+ callback() {
+ const { error } = store.getState().home;
+
+ // 에러 상태면 무한 스크롤 해제
+ if (error) {
+ if (scrollObserver) {
+ teardownInfiniteScroll(scrollObserver);
+ scrollObserver = null;
+ }
+ return;
+ }
+
+ // 기존 observer 해제 후 재연결
+ if (scrollObserver) {
+ teardownInfiniteScroll(scrollObserver);
+ }
+ setTimeout(() => {
+ scrollObserver = setupInfiniteScroll("#scroll-trigger", loadMoreProducts);
+ }, 100);
+ },
+ },
+ ],
+ },
+
+ // 렌더링 함수
+ () => {
+ const { products, loading, pagination, error } = store.getState().home;
+ const { categories } = store.getState();
+ const filters = router.getCurrentRoute().query;
+
+ return PageLayout({
+ children: /* HTML */ `
+ ${SearchForm({ filters, categories })} ${ProductList({ loading, products, pagination, error })}
+
+
+ `,
+ });
+ },
+);
diff --git a/src/pages/NotFoundPage.js b/src/pages/NotFoundPage.js
new file mode 100644
index 00000000..1576ce2a
--- /dev/null
+++ b/src/pages/NotFoundPage.js
@@ -0,0 +1,66 @@
+import { PageLayout } from "./PageLayout.js";
+
+/**
+ * 404 Not Found 페이지
+ */
+export function NotFoundPage() {
+ return PageLayout({
+ children: /* HTML */ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 404
+
+
+
+
+
+
+
+
+
+
+ 페이지를 찾을 수 없습니다
+
+
+
+
+
+
+
홈으로
+
+ `,
+ });
+}
diff --git a/src/pages/PageLayout.js b/src/pages/PageLayout.js
new file mode 100644
index 00000000..760580aa
--- /dev/null
+++ b/src/pages/PageLayout.js
@@ -0,0 +1,15 @@
+import { Header, Footer } from "../components/index.js";
+import { store } from "../state/store.js";
+
+export const PageLayout = ({ children }) => {
+ const cart = store.getState().cart;
+ const cartCount = cart.length;
+
+ return /* HTML */ `
+
+ ${Header({ cartCount })}
+ ${children}
+ ${Footer()}
+
+ `;
+};
diff --git a/src/state/store.js b/src/state/store.js
new file mode 100644
index 00000000..d03bfaae
--- /dev/null
+++ b/src/state/store.js
@@ -0,0 +1,198 @@
+import { createStore } from "../core/store.js";
+import { save, load } from "../core/storage.js";
+
+/**
+ * 앱 전역 Store
+ *
+ * 상태 구조:
+ * - home: 홈페이지 상태 (상품 목록)
+ * - detail: 상세 페이지 상태 (단일 상품)
+ * - cart: 장바구니
+ *
+ * loading/error 패턴:
+ * - pending: 로딩 시작
+ * - set: 성공
+ * - error: 에러
+ */
+export const store = createStore({
+ // ===== 초기 상태 =====
+ state: {
+ // 홈페이지 (상품 목록)
+ home: {
+ products: [],
+ filters: {},
+ pagination: {},
+ loading: false,
+ error: null,
+ },
+
+ // 카테고리
+ categories: {},
+
+ // 상세 페이지 (단일 상품)
+ detail: {
+ product: null,
+ relatedProducts: [],
+ loading: false,
+ error: null,
+ },
+
+ // 장바구니 (localStorage에서 복원)
+ cart: load("shopping_cart") || [],
+ },
+
+ // ===== 액션 =====
+ actions: {
+ // ━━━━━ Home (상품 목록) ━━━━━
+
+ // 로딩 시작
+ pendingProducts(setState) {
+ setState({
+ home: {
+ products: [],
+ filters: {},
+ pagination: {},
+ loading: true,
+ error: null,
+ },
+ });
+ },
+
+ // 성공
+ setProducts(setState, data) {
+ setState({
+ home: {
+ products: data.products || [],
+ filters: data.filters || {},
+ pagination: data.pagination || {},
+ loading: false,
+ error: null,
+ },
+ });
+ },
+
+ // 에러
+ errorProducts(setState, error) {
+ setState({
+ home: {
+ products: [],
+ filters: {},
+ pagination: {},
+ loading: false,
+ error: error.message || "상품 로딩 실패",
+ },
+ });
+ },
+
+ // ━━━━━ Categories (카테고리) ━━━━━
+
+ // 카테고리 설정
+ setCategories(setState, categories) {
+ setState({ categories });
+ },
+
+ // ━━━━━ Detail (상품 상세) ━━━━━
+
+ // 로딩 시작
+ pendingProduct(setState) {
+ setState({
+ detail: {
+ product: null,
+ relatedProducts: [],
+ loading: true,
+ error: null,
+ },
+ });
+ },
+
+ // 성공
+ setProduct(setState, product, getState) {
+ const currentState = getState();
+ setState({
+ detail: {
+ product,
+ relatedProducts: currentState.detail.relatedProducts || [],
+ loading: false,
+ error: null,
+ },
+ });
+ },
+
+ // 관련 상품 설정
+ setRelatedProducts(setState, relatedProducts, getState) {
+ const currentState = getState();
+ setState({
+ detail: {
+ ...currentState.detail,
+ relatedProducts,
+ },
+ });
+ },
+
+ // 에러
+ errorProduct(setState, error) {
+ setState({
+ detail: {
+ product: null,
+ relatedProducts: [],
+ loading: false,
+ error: error.message || "상품 상세 로딩 실패",
+ },
+ });
+ },
+
+ // ━━━━━ Cart (장바구니) ━━━━━
+
+ // 장바구니에 상품 추가
+ addToCart(setState, product, getState) {
+ const currentState = getState();
+ const cart = [...currentState.cart];
+
+ // 이미 있는 상품인지 확인
+ const existing = cart.find((item) => item.id === product.id);
+
+ if (existing) {
+ // 수량만 증가 (product.quantity가 있으면 그만큼, 없으면 1)
+ existing.quantity += product.quantity || 1;
+ } else {
+ // 새로 추가 (product.quantity가 있으면 그대로, 없으면 1)
+ cart.push({ ...product, quantity: product.quantity || 1 });
+ }
+
+ // localStorage에 저장
+ save("shopping_cart", cart);
+
+ // 상태 업데이트
+ setState({ cart });
+ },
+
+ // 장바구니에서 상품 제거
+ removeFromCart(setState, productId, getState) {
+ const currentState = getState();
+ const cart = currentState.cart.filter((item) => item.id !== productId);
+
+ save("shopping_cart", cart);
+ setState({ cart });
+ },
+
+ // 수량 변경
+ updateQuantity(setState, { productId, quantity }, getState) {
+ const currentState = getState();
+ const cart = [...currentState.cart];
+ const item = cart.find((item) => item.id === productId);
+
+ if (item) {
+ // 최소 수량은 1
+ item.quantity = Math.max(1, quantity);
+ save("shopping_cart", cart);
+ setState({ cart });
+ }
+ },
+
+ // 장바구니 전체 비우기
+ clearCart(setState) {
+ save("shopping_cart", []);
+ setState({ cart: [] });
+ },
+ },
+});
diff --git a/src/template.js b/src/template.js
new file mode 100644
index 00000000..5be31dc3
--- /dev/null
+++ b/src/template.js
@@ -0,0 +1,1460 @@
+const 상품목록_레이아웃_로딩 = /* HTML */ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 개수:
+
+ 10개
+ 20개
+ 50개
+ 100개
+
+
+
+
+ 정렬:
+
+ 가격 낮은순
+ 가격 높은순
+ 이름순
+ 이름 역순
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
상품을 불러오는 중...
+
+
+
+
+
+
+
+
© 2025 항해플러스 프론트엔드 쇼핑몰
+
+
+
+`;
+
+const 상품목록_레이아웃_로딩완료 = /* HTML */ `
+
+
+
+
+
+
+
+
+
+
+
+
+ 카테고리:
+ 전체
+
+
+
+
+ 생활/건강
+
+
+ 디지털/가전
+
+
+
+
+
+
+
+
+ 개수:
+
+ 10개
+ 20개
+ 50개
+ 100개
+
+
+
+
+ 정렬:
+
+ 가격 낮은순
+ 가격 높은순
+ 이름순
+ 이름 역순
+
+
+
+
+
+
+
+
+
+
총 340개 의 상품
+
+
+
+
+
+
+
+
+
+
+
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
+
+
+
220원
+
+
+
+ 장바구니 담기
+
+
+
+
+
+
+
+
+
+
+
+
+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
+
+
이지웨이건축자재
+
230원
+
+
+
+ 장바구니 담기
+
+
+
+
+
+
모든 상품을 확인했습니다
+
+
+
+
+
+
© 2025 항해플러스 프론트엔드 쇼핑몰
+
+
+
+`;
+
+const 상품목록_레이아웃_카테고리_1Depth = /* HTML */ `
+
+
+
+
+
+
+
+
+
+
+
+ 카테고리:
+ 전체 >
+ 생활/건강
+
+
+
+
+
+ 생활용품
+
+
+ 주방용품
+
+
+ 문구/사무용품
+
+
+
+
+
+
+
+
+
+ 개수:
+
+ 10개
+ 20개
+ 50개
+ 100개
+
+
+
+
+ 정렬:
+
+ 가격 낮은순
+ 가격 높은순
+ 이름순
+ 이름 역순
+
+
+
+
+
+
+`;
+
+const 상품목록_레이아웃_카테고리_2Depth = /* HTML */ `
+
+
+
+
+
+
+
+
+
+
+
+ 카테고리:
+ 전체 >
+ 생활/건강 > 주방용품
+
+
+
+
+ 생활용품
+
+
+ 주방용품
+
+
+ 문구/사무용품
+
+
+
+
+
+
+
+
+
+ 개수:
+
+ 10개
+ 20개
+ 50개
+ 100개
+
+
+
+
+ 정렬:
+
+ 가격 낮은순
+ 가격 높은순
+ 이름순
+ 이름 역순
+
+
+
+
+
+
+`;
+
+const 토스트 = /* HTML */ `
+
+
+
+
장바구니에 추가되었습니다
+
+
+
+
+
+
+
+
+
+
선택된 상품들이 삭제되었습니다
+
+
+
+
+
+
+
+
+
+
오류가 발생했습니다.
+
+
+
+
+
+
+
+`;
+
+const 장바구니_비어있음 = /* HTML */ `
+
+
+
+
+
+
+
+
+ 장바구니
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
장바구니가 비어있습니다
+
원하는 상품을 담아보세요!
+
+
+
+
+
+`;
+
+const 장바구니_선택없음 = /* HTML */ `
+
+
+
+
+
+
+
+
+ 장바구니
+ (2)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 전체선택 (2개)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
+
+
220원
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
+
+
230원
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 총 금액
+ 670원
+
+
+
+
+
+ 전체 비우기
+
+
+ 구매하기
+
+
+
+
+
+
+`;
+
+const 장바구니_선택있음 = /* HTML */ `
+
+
+
+
+
+
+
+
+ 장바구니
+ (2)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 전체선택 (2개)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
+
+
220원
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
+
+
230원
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 선택한 상품 (1개)
+ 440원
+
+
+
+ 총 금액
+ 670원
+
+
+
+
+ 선택한 상품 삭제 (1개)
+
+
+
+ 전체 비우기
+
+
+ 구매하기
+
+
+
+
+
+
+`;
+
+const 상세페이지_로딩 = /* HTML */ `
+
+
+
+
+
+
+
+
© 2025 항해플러스 프론트엔드 쇼핑몰
+
+
+
+`;
+
+const 상세페이지_로딩완료 = /* HTML */ `
+
+
+
+
+
+
+
홈
+
+
+
+
생활/건강
+
+
+
+
생활용품
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
4.0 (749개 리뷰)
+
+
+
+ 220원
+
+
+
재고 107개
+
+
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장에 대한 상세 설명입니다.
+ 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.
+
+
+
+
+
+
+
+
+
+ 상품 목록으로 돌아가기
+
+
+
+
+
+
관련 상품
+
같은 카테고리의 다른 상품들
+
+
+
+
+
+
+
© 2025 항해플러스 프론트엔드 쇼핑몰
+
+
+
+`;
+
+const _404_ = /* HTML */ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 404
+
+
+
+
+
+
+
+
+
+
+ 페이지를 찾을 수 없습니다
+
+
+
+
+
+
+
홈으로
+
+
+`;
+
+document.body.innerHTML = /* HTML */ `
+ ${상품목록_레이아웃_로딩}
+
+ ${상품목록_레이아웃_로딩완료}
+
+ ${상품목록_레이아웃_카테고리_1Depth}
+
+ ${상품목록_레이아웃_카테고리_2Depth}
+
+ ${토스트}
+
+ ${장바구니_비어있음}
+
+ ${장바구니_선택없음}
+
+ ${장바구니_선택있음}
+
+ ${상세페이지_로딩}
+
+ ${상세페이지_로딩완료}
+
+ ${_404_}
+`;
diff --git a/src/utils/cartModal.js b/src/utils/cartModal.js
new file mode 100644
index 00000000..fd2c7ea2
--- /dev/null
+++ b/src/utils/cartModal.js
@@ -0,0 +1,93 @@
+import { CartModal } from "../components/CartModal.js";
+import { store } from "../state/store.js";
+import { load, save } from "../core/storage.js";
+
+// 선택된 아이템 ID 목록 (모달 내 상태)
+let selectedIds = [];
+
+// store 구독 해제 함수
+let unsubscribe = null;
+
+/**
+ * 장바구니 모달 열기
+ */
+export function openCartModal() {
+ // 이미 열려있으면 무시
+ if (document.getElementById("cart-modal-container")) {
+ return;
+ }
+
+ // 장바구니 상태 가져오기
+ const cart = store.getState().cart;
+
+ // 선택 상태 복원 (localStorage에서)
+ const savedSelectedIds = load("cart_selected_ids") || [];
+ // 현재 장바구니에 있는 상품들의 ID만 유효한 선택으로 필터링
+ selectedIds = savedSelectedIds.filter((id) => cart.some((item) => item.id === id));
+
+ // 모달 컨테이너 생성
+ const modalContainer = document.createElement("div");
+ modalContainer.id = "cart-modal-container";
+ modalContainer.className = "fixed inset-0 z-50 bg-black bg-opacity-50";
+ modalContainer.innerHTML = CartModal({ cart, selectedIds });
+
+ // #root에 추가 (기존 내용 유지)
+ const root = document.getElementById("root");
+ if (root) {
+ root.appendChild(modalContainer);
+ }
+
+ // body 스크롤 방지
+ document.body.style.overflow = "hidden";
+
+ // store 구독 - 장바구니 변경 시 모달 자동 업데이트
+ unsubscribe = store.subscribe(() => {
+ const modalContainer = document.getElementById("cart-modal-container");
+ if (modalContainer) {
+ const cart = store.getState().cart;
+ modalContainer.innerHTML = CartModal({ cart, selectedIds });
+ }
+ });
+}
+
+/**
+ * 장바구니 모달 닫기
+ */
+export function closeCartModal() {
+ const modalContainer = document.getElementById("cart-modal-container");
+ if (modalContainer) {
+ modalContainer.remove();
+
+ // body 스크롤 복원
+ document.body.style.overflow = "";
+ // store 구독 해제
+ if (unsubscribe) {
+ unsubscribe();
+ unsubscribe = null;
+ }
+ // 선택 상태 localStorage에 저장 (새로고침 후에도 유지)
+ save("cart_selected_ids", selectedIds);
+ }
+}
+
+/**
+ * 선택된 아이템 ID 가져오기
+ */
+export function getSelectedIds() {
+ return selectedIds;
+}
+
+/**
+ * 선택된 아이템 ID 설정 및 모달 업데이트
+ */
+export function setSelectedIds(newSelectedIds) {
+ selectedIds = newSelectedIds;
+ // localStorage에 저장
+ save("cart_selected_ids", selectedIds);
+
+ const modalContainer = document.getElementById("cart-modal-container");
+ if (modalContainer) {
+ const cart = store.getState().cart;
+ modalContainer.innerHTML = CartModal({ cart, selectedIds });
+ }
+}
diff --git a/src/utils/infiniteScroll.js b/src/utils/infiniteScroll.js
new file mode 100644
index 00000000..82059a67
--- /dev/null
+++ b/src/utils/infiniteScroll.js
@@ -0,0 +1,39 @@
+/**
+ * 무한 스크롤 초기화
+ * @param {string} selector - 감지할 요소 선택자
+ * @param {Function} callback - 화면에 보일 때 실행할 함수
+ * @returns {IntersectionObserver | null}
+ */
+export const setupInfiniteScroll = (selector, callback) => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ callback();
+ }
+ });
+ },
+ {
+ root: null,
+ rootMargin: "100px",
+ threshold: 0.1,
+ },
+ );
+
+ const $target = document.querySelector(selector);
+ if ($target) {
+ observer.observe($target);
+ }
+
+ return observer;
+};
+
+/**
+ * 무한 스크롤 해제
+ * @param {IntersectionObserver} observer
+ */
+export const teardownInfiniteScroll = (observer) => {
+ if (observer) {
+ observer.disconnect();
+ }
+};
diff --git a/src/utils/toast.js b/src/utils/toast.js
new file mode 100644
index 00000000..caba4683
--- /dev/null
+++ b/src/utils/toast.js
@@ -0,0 +1,105 @@
+/**
+ * 토스트 메시지 표시
+ * @param {string} message - 표시할 메시지
+ * @param {string} type - 토스트 타입 ('success' | 'info' | 'error')
+ */
+export function showToast(message, type = "success") {
+ // 기존 토스트가 있으면 모두 제거
+ const existingContainer = document.getElementById("toast-container");
+ if (existingContainer) {
+ existingContainer.remove();
+ }
+
+ // 토스트 컨테이너 생성
+ const toastContainer = document.createElement("div");
+ toastContainer.id = "toast-container";
+ toastContainer.className = "fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 flex flex-col gap-2";
+ document.body.appendChild(toastContainer);
+
+ // 타입별 스타일과 아이콘
+ const styles = {
+ success: {
+ bg: "bg-green-600",
+ icon: /* HTML */ `
+
+
+
+ `,
+ },
+ info: {
+ bg: "bg-blue-600",
+ icon: /* HTML */ `
+
+
+
+ `,
+ },
+ error: {
+ bg: "bg-red-600",
+ icon: /* HTML */ `
+
+
+
+ `,
+ },
+ };
+
+ const style = styles[type] || styles.success;
+
+ // 토스트 요소 생성
+ const toast = document.createElement("div");
+ toast.className = `${style.bg} text-white px-4 py-3 rounded-lg shadow-lg flex items-center space-x-2 max-w-sm`;
+ toast.innerHTML = /* HTML */ `
+ ${style.icon}
+ ${message}
+
+
+
+
+
+ `;
+
+ // 닫기 버튼 이벤트
+ const closeBtn = toast.querySelector(".toast-close-btn");
+ closeBtn.addEventListener("click", () => removeToast(toast));
+
+ // 토스트 추가
+ toastContainer.appendChild(toast);
+
+ // 3초 후 자동 제거
+ const timeoutId = setTimeout(() => removeToast(toast), 3000);
+
+ // 토스트에 타이머 ID 저장 (나중에 취소할 수 있도록)
+ toast.dataset.timeoutId = timeoutId;
+}
+
+/**
+ * 토스트 제거
+ * @param {HTMLElement} toast - 제거할 토스트 요소
+ */
+function removeToast(toast) {
+ // 타이머 취소
+ if (toast.dataset.timeoutId) {
+ clearTimeout(Number(toast.dataset.timeoutId));
+ }
+
+ // fade-out 애니메이션
+ toast.style.opacity = "0";
+ toast.style.transform = "translateY(-20px)";
+ toast.style.transition = "all 0.3s ease-out";
+
+ setTimeout(() => {
+ toast.remove();
+
+ // 컨테이너가 비어있으면 제거
+ const container = document.getElementById("toast-container");
+ if (container && container.children.length === 0) {
+ container.remove();
+ }
+ }, 300);
+}
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 00000000..14d631dd
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from "vite";
+
+export default defineConfig(({ mode }) => ({
+ // 프로덕션 빌드(GitHub Pages)에만 base path 적용
+ base: mode === "production" ? "/front_7th_chapter2-1/" : "/",
+ publicDir: "public", // MSW mockServiceWorker.js 포함
+ build: {
+ outDir: "dist",
+ },
+}));