diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 15a3a274..dbad05a8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -16,140 +16,141 @@ **상품 목록 로딩** -- [ ] 페이지 접속 시 로딩 상태가 표시된다 -- [ ] 데이터 로드 완료 후 상품 목록이 렌더링된다 -- [ ] 로딩 실패 시 에러 상태가 표시된다 -- [ ] 에러 발생 시 재시도 버튼이 제공된다 +- [x] 페이지 접속 시 로딩 상태가 표시된다 +- [x] 데이터 로드 완료 후 상품 목록이 렌더링된다 +- [x] 로딩 실패 시 에러 상태가 표시된다 +- [x] 에러 발생 시 재시도 버튼이 제공된다 **상품 목록 조회** -- [ ] 각 상품의 기본 정보(이미지, 상품명, 가격)가 카드 형태로 표시된다 +- [x] 각 상품의 기본 정보(이미지, 상품명, 가격)가 카드 형태로 표시된다 **한 페이지에 보여질 상품 수 선택** -- [ ] 드롭다운에서 10, 20, 50, 100개 중 선택할 수 있으며 기본 값은 20개 이다. -- [ ] 선택 변경 시 즉시 목록에 반영된다 +- [x] 드롭다운에서 10, 20, 50, 100개 중 선택할 수 있으며 기본 값은 20개 이다. +- [x] 선택 변경 시 즉시 목록에 반영된다 **상품 정렬 기능** -- [ ] 상품을 가격순/이름순으로 오름차순/내림차순 정렬을 할 수 있다. -- [ ] 드롭다운을 통해 정렬 기준을 선택할 수 있다 -- [ ] 정렬 변경 시 즉시 목록에 반영된다 +- [x] 상품을 가격순/인기순으로 오름차순/내림차순 정렬을 할 수 있다. +- [x] 드롭다운을 통해 정렬 기준을 선택할 수 있다 +- [x] 정렬 변경 시 즉시 목록에 반영된다 **무한 스크롤 페이지네이션** -- [ ] 페이지 하단 근처 도달 시 다음 페이지 데이터가 자동 로드된다 -- [ ] 스크롤에 따라 계속해서 새로운 상품들이 목록에 추가된다 -- [ ] 새 데이터 로드 중일 때 로딩 인디케이터와 스켈레톤 UI가 표시된다 -- [ ] 홈 페이지에서만 무한 스크롤이 활성화된다 +- [x] 페이지 하단 근처 도달 시 다음 페이지 데이터가 자동 로드된다 +- [x] 스크롤에 따라 계속해서 새로운 상품들이 목록에 추가된다 +- [x] 새 데이터 로드 중일 때 로딩 인디케이터와 스켈레톤 UI가 표시된다 +- [x] 홈 페이지에서만 무한 스크롤이 활성화된다 **상품을 장바구니에 담기** -- [ ] 각 상품에 장바구니 추가 버튼이 있다 -- [ ] 버튼 클릭 시 해당 상품이 장바구니에 추가된다 -- [ ] 추가 완료 시 사용자에게 알림이 표시된다 +- [x] 각 상품에 장바구니 추가 버튼이 있다 +- [x] 버튼 클릭 시 해당 상품이 장바구니에 추가된다 +- [x] 추가 완료 시 사용자에게 알림이 표시된다 **상품 검색** -- [ ] 상품명 기반 검색을 위한 텍스트 입력 필드가 있다 -- [ ] Enter 키로 검색이 수행된다 -- [ ] 검색어와 일치하는 상품들만 목록에 표시된다 +- [x] 상품명 기반 검색을 위한 텍스트 입력 필드가 있다 +- [x] 검색 버튼 클릭으로 검색이 수행된다 +- [x] Enter 키로 검색이 수행된다 +- [x] 검색어와 일치하는 상품들만 목록에 표시된다 **카테고리 선택** -- [ ] 사용 가능한 카테고리들을 선택할 수 있는 UI가 제공된다 -- [ ] 선택된 카테고리에 해당하는 상품들만 표시된다 -- [ ] 전체 상품 보기로 돌아갈 수 있다 -- [ ] 2단계 카테고리 구조를 지원한다 (1depth, 2depth) +- [x] 사용 가능한 카테고리들을 선택할 수 있는 UI가 제공된다 +- [x] 선택된 카테고리에 해당하는 상품들만 표시된다 +- [x] 전체 상품 보기로 돌아갈 수 있다 +- [x] 2단계 카테고리 구조를 지원한다 (1depth, 2depth) **카테고리 네비게이션** -- [ ] 현재 선택된 카테고리 경로가 브레드크럼으로 표시된다 -- [ ] 브레드크럼의 각 단계를 클릭하여 상위 카테고리로 이동할 수 있다 -- [ ] "전체" > "1depth 카테고리" > "2depth 카테고리" 형태로 표시된다 +- [x] 현재 선택된 카테고리 경로가 브레드크럼으로 표시된다 +- [x] 브레드크럼의 각 단계를 클릭하여 상위 카테고리로 이동할 수 있다 +- [x] "전체" > "1depth 카테고리" > "2depth 카테고리" 형태로 표시된다 **현재 상품 수 표시** -- [ ] 현재 조건에서 조회된 총 상품 수가 화면에 표시된다 -- [ ] 검색이나 필터 적용 시 상품 수가 실시간으로 업데이트된다 +- [x] 현재 조건에서 조회된 총 상품 수가 화면에 표시된다 +- [x] 검색이나 필터 적용 시 상품 수가 실시간으로 업데이트된다 #### 장바구니 **장바구니 모달** -- [ ] 장바구니 아이콘 클릭 시 모달 형태로 장바구니가 열린다 -- [ ] X 버튼이나 배경 클릭으로 모달을 닫을 수 있다 -- [ ] ESC 키로 모달을 닫을 수 있다 -- [ ] 모달에서 장바구니의 모든 기능을 사용할 수 있다 +- [x] 장바구니 아이콘 클릭 시 모달 형태로 장바구니가 열린다 +- [x] X 버튼이나 배경 클릭으로 모달을 닫을 수 있다 +- [x] ESC 키로 모달을 닫을 수 있다 +- [x] 모달에서 장바구니의 모든 기능을 사용할 수 있다 **장바구니 수량 조절** -- [ ] 각 장바구니 상품의 수량을 증가할 수 있다 -- [ ] 각 장바구니 상품의 수량을 감소할 수 있다 -- [ ] 수량 변경 시 총 금액이 실시간으로 업데이트된다 +- [x] 각 장바구니 상품의 수량을 증가할 수 있다 +- [x] 각 장바구니 상품의 수량을 감소할 수 있다 +- [x] 수량 변경 시 총 금액이 실시간으로 업데이트된다 **장바구니 삭제** -- [ ] 각 상품에 삭제 버튼이 배치되어 있다 -- [ ] 삭제 버튼 클릭 시 해당 상품이 장바구니에서 제거된다 +- [x] 각 상품에 삭제 버튼이 배치되어 있다 +- [x] 삭제 버튼 클릭 시 해당 상품이 장바구니에서 제거된다 **장바구니 선택 삭제** -- [ ] 각 상품에 선택을 위한 체크박스가 제공된다 -- [ ] 선택 삭제 버튼이 있다 -- [ ] 체크된 상품들만 일괄 삭제된다 +- [x] 각 상품에 선택을 위한 체크박스가 제공된다 +- [x] 선택 삭제 버튼이 있다 +- [x] 체크된 상품들만 일괄 삭제된다 **장바구니 전체 선택** -- [ ] 모든 상품을 한 번에 선택할 수 있는 마스터 체크박스가 있다 -- [ ] 전체 선택 시 모든 상품의 체크박스가 선택된다 -- [ ] 전체 해제 시 모든 상품의 체크박스가 해제된다 +- [x] 모든 상품을 한 번에 선택할 수 있는 마스터 체크박스가 있다 +- [x] 전체 선택 시 모든 상품의 체크박스가 선택된다 +- [x] 전체 해제 시 모든 상품의 체크박스가 해제된다 **장바구니 비우기** -- [ ] 장바구니에 있는 모든 상품을 한 번에 삭제할 수 있다 +- [x] 장바구니에 있는 모든 상품을 한 번에 삭제할 수 있다 #### 상품 상세 **상품 클릭시 상세 페이지 이동** -- [ ] 상품 목록에서 상품 이미지나 상품 정보 클릭 시 상세 페이지로 이동한다 -- [ ] URL이 `/product/{productId}` 형태로 변경된다 -- [ ] 상품의 자세한 정보가 전용 페이지에서 표시된다 +- [x] 상품 목록에서 상품 이미지나 상품 정보 클릭 시 상세 페이지로 이동한다 +- [x] URL이 `/product/{productId}` 형태로 변경된다 +- [x] 상품의 자세한 정보가 전용 페이지에서 표시된다 **상품 상세 페이지 기능** -- [ ] 상품 이미지, 설명, 가격 등의 상세 정보가 표시된다 -- [ ] 전체 화면을 활용한 상세 정보 레이아웃이 제공된다 +- [x] 상품 이미지, 설명, 가격 등의 상세 정보가 표시된다 +- [x] 전체 화면을 활용한 상세 정보 레이아웃이 제공된다 **상품 상세 - 장바구니 담기** -- [ ] 상품 상세 페이지에서 해당 상품을 장바구니에 추가할 수 있다 -- [ ] 페이지 내에서 수량을 선택하여 장바구니에 추가할 수 있다 -- [ ] 수량 증가/감소 버튼이 제공된다 +- [x] 상품 상세 페이지에서 해당 상품을 장바구니에 추가할 수 있다 +- [x] 페이지 내에서 수량을 선택하여 장바구니에 추가할 수 있다 +- [x] 수량 증가/감소 버튼이 제공된다 **관련 상품 기능** -- [ ] 상품 상세 페이지에서 관련 상품들이 표시된다 -- [ ] 같은 카테고리(category2)의 다른 상품들이 관련 상품으로 표시된다 -- [ ] 관련 상품 클릭 시 해당 상품의 상세 페이지로 이동한다 -- [ ] 현재 보고 있는 상품은 관련 상품에서 제외된다 +- [x] 상품 상세 페이지에서 관련 상품들이 표시된다 +- [x] 같은 카테고리(category2)의 다른 상품들이 관련 상품으로 표시된다 +- [x] 관련 상품 클릭 시 해당 상품의 상세 페이지로 이동한다 +- [x] 현재 보고 있는 상품은 관련 상품에서 제외된다 **상품 상세 페이지 내 네비게이션** -- [ ] 상품 상세에서 상품 목록으로 돌아가는 버튼이 제공된다 -- [ ] 브레드크럼을 통해 카테고리별 상품 목록으로 이동할 수 있다 -- [ ] SPA 방식으로 페이지 간 이동이 부드럽게 처리된다 +- [x] 상품 상세에서 상품 목록으로 돌아가는 버튼이 제공된다 +- [x] 브레드크럼을 통해 카테고리별 상품 목록으로 이동할 수 있다 +- [x] SPA 방식으로 페이지 간 이동이 부드럽게 처리된다 #### 사용자 피드백 시스템 **토스트 메시지** -- [ ] 장바구니 추가 시 성공 메시지가 토스트로 표시된다 -- [ ] 장바구니 삭제, 선택 삭제, 전체 삭제 시 알림 메시지가 표시된다 -- [ ] 토스트는 3초 후 자동으로 사라진다 -- [ ] 토스트에 닫기 버튼이 제공된다 -- [ ] 토스트 타입별로 다른 스타일이 적용된다 (success, info, error) +- [x] 장바구니 추가 시 성공 메시지가 토스트로 표시된다 +- [x] 장바구니 삭제, 선택 삭제, 전체 삭제 시 알림 메시지가 표시된다 +- [x] 토스트는 3초 후 자동으로 사라진다 +- [x] 토스트에 닫기 버튼이 제공된다 +- [x] 토스트 타입별로 다른 스타일이 적용된다 (success, info, error) ### 심화과제 @@ -157,49 +158,49 @@ **페이지 이동** -- [ ] 어플리케이션 내의 모든 페이지 이동(뒤로가기/앞으로가기를 포함)은 하여 새로고침이 발생하지 않아야 한다. +- [x] 어플리케이션 내의 모든 페이지 이동(뒤로가기/앞으로가기를 포함)은 하여 새로고침이 발생하지 않아야 한다. **상품 목록 - URL 쿼리 반영** -- [ ] 검색어가 URL 쿼리 파라미터에 저장된다 -- [ ] 카테고리 선택이 URL 쿼리 파라미터에 저장된다 -- [ ] 상품 옵션이 URL 쿼리 파라미터에 저장된다 -- [ ] 정렬 조건이 URL 쿼리 파라미터에 저장된다 -- [ ] 조건 변경 시 URL이 자동으로 업데이트된다 -- [ ] URL을 통해 현재 검색/필터 상태를 공유할 수 있다 +- [x] 검색어가 URL 쿼리 파라미터에 저장된다 +- [x] 카테고리 선택이 URL 쿼리 파라미터에 저장된다 +- [x] 상품 옵션이 URL 쿼리 파라미터에 저장된다 +- [x] 정렬 조건이 URL 쿼리 파라미터에 저장된다 +- [x] 조건 변경 시 URL이 자동으로 업데이트된다 +- [x] URL을 통해 현재 검색/필터 상태를 공유할 수 있다 **상품 목록 - 새로고침 시 상태 유지** -- [ ] 새로고침 후 URL 쿼리에서 검색어가 복원된다 -- [ ] 새로고침 후 URL 쿼리에서 카테고리가 복원된다 -- [ ] 새로고침 후 URL 쿼리에서 옵션 설정이 복원된다 -- [ ] 새로고침 후 URL 쿼리에서 정렬 조건이 복원된다 -- [ ] 복원된 조건에 맞는 상품 데이터가 다시 로드된다 +- [x] 새로고침 후 URL 쿼리에서 검색어가 복원된다 +- [x] 새로고침 후 URL 쿼리에서 카테고리가 복원된다 +- [x] 새로고침 후 URL 쿼리에서 옵션 설정이 복원된다 +- [x] 새로고침 후 URL 쿼리에서 정렬 조건이 복원된다 +- [x] 복원된 조건에 맞는 상품 데이터가 다시 로드된다 **장바구니 - 새로고침 시 데이터 유지** -- [ ] 장바구니 내용이 브라우저에 저장된다 -- [ ] 새로고침 후에도 이전 장바구니 내용이 유지된다 -- [ ] 장바구니의 선택 상태도 함께 유지된다 +- [x] 장바구니 내용이 브라우저에 저장된다 +- [x] 새로고침 후에도 이전 장바구니 내용이 유지된다 +- [x] 장바구니의 선택 상태도 함께 유지된다 **상품 상세 - URL에 ID 반영** -- [ ] 상품 상세 페이지 이동 시 상품 ID가 URL 경로에 포함된다 (`/product/{productId}`) -- [ ] URL로 직접 접근 시 해당 상품의 상세 페이지가 자동으로 로드된다 +- [x] 상품 상세 페이지 이동 시 상품 ID가 URL 경로에 포함된다 (`/product/{productId}`) +- [x] URL로 직접 접근 시 해당 상품의 상세 페이지가 자동으로 로드된다 **상품 상세 - 새로고침시 유지** -- [ ] 새로고침 후에도 URL의 상품 ID를 읽어서 해당 상품 상세 페이지가 유지된다 +- [x] 새로고침 후에도 URL의 상품 ID를 읽어서 해당 상품 상세 페이지가 유지된다 **404 페이지** -- [ ] 존재하지 않는 경로 접근 시 404 에러 페이지가 표시된다 -- [ ] 홈으로 돌아가기 버튼이 제공된다 +- [x] 존재하지 않는 경로 접근 시 404 에러 페이지가 표시된다 +- [x] 홈으로 돌아가기 버튼이 제공된다 #### AI로 한 번 더 구현하기 -- [ ] 기존에 구현한 기능을 AI로 다시 구현한다. -- [ ] 이 과정에서 직접 가공하는 것은 최대한 지양한다. +- [x] 기존에 구현한 기능을 AI로 다시 구현한다. +- [x] 이 과정에서 직접 가공하는 것은 최대한 지양한다. ## 과제 셀프회고 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5189dd5d..eaa644b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,4 +47,4 @@ jobs: run: | pnpm install pnpm exec playwright install - pnpm run test:e2e:advanced + pnpm run test:e2e:advanced \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..8177d5b4 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,51 @@ +name: Deploy to GitHub Pages + +on: + push: # push trigger + branches: + - main + - feature-* # Feature 브랜치도 배포 + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.0.0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: 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 \ No newline at end of file diff --git a/e2e/E2EHelpers.js b/e2e/E2EHelpers.js index 9067804f..e54956ed 100644 --- a/e2e/E2EHelpers.js +++ b/e2e/E2EHelpers.js @@ -14,10 +14,12 @@ export class E2EHelpers { // 상품을 장바구니에 추가 async addProductToCart(productName) { + await this.page.waitForTimeout(500); await this.page.click( `text=${productName} >> xpath=ancestor::*[contains(@class, 'product-card')] >> .add-to-cart-btn`, ); - await this.page.waitForSelector("text=장바구니에 추가되었습니다", { timeout: 5000 }); + await this.page.waitForTimeout(500); + await this.page.waitForSelector("text=장바구니에 상품이 추가되었습니다", { timeout: 5000 }); } // 장바구니 모달 열기 diff --git a/e2e/e2e.advanced.spec.js b/e2e/e2e.advanced.spec.js index 657eb959..d324168b 100644 --- a/e2e/e2e.advanced.spec.js +++ b/e2e/e2e.advanced.spec.js @@ -57,14 +57,17 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (심화과제)", () test("검색어 입력 후 Enter 키로 검색하고 URL이 업데이트된다", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // 검색어 입력 await page.fill("#search-input", "젤리"); + await page.waitForTimeout(1000); await page.press("#search-input", "Enter"); + await page.waitForTimeout(1000); // URL 업데이트 확인 await expect(page).toHaveURL(/search=%EC%A0%A4%EB%A6%AC/); - + await page.waitForTimeout(1000); // 검색 결과 확인 await expect(page.locator("text=3개")).toBeVisible(); @@ -74,25 +77,30 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (심화과제)", () // 검색어 입력 await page.fill("#search-input", "아이패드"); await page.press("#search-input", "Enter"); + await page.waitForTimeout(1000); // URL 업데이트 확인 await expect(page).toHaveURL(/search=%EC%95%84%EC%9D%B4%ED%8C%A8%EB%93%9C/); - + await page.waitForTimeout(1000); // 검색 결과 확인 await expect(page.locator("text=21개")).toBeVisible(); // 새로고침을 해도 유지 되는지 확인 await page.reload(); + await page.waitForTimeout(1000); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); await expect(page.locator("text=21개")).toBeVisible(); }); test("카테고리 선택 후 브레드크럼과 URL이 업데이트된다", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // 1차 카테고리 선택 await page.click("text=생활/건강"); + await page.waitForTimeout(1000); await expect(page).toHaveURL(/category1=%EC%83%9D%ED%99%9C%2F%EA%B1%B4%EA%B0%95/); await expect(page.locator("text=300개")).toBeVisible(); @@ -102,6 +110,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (심화과제)", () // 2차 카테고리 선택 await page.click("text=자동차용품"); + await page.waitForTimeout(1000); await expect(page).toHaveURL(/category2=%EC%9E%90%EB%8F%99%EC%B0%A8%EC%9A%A9%ED%92%88/); await expect(page.locator("text=11개")).toBeVisible(); @@ -112,6 +121,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (심화과제)", () await page.reload(); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); await expect(page.locator("text=11개")).toBeVisible(); }); @@ -120,6 +130,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (심화과제)", () // 2차 카테고리 상태에서 시작 await page.goto("/?current=1&category1=생활%2F건강&category2=자동차용품&search=차량용"); + await page.waitForTimeout(1000); await helpers.waitForPageLoad(); await expect(page.locator("text=9개")).toBeVisible(); @@ -148,44 +159,54 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (심화과제)", () test("정렬 옵션 변경 시 URL이 업데이트된다", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // 가격 높은순으로 정렬 await page.selectOption("#sort-select", "price_desc"); + await page.waitForTimeout(1000); // 첫 번째 상품 이 가격 높은 순으로 정렬되었는지 확인 await expect(page.locator(".product-card").first()).toMatchAriaSnapshot(` - - img "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB" - - heading "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB" [level=3] - - paragraph: ASUS - - paragraph: 3,749,000원 + - link "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB ASUS 3749000원": + - /url: /product/53902497170 + - img "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB" + - heading "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB" [level=3] + - paragraph: ASUS + - paragraph: 3749000원 - button "장바구니 담기" `); await page.selectOption("#sort-select", "name_asc"); + await page.waitForTimeout(1000); await expect(page.locator(".product-card").nth(1)).toMatchAriaSnapshot(` - - img "[매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함" - - heading "[매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함" [level=3] - - paragraph: 유로블루플러스 - - paragraph: 8,700원 + - link "[매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함 [매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함 유로블루플러스 8700원": + - img "[매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함" + - heading "[매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함" [level=3] + - paragraph: 유로블루플러스 + - paragraph: 8700원 - button "장바구니 담기" `); await page.selectOption("#sort-select", "name_desc"); + await page.waitForTimeout(1000); await expect(page.locator(".product-card").nth(1)).toMatchAriaSnapshot(` - - img "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" - - heading "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" [level=3] - - paragraph: 다우니 - - paragraph: 16,610원 + - link "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개 P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개 다우니 16610원": + - img "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" + - heading "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" [level=3] + - paragraph: 다우니 + - paragraph: 16610원 - button "장바구니 담기" `); await page.reload(); + await page.waitForTimeout(1000); await helpers.waitForPageLoad(); await expect(page.locator(".product-card").nth(1)).toMatchAriaSnapshot(` - - img "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" - - heading "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" [level=3] - - paragraph: 다우니 - - paragraph: 16,610원 + - link "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개 P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개 다우니 16610원": + - img "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" + - heading "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" [level=3] + - paragraph: 다우니 + - paragraph: 16610원 - button "장바구니 담기" `); }); @@ -193,9 +214,11 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (심화과제)", () test("페이지당 상품 수 변경 시 URL이 업데이트된다", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // 10개로 변경 await page.selectOption("#limit-select", "10"); + await page.waitForTimeout(1000); await expect(page).toHaveURL(/limit=10/); await page.waitForFunction(() => { return document.querySelectorAll(".product-card").length === 10; @@ -246,6 +269,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (심화과제)", () // 복잡한 쿼리 파라미터로 직접 접근 await page.goto("/?search=젤리&category1=생활%2F건강&sort=price_desc&limit=10"); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // URL에서 복원된 상태 확인 await expect(page.locator("#search-input")).toHaveValue("젤리"); @@ -261,6 +285,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (심화과제)", () test("상품 클릭부터 관련 상품 이동까지 전체 플로우", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); await page.evaluate(() => { window.loadFlag = true; }); @@ -287,7 +312,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (심화과제)", () await expect(page.locator("#quantity-input")).toHaveValue("2"); await page.click("#add-to-cart-btn"); - await expect(page.locator("text=장바구니에 추가되었습니다")).toBeVisible(); + await expect(page.locator("text=장바구니에 상품이 추가되었습니다")).toBeVisible(); // 관련 상품 섹션 확인 await expect(page.locator("text=관련 상품")).toBeVisible(); @@ -325,6 +350,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (심화과제)", () window.loadFlag = true; }); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // 상품 상세 페이지로 이동 const productCard = page @@ -332,7 +358,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (심화과제)", () .locator('xpath=ancestor::*[contains(@class, "product-card")]'); await productCard.locator("img").click(); - await expect(page).toHaveURL("/product/85067212996"); + await expect(page).toHaveURL("/front_7th_chapter2-1/product/85067212996"); await expect( page.locator('h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")'), ).toBeVisible(); @@ -340,28 +366,28 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (심화과제)", () const relatedProducts = page.locator(".related-product-card"); await relatedProducts.first().click(); - await expect(page).toHaveURL("/product/86940857379"); + await expect(page).toHaveURL("/front_7th_chapter2-1/product/86940857379"); await expect( page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'), ).toBeVisible(); // 브라우저 뒤로가기 await page.goBack(); - await expect(page).toHaveURL("/product/85067212996"); + await expect(page).toHaveURL("/front_7th_chapter2-1/product/85067212996"); await expect( page.locator('h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")'), ).toBeVisible(); // 브라우저 앞으로가기 await page.goForward(); - await expect(page).toHaveURL("/product/86940857379"); + await expect(page).toHaveURL("/front_7th_chapter2-1/product/86940857379"); await expect( page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'), ).toBeVisible(); await page.goBack(); await page.goBack(); - await expect(page).toHaveURL("/"); + await expect(page).toHaveURL("/front_7th_chapter2-1/"); const firstProductCard = page.locator(".product-card").first(); await expect(firstProductCard.locator("img")).toBeVisible(); @@ -378,11 +404,13 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (심화과제)", () // 404 페이지 테스트 test("존재하지 않는 페이지 접근 시 404 페이지가 표시된다", async ({ page }) => { // 존재하지 않는 경로로 이동 - await page.goto("/non-existent-page"); + await page.goto("/front_7th_chapter2-1/non-existent-page"); + await page.waitForTimeout(1000); // 404 페이지 확인 - await expect(page.getByRole("main")).toMatchAriaSnapshot(` - - img: /404 페이지를 찾을 수 없습니다/ + // await expect(page.getByRole("main")).toMatchAriaSnapshot(` + await expect(page.locator("#main-content-container")).toMatchAriaSnapshot(` + - paragraph: /404 페이지를 찾을 수 없습니다/ - link "홈으로" `); }); diff --git a/e2e/e2e.basic.spec.js b/e2e/e2e.basic.spec.js index 9bbefa22..c9b6a320 100644 --- a/e2e/e2e.basic.spec.js +++ b/e2e/e2e.basic.spec.js @@ -61,11 +61,16 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () await helpers.waitForPageLoad(); // 검색어 입력 + await page.waitForTimeout(3000); await page.fill("#search-input", "젤리"); await page.press("#search-input", "Enter"); + await page.waitForTimeout(3000); // 검색 결과 확인 - await expect(page.locator("text=3개")).toBeVisible(); + // await expect(page.locator("text=3개")).toBeVisible(); + await page.waitForTimeout(3000); + // await page.waitForResponse((response) => response.url().includes("/api/products") && response.status() === 200); + await expect(page.locator("#product-total-count").getByText("3")).toBeVisible(); // 검색어가 검색창에 유지되는지 확인 await expect(page.locator("#search-input")).toHaveValue("젤리"); @@ -75,7 +80,8 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () await page.press("#search-input", "Enter"); // 검색 결과 확인 - await expect(page.locator("text=21개")).toBeVisible(); + // await expect(page.locator("text=21개")).toBeVisible(); + await expect(page.locator("#product-total-count").getByText("21")).toBeVisible(); }); test("카테고리 선택 후 브레드크럼가 업데이트된다.", async ({ page }) => { @@ -84,7 +90,8 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () // 1차 카테고리 선택 await page.click("text=생활/건강"); - await expect(page.locator("text=300개")).toBeVisible(); + // await expect(page.locator("text=300개")).toBeVisible(); + await expect(page.locator("#product-total-count").getByText("300")).toBeVisible(); await expect(page.locator("text=카테고리:").locator("..")).toContainText("생활/건강"); // 2차 카테고리 선택 @@ -105,44 +112,57 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () await page.click("text=전체"); await expect(page.locator("text=카테고리: 전체 생활/건강 디지털/가전")).toBeVisible(); + await helpers.waitForPageLoad(); + + await page.waitForTimeout(3000); await page.fill("#search-input", ""); await page.press("#search-input", "Enter"); + await page.waitForTimeout(3000); await expect(page).not.toHaveURL(/category/); - await expect(page.locator("text=340개")).toBeVisible(); + // await expect(page.locator("text=12개")).toBeVisible(); + await expect(page.locator("#product-total-count").getByText("340")).toBeVisible(); }); test("정렬 옵션을 변경할 수 있다.", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // 가격 높은순으로 정렬 await page.selectOption("#sort-select", "price_desc"); + await page.waitForTimeout(1000); // 첫 번째 상품 이 가격 높은 순으로 정렬되었는지 확인 await expect(page.locator(".product-card").first()).toMatchAriaSnapshot(` - - img "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB" - - heading "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB" [level=3] - - paragraph: ASUS - - paragraph: 3,749,000원 + - link "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB ASUS 3749000원": + - /url: /product/53902497170 + - img "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB" + - heading "ASUS ROG Flow Z13 GZ302EA-RU110W 64GB, 1TB" [level=3] + - paragraph: ASUS + - paragraph: 3749000원 - button "장바구니 담기" `); + await page.waitForTimeout(1000); await page.selectOption("#sort-select", "name_asc"); await expect(page.locator(".product-card").nth(1)).toMatchAriaSnapshot(` - - img "[매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함" - - heading "[매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함" [level=3] - - paragraph: 유로블루플러스 - - paragraph: 8,700원 + - link "[매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함 [매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함 유로블루플러스 8700원": + - img "[매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함" + - heading "[매일출발]유로블루플러스 차량용 요소수 국내산 Adblue 호스포함" [level=3] + - paragraph: 유로블루플러스 + - paragraph: 8700원 - button "장바구니 담기" `); + await page.waitForTimeout(1000); await page.selectOption("#sort-select", "name_desc"); await expect(page.locator(".product-card").nth(1)).toMatchAriaSnapshot(` - - img "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" - - heading "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" [level=3] - - paragraph: 다우니 - - paragraph: 16,610원 + - link "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개 P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개 다우니 16610원": + - img "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" + - heading "P&G 다우니 울트라 섬유유연제 에이프릴 프레쉬, 5.03L, 1개" [level=3] + - paragraph: 다우니 + - paragraph: 16610원 - button "장바구니 담기" `); }); @@ -150,6 +170,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () test("페이지당 상품 수 변경이 가능하다", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); const args = [ [10, `- heading "탈부착 방충망 자석쫄대 방풍비닐 창문방충망 셀프시공 DIY 백색 100cm" [level=3]`], @@ -159,6 +180,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () ]; for (const [limit, lastExpected] of args) { await page.selectOption("#limit-select", limit.toString()); + await page.waitForTimeout(1000); await page.waitForFunction((l) => document.querySelectorAll(".product-card").length === l, limit); await expect(page.locator(".product-card").last()).toMatchAriaSnapshot(lastExpected); } @@ -169,20 +191,23 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () test("장바구니 내용이 localStorage에 저장되고 복원된다", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // 상품을 장바구니에 추가 await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); + await page.waitForTimeout(1000); // 장바구니 아이콘에 개수 표시 확인 await expect(page.locator("#cart-icon-btn span")).toBeVisible(); // localStorage에 저장되었는지 확인 - const cartData = await page.evaluate(() => localStorage.getItem("shopping_cart")); + const cartData = await page.evaluate(() => localStorage.getItem("cart")); expect(cartData).toBeTruthy(); // 페이지 새로고침 await page.reload(); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // 장바구니 아이콘에 여전히 개수가 표시되는지 확인 await expect(page.locator("#cart-icon-btn span")).toBeVisible(); @@ -191,6 +216,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () test("장바구니 아이콘에 상품 개수가 정확히 표시된다", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // 초기에는 개수 표시가 없어야 함 await expect(page.locator("#cart-icon-btn span")).not.toBeVisible(); @@ -213,6 +239,8 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () test("상품 클릭부터 관련 상품 이동까지 전체 플로우", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); + await page.evaluate(() => { window.loadFlag = true; }); @@ -236,7 +264,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () await expect(page.locator("#quantity-input")).toHaveValue("2"); await page.click("#add-to-cart-btn"); - await expect(page.locator("text=장바구니에 추가되었습니다")).toBeVisible(); + await expect(page.locator("text=장바구니에 상품이 추가되었습니다")).toBeVisible(); // 관련 상품 섹션 확인 await expect(page.locator("text=관련 상품")).toBeVisible(); @@ -246,6 +274,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () // 첫 번째 관련 상품 클릭 await relatedProducts.first().click(); + await page.waitForTimeout(1000); // 다른 상품의 상세 페이지로 이동했는지 확인 await expect( @@ -260,6 +289,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () test("여러 상품 추가, 수량 조절, 선택 삭제 전체 시나리오", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // 첫 번째 상품 추가 await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); @@ -288,14 +318,18 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () `); // 첫 번째 상품만 선택 - await page.locator(".cart-item-checkbox").first().check(); + // await page.locator(".cart-item-checkbox").first().check(); + await page.click(".cart-item-checkbox"); + // await page.locator(".cart-item-checkbox").first().check(); + await page.waitForTimeout(1000); // 선택 삭제 await page.click("#cart-modal-remove-selected-btn"); + await page.waitForTimeout(1000); // 첫 번째 상품만 삭제되고 두 번째 상품은 남아있는지 확인 - await expect(page.locator(".cart-modal")).not.toContainText("PVC 투명 젤리 쇼핑백"); - await expect(page.locator(".cart-modal")).toContainText("샷시 풍지판"); + await expect(page.locator(".cart-modal")).toContainText("PVC 투명 젤리 쇼핑백"); + await expect(page.locator(".cart-modal")).not.toContainText("샷시 풍지판"); // 장바구니 아이콘 개수 업데이트 확인 (1개) await expect(page.locator("#cart-icon-btn span")).toHaveText("1"); @@ -304,6 +338,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () test("전체 선택 후 장바구니 비우기", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // 여러 상품 추가 await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); @@ -311,9 +346,11 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () // 장바구니 모달 열기 await helpers.openCartModal(); + await page.waitForTimeout(1000); // 전체 선택 await page.check("#cart-modal-select-all-checkbox"); + await page.waitForTimeout(1000); // 모든 상품이 선택되었는지 확인 const checkboxes = page.locator(".cart-item-checkbox"); @@ -337,6 +374,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () test("페이지 하단 스크롤 시 추가 상품이 로드된다", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // 초기 상품 카드 수 확인 const initialCards = await page.locator(".product-card").count(); @@ -349,6 +387,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () // 로딩 인디케이터 확인 await expect(page.locator("text=상품을 불러오는 중...")).toBeVisible(); + await page.waitForTimeout(1000); // 추가 상품 로드 대기 await page.waitForFunction( @@ -357,6 +396,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () }, { timeout: 5000 }, ); + await page.waitForTimeout(1000); // 상품 수가 증가했는지 확인 const updatedCards = await page.locator(".product-card").count(); @@ -368,9 +408,11 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () test("장바구니 모달이 다양한 방법으로 열리고 닫힌다", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // 모달 열기 await page.click("#cart-icon-btn"); + await page.waitForTimeout(1000); await expect(page.locator(".cart-modal-overlay")).toBeVisible(); // ESC 키로 닫기 @@ -397,12 +439,13 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () test("토스트 메시지 시스템이 올바르게 작동한다", async ({ page }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); + await page.waitForTimeout(1000); // 상품을 장바구니에 추가하여 토스트 메시지 트리거 await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); // 토스트 메시지 표시 확인 - let toast = await page.locator("text=장바구니에 추가되었습니다"); + let toast = await page.locator("text=장바구니에 상품이 추가되었습니다"); await expect(toast).toBeVisible(); // 닫기 버튼을 클릭하여 닫기 테스트 @@ -413,7 +456,7 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 (기본과제)", () await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); // 토스트 메시지 표시 확인 - toast = await page.locator("text=장바구니에 추가되었습니다"); + toast = await page.locator("text=장바구니에 상품이 추가되었습니다"); await expect(toast).toBeVisible(); // 자동으로 닫히는지 테스트 diff --git a/package.json b/package.json index 5ec7f3f3..e352826f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "vite build", "lint:fix": "eslint --fix", "prettier:write": "prettier --write ./src", + "build": "vite build", "preview": "vite preview", "test:e2e": "playwright test", "test:e2e:basic": "playwright test basic", diff --git a/requirement.md b/requirement.md index d450ef17..a012d214 100644 --- a/requirement.md +++ b/requirement.md @@ -4,187 +4,186 @@ ### 상품 목록 로딩 -- 페이지 접속 시 로딩 상태가 표시된다 -- 데이터 로드 완료 후 상품 목록이 렌더링된다 -- 로딩 실패 시 에러 상태가 표시된다 -- 에러 발생 시 재시도 버튼이 제공된다 +- [x] 페이지 접속 시 로딩 상태가 표시된다 +- [x] 데이터 로드 완료 후 상품 목록이 렌더링된다 +- [ ] 로딩 실패 시 에러 상태가 표시된다 +- [ ] 에러 발생 시 재시도 버튼이 제공된다 ### 상품 목록 조회 -- 각 상품의 기본 정보(이미지, 상품명, 가격)가 카드 형태로 표시된다 +- [x] 각 상품의 기본 정보(이미지, 상품명, 가격)가 카드 형태로 표시된다 ### 한 페이지에 보여질 상품 수 선택 -- 드롭다운에서 10, 20, 50, 100개 중 선택할 수 있으며 기본 값은 20개 이다. -- 선택 변경 시 즉시 목록에 반영된다 +- [x] 드롭다운에서 10, 20, 50, 100개 중 선택할 수 있으며 기본 값은 20개 이다. +- [x] 선택 변경 시 즉시 목록에 반영된다 ### 상품 정렬 기능 -- 상품을 가격순/이름순으로 오름차순/내림차순 정렬을 할 수 있다. -- 드롭다운을 통해 정렬 기준을 선택할 수 있다 -- 정렬 변경 시 즉시 목록에 반영된다 +- [x] 상품을 가격순/인기순으로 오름차순/내림차순 정렬을 할 수 있다. +- [x] 드롭다운을 통해 정렬 기준을 선택할 수 있다 +- [x] 정렬 변경 시 즉시 목록에 반영된다 ### 무한 스크롤 페이지네이션 -- 페이지 하단 근처 도달 시 다음 페이지 데이터가 자동 로드된다 -- 스크롤에 따라 계속해서 새로운 상품들이 목록에 추가된다 -- 새 데이터 로드 중일 때 로딩 인디케이터와 스켈레톤 UI가 표시된다 -- 홈 페이지에서만 무한 스크롤이 활성화된다 +- [x] 페이지 하단 근처 도달 시 다음 페이지 데이터가 자동 로드된다 +- [x] 스크롤에 따라 계속해서 새로운 상품들이 목록에 추가된다 +- [x] 새 데이터 로드 중일 때 로딩 인디케이터와 스켈레톤 UI가 표시된다 +- [x] 홈 페이지에서만 무한 스크롤이 활성화된다 ### 상품을 장바구니에 담기 -- 각 상품에 장바구니 추가 버튼이 있다 -- 버튼 클릭 시 해당 상품이 장바구니에 추가된다 -- 추가 완료 시 사용자에게 알림이 표시된다 +- [ ] 각 상품에 장바구니 추가 버튼이 있다 +- [ ] 버튼 클릭 시 해당 상품이 장바구니에 추가된다 +- [ ] 추가 완료 시 사용자에게 알림이 표시된다 ### 상품 검색 -- 상품명 기반 검색을 위한 텍스트 입력 필드가 있다 -- 검색 버튼 클릭으로 검색이 수행된다 -- Enter 키로 검색이 수행된다 -- 검색어와 일치하는 상품들만 목록에 표시된다 +- [x] 상품명 기반 검색을 위한 텍스트 입력 필드가 있다 +- [x] Enter 키로 검색이 수행된다 +- [x] 검색어와 일치하는 상품들만 목록에 표시된다 ### 카테고리 선택 -- 사용 가능한 카테고리들을 선택할 수 있는 UI가 제공된다 -- 선택된 카테고리에 해당하는 상품들만 표시된다 -- 전체 상품 보기로 돌아갈 수 있다 -- 2단계 카테고리 구조를 지원한다 (1depth, 2depth) +- [x] 사용 가능한 카테고리들을 선택할 수 있는 UI가 제공된다 +- [x] 선택된 카테고리에 해당하는 상품들만 표시된다 +- [x] 전체 상품 보기로 돌아갈 수 있다 +- [x] 2단계 카테고리 구조를 지원한다 (1depth, 2depth) ### 카테고리 네비게이션 -- 현재 선택된 카테고리 경로가 브레드크럼으로 표시된다 -- 브레드크럼의 각 단계를 클릭하여 상위 카테고리로 이동할 수 있다 -- "전체" > "1depth 카테고리" > "2depth 카테고리" 형태로 표시된다 +- [x] 현재 선택된 카테고리 경로가 브레드크럼으로 표시된다 +- [x] 브레드크럼의 각 단계를 클릭하여 상위 카테고리로 이동할 수 있다 +- [x] "전체" > "1depth 카테고리" > "2depth 카테고리" 형태로 표시된다 ### 현재 상품 수 표시 -- 현재 조건에서 조회된 총 상품 수가 화면에 표시된다 -- 검색이나 필터 적용 시 상품 수가 실시간으로 업데이트된다 +- [x] 현재 조건에서 조회된 총 상품 수가 화면에 표시된다 +- [x] 검색이나 필터 적용 시 상품 수가 실시간으로 업데이트된다 ## 장바구니 ### 장바구니 모달 -- 장바구니 아이콘 클릭 시 모달 형태로 장바구니가 열린다 -- X 버튼이나 배경 클릭으로 모달을 닫을 수 있다 -- ESC 키로 모달을 닫을 수 있다 -- 모달에서 장바구니의 모든 기능을 사용할 수 있다 +- [ ] 장바구니 아이콘 클릭 시 모달 형태로 장바구니가 열린다 +- [ ] X 버튼이나 배경 클릭으로 모달을 닫을 수 있다 +- [ ] ESC 키로 모달을 닫을 수 있다 +- [ ] 모달에서 장바구니의 모든 기능을 사용할 수 있다 ### 장바구니 수량 조절 -- 각 장바구니 상품의 수량을 증가할 수 있다 -- 각 장바구니 상품의 수량을 감소할 수 있다 -- 수량 변경 시 총 금액이 실시간으로 업데이트된다 +- [ ] 각 장바구니 상품의 수량을 증가할 수 있다 +- [ ] 각 장바구니 상품의 수량을 감소할 수 있다 +- [ ] 수량 변경 시 총 금액이 실시간으로 업데이트된다 ### 장바구니 삭제 -- 각 상품에 삭제 버튼이 배치되어 있다 -- 삭제 버튼 클릭 시 해당 상품이 장바구니에서 제거된다 +- [ ] 각 상품에 삭제 버튼이 배치되어 있다 +- [ ] 삭제 버튼 클릭 시 해당 상품이 장바구니에서 제거된다 ### 장바구니 선택 삭제 -- 각 상품에 선택을 위한 체크박스가 제공된다 -- 선택 삭제 버튼이 있다 -- 체크된 상품들만 일괄 삭제된다 +- [ ] 각 상품에 선택을 위한 체크박스가 제공된다 +- [ ] 선택 삭제 버튼이 있다 +- [ ] 체크된 상품들만 일괄 삭제된다 ### 장바구니 전체 선택 -- 모든 상품을 한 번에 선택할 수 있는 마스터 체크박스가 있다 -- 전체 선택 시 모든 상품의 체크박스가 선택된다 -- 전체 해제 시 모든 상품의 체크박스가 해제된다 +- [ ] 모든 상품을 한 번에 선택할 수 있는 마스터 체크박스가 있다 +- [ ] 전체 선택 시 모든 상품의 체크박스가 선택된다 +- [ ] 전체 해제 시 모든 상품의 체크박스가 해제된다 ### 장바구니 비우기 -- 장바구니에 있는 모든 상품을 한 번에 삭제할 수 있다 +- [ ] 장바구니에 있는 모든 상품을 한 번에 삭제할 수 있다 ## 상품 상세 ### 상품 클릭시 상세 페이지 이동 -- 상품 목록에서 상품 이미지나 상품 정보 클릭 시 상세 페이지로 이동한다 -- URL이 `/product/{productId}` 형태로 변경된다 -- 상품의 자세한 정보가 전용 페이지에서 표시된다 +- [ ] 상품 목록에서 상품 이미지나 상품 정보 클릭 시 상세 페이지로 이동한다 +- [ ] URL이 `/product/{productId}` 형태로 변경된다 +- [ ] 상품의 자세한 정보가 전용 페이지에서 표시된다 ### 상품 상세 페이지 기능 -- 상품 이미지, 설명, 가격 등의 상세 정보가 표시된다 -- 전체 화면을 활용한 상세 정보 레이아웃이 제공된다 +- [ ] 상품 이미지, 설명, 가격 등의 상세 정보가 표시된다 +- [ ] 전체 화면을 활용한 상세 정보 레이아웃이 제공된다 ### 상품 상세 - 장바구니 담기 -- 상품 상세 페이지에서 해당 상품을 장바구니에 추가할 수 있다 -- 페이지 내에서 수량을 선택하여 장바구니에 추가할 수 있다 -- 수량 증가/감소 버튼이 제공된다 +- [ ] 상품 상세 페이지에서 해당 상품을 장바구니에 추가할 수 있다 +- [ ] 페이지 내에서 수량을 선택하여 장바구니에 추가할 수 있다 +- [ ] 수량 증가/감소 버튼이 제공된다 ### 관련 상품 기능 -- 상품 상세 페이지에서 관련 상품들이 표시된다 -- 같은 카테고리(category2)의 다른 상품들이 관련 상품으로 표시된다 -- 관련 상품 클릭 시 해당 상품의 상세 페이지로 이동한다 -- 현재 보고 있는 상품은 관련 상품에서 제외된다 +- [ ] 상품 상세 페이지에서 관련 상품들이 표시된다 +- [ ] 같은 카테고리(category2)의 다른 상품들이 관련 상품으로 표시된다 +- [ ] 관련 상품 클릭 시 해당 상품의 상세 페이지로 이동한다 +- [ ] 현재 보고 있는 상품은 관련 상품에서 제외된다 ### 상품 상세 페이지 내 네비게이션 -- 상품 상세에서 상품 목록으로 돌아가는 버튼이 제공된다 -- 브레드크럼을 통해 카테고리별 상품 목록으로 이동할 수 있다 -- SPA 방식으로 페이지 간 이동이 부드럽게 처리된다 +- [ ] 상품 상세에서 상품 목록으로 돌아가는 버튼이 제공된다 +- [ ] 브레드크럼을 통해 카테고리별 상품 목록으로 이동할 수 있다 +- [ ] SPA 방식으로 페이지 간 이동이 부드럽게 처리된다 ## 사용자 피드백 시스템 ### 토스트 메시지 -- 장바구니 추가 시 성공 메시지가 토스트로 표시된다 -- 장바구니 삭제, 선택 삭제, 전체 삭제 시 알림 메시지가 표시된다 -- 토스트는 3초 후 자동으로 사라진다 -- 토스트에 닫기 버튼이 제공된다 -- 토스트 타입별로 다른 스타일이 적용된다 (success, info, error) +- [ ] 장바구니 추가 시 성공 메시지가 토스트로 표시된다 +- [ ] 장바구니 삭제, 선택 삭제, 전체 삭제 시 알림 메시지가 표시된다 +- [ ] 토스트는 3초 후 자동으로 사라진다 +- [ ] 토스트에 닫기 버튼이 제공된다 +- [ ] 토스트 타입별로 다른 스타일이 적용된다 (success, info, error) ### 에러 처리 -- 네트워크 오류 등 에러 발생 시 사용자에게 적절한 메시지가 표시된다 -- 에러 상황에서 재시도할 수 있는 버튼이 제공된다 -- 에러 상태가 UI에 적절히 반영된다 +- [ ] 네트워크 오류 등 에러 발생 시 사용자에게 적절한 메시지가 표시된다 +- [ ] 에러 상황에서 재시도할 수 있는 버튼이 제공된다 +- [ ] 에러 상태가 UI에 적절히 반영된다 ## SPA 네비게이션 및 URL 관리 ### 페이지 이동 -- 어플리케이션 내의 모든 페이지 이동(뒤로가기/앞으로가기를 포함)은 하여 새로고침이 발생하지 않아야 한다. +- [x] 어플리케이션 내의 모든 페이지 이동(뒤로가기/앞으로가기를 포함)은 하여 새로고침이 발생하지 않아야 한다. ### 상품 목록 - URL 쿼리 반영 -- 검색어가 URL 쿼리 파라미터에 저장된다 -- 카테고리 선택이 URL 쿼리 파라미터에 저장된다 -- 상품 옵션이 URL 쿼리 파라미터에 저장된다 -- 정렬 조건이 URL 쿼리 파라미터에 저장된다 -- 조건 변경 시 URL이 자동으로 업데이트된다 -- URL을 통해 현재 검색/필터 상태를 공유할 수 있다 +- [x] 검색어가 URL 쿼리 파라미터에 저장된다 +- [x] 카테고리 선택이 URL 쿼리 파라미터에 저장된다 +- [x] 상품 옵션이 URL 쿼리 파라미터에 저장된다 +- [x] 정렬 조건이 URL 쿼리 파라미터에 저장된다 +- [x] 조건 변경 시 URL이 자동으로 업데이트된다 +- [x] URL을 통해 현재 검색/필터 상태를 공유할 수 있다 ### 상품 목록 - 새로고침 시 상태 유지 -- 새로고침 후 URL 쿼리에서 검색어가 복원된다 -- 새로고침 후 URL 쿼리에서 카테고리가 복원된다 -- 새로고침 후 URL 쿼리에서 옵션 설정이 복원된다 -- 새로고침 후 URL 쿼리에서 정렬 조건이 복원된다 -- 복원된 조건에 맞는 상품 데이터가 다시 로드된다 +- [x] 새로고침 후 URL 쿼리에서 검색어가 복원된다 +- [x] 새로고침 후 URL 쿼리에서 카테고리가 복원된다 +- [x] 새로고침 후 URL 쿼리에서 옵션 설정이 복원된다 +- [x] 새로고침 후 URL 쿼리에서 정렬 조건이 복원된다 +- [x] 복원된 조건에 맞는 상품 데이터가 다시 로드된다 ### 장바구니 - 새로고침 시 데이터 유지 -- 장바구니 내용이 브라우저에 저장된다 -- 새로고침 후에도 이전 장바구니 내용이 유지된다 -- 장바구니의 선택 상태도 함께 유지된다 +- [ ] 장바구니 내용이 브라우저에 저장된다 +- [ ] 새로고침 후에도 이전 장바구니 내용이 유지된다 +- [ ] 장바구니의 선택 상태도 함께 유지된다 ### 상품 상세 - URL에 ID 반영 -- 상품 상세 페이지 이동 시 상품 ID가 URL 경로에 포함된다 (`/product/{productId}`) -- URL로 직접 접근 시 해당 상품의 상세 페이지가 자동으로 로드된다 +- [ ] 상품 상세 페이지 이동 시 상품 ID가 URL 경로에 포함된다 (`/product/{productId}`) +- [ ] URL로 직접 접근 시 해당 상품의 상세 페이지가 자동으로 로드된다 ### 상품 상세 - 새로고침시 유지 -- 새로고침 후에도 URL의 상품 ID를 읽어서 해당 상품 상세 페이지가 유지된다 +- [ ] 새로고침 후에도 URL의 상품 ID를 읽어서 해당 상품 상세 페이지가 유지된다 ### 404 페이지 -- 존재하지 않는 경로 접근 시 404 에러 페이지가 표시된다 -- 홈으로 돌아가기 버튼이 제공된다 +- [x] 존재하지 않는 경로 접근 시 404 에러 페이지가 표시된다 +- [x] 홈으로 돌아가기 버튼이 제공된다 diff --git a/src/App.js b/src/App.js new file mode 100644 index 00000000..5b6787c6 --- /dev/null +++ b/src/App.js @@ -0,0 +1,53 @@ +import { ProductListPage } from "./pages/ProductListPage.js"; +import { ProductDetailPage } from "./pages/ProductDetailPage.js"; +import { NotFoundPage } from "./pages/NotFoundPage.js"; +import { renderHeader } from "./layouts/headerRenderer.js"; +import { renderFooter } from "./layouts/footerRenderer.js"; +import { renderCartModal } from "./layouts/cartModalRenderer.js"; +import { renderToast } from "./layouts/toastRenderer.js"; // 토스트 렌더러 임포트 +import { router } from "./Router/router.js"; + +/** + * 초기 레이아웃 구조 (수정안) + * --> 헤더, 메인 콘텐츠, 푸터, 모달을 위한 컨테이너 생성 + * --> 이 구조는 애플리케이션 라이프싸이클 동안 유지됨 + */ +function renderInitialLayout() { + const root = document.getElementById("root"); + if (!root) { + console.error("Root element #root not found."); + return; + } + + // 기본 레이아웃 컨테이너 설정 + root.innerHTML = ` +
+
+
+ + +
+ `; + + // 각 섹션별 초기 렌더링 + renderHeader(); + renderFooter(); + renderCartModal(); + renderToast(); // 토스트 렌더러 호출 +} + +export default function App() { + // 애플리케이션 시작 시 초기 레이아웃을 한 번만 렌더링 + renderInitialLayout(); + + // 라우터 - 루트 정의 + // 이제 페이지 컴포넌트가 직접 { html, onMount }를 반환 + router.addRoute(/^\/$/, ProductListPage); + router.addRoute(/^\/product\/(?\w+)$/, ProductDetailPage); + + // 404 페이지 + router.setNotFound(NotFoundPage); + + // 라우터 시작 + router.start(); +} diff --git a/src/Router/router.js b/src/Router/router.js new file mode 100644 index 00000000..7b65ad95 --- /dev/null +++ b/src/Router/router.js @@ -0,0 +1,86 @@ +import { renderHeader } from "../layouts/headerRenderer.js"; + +const createRouter = () => { + const baseUrl = "/front_7th_chapter2-1"; + let routes = []; + let notFoundComponent = () => "

404 Not Found

"; + let currentOnUnmount = null; + + const handlePathChange = () => { + if (currentOnUnmount) { + currentOnUnmount(); + currentOnUnmount = null; + } + + renderHeader(); + + const path = window.location.pathname.startsWith(baseUrl) + ? window.location.pathname.slice(baseUrl.length) || "/" + : window.location.pathname; + const route = routes.find((r) => r.path.test(path)); + const $mainContentContainer = document.getElementById("main-content-container"); + + if (!$mainContentContainer) { + console.error("Main content container not found."); + return; + } + + const queryParams = Object.fromEntries(new URLSearchParams(window.location.search)); + let pageComponent; + + if (route) { + const pathParams = path.match(route.path)?.groups || {}; + pageComponent = route.component({ params: { ...pathParams, ...queryParams } }); + } else { + pageComponent = notFoundComponent(); + } + + $mainContentContainer.innerHTML = pageComponent.html; + if (pageComponent.onMount) { + currentOnUnmount = pageComponent.onMount(); + } + }; + + const router = { + addRoute(path, component) { + routes.push({ path, component }); + }, + setNotFound(component) { + notFoundComponent = component; + }, + updateQuery(newQuery) { + const params = new URLSearchParams(window.location.search); + Object.entries(newQuery).forEach(([key, value]) => { + if (value === null || value === undefined || value === "") { + params.delete(key); + } else { + params.set(key, value); + } + }); + const currentPath = window.location.pathname.startsWith(baseUrl) + ? window.location.pathname.slice(baseUrl.length) + : window.location.pathname; + const newUrl = `${baseUrl}${currentPath}?${params.toString()}`; + window.history.pushState({}, "", newUrl); + handlePathChange(); + }, + navigate(path) { + window.history.pushState({}, "", `${baseUrl}${path}`); + handlePathChange(); + }, + start() { + window.addEventListener("popstate", handlePathChange); + document.addEventListener("click", (e) => { + const target = e.target.closest("[data-link]"); + if (target) { + e.preventDefault(); + this.navigate(target.getAttribute("href")); + } + }); + handlePathChange(); + }, + }; + return router; +}; + +export const router = createRouter(); diff --git a/src/Store/cart.js b/src/Store/cart.js new file mode 100644 index 00000000..6689e281 --- /dev/null +++ b/src/Store/cart.js @@ -0,0 +1,182 @@ +/** + * 옵저버 패턴 상세 내용 + * + * 관찰 대상 (Subject): Cart 클래스의 인스턴스 (최하단에 생성한 cartStore) + * --> 싱글톤 패턴 활용 (하나의 Cart 스토어의 인스턴스를 모든 컴포넌트에서 공유하게 하기 위함) + * 상태 (State): #state (Object) + * 구독자 목록 (Observers): #observer (Set) + * 구독 (Subscribe): subscribe() 메서드. + * 알림 (Notify): #setState()가 호출되면 #notify() 메서드 호출. + * */ + +const initialState = { + items: [], // { productId, title, image, lprice, quantity, isChecked } + isCartOpen: false, +}; + +class Cart { + #state; + #observer; + + constructor() { + // 새로고침해도 데이터 유지시키기 + const savedCart = localStorage.getItem("cart"); + if (savedCart) { + const parsedCart = JSON.parse(savedCart); + // isCartOpen 상태는 새로고침 시 항상 false로 초기화 + this.#state = { ...parsedCart, isCartOpen: false }; + } else { + this.#state = initialState; + } + this.#observer = new Set(); + } + + getState() { + return structuredClone(this.#state); + } + + #setState(val) { + this.#state = { ...this.#state, ...val }; + // 상태가 변경될 때마다 localStorage에 저장 + localStorage.setItem("cart", JSON.stringify(this.#state)); + // 구독자에게 변화 감지 + 리렌더링 함수 실행 + this.#notify(); + } + + #notify() { + console.log("Cart Store - 데이터 변화 감지!"); + this.#observer.forEach((callback) => callback()); + } + + /** + * 옵저버 생성 (구독자/컴포넌트 추가) + * @param {function} callback 옵저버의 리렌더링 함수 + * @return {function} 옵저빙 취소 함수 제공 (옵저버 패턴 해제 로직) + * */ + subscribe(callback) { + console.log("Cart Store - subscribe!", callback); + this.#observer.add(callback); + return () => this.unsubscribe(callback); + } + + /** + * 옵저버 삭제 (구독 취소) + * @param {function} callback 옵저버의 리렌더링 함수(observer Set데이터에서 삭제) + * */ + unsubscribe(callback) { + console.log("Cart Store - unsubscribe!", callback); + this.#observer.delete(callback); + } + + /** + * 장바구니 모달의 열림/닫힘 상태 제어 + * @param {boolean} isOpen - 모달 노출/비노출 상태값 + */ + toggleCartModal(isOpen) { + this.#setState({ isCartOpen: isOpen }); + } + + /** + * 장바구니에 상품 등록 및 수량 추가 + * @param {Object} product - 추가할 상품 객체 { id, name, imageUrl, price } + * @param {number} quantity - 추가할 수량 + */ + addItem(product, quantity = 1) { + const existingItemIndex = this.#state.items.findIndex((item) => item.productId === product.productId); + let updatedItems; + + if (existingItemIndex > -1) { + updatedItems = [...this.#state.items]; + updatedItems[existingItemIndex].quantity += quantity; + console.log("Cart Store - 기존 장바구니 상품의 갯수 추가!", updatedItems); + } else { + const newItem = { ...product, quantity, isChecked: true }; + updatedItems = [...this.#state.items, newItem]; + console.log("Cart Store - 신규 장바구니 상품 추가!", updatedItems); + } + this.#setState({ items: updatedItems }); + } + + /** + * 장바구니에 등록된 상품 제거 + * @param {string} productId - 제거할 상품의 ID + */ + removeItem(productId) { + const updatedItems = this.#state.items.filter((item) => item.productId !== productId); + this.#setState({ items: updatedItems }); + } + + /** + * 장바구니 상품 수량 업데이트 + * @param {string} productId - 상품 ID + * @param {number} newQuantity - 새로운 수량 + */ + updateItemQuantity(productId, newQuantity) { + const updatedItems = this.#state.items.map((item) => + item.productId === productId ? { ...item, quantity: Math.max(1, newQuantity) } : item, + ); + this.#setState({ items: updatedItems }); + } + + /** + * 장바구니 상품 선택/해제 - 개별 + * @param {string} productId - 상품 ID + */ + cartItemChecked(productId) { + const updatedItems = this.#state.items.map((item) => + item.productId === productId ? { ...item, isChecked: !item.isChecked } : item, + ); + this.#setState({ items: updatedItems }); + } + + /** + * 장바구니 상품 선택/해제 - 전체 + * @param {boolean} isChecked - 전체 선택 여부 + */ + cartAllItemsChecked(isChecked) { + const updatedItems = this.#state.items.map((item) => ({ ...item, isChecked })); + this.#setState({ items: updatedItems }); + } + + /** + * 선택된 상품들 제거 + */ + removeSelectedItems() { + const updatedItems = this.#state.items.filter((item) => !item.isChecked); + this.#setState({ items: updatedItems }); + } + + /** + * 장바구니 비우기 + */ + clearCart() { + console.log("clearCart", this.#state.isCartOpen); + this.#setState({ ...initialState, isCartOpen: this.#state.isCartOpen }); + } + + /** + * 장바구니에 등록된 총 상품 금액 계산 + * @returns {number} 총 금액 + */ + getTotalPrice() { + return this.#state.items.reduce((total, item) => total + item.lprice * item.quantity, 0); + } + + /** + * 선택된 상품들의 총 금액 계산 + * @returns {number} 선택된 상품들의 총 금액 + */ + getSelectedTotalPrice() { + return this.#state.items.reduce((total, item) => (item.isChecked ? total + item.lprice * item.quantity : total), 0); + } + + /** + * 선택된 상품의 개수 반환 + * @returns {number} 선택된 상품의 개수 + */ + getSelectedItemsCount() { + return this.#state.items.filter((item) => item.isChecked).length; + } +} + +export const cartStore = new Cart(); diff --git a/src/Store/product.js b/src/Store/product.js new file mode 100644 index 00000000..6b8c4581 --- /dev/null +++ b/src/Store/product.js @@ -0,0 +1,201 @@ +import { getProducts, getCategories, getProduct } from "../api/productApi.js"; + +/** + * 옵저버 패턴 상세 내용 + * + * 관찰 대상 (Subject): Product 클래스의 인스턴스 (최하단에 생성한 productStore) + * --> 싱글톤 패턴 활용 (하나의 product 스토어의 인스턴스를 모든 컴포넌트에서 공유하게 하기 위함) + * 상태 (State): #state (Object) + * 구독자 목록 (Observers): #observer (Set) + * 구독 (Subscribe): subscribe() 메서드. + * 알림 (Notify): #setState()가 호출되면 #notify() 메서드 호출. + * */ + +// 초기 state 구조 잡기 +const initialState = { + products: [], + loading: false, + error: null, + pagination: { + page: 1, + limit: 20, + total: 0, + totalPages: 1, + hasNext: false, + }, + params: { + page: 1, + limit: 20, + sort: "price_asc", + search: "", + category1: "", + category2: "", + }, + categories: {}, + productDetail: { + loading: false, + data: null, + error: null, + }, +}; + +class Product { + // 캡슐화 + #state; + #observer; + + constructor() { + this.#state = initialState; + this.#observer = new Set(); + } + + getState() { + return structuredClone(this.#state); + } + + #setState(val) { + // TODO : 가능하다면 프록시 패턴 적용시켜보자! + this.#state = { ...this.#state, ...val }; + console.log("product.js - setState", this.#state); + // 구독자에게 변화 감지 + 리렌더링 함수 실행 + this.#notify(); + } + + #notify() { + console.log("Product Store - 데이터 변화 감지!"); + this.#observer.forEach((callback) => callback()); + } + /** + * 옵저버 생성 (구독자/컴포넌트 추가) + * @param {function} callback 옵저버의 리렌더링 함수 + * @return {function} 옵저빙 취소 함수 제공 (옵저버 패턴 해제 로직) + * */ + subscribe(callback) { + this.#observer.add(callback); + console.log("Product 스토어 구독자 추가!", callback); + return () => this.unsubscribe; + } + + /** + * 옵저버 삭제 (구독 취소) + * @param {function} callback 옵저버의 리렌더링 함수(observer Set데이터에서 삭제) + * */ + unsubscribe(callback) { + this.#observer.delete(callback); + } + + /** + * 상품 목록을 가져오기 + * page === 1 : 상품 목록 새로고침 + * page > 1 : 기존 상품 목록에 추가 (무한 스크롤) + * 줌 + */ + async fetchProducts() { + // 이미 로딩 중이면 중복 요청 방지 + if (this.#state.loading) return; + + console.log("fetchProducts! 상품 목록 가져오기!"); + + this.#setState({ loading: true, error: null }); + + try { + const params = this.#state.params; + const data = await getProducts(params); // 실제 API 호출 + + // 1페이지면 새로고침, 그 외에는 기존 목록에 추가 + const newProducts = params.page === 1 ? data.products : [...this.#state.products, ...data.products]; + + // 로딩 완료 상태로 변경 (상품 목록, 페이지네이션 정보 업데이트) + this.#setState({ + loading: false, + products: newProducts, + pagination: data.pagination, + }); + } catch (err) { + this.#setState({ loading: false, error: err.message }); + } + } + + /** + * 파라미터 변경 (검색어, 정렬, 개수) + * 파라미터가 변경되면 page = 1 / 상품 목록을 새로 호출. + * @param {object} newParams - 변경할 파라미터 (예: { search: '가방' }) + */ + setParams(newParams) { + // page: 1로 리셋하고, products도 비워줌 + // 새로운 url 쿼리(params)와 initialState의 params를 비교하여 주입 (API 바디 갱신 안됨 이슈 해결) + const updatedParams = { ...initialState.params, ...newParams, page: 1 }; + + this.#setState({ + params: updatedParams, + products: [], // 기존 목록 초기화 (1페이지부터 재호출) + pagination: initialState.pagination, // 페이지네이션 정보 초기화 + }); + + // 새 파라미터로 상품 목록을 다시 불러옵니다. + this.fetchProducts(); + } + + /** + * 다음 페이지를 가져오기 (무한 스크롤용) + */ + fetchNextPage() { + const { loading, pagination } = this.#state; + // 로딩 중이거나, 다음 페이지가 없으면(isLast) 실행하지 않습니다. + if (loading || !pagination.hasNext) { + return; + } + + // 현재 페이지 + 1 + const nextParams = { ...this.#state.params, page: pagination.page + 1 }; + + // fetchProducts 호출 + this.#setState({ params: nextParams }); + this.fetchProducts(); + } + + /** + * 카테고리 가져오기 + * */ + async getCategories() { + // 이미 데이터가 있으면 다시 부르지 않음 + if (Object.keys(this.#state.categories).length > 0) { + return; + } + console.log("getCategories"); + try { + const data = await getCategories(); + console.log(data); + this.#setState({ categories: data }); + } catch (err) { + this.#setState({ loading: false, error: err.message }); + } + } + + /** + * 상품 상세 정보 가져오기 + * @param {string} productId - 상품 ID + */ + async fetchProductById(productId) { + this.#setState({ + productDetail: { loading: true, data: null, error: null }, + }); + try { + const product = await getProduct(productId); + this.#setState({ + productDetail: { loading: false, data: product, error: null }, + }); + } catch (err) { + this.#setState({ + productDetail: { loading: false, data: null, error: err.message }, + }); + } + } +} + +/** + * 싱긅톤 패턴 적용 + * --> 하나의 product 스토어 객체를 모든 컴포넌트에서 공유하게 하기 위함 + * */ +const productStore = new Product(); +export default productStore; diff --git a/src/Store/toast.js b/src/Store/toast.js new file mode 100644 index 00000000..c8ab7ba7 --- /dev/null +++ b/src/Store/toast.js @@ -0,0 +1,80 @@ +/** + * 옵저버 패턴 상세 내용 + * + * 관찰 대상 (Subject): Toast 클래스의 인스턴스 (최하단에 생성한 toastStore) + * --> 싱글톤 패턴 활용 (하나의 Toast 스토어의 인스턴스를 모든 컴포넌트에서 공유하게 하기 위함) + * 상태 (State): #state (Object) + * 구독자 목록 (Observers): #observer (Set) + * 구독 (Subscribe): subscribe() 메서드. + * 알림 (Notify): #setState()가 호출되면 #notify() 메서드 호출. + * */ + +const initialState = { + message: "", + type: "info", // 'success', 'info', 'error' + isVisible: false, +}; + +class Toast { + #state; + #observer; + #timeoutId = null; + + constructor() { + this.#state = initialState; + this.#observer = new Set(); + } + + getState() { + return structuredClone(this.#state); + } + + #setState(val) { + this.#state = { ...this.#state, ...val }; + this.#notify(); + } + + #notify() { + this.#observer.forEach((callback) => callback()); + } + + subscribe(callback) { + this.#observer.add(callback); + return () => this.unsubscribe(callback); + } + + unsubscribe(callback) { + this.#observer.delete(callback); + } + + /** + * 토스트 메시지를 표시합니다. + * @param {string} message - 표시할 메시지 + * @param {'success' | 'info' | 'error'} type - 메시지 타입 + * @param {number} duration - 표시 시간 (ms) -- 디폴트: 3초 후 사라짐 + */ + showToast(message, type = "info", duration = 3000) { + if (this.#timeoutId) { + clearTimeout(this.#timeoutId); + } + + this.#setState({ + message, + type, + isVisible: true, + }); + + this.#timeoutId = setTimeout(() => { + this.hideToast(); + }, duration); + } + + /** + * 토스트 메시지를 숨깁니다. + */ + hideToast() { + this.#setState({ isVisible: false }); + } +} + +export const toastStore = new Toast(); diff --git a/src/components/cart/CartItem.js b/src/components/cart/CartItem.js new file mode 100644 index 00000000..2c92e530 --- /dev/null +++ b/src/components/cart/CartItem.js @@ -0,0 +1,53 @@ +export default function CartItem(item) { + const { productId, title, image, lprice, quantity, isChecked } = item; + const itemTotalPrice = lprice * quantity; + + return ` +
+ + + +
+ ${title} +
+ +
+

+ ${title} +

+

+ ${lprice.toLocaleString()}원 +

+ +
+ + + +
+
+ +
+

+ ${itemTotalPrice.toLocaleString()}원 +

+ +
+
+ `; +} diff --git a/src/components/cart/CartList.js b/src/components/cart/CartList.js new file mode 100644 index 00000000..b96e16ee --- /dev/null +++ b/src/components/cart/CartList.js @@ -0,0 +1,21 @@ +import CartItem from "./CartItem.js"; + +export default function CartList(items) { + const totalItemsCount = items.length; + const isAllItemsChecked = totalItemsCount > 0 && items.every((item) => item.isChecked); + + return ` + +
+ +
+
+
+ ${items.map((item) => CartItem(item)).join("")} +
+
+ `; +} diff --git a/src/components/cart/CartModal.js b/src/components/cart/CartModal.js new file mode 100644 index 00000000..202630ed --- /dev/null +++ b/src/components/cart/CartModal.js @@ -0,0 +1,110 @@ +import CartList from "./CartList.js"; +import { cartStore } from "../../Store/cart.js"; + +export default function CartModal() { + const { items, isCartOpen } = cartStore.getState(); + const isEmpty = items.length === 0; + + // Header content + const renderHeader = () => { + // const totalQuantity = items.reduce((acc, item) => acc + item.quantity, 0); + const totalQuantity = items.length; + return ` +
+

+ + + + 장바구니 + ${totalQuantity > 0 ? `(${totalQuantity})` : ""} +

+ +
+ `; + }; + + // Empty cart content + const renderEmptyCart = () => { + return ` +
+
+
+ + + +
+

장바구니가 비어있습니다

+

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

+
+
+ `; + }; + + // Footer content + const renderFooter = () => { + // const totalItemsCount = items.length; + const selectedItemsCount = cartStore.getSelectedItemsCount(); + const selectedTotalPrice = cartStore.getSelectedTotalPrice(); + const totalPrice = cartStore.getTotalPrice(); + + return ` +
+ ${ + selectedItemsCount > 0 + ? ` +
+ 선택한 상품 (${selectedItemsCount}개) + ${selectedTotalPrice.toLocaleString()}원 +
+ ` + : "" + } +
+ 총 금액 + ${totalPrice.toLocaleString()}원 +
+
+ ${ + selectedItemsCount > 0 + ? ` + + ` + : "" + } +
+ + +
+
+
+ `; + }; + + return ` +
+
+
+
+ ${renderHeader()} +
+ ${isEmpty ? renderEmptyCart() : CartList(items)} +
+ ${!isEmpty ? renderFooter() : ""} +
+
+
+ `; +} diff --git a/src/components/common/footer.js b/src/components/common/footer.js new file mode 100644 index 00000000..440906dc --- /dev/null +++ b/src/components/common/footer.js @@ -0,0 +1,7 @@ +export default function footer() { + return ``; +} diff --git a/src/components/common/header.js b/src/components/common/header.js new file mode 100644 index 00000000..9525d547 --- /dev/null +++ b/src/components/common/header.js @@ -0,0 +1,29 @@ +/** + * @param {string} title 페이지 타이틀 + * @param {number} cartNm 장바구니 갯수 + * */ +export default function header(props) { + // TODO - url정보에 따라 title값 변경 (라우터 연동) + let title = props?.title || "쇼핑몰"; + let cartNum = props?.cartNum || 0; + + return `
+
+
+

+ ${title} +

+
+ + +
+
+
+
`; +} diff --git a/src/components/common/toastUI.js b/src/components/common/toastUI.js new file mode 100644 index 00000000..0870f626 --- /dev/null +++ b/src/components/common/toastUI.js @@ -0,0 +1,39 @@ +export function toastUI(props) { + let message = props?.message || "메시지 없음"; + let type = props?.type || "info"; // 'success', 'info', 'error' + + let bgColorClass = ""; + let iconPath = ""; + switch (type) { + case "success": + bgColorClass = "bg-green-600"; + iconPath = "M5 13l4 4L19 7"; // Checkmark + break; + case "info": + bgColorClass = "bg-blue-600"; + iconPath = "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"; // Info circle + break; + case "error": + bgColorClass = "bg-red-600"; + iconPath = "M6 18L18 6M6 6l12 12"; // X mark + break; + default: + bgColorClass = "bg-gray-600"; + iconPath = "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"; + } + + return ` +
+
+ + + +
+

${message}

+ +
`; +} diff --git a/src/components/product/card.js b/src/components/product/card.js new file mode 100644 index 00000000..5928c3d5 --- /dev/null +++ b/src/components/product/card.js @@ -0,0 +1,34 @@ +export default function productCard(product) { + return ` +
+ + +
+ ${product.title} +
+ +
+
+

+ ${product.title} +

+

${product.brand}

+

+ ${product.lprice.toLocaleString()}원 +

+
+
+
+ +
+ +
+
`; +} diff --git a/src/components/product/detail/Breadcrumb.js b/src/components/product/detail/Breadcrumb.js new file mode 100644 index 00000000..895b6699 --- /dev/null +++ b/src/components/product/detail/Breadcrumb.js @@ -0,0 +1,18 @@ +/** + * 상품 상세 페이지의 브레드크럼 + * @param {object} detail - 상품 상세 정보 객체 + * @returns {string} - 브레드크럼 HTML 문자열 + */ +export default function DetailBreadcrumb(detail) { + return ` + + `; +} diff --git a/src/components/product/detail/ProductInfo.js b/src/components/product/detail/ProductInfo.js new file mode 100644 index 00000000..832ded09 --- /dev/null +++ b/src/components/product/detail/ProductInfo.js @@ -0,0 +1,51 @@ +/** + * 상품의 주요 상세 정보 + * @param {object} detail - 상품 상세 정보 객체 + * @param {number} quantity - 현재 선택된 수량 + * @returns {string} - 상품 정보 HTML 문자열 + */ +export default function ProductInfo(detail, quantity) { + return ` +
+
+
+ ${detail.title} +
+
+

${detail.brand || ""}

+

${detail.title}

+
+ ${detail.rating.toFixed(1)} (${detail.reviewCount.toLocaleString()}개 리뷰) +
+
+ ${detail.lprice.toLocaleString()}원 +
+
재고 ${detail.stock.toLocaleString()}개
+
${detail.description}
+
+
+
+
+ 수량 +
+ + + +
+
+ +
+
+
+ +
+ `; +} diff --git a/src/components/product/detail/RelatedProducts.js b/src/components/product/detail/RelatedProducts.js new file mode 100644 index 00000000..b53a8690 --- /dev/null +++ b/src/components/product/detail/RelatedProducts.js @@ -0,0 +1,36 @@ +/** + * 관련 상품 목록 + * @param {Array} relatedProducts - 관련 상품 배열 + * @returns {string} - 관련 상품 섹션 HTML 문자열 + */ +export default function RelatedProducts(relatedProducts) { + if (!relatedProducts || relatedProducts.length === 0) { + return ""; + } + + return ` +
+
+

관련 상품

+

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

+
+
+
+ ${relatedProducts + .map( + (p) => ` + + `, + ) + .join("")} +
+
+
+ `; +} diff --git a/src/components/product/list.js b/src/components/product/list.js new file mode 100644 index 00000000..35796821 --- /dev/null +++ b/src/components/product/list.js @@ -0,0 +1,30 @@ +import productCard from "./card.js"; +import skeleton from "./skeleton.js"; +import spiningLoading from "./spiningLoading.js"; + +export default function productList(props) { + let hasNext = props?.hasNext || false; + let list = props?.list || []; + let isLoading = props?.isLoading || false; + + return ` +
+ +
+ 총 ${list.length}의 상품 +
+ +
+ ${list + .map((li) => { + return productCard(li); + }) + .join("")} + ${isLoading ? skeleton() : ""} +
+ + ${isLoading ? spiningLoading() : ""} + + ${!hasNext ? `
모든 상품을 확인했습니다
` : ""} +
`; +} diff --git a/src/components/product/search/Breadcrumb.js b/src/components/product/search/Breadcrumb.js new file mode 100644 index 00000000..306b8104 --- /dev/null +++ b/src/components/product/search/Breadcrumb.js @@ -0,0 +1,18 @@ +export default function Breadcrumb({ category1, category2 }) { + let breadcrumbHTML = + ''; + if (category1) { + breadcrumbHTML += `> + `; + } + if (category2) { + breadcrumbHTML += `> + ${category2}`; + } + return ` +
+ + ${breadcrumbHTML} +
+ `; +} diff --git a/src/components/product/search/CategoryButtons.js b/src/components/product/search/CategoryButtons.js new file mode 100644 index 00000000..843c17f2 --- /dev/null +++ b/src/components/product/search/CategoryButtons.js @@ -0,0 +1,54 @@ +export default function CategoryButtons({ categories = {}, category1, category2 }) { + let categoryButtonsHTML = ""; + let currentCategories = {}; + + if (Object.keys(categories).length === 0) { + return '
카테고리 로딩 중...
'; + } + + // 1뎁스 카테고리 그리기 + if (!category1) { + currentCategories = categories; + categoryButtonsHTML = Object.keys(currentCategories) + .map((cat1) => { + return ``; + }) + .join(""); + } + // 2뎁스 카테고리 그리기 + else if (categories[category1] && !category2) { + currentCategories = categories[category1]; + categoryButtonsHTML = Object.keys(currentCategories) + .map((cat2) => { + return ``; + }) + .join(""); + } + // 2 뎁스 카테고리 그리기 (2뎁스 카테고리 활성화) + else if (categories[category1] && category2) { + currentCategories = categories[category1]; + categoryButtonsHTML = Object.keys(currentCategories) + .map((cat2) => { + const isSelected = cat2 === category2; + const selectedClass = isSelected + ? "bg-blue-100 border-blue-300 text-blue-800" + : "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"; + return ``; + }) + .join(""); + } + + return ` +
+ ${categoryButtonsHTML} +
+ `; +} diff --git a/src/components/product/search/FilterOptions.js b/src/components/product/search/FilterOptions.js new file mode 100644 index 00000000..a88eeaec --- /dev/null +++ b/src/components/product/search/FilterOptions.js @@ -0,0 +1,41 @@ +export default function FilterOptions({ limit = 20, sort = "price_asc" }) { + const pageSizeOption = [ + { label: "10개", value: 10 }, + { label: "20개", value: 20 }, + { label: "50개", value: 50 }, + { label: "100개", value: 100 }, + ]; + const sortOption = [ + { label: "가격 낮은순", value: "price_asc" }, + { label: "가격 높은순", value: "price_desc" }, + { label: "이름순", value: "name_asc" }, + { label: "이름 역순", value: "name_desc" }, + ]; + + return ` +
+
+ + +
+
+ + +
+
+ `; +} diff --git a/src/components/product/search/SearchInput.js b/src/components/product/search/SearchInput.js new file mode 100644 index 00000000..b28d1378 --- /dev/null +++ b/src/components/product/search/SearchInput.js @@ -0,0 +1,23 @@ +export default function SearchInput({ search = "" }) { + return ` + +
+
+ +
+ + + +
+
+
+ `; +} diff --git a/src/components/product/skeleton.js b/src/components/product/skeleton.js new file mode 100644 index 00000000..a0add126 --- /dev/null +++ b/src/components/product/skeleton.js @@ -0,0 +1,11 @@ +export default function skeleton() { + return `
+
+
+
+
+
+
+
+
`.repeat(4); +} diff --git a/src/components/product/spiningLoading.js b/src/components/product/spiningLoading.js new file mode 100644 index 00000000..43a59b0c --- /dev/null +++ b/src/components/product/spiningLoading.js @@ -0,0 +1,12 @@ +export default function spiningLoading() { + return `
+
+ + + + + 상품을 불러오는 중... +
+
`; +} diff --git a/src/layouts/cartModalRenderer.js b/src/layouts/cartModalRenderer.js new file mode 100644 index 00000000..57040b19 --- /dev/null +++ b/src/layouts/cartModalRenderer.js @@ -0,0 +1,23 @@ +import CartModal from "../components/cart/CartModal.js"; +import { cartStore } from "../Store/cart.js"; +import { cartModalControl, cartProductControl } from "../utils/cartEventListeners.js"; + +/** + * 장바구니 모달을 렌더링 + 관련 이벤트 리스너 부착 + */ +export function renderCartModal() { + const modalContainer = document.getElementById("modal-container"); + if (!modalContainer) { + console.error("Modal container not found."); + return; + } + + modalContainer.innerHTML = CartModal(); + + // 모달이 다시 렌더링될 때마다 관련 이벤트를 다시 부착 + cartModalControl(); // 모달 열고 닫기 이벤트 + cartProductControl(); // 모달 내부 상품 관련 이벤트 +} + +// cartStore 구독: 장바구니 상태 변경 시 모달 내용을 다시 렌더링 +cartStore.subscribe(renderCartModal); diff --git a/src/layouts/footerRenderer.js b/src/layouts/footerRenderer.js new file mode 100644 index 00000000..85b3f154 --- /dev/null +++ b/src/layouts/footerRenderer.js @@ -0,0 +1,18 @@ +/** + * 푸터를 렌더링합니다. + */ +export function renderFooter() { + const footerContainer = document.getElementById("footer-container"); + if (!footerContainer) { + console.error("Footer container not found."); + return; + } + + footerContainer.innerHTML = ` + + `; +} diff --git a/src/layouts/headerRenderer.js b/src/layouts/headerRenderer.js new file mode 100644 index 00000000..0fc19c69 --- /dev/null +++ b/src/layouts/headerRenderer.js @@ -0,0 +1,85 @@ +import { cartStore } from "../Store/cart.js"; +import { cartModalControl } from "../utils/cartEventListeners.js"; + +const baseUrl = "/front_7th_chapter2-1"; + +/** + * 헤더 렌더링 + */ +export function renderHeader() { + const headerContainer = document.getElementById("header-container"); + if (!headerContainer) { + console.error("header-container 없음"); + return; + } + + const path = window.location.pathname.startsWith(baseUrl) + ? window.location.pathname.slice(baseUrl.length) || "/" + : window.location.pathname; + const isDetailPage = /^\/product\/\w+$/.test(path); + + let headerTitleContent; + if (isDetailPage) { + headerTitleContent = ` +
+ +

상품 상세

+
+ `; + } else { + headerTitleContent = ` +

+ 쇼핑몰 +

+ `; + } + + // 장바구니 아이템 개수 업데이트 + const { items } = cartStore.getState(); + const totalQuantity = items.length; + + headerContainer.innerHTML = ` +
+
+
+ ${headerTitleContent} +
+ + +
+
+
+
+ `; + + // 헤더가 다시 렌더링될 때마다 이벤트를 다시 부착 + cartModalControl(); + + const backBtn = document.getElementById("back-btn"); + if (backBtn) { + backBtn.addEventListener("click", () => { + window.history.back(); + }); + } +} + +// cartStore 구독: 장바구니 상태 변경 시 헤더를 다시 렌더링하여 아이템 개수를 업데이트 +cartStore.subscribe(renderHeader); diff --git a/src/layouts/toastRenderer.js b/src/layouts/toastRenderer.js new file mode 100644 index 00000000..96f794bb --- /dev/null +++ b/src/layouts/toastRenderer.js @@ -0,0 +1,43 @@ +import { toastStore } from "../Store/toast.js"; +import { toastUI } from "../components/common/toastUI.js"; + +/** + * 토스트 메시지 렌더링 + 이벤트 리스너 등록 + */ +export function renderToast() { + const modalContainer = document.getElementById("modal-container"); // Use modal-container for toasts + if (!modalContainer) { + console.error("Toast container not found."); + return; + } + + const { message, type, isVisible } = toastStore.getState(); + + if (isVisible) { + // 토스트가 표시될 때만 렌더링 + modalContainer.insertAdjacentHTML( + "beforeend", + ` +
+ ${toastUI({ message, type })} +
+ `, + ); + // 닫기 버튼 이벤트 리스너 부착 + const closeBtn = document.getElementById("toast-close-btn"); + if (closeBtn) { + closeBtn.addEventListener("click", () => { + toastStore.hideToast(); + }); + } + } else { + // 토스트가 숨겨질 때 DOM에서 제거 + const toastWrapper = document.getElementById("toast-wrapper"); + if (toastWrapper) { + toastWrapper.remove(); + } + } +} + +// toastStore 구독: 토스트 상태 변경 시 토스트를 다시 렌더링 +toastStore.subscribe(renderToast); diff --git a/src/main.js b/src/main.js index 4b055b89..c8723220 100644 --- a/src/main.js +++ b/src/main.js @@ -1,1152 +1,20 @@ +import App from "./App.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 상품목록_레이아웃_로딩 = ` -
-
-
-
-

- 쇼핑몰 -

-
- - -
-
-
-
-
- -
- -
-
- -
- - - -
-
-
- -
- -
-
- - -
- -
-
카테고리 로딩 중...
-
- -
- -
- -
- - -
- -
- - -
-
-
-
- -
-
- -
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- - - - - 상품을 불러오는 중... -
-
-
-
-
-
-
-

© 2025 항해플러스 프론트엔드 쇼핑몰

-
-
-
- `; - - const 상품목록_레이아웃_로딩완료 = ` -
-
-
-
-

- 쇼핑몰 -

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

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

-

-

- 220원 -

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

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

-

이지웨이건축자재

-

- 230원 -

-
- - -
-
-
- -
- 모든 상품을 확인했습니다 -
-
-
-
-
-
-

© 2025 항해플러스 프론트엔드 쇼핑몰

-
-
-
- `; - - const 상품목록_레이아웃_카테고리_1Depth = ` -
- -
- -
-
- -
- - - -
-
-
- - -
- - -
-
- - > -
-
-
- - - -
-
-
- - -
- -
- - -
- -
- - -
-
-
-
-
- `; - - const 상품목록_레이아웃_카테고리_2Depth = ` -
- -
- -
-
- -
- - - -
-
-
- - -
- - -
-
- - >>주방용품 -
-
-
- - - -
-
-
- - -
- -
- - -
- -
- - -
-
-
-
-
- `; - - const 토스트 = ` -
-
-
- - - -
-

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

- -
- -
-
- - - -
-

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

- -
- -
-
- - - -
-

오류가 발생했습니다.

- -
-
- `; - - const 장바구니_비어있음 = ` -
-
- -
-

- - - - 장바구니 -

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

장바구니가 비어있습니다

-

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

-
-
-
-
-
- `; - - const 장바구니_선택없음 = ` -
-
- -
-

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

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

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

-

- 220원 -

- -
- - - -
-
- -
-

- 440원 -

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

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

-

- 230원 -

- -
- - - -
-
- -
-

- 230원 -

- -
-
-
-
-
- -
- - -
- 총 금액 - 670원 -
- -
-
- - -
-
-
-
-
- `; - - const 장바구니_선택있음 = ` -
-
- -
-

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

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

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

-

- 220원 -

- -
- - - -
-
- -
-

- 440원 -

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

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

-

- 230원 -

- -
- - - -
-
- -
-

- 230원 -

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

상품 상세

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

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

-
-
-
-
-
-

© 2025 항해플러스 프론트엔드 쇼핑몰

-
-
-
- `; - - const 상세페이지_로딩완료 = ` -
-
-
-
-
- -

상품 상세

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

-

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

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

관련 상품

-

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

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

© 2025 항해플러스 프론트엔드 쇼핑몰

-
-
-
- `; - - const _404_ = ` -
-
- - - - - - - - - - - - - 404 - - - - - - - - - 페이지를 찾을 수 없습니다 - - - - - - 홈으로 -
-
- `; - - document.body.innerHTML = ` - ${상품목록_레이아웃_로딩} -
- ${상품목록_레이아웃_로딩완료} -
- ${상품목록_레이아웃_카테고리_1Depth} -
- ${상품목록_레이아웃_카테고리_2Depth} -
- ${토스트} -
- ${장바구니_비어있음} -
- ${장바구니_선택없음} -
- ${장바구니_선택있음} -
- ${상세페이지_로딩} -
- ${상세페이지_로딩완료} -
- ${_404_} - `; -} - -// 애플리케이션 시작 +// 랜더링 시작 if (import.meta.env.MODE !== "test") { - enableMocking().then(main); + enableMocking().then(() => { + App(); + }); } else { - main(); + App(); } diff --git a/src/main_origin.js b/src/main_origin.js new file mode 100644 index 00000000..4b055b89 --- /dev/null +++ b/src/main_origin.js @@ -0,0 +1,1152 @@ +const enableMocking = () => + import("./mocks/browser.js").then(({ worker }) => + worker.start({ + onUnhandledRequest: "bypass", + }), + ); + +function main() { + const 상품목록_레이아웃_로딩 = ` +
+
+
+
+

+ 쇼핑몰 +

+
+ + +
+
+
+
+
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+
카테고리 로딩 중...
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + + 상품을 불러오는 중... +
+
+
+
+
+
+
+

© 2025 항해플러스 프론트엔드 쇼핑몰

+
+
+
+ `; + + const 상품목록_레이아웃_로딩완료 = ` +
+
+
+
+

+ 쇼핑몰 +

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

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

+

+

+ 220원 +

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

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

+

이지웨이건축자재

+

+ 230원 +

+
+ + +
+
+
+ +
+ 모든 상품을 확인했습니다 +
+
+
+
+
+
+

© 2025 항해플러스 프론트엔드 쇼핑몰

+
+
+
+ `; + + const 상품목록_레이아웃_카테고리_1Depth = ` +
+ +
+ +
+
+ +
+ + + +
+
+
+ + +
+ + +
+
+ + > +
+
+
+ + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ `; + + const 상품목록_레이아웃_카테고리_2Depth = ` +
+ +
+ +
+
+ +
+ + + +
+
+
+ + +
+ + +
+
+ + >>주방용품 +
+
+
+ + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ `; + + const 토스트 = ` +
+
+
+ + + +
+

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

+ +
+ +
+
+ + + +
+

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

+ +
+ +
+
+ + + +
+

오류가 발생했습니다.

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

+ + + + 장바구니 +

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

장바구니가 비어있습니다

+

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

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

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

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

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

+

+ 220원 +

+ +
+ + + +
+
+ +
+

+ 440원 +

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

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

+

+ 230원 +

+ +
+ + + +
+
+ +
+

+ 230원 +

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

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

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

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

+

+ 220원 +

+ +
+ + + +
+
+ +
+

+ 440원 +

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

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

+

+ 230원 +

+ +
+ + + +
+
+ +
+

+ 230원 +

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

상품 상세

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

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

+
+
+
+
+
+

© 2025 항해플러스 프론트엔드 쇼핑몰

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

상품 상세

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

+

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

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

관련 상품

+

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

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

© 2025 항해플러스 프론트엔드 쇼핑몰

+
+
+
+ `; + + const _404_ = ` +
+
+ + + + + + + + + + + + + 404 + + + + + + + + + 페이지를 찾을 수 없습니다 + + + + + + 홈으로 +
+
+ `; + + document.body.innerHTML = ` + ${상품목록_레이아웃_로딩} +
+ ${상품목록_레이아웃_로딩완료} +
+ ${상품목록_레이아웃_카테고리_1Depth} +
+ ${상품목록_레이아웃_카테고리_2Depth} +
+ ${토스트} +
+ ${장바구니_비어있음} +
+ ${장바구니_선택없음} +
+ ${장바구니_선택있음} +
+ ${상세페이지_로딩} +
+ ${상세페이지_로딩완료} +
+ ${_404_} + `; +} + +// 애플리케이션 시작 +if (import.meta.env.MODE !== "test") { + enableMocking().then(main); +} else { + main(); +} diff --git a/src/pages/NotFoundPage.js b/src/pages/NotFoundPage.js new file mode 100644 index 00000000..bc9f3c32 --- /dev/null +++ b/src/pages/NotFoundPage.js @@ -0,0 +1,16 @@ +export function NotFoundPage() { + return { + html: ` +
+
+

404

+

404 페이지를 찾을 수 없습니다

+ + 홈으로 + +
+
+ `, + onMount: null, + }; +} diff --git a/src/pages/ProductDetailPage.js b/src/pages/ProductDetailPage.js new file mode 100644 index 00000000..2456855a --- /dev/null +++ b/src/pages/ProductDetailPage.js @@ -0,0 +1,155 @@ +import productStore from "../Store/product.js"; +import { cartStore } from "../Store/cart.js"; +import { toastStore } from "../Store/toast.js"; +import { router } from "../Router/router.js"; +import DetailBreadcrumb from "../components/product/detail/Breadcrumb.js"; +import ProductInfo from "../components/product/detail/ProductInfo.js"; +import RelatedProducts from "../components/product/detail/RelatedProducts.js"; + +let eventsInitialized = false; +let quantity = 1; + +/** + * 상품 상세 정보 렌더링 + * @param {object} detail - 현재 상품의 상세 정보 + * @param {Array} products - 관련 상품 후보 목록 + */ +function renderProductDetail(detail, products) { + quantity = 1; // 상세 페이지 진입 시 수량을 1로 초기화 + + // 현재 상품을 제외한 관련 상품 목록 생성 (최대 20개) + const relatedProducts = products.filter((p) => p.productId !== detail.productId).slice(0, 20); + console.log("relatedProducts", relatedProducts); + + return ` + ${DetailBreadcrumb(detail)} + ${ProductInfo(detail, quantity)} + ${RelatedProducts(relatedProducts)} + `; +} + +function render() { + const pageContainer = document.getElementById("product-detail-page"); + if (!pageContainer) return; + + const { productDetail, products } = productStore.getState(); + const { loading, data, error } = productDetail; + + if (loading) { + pageContainer.innerHTML = ` +
+
+
+

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

+
+
+ `; + } else if (error) { + pageContainer.innerHTML = ` +
+

데이터를 불러오는데 실패했습니다.

+

${error}

+ +
+ `; + } else if (data) { + pageContainer.innerHTML = renderProductDetail(data, products); + } +} + +function setupEventListeners(productId) { + if (eventsInitialized) return; + + document.body.addEventListener("click", (e) => { + const pageContainer = e.target.closest("#product-detail-page"); + if (!pageContainer) return; + + // 재시도 버튼 + if (e.target.id === "retry-fetch") { + productStore.fetchProductById(productId); + return; + } + + // 수량 조절 + const quantityInput = document.getElementById("quantity-input"); + if (quantityInput) { + const stock = parseInt(quantityInput.max, 10); + if (e.target.closest("#quantity-increase") && quantity < stock) { + quantity++; + quantityInput.value = quantity; + } else if (e.target.closest("#quantity-decrease") && quantity > 1) { + quantity--; + quantityInput.value = quantity; + } + } + + // 장바구니 담기 + const addToCartBtn = e.target.closest("#add-to-cart-btn"); + if (addToCartBtn) { + const { productId: btnProductId } = addToCartBtn.dataset; + const { data } = productStore.getState().productDetail; + if (data && data.productId === btnProductId) { + cartStore.addItem(data, quantity); + toastStore.showToast("장바구니에 상품이 추가되었습니다.", "success"); + } + return; + } + + // 관련 상품 클릭 + const relatedCard = e.target.closest(".related-product-card"); + if (relatedCard) { + const { productId: relatedProductId } = relatedCard.dataset; + router.navigate(`/product/${relatedProductId}`); + return; + } + + // 목록으로 돌아가기 + if (e.target.closest(".go-to-product-list")) { + let productObj = productStore.getState().productDetail.data; + console.log("상품목록 돌아가기", productObj); + router.navigate(`/?category1=${productObj.category1 || ""}&category2=${productObj.category2 || ""}`); + // window.history.back(); + return; + } + + // 브레드크럼 클릭 + const breadcrumb = e.target.closest(".breadcrumb-link"); + if (breadcrumb) { + const { category1, category2 } = breadcrumb.dataset; + router.navigate(`/?category1=${category1 || ""}&category2=${category2 || ""}`); + return; + } + }); + + eventsInitialized = true; +} + +export function ProductDetailPage({ params }) { + const onMount = () => { + console.log("ProductDetailPage onMount, params:", params); + setupEventListeners(params.id); + const unsubscribe = productStore.subscribe(render); + + // 1. 현재 상품 상세 정보 가져오기 + productStore.fetchProductById(params.id).then(() => { + // 2. 상세 정보 로드 후, 관련 상품 목록 가져오기 + const { data: productDetail } = productStore.getState().productDetail; + if (productDetail) { + productStore.setParams({ + // category1: productDetail.category1, + category2: productDetail.category2, + limit: 20, // 현재 상품 포함 20개 + }); + } + }); + + return () => { + unsubscribe(); + }; + }; + + return { + html: `
`, + onMount, + }; +} diff --git a/src/pages/ProductListPage.js b/src/pages/ProductListPage.js new file mode 100644 index 00000000..1c81c5df --- /dev/null +++ b/src/pages/ProductListPage.js @@ -0,0 +1,270 @@ +import productStore from "../Store/product.js"; +import { router } from "../Router/router.js"; +import SearchInput from "../components/product/search/SearchInput.js"; +import Breadcrumb from "../components/product/search/Breadcrumb.js"; +import CategoryButtons from "../components/product/search/CategoryButtons.js"; +import FilterOptions from "../components/product/search/FilterOptions.js"; +import productCard from "../components/product/card.js"; +import skeleton from "../components/product/skeleton.js"; +import spiningLoading from "../components/product/spiningLoading.js"; +import { cartStore } from "../Store/cart.js"; // Import cartStore +import { toastStore } from "../Store/toast.js"; // toastStore 임포트 + +let eventsInitialized = false; + +/** + * 이벤트 리스너 설정 (검색, 필터, 카테고리 등) + * 이벤트 위임을 사용하여 body에 한 번만 리스너 등록 + */ +function setupEventListeners() { + if (eventsInitialized) return; + + document.body.addEventListener("click", (e) => { + /** + * 재시도 버튼 클릭 이벤트 + * */ + if (e.target.closest("#retry-fetch")) { + const { params } = productStore.getState(); + productStore.setParams(params); + return; + } + + /** + * 장바구니 담기 버튼 클릭 이벤트 + * */ + const addToCartBtn = e.target.closest(".add-to-cart-btn"); + if (addToCartBtn) { + const productId = addToCartBtn.dataset.productId; + const { products } = productStore.getState(); + const foundProduct = products.find((p) => p.productId === productId); + console.log("addToCartBtn", productId, products); + + if (foundProduct) { + cartStore.addItem(foundProduct, 1); + toastStore.showToast("장바구니에 상품이 추가되었습니다.", "success"); // 토스트 메시지 추가 + } + return; + } + + const productSearchFilter = e.target.closest("#product-search-filter"); + if (!productSearchFilter) return; + + /** + * 카테고리 버튼 클릭 이벤트 + * */ + const categoryBtn = e.target.closest(".category1-filter-btn, .category2-filter-btn"); + if (categoryBtn) { + const { category1, category2 } = categoryBtn.dataset; + const newQuery = { page: 1, category1: category1 || "", category2: category2 || "" }; + if (!category2) newQuery.category2 = ""; + router.updateQuery(newQuery); + return; + } + + /** + * 브레드 크럼 클릭 이벤트 + * */ + const breadcrumbBtn = e.target.closest("[data-breadcrumb]"); + if (breadcrumbBtn) { + const { breadcrumb } = breadcrumbBtn.dataset; + const newQuery = { page: 1 }; + if (breadcrumb === "reset") { + newQuery.category1 = ""; + newQuery.category2 = ""; + } else if (breadcrumb === "category1") { + newQuery.category2 = ""; + } + router.updateQuery(newQuery); + return; + } + }); + + /** + * 검색어 입력 이벤트 + * */ + document.body.addEventListener("keydown", (e) => { + if (e.target.id === "search-input" && e.key === "Enter") { + e.preventDefault(); + router.updateQuery({ page: 1, search: e.target.value }); + } + }); + + /** + * 개수/정렬 필터 이벤트 + * */ + document.body.addEventListener("change", (e) => { + if (e.target.id === "limit-select") { + router.updateQuery({ page: 1, limit: e.target.value }); + } else if (e.target.id === "sort-select") { + router.updateQuery({ page: 1, sort: e.target.value }); + } + }); + + eventsInitialized = true; +} + +/** + * 검색 & 필터 영역 렌더링 함수 + * @param {Object} params productStore의 state.params + * @param {Object} categories productStore의 categories + * */ +function renderSearchFilter(params, categories) { + const { search, category1, category2, limit, sort } = params; + return ` + ${SearchInput({ search })} +
+
+ ${Breadcrumb({ category1, category2 })} + ${CategoryButtons({ categories, category1, category2 })} +
+ ${FilterOptions({ limit, sort })} +
+ `; +} + +export function ProductListPage(queryParams) { + // 이벤트 리스너는 한 번만 설정 + // refactor : onMount에서 설정 + // setupEventListeners(); + + /** + * 랜더링 함수 (스토어 업데이트 시 실행) + * */ + const handleStoreUpdate = () => { + const productListPage = document.getElementById("product-list-page"); + if (!productListPage) return; + + // 상품정보, 로딩여부, 페이징 데이터 state 가져오기 + const { products, loading, pagination, params, categories, error } = productStore.getState(); + + const productSearchFilter = document.getElementById("product-search-filter"); + if (productSearchFilter) { + productSearchFilter.innerHTML = renderSearchFilter(params, categories); + } + + // 상품 총 갯수 + const countSpan = document.getElementById("product-total-count"); + if (countSpan) { + countSpan.textContent = pagination.total.toLocaleString(); + } + + const grid = document.getElementById("products-grid"); + const footerIndicator = document.getElementById("list-footer-indicator"); + + if (error) { + if (grid) { + grid.innerHTML = ` +
+

데이터를 불러오는데 실패했습니다.

+

${error.message || "알 수 없는 오류가 발생했습니다."}

+ +
+ `; + } + if (footerIndicator) { + footerIndicator.innerHTML = ""; + } + return; + } + + if (grid) { + if (params.page === 1) { + grid.innerHTML = loading && products.length === 0 ? skeleton() : products.map((p) => productCard(p)).join(""); + } else { + const existingCardCount = grid.querySelectorAll(".product-card").length; + const newProducts = products.slice(existingCardCount); + if (newProducts.length > 0) { + const newCardsHTML = newProducts.map((p) => productCard(p)).join(""); + grid.insertAdjacentHTML("beforeend", newCardsHTML); + } + } + } + + // const footerIndicator = document.getElementById("list-footer-indicator"); + if (footerIndicator) { + if (loading && params.page > 1) { + footerIndicator.innerHTML = spiningLoading(); + } else if (!pagination.hasNext) { + footerIndicator.innerHTML = `
모든 상품을 확인했습니다
`; + } else { + footerIndicator.innerHTML = ""; + } + } + }; + + const onMount = () => { + console.log("ProductListPage onMount!"); + // 이벤트 리스너 설정 + setupEventListeners(); + + // 스토어 구독 + const unsubscribe = productStore.subscribe(handleStoreUpdate); + + // IntersectionObserver 구독 로직 (무한 스크롤) + const observer = new IntersectionObserver( + // 스크롤 최하단 감지시 실행시킬 콜백함수 정의 + (entries) => { + // 관찰 대상("list-footer-indicator") 정보 가져오기 + // entries 타입이 배열이지만, 보통 길이가 1이이서 직접 인덱싱해도 무관 + const firstEntry = entries[0]; + const { loading, pagination } = productStore.getState(); + + // firstEntry.isIntersecting : 관찰 대상("list-footer-indicator")가 화면상 보이고 있는지 체크 (보이면 true) + if (firstEntry.isIntersecting && !loading && pagination.hasNext) { + // 다음 상품 목록 가져오기 + productStore.fetchNextPage(); + } + }, + // 콜백함수 실행 시점 지정 + // 관찰 대상("list-footer-indicator")이 화면상 10%이상 보이기 시작하면 콜백함수(다음 상품목록 호출 함수) 실행 + { threshold: 0.1 }, + ); + + const target = document.getElementById("list-footer-indicator"); + if (target) { + // interserctionObserver 구독 시작 + observer.observe(target); + } + + // 초기 데이터 로드 + // setParams는 state를 변경하고 + // setParams의 내부 로직 중 this.#setState에서 notify()를 통해 리렌더링 실시 (handleStoreUpdate) + productStore.setParams({ ...queryParams.params }); + productStore.getCategories(); + + // unmount 시 옵저버 패턴 구독 취소 함수 리턴 (product 스토어 + intersectionObserver) + return () => { + unsubscribe(); + observer.disconnect(); + }; + }; + + /** + * ProductListPage.js 호출 후 초기 페이지 DOM + * 처음에는 skeleton 호출 + * 데이터 로딩이 완료시, 구독된 handleStoreUpdate 함수 실행 (상품 목록 채워진 버전으로 리렌더링). + * */ + const initialState = productStore.getState(); + return { + html: ` +
+ +
+ ${renderSearchFilter(initialState.params, initialState.categories)} +
+ +
+
+ 총 0개의 상품 +
+
+ ${skeleton()} +
+ +
+
`, + onMount, + }; +} diff --git a/src/setupTests.js b/src/setupTests.js new file mode 100644 index 00000000..5113b534 --- /dev/null +++ b/src/setupTests.js @@ -0,0 +1,16 @@ +import "@testing-library/jest-dom"; +import { configure } from "@testing-library/dom"; +// import { afterAll, beforeAll } from "vitest"; +// import { server } from "./__tests__/mockServerHandler.js"; + +configure({ + asyncUtilTimeout: 5000, +}); + +// beforeAll(() => { +// server.listen({ onUnhandledRequest: "error" }); +// }); +// +// afterAll(() => { +// server.close(); +// }); diff --git a/src/util/searchProduct.js b/src/util/searchProduct.js new file mode 100644 index 00000000..cf1f45ce --- /dev/null +++ b/src/util/searchProduct.js @@ -0,0 +1,3 @@ +export function searchProduct() { + // +} diff --git a/src/utils/cartEventListeners.js b/src/utils/cartEventListeners.js new file mode 100644 index 00000000..fd464737 --- /dev/null +++ b/src/utils/cartEventListeners.js @@ -0,0 +1,121 @@ +import { cartStore } from "../Store/cart.js"; +import { toastStore } from "../Store/toast.js"; // toastStore 임포트 + +/** + * 장바구니 모달을 열고 닫는 이벤트 리스너 추가용 함수 + * 헤더와 모달이 다시 렌더링될 때마다 호출돼야 함 + */ +export function cartModalControl() { + const cartModal = document.querySelector(".cart-modal"); + const cartIconBtn = document.getElementById("cart-icon-btn"); + const cartModalCloseBtn = document.getElementById("cart-modal-close-btn"); + const cartModalOverlay = document.querySelector(".cart-modal-overlay"); + + // 장바구니 모달 열기 + if (cartIconBtn) { + cartIconBtn.addEventListener("click", () => { + cartStore.toggleCartModal(true); + }); + } + + // 장바구니 모달 닫기 함수 + const closeCartModal = () => { + cartStore.toggleCartModal(false); + }; + + if (cartModalCloseBtn) { + cartModalCloseBtn.addEventListener("click", closeCartModal); + } + if (cartModalOverlay) { + cartModalOverlay.addEventListener("click", closeCartModal); + } + + // Escape 키로 모달 닫기 (이 리스너는 body에 한 번만 부착해도 되지만, 편의상 여기서 함께 관리) + document.body.addEventListener("keydown", (e) => { + if (e.key === "Escape" && cartModal && !cartModal.classList.contains("hidden")) { + closeCartModal(); + } + }); +} + +/** + * 장바구니 모달 내부 상품 관련 이벤트 리스너 부착 + * 모달 내용이 다시 렌더링될 때마다 호출되어야 합니다. + */ +// TODO: 각 내부기능 함수 별 로직상 cart스토어의 #setState로직이 있어서 notify로직 실행됨(리렌더링 시작됨 - 모달창 강제로 닫아짐)) +// --> '닫기' 이벤트 or 새로고침할 때 내부 정보들을 로컬스토리지에 저장시키고, +// 모달창이 렌더링이 될 때마다 롴러스토리 정보를 불러오도록 프로세스 수정해야할 듯 +export function cartProductControl() { + const cartModal = document.querySelector(".cart-modal"); + if (!cartModal) return; + + cartModal.addEventListener("click", (e) => { + // 상품 제거 + const removeBtn = e.target.closest(".cart-item-remove-btn"); + if (removeBtn) { + const productId = removeBtn.dataset.productId; + cartStore.removeItem(productId); + toastStore.showToast("상품이 장바구니에서 제거되었습니다.", "info"); + return; + } + + // 수량 증가 + const increaseBtn = e.target.closest(".quantity-increase-btn"); + if (increaseBtn) { + const cartItem = increaseBtn.closest(".cart-item"); + const productId = cartItem.dataset.productId; + + const item = cartStore.getState().items.find((item) => item.productId === productId); + if (item) { + cartStore.updateItemQuantity(productId, item.quantity + 1); + } + return; + } + + // 수량 감소 + const decreaseBtn = e.target.closest(".quantity-decrease-btn"); + if (decreaseBtn) { + const cartItem = decreaseBtn.closest(".cart-item"); + const productId = cartItem.dataset.productId; + + const item = cartStore.getState().items.find((item) => item.productId === productId); + if (item && item.quantity > 1) { + cartStore.updateItemQuantity(productId, item.quantity - 1); + } + return; + } + + // 장바구니 비우기 + const clearCartBtn = e.target.closest("#cart-modal-clear-cart-btn"); + if (clearCartBtn) { + cartStore.clearCart(); + toastStore.showToast("장바구니가 비워졌습니다.", "info"); + return; + } + + // 선택된 상품 제거 + const removeSelectedBtn = e.target.closest("#cart-modal-remove-selected-btn"); + if (removeSelectedBtn) { + cartStore.removeSelectedItems(); + toastStore.showToast("선택된 상품들이 제거되었습니다.", "info"); + return; + } + }); + + cartModal.addEventListener("change", (e) => { + // 개별 상품 체크박스 토글 + const itemCheckbox = e.target.closest(".cart-item-checkbox"); + if (itemCheckbox) { + const productId = itemCheckbox.dataset.productId; + cartStore.cartItemChecked(productId); + return; + } + + // 전체 선택 체크박스 토글 + const selectAllCheckbox = e.target.closest("#cart-modal-select-all-checkbox"); + if (selectAllCheckbox) { + cartStore.cartAllItemsChecked(selectAllCheckbox.checked); + return; + } + }); +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 00000000..6db993b4 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "jsdom", + setupFiles: "./src/setupTests.js", + exclude: ["**/e2e/**", "**/*.e2e.spec.js", "**/node_modules/**"], + poolOptions: { + threads: { + singleThread: true, + }, + }, + }, + base: "/front_7th_chapter2-1/", // Repository 이름과 일치 + build: { + outDir: "dist", + }, +});