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} +
+ +
+

+ ${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}) +

+ +
+ +
+ +
+ +
+ +
+
+ ${cart.map((item) => CartItem({ ...item, checked: selectedIds.includes(item.id) })).join("")} +
+
+
+ +
+ ${hasSelection + ? /* HTML */ ` + +
+ 선택한 상품 (${selectedIds.length}개) + ${selectedAmount.toLocaleString()}원 +
+ ` + : ""} + +
+ 총 금액 + ${totalAmount.toLocaleString()}원 +
+ +
+ ${hasSelection + ? /* HTML */ ` + + ` + : ""} +
+ + +
+
+
+
+
+ `; +}; 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 */ ` + + `; +}; 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 */ ` +
+
+
+

+ 쇼핑몰 +

+
+ + +
+
+
+
+ `; +}; 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} +
+ +
+
+

${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 */ `>` + : ""} + ${selectedCategory2 + ? /* HTML */ `>${selectedCategory2}` + : ""} +
+
+ + +
+ ${!selectedCategory1 + ? // category1 버튼들 + category1Keys.length > 0 + ? category1Keys + .map( + (cat1) => /* HTML */ ` + + `, + ) + .join("") + : /* HTML */ `
카테고리 로딩 중...
` + : // category2 버튼들 + category2Keys + .map( + (cat2) => /* HTML */ ` + + `, + ) + .join("")} +
+
+
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ `; +}; 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 상품목록_레이아웃_로딩 = ` -
-
-
-
-

- 쇼핑몰 -

-
- - -
-
-
-
-
- -
- -
-
- -
- - - -
-
-
- -
- -
-
- - -
- -
-
카테고리 로딩 중...
-
- -
- -
- -
- - -
- -
- - -
-
-
-
- -
-
- -
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- - - - - 상품을 불러오는 중... -
-
-
-
-
- -
- `; +// 라우트 설정 +router.setup({ + "/": Homepage, + "/product/:id": DetailPage, + "*": NotFoundPage, +}); - const 상품목록_레이아웃_로딩완료 = ` -
-
-
-
-

- 쇼핑몰 -

-
- - -
-
-
-
-
- -
- -
-
- -
- - - -
-
-
- -
- -
-
- - -
- -
- - -
- -
- -
- -
- - -
- -
- - -
-
-
-
- -
-
- -
- 총 340개의 상품 -
- -
-
- -
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -
- -
-
-

- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -

-

-

- 220원 -

-
- - -
-
-
- -
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -
- -
-
-

- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -

-

이지웨이건축자재

-

- 230원 -

-
- - -
-
-
- -
- 모든 상품을 확인했습니다 -
-
-
-
- -
- `; +// 현재 컴포넌트 저장 (unmount 호출용) +let currentComponent = null; - const 상품목록_레이아웃_카테고리_1Depth = ` -
- -
- -
-
- -
- - - -
-
-
- - -
+// 렌더링 함수 +const render = () => { + const route = router.getCurrentRoute(); + const $root = document.querySelector("#root"); + const newComponent = route.component; - -
-
- - > -
-
-
- - - -
-
-
- - -
- -
- - -
- -
- - -
-
-
-
-
- `; + // 모달이 열려있으면 전체 렌더링 스킵 (모달이 사라지는 것 방지) + // 단, 장바구니 개수는 업데이트 + 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(); + } + } - -
-
- - >>주방용품 -
-
-
- - - -
-
-
- - -
- -
- - -
- -
- - -
-
-
-
-
- `; + 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) -

- -
- -
- -
- -
- -
-
-
- - - -
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -
- -
-

- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -

-

- 220원 -

- -
- - - -
-
- -
-

- 440원 -

- -
-
-
- - - -
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -
- -
-

- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -

-

- 230원 -

- -
- - - -
-
- -
-

- 230원 -

- -
-
-
-
-
- -
- - -
- 총 금액 - 670원 -
- -
-
- - -
-
-
-
-
- `; + // 장바구니 아이콘 클릭 - 모달 열기 + if (e.target.closest("#cart-icon-btn")) { + openCartModal(); + return; + } - const 장바구니_선택있음 = ` -
-
- -
-

- - - - 장바구니 - (2) -

- -
- -
- -
- -
- -
-
-
- - - -
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -
- -
-

- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -

-

- 220원 -

- -
- - - -
-
- -
-

- 440원 -

- -
-
-
- - - -
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -
- -
-

- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -

-

- 230원 -

- -
- - - -
-
- -
-

- 230원 -

- -
-
-
-
-
- -
- -
- 선택한 상품 (1개) - 440원 -
- -
- 총 금액 - 670원 -
- -
- -
- - -
-
-
-
-
- `; + // 장바구니 모달 닫기 버튼 + if (e.target.closest("#cart-modal-close-btn")) { + closeCartModal(); + return; + } - const 상세페이지_로딩 = ` -
-
-
-
-
- -

상품 상세

-
-
- - -
-
-
-
-
-
-
-
-

상품 정보를 불러오는 중...

-
-
-
- -
- `; + // 장바구니 모달 배경 클릭 - 닫기 + if (e.target.classList.contains("cart-modal-overlay")) { + closeCartModal(); + return; + } - const 상세페이지_로딩완료 = ` -
-
-
-
-
- -

상품 상세

-
-
- - -
-
-
-
-
- - - -
- -
-
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -
- -
-

-

PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장

- -
-
- - - - - - - - - - - - - - - -
- 4.0 (749개 리뷰) -
- -
- 220원 -
- -
- 재고 107개 -
- -
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장에 대한 상세 설명입니다. 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다. -
-
-
- -
-
- 수량 -
- - - -
-
- - -
-
- -
- -
- -
-
-

관련 상품

-

같은 카테고리의 다른 상품들

-
-
-
- - -
-
-
-
- -
- `; + // 장바구니 모달 내 기능들 + 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 */ ` +
+ +
+
+
+
+ +

상품 상세

+
+
+ + +
+
+
+
+ + ${error + ? /* HTML */ ` +
+
+ + + +

상품 정보를 불러올 수 없습니다

+

네트워크 연결을 확인해주세요

+ +
+
+ ` + : loading + ? /* HTML */ ` +
+
+
+
+

상품 정보를 불러오는 중...

+
+
+
+ ` + : /* HTML */ `
+ + + +
+ +
+
+ ${product.title} +
+ +
+

+

${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 */ ` +
+
+
+
+

+ 쇼핑몰 +

+
+ + +
+
+
+
+
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+
카테고리 로딩 중...
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + + 상품을 불러오는 중... +
+
+
+
+
+ +
+`; + +const 상품목록_레이아웃_로딩완료 = /* HTML */ ` +
+
+
+
+

+ 쇼핑몰 +

+
+ + +
+
+
+
+
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+ +
340개의 상품
+ +
+
+ +
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +
+ +
+
+

+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +

+

+

220원

+
+ + +
+
+
+ +
+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +
+ +
+
+

+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +

+

이지웨이건축자재

+

230원

+
+ + +
+
+
+ +
모든 상품을 확인했습니다
+
+
+
+ +
+`; + +const 상품목록_레이아웃_카테고리_1Depth = /* HTML */ ` +
+ +
+ +
+
+ +
+ + + +
+
+
+ + +
+ +
+
+ + > +
+
+
+ + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+`; + +const 상품목록_레이아웃_카테고리_2Depth = /* HTML */ ` +
+ +
+ +
+
+ +
+ + + +
+
+
+ + +
+ +
+
+ + >>주방용품 +
+
+
+ + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+`; + +const 토스트 = /* HTML */ ` +
+
+
+ + + +
+

장바구니에 추가되었습니다

+ +
+ +
+
+ + + +
+

선택된 상품들이 삭제되었습니다

+ +
+ +
+
+ + + +
+

오류가 발생했습니다.

+ +
+
+`; + +const 장바구니_비어있음 = /* HTML */ ` +
+
+ +
+

+ + + + 장바구니 +

+ + +
+ + +
+ +
+
+
+ + + +
+

장바구니가 비어있습니다

+

원하는 상품을 담아보세요!

+
+
+
+
+
+`; + +const 장바구니_선택없음 = /* HTML */ ` +
+
+ +
+

+ + + + 장바구니 + (2) +

+ +
+ +
+ +
+ +
+ +
+
+
+ + + +
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +
+ +
+

+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +

+

220원

+ +
+ + + +
+
+ +
+

440원

+ +
+
+
+ + + +
+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +
+ +
+

+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +

+

230원

+ +
+ + + +
+
+ +
+

230원

+ +
+
+
+
+
+ +
+ + +
+ 총 금액 + 670원 +
+ +
+
+ + +
+
+
+
+
+`; + +const 장바구니_선택있음 = /* HTML */ ` +
+
+ +
+

+ + + + 장바구니 + (2) +

+ +
+ +
+ +
+ +
+ +
+
+
+ + + +
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +
+ +
+

+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +

+

220원

+ +
+ + + +
+
+ +
+

440원

+ +
+
+
+ + + +
+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +
+ +
+

+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +

+

230원

+ +
+ + + +
+
+ +
+

230원

+ +
+
+
+
+
+ +
+ +
+ 선택한 상품 (1개) + 440원 +
+ +
+ 총 금액 + 670원 +
+ +
+ +
+ + +
+
+
+
+
+`; + +const 상세페이지_로딩 = /* HTML */ ` +
+
+
+
+
+ +

상품 상세

+
+
+ + +
+
+
+
+
+
+
+
+

상품 정보를 불러오는 중...

+
+
+
+ +
+`; + +const 상세페이지_로딩완료 = /* HTML */ ` +
+
+
+
+
+ +

상품 상세

+
+
+ + +
+
+
+
+
+ + + +
+ +
+
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +
+ +
+

+

+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +

+ +
+
+ + + + + + + + + + + + + + + +
+ 4.0 (749개 리뷰) +
+ +
+ 220원 +
+ +
재고 107개
+ +
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장에 대한 상세 설명입니다. + 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다. +
+
+
+ +
+
+ 수량 +
+ + + +
+
+ + +
+
+ +
+ +
+ +
+
+

관련 상품

+

같은 카테고리의 다른 상품들

+
+
+
+ + +
+
+
+
+ +
+`; + +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", + }, +}));