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 = () => "