diff --git a/e2e/e2e-easy.advanced.spec.js b/e2e/e2e-easy.advanced.spec.js
new file mode 100644
index 000000000..259379c41
--- /dev/null
+++ b/e2e/e2e-easy.advanced.spec.js
@@ -0,0 +1,305 @@
+import { expect, test } from "@playwright/test";
+
+// 테스트 설정
+test.describe.configure({ mode: "serial" });
+
+// 헬퍼 함수들
+class E2EHelpers {
+ constructor(page) {
+ this.page = page;
+ }
+
+ // 페이지 로딩 대기
+ async waitForPageLoad() {
+ await this.page.waitForSelector('[data-testid="products-grid"], #products-grid', { timeout: 10000 });
+ await this.page.waitForFunction(() => {
+ const text = document.body.textContent;
+ return text.includes("총") && text.includes("개");
+ });
+ }
+
+ // 상품을 장바구니에 추가
+ async addProductToCart(productName) {
+ await this.page.click(
+ `text=${productName} >> xpath=ancestor::*[contains(@class, 'product-card')] >> .add-to-cart-btn`,
+ );
+ await this.page.waitForSelector("text=장바구니에 추가되었습니다", { timeout: 5000 });
+ }
+
+ // 장바구니 모달 열기
+ async openCartModal() {
+ await this.page.click("#cart-icon-btn");
+ await this.page.waitForSelector(".cart-modal-overlay", { timeout: 5000 });
+ }
+}
+
+test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => {
+ test.beforeEach(async ({ page }) => {
+ // 로컬 스토리지 초기화
+ await page.goto("/");
+ await page.evaluate(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ });
+ });
+
+ test.describe("1. 검색 및 필터링 기능이 URL과 연동된다.", () => {
+ test("검색어 입력 후 Enter 키로 검색하고 URL이 업데이트된다", async ({ page }) => {
+ const helpers = new E2EHelpers(page);
+ await helpers.waitForPageLoad();
+
+ // 검색어 입력
+ await page.fill("#search-input", "젤리");
+ await page.press("#search-input", "Enter");
+
+ // URL 업데이트 확인
+ await expect(page).toHaveURL(/search=%EC%A0%A4%EB%A6%AC/);
+
+ // 검색 결과 확인
+ await expect(page.locator("text=3개")).toBeVisible();
+
+ // 검색어가 검색창에 유지되는지 확인
+ await expect(page.locator("#search-input")).toHaveValue("젤리");
+
+ // 검색어 입력
+ await page.fill("#search-input", "아이패드");
+ await page.press("#search-input", "Enter");
+
+ // URL 업데이트 확인
+ await expect(page).toHaveURL(/search=%EC%95%84%EC%9D%B4%ED%8C%A8%EB%93%9C/);
+
+ // 검색 결과 확인
+ await expect(page.locator("text=21개")).toBeVisible();
+
+ // 새로고침을 해도 유지 되는지 확인
+ await page.reload();
+ await helpers.waitForPageLoad();
+ await expect(page.locator("text=21개")).toBeVisible();
+ });
+
+ test("정렬 옵션 변경 시 URL이 업데이트된다", async ({ page }) => {
+ const helpers = new E2EHelpers(page);
+ await helpers.waitForPageLoad();
+
+ // 가격 높은순으로 정렬
+ await page.selectOption("#sort-select", "price_desc");
+
+ // 첫 번째 상품 이 가격 높은 순으로 정렬되었는지 확인
+ 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원
+ - button "장바구니 담기"
+ `);
+
+ 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원
+ - button "장바구니 담기"
+ `);
+
+ 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원
+ - button "장바구니 담기"
+ `);
+
+ await page.reload();
+ 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원
+ - button "장바구니 담기"
+ `);
+ });
+
+ test("페이지당 상품 수 변경 시 URL이 업데이트된다", async ({ page }) => {
+ const helpers = new E2EHelpers(page);
+ await helpers.waitForPageLoad();
+
+ // 10개로 변경
+ await page.selectOption("#limit-select", "10");
+ await expect(page).toHaveURL(/limit=10/);
+ await page.waitForFunction(() => {
+ return document.querySelectorAll(".product-card").length === 10;
+ });
+ await expect(page.locator(".product-card").last()).toMatchAriaSnapshot(
+ `- heading "탈부착 방충망 자석쫄대 방풍비닐 창문방충망 셀프시공 DIY 백색 100cm" [level=3]`,
+ );
+
+ await page.selectOption("#limit-select", "20");
+ await expect(page).toHaveURL(/limit=20/);
+ await page.waitForFunction(() => {
+ return document.querySelectorAll(".product-card").length === 20;
+ });
+ await expect(page.locator(".product-card").last()).toMatchAriaSnapshot(
+ `- heading "고양이 난간 안전망 복층 베란다 방묘창 방묘문 방충망 캣도어 일반형검정1mx1m" [level=3]`,
+ );
+
+ await page.selectOption("#limit-select", "50");
+ await expect(page).toHaveURL(/limit=50/);
+ await page.waitForFunction(() => {
+ return document.querySelectorAll(".product-card").length === 50;
+ });
+ await expect(page.locator(".product-card").last()).toMatchAriaSnapshot(
+ `- heading "강아지 고양이 아이스팩 파우치 여름 베개 젤리곰 M사이즈" [level=3]`,
+ );
+
+ await page.selectOption("#limit-select", "100");
+ await expect(page).toHaveURL(/limit=100/);
+ await page.waitForFunction(() => {
+ return document.querySelectorAll(".product-card").length === 100;
+ });
+ await expect(page.locator(".product-card").last()).toMatchAriaSnapshot(
+ `- heading "고양이 스크래쳐 숨숨집 하우스 대형 원목 스크레쳐 A type" [level=3]`,
+ );
+
+ await page.reload();
+ await helpers.waitForPageLoad();
+ await expect(page.locator(".product-card").last()).toMatchAriaSnapshot(
+ `- heading "고양이 스크래쳐 숨숨집 하우스 대형 원목 스크레쳐 A type" [level=3]`,
+ );
+ });
+
+ test("검색어와 필터 조건이 URL에서 복원된다", async ({ page }) => {
+ const helpers = new E2EHelpers(page);
+
+ await page.goto("/?search=젤리&sort=price_desc&limit=10");
+ await helpers.waitForPageLoad();
+
+ await expect(page.locator("#search-input")).toHaveValue("젤리");
+ await expect(page.locator("#sort-select")).toHaveValue("price_desc");
+ await expect(page.locator("#limit-select")).toHaveValue("10");
+ await expect(page.getByRole("main")).toMatchAriaSnapshot(`- text: /총 3개의 상품/`);
+
+ await page.goto("/?search=고양이&sort=name_desc&limit=50");
+ await helpers.waitForPageLoad();
+
+ await expect(page.locator("#search-input")).toHaveValue("고양이");
+ await expect(page.locator("#sort-select")).toHaveValue("name_desc");
+ await expect(page.locator("#limit-select")).toHaveValue("50");
+ await expect(page.getByRole("main")).toMatchAriaSnapshot(`- text: /총 84개의 상품/`);
+ });
+ });
+
+ test.describe("2. 상품 상세 페이지와 URL이 연동된다.", () => {
+ test("상품 클릭부터 관련 상품 이동까지 전체 플로우", async ({ page }) => {
+ await page.evaluate(() => {
+ window.loadFlag = true;
+ window.history.pushState({}, "", "/product/85067212996");
+ window.dispatchEvent(new Event("popstate"));
+ });
+
+ // 상세 페이지 로딩 확인
+ await expect(page.locator("text=상품 상세")).toBeVisible();
+
+ // h1 태그에 상품명 확인
+ await expect(
+ page.locator('h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")'),
+ ).toBeVisible();
+
+ // 관련 상품 섹션 확인
+ await expect(page.locator("text=관련 상품")).toBeVisible();
+ const relatedProducts = page.locator(".related-product-card");
+ await expect(relatedProducts.first()).toBeVisible();
+
+ // 첫 번째 관련 상품 클릭
+ await relatedProducts.first().click();
+
+ // 다른 상품의 상세 페이지로 이동했는지 확인
+ await expect(page).toHaveURL("/product/86940857379");
+ await expect(
+ page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'),
+ ).toBeVisible();
+
+ await expect(await page.evaluate(() => window.loadFlag)).toBe(true);
+
+ await page.reload();
+
+ await expect(
+ page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'),
+ ).toBeVisible();
+
+ await expect(await page.evaluate(() => window.loadFlag)).toBe(undefined);
+ });
+ });
+
+ test.describe("3. SPA 네비게이션", () => {
+ test("브라우저 뒤로가기/앞으로가기가 올바르게 작동한다", async ({ page }) => {
+ const helpers = new E2EHelpers(page);
+ await page.evaluate(() => {
+ window.loadFlag = true;
+ });
+ await helpers.waitForPageLoad();
+
+ // 상품 상세 페이지로 이동
+ const productCard = page
+ .locator("text=PVC 투명 젤리 쇼핑백")
+ .locator('xpath=ancestor::*[contains(@class, "product-card")]');
+ await productCard.locator("img").click();
+
+ await expect(page).toHaveURL("/product/85067212996");
+ await expect(
+ page.locator('h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")'),
+ ).toBeVisible();
+ await expect(page.locator("text=관련 상품")).toBeVisible();
+ const relatedProducts = page.locator(".related-product-card");
+ await relatedProducts.first().click();
+
+ await expect(page).toHaveURL("/product/86940857379");
+ await expect(
+ page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'),
+ ).toBeVisible();
+
+ // 브라우저 뒤로가기
+ await page.goBack();
+ await expect(page).toHaveURL("/product/85067212996");
+ await expect(
+ page.locator('h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")'),
+ ).toBeVisible();
+
+ // 브라우저 앞으로가기
+ await page.goForward();
+ await expect(page).toHaveURL("/product/86940857379");
+ await expect(
+ page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'),
+ ).toBeVisible();
+
+ await page.goBack();
+ await page.goBack();
+ await expect(page).toHaveURL("/");
+ const firstProductCard = page.locator(".product-card").first();
+ await expect(firstProductCard.locator("img")).toBeVisible();
+
+ expect(await page.evaluate(() => window.loadFlag)).toBe(true);
+
+ await page.reload();
+ expect(
+ await page.evaluate(() => {
+ return window.loadFlag;
+ }),
+ ).toBe(undefined);
+ });
+
+ // 404 페이지 테스트
+ test("존재하지 않는 페이지 접근 시 404 페이지가 표시된다", async ({ page }) => {
+ // 존재하지 않는 경로로 이동
+ await page.goto("/non-existent-page");
+
+ // 404 페이지 확인
+ await expect(page.getByRole("main")).toMatchAriaSnapshot(`
+ - img: /404 페이지를 찾을 수 없습니다/
+ - link "홈으로"
+ `);
+ });
+ });
+});
diff --git a/e2e/e2e-easy.basic.spec.js b/e2e/e2e-easy.basic.spec.js
new file mode 100644
index 000000000..9b10a55fa
--- /dev/null
+++ b/e2e/e2e-easy.basic.spec.js
@@ -0,0 +1,278 @@
+import { expect, test } from "@playwright/test";
+
+// 테스트 설정
+test.describe.configure({ mode: "serial" });
+
+// 헬퍼 함수들
+class E2EHelpers {
+ constructor(page) {
+ this.page = page;
+ }
+
+ // 페이지 로딩 대기
+ async waitForPageLoad() {
+ await this.page.waitForSelector('[data-testid="products-grid"], #products-grid', { timeout: 10000 });
+ await this.page.waitForFunction(() => {
+ const text = document.body.textContent;
+ return text.includes("총") && text.includes("개");
+ });
+ }
+
+ // 상품을 장바구니에 추가
+ async addProductToCart(productName) {
+ await this.page.click(
+ `text=${productName} >> xpath=ancestor::*[contains(@class, 'product-card')] >> .add-to-cart-btn`,
+ );
+ }
+}
+
+test.describe("E2E: 쇼핑몰 전체 사용자 시나리오 > 난이도 쉬움 > 기본과제", () => {
+ test.beforeEach(async ({ page }) => {
+ // 로컬 스토리지 초기화
+ await page.goto("/");
+ await page.evaluate(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ });
+ });
+
+ test.describe("1. 애플리케이션 초기화 및 기본 기능", () => {
+ test("페이지 접속 시 로딩 상태가 표시되고 상품 목록이 정상적으로 로드된다", async ({ page }) => {
+ const helpers = new E2EHelpers(page);
+
+ // 로딩 상태 확인
+ await expect(page.locator("text=카테고리 로딩 중...")).toBeVisible();
+
+ // 상품 목록 로드 완료 대기
+ await helpers.waitForPageLoad();
+
+ // 상품 개수 확인 (340개)
+ await expect(page.locator("text=340개")).toBeVisible();
+
+ // 기본 UI 요소들 존재 확인
+ await expect(page.locator("#search-input")).toBeVisible();
+ await expect(page.locator("#cart-icon-btn")).toBeVisible();
+ await expect(page.locator("#limit-select")).toBeVisible();
+ await expect(page.locator("#sort-select")).toBeVisible();
+ });
+
+ test("상품 카드에 기본 정보가 올바르게 표시된다", async ({ page }) => {
+ const helpers = new E2EHelpers(page);
+ await helpers.waitForPageLoad();
+
+ // 첫 번째 상품 카드 확인
+ const firstProductCard = page.locator(".product-card").first();
+
+ // 상품 이미지 존재 확인
+ await expect(firstProductCard.locator("img")).toBeVisible();
+
+ // 상품명 확인
+ await expect(firstProductCard).toContainText(/pvc 투명 젤리 쇼핑백|고양이 난간 안전망/i);
+
+ // 가격 정보 확인 (숫자 + 원)
+ await expect(firstProductCard).toContainText(/\d{1,3}(,\d{3})*원/);
+
+ // 장바구니 버튼 확인
+ await expect(firstProductCard.locator(".add-to-cart-btn")).toBeVisible();
+ });
+ });
+
+ test.describe("2. 검색 및 필터링 기능", () => {
+ test("검색어 입력 후 Enter 키로 검색할 수 있다.", async ({ page }) => {
+ const helpers = new E2EHelpers(page);
+ await helpers.waitForPageLoad();
+
+ // 검색어 입력
+ await page.fill("#search-input", "젤리");
+ await page.press("#search-input", "Enter");
+
+ // 검색 결과 확인
+ await expect(page.locator("text=3개")).toBeVisible();
+
+ // 검색어가 검색창에 유지되는지 확인
+ await expect(page.locator("#search-input")).toHaveValue("젤리");
+
+ // 검색어 입력
+ await page.fill("#search-input", "아이패드");
+ await page.press("#search-input", "Enter");
+
+ // 검색 결과 확인
+ await expect(page.locator("text=21개")).toBeVisible();
+ });
+
+ test("상품의 정렬을 변경할 수 있다.", async ({ page }) => {
+ const helpers = new E2EHelpers(page);
+ await helpers.waitForPageLoad();
+
+ // 가격 높은순으로 정렬
+ await page.selectOption("#sort-select", "price_desc");
+
+ // 첫 번째 상품 이 가격 높은 순으로 정렬되었는지 확인
+ 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원
+ - button "장바구니 담기"
+ `);
+
+ 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원
+ - button "장바구니 담기"
+ `);
+
+ 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원
+ - button "장바구니 담기"
+ `);
+
+ await page.reload();
+ 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원
+ - button "장바구니 담기"
+ `);
+ });
+
+ test("페이지당 상품 수 변경이 가능하다.", async ({ page }) => {
+ const helpers = new E2EHelpers(page);
+
+ await page.goto("/");
+ await helpers.waitForPageLoad();
+
+ // 10개로 변경
+ await page.selectOption("#limit-select", "10");
+
+ await page.waitForFunction(() => document.querySelectorAll(".product-card").length === 10);
+ await expect(page.locator(".product-card").last()).toMatchAriaSnapshot(
+ `- heading "탈부착 방충망 자석쫄대 방풍비닐 창문방충망 셀프시공 DIY 백색 100cm" [level=3]`,
+ );
+
+ await page.selectOption("#limit-select", "20");
+
+ await page.waitForFunction(() => document.querySelectorAll(".product-card").length === 20);
+ await expect(page.locator(".product-card").last()).toMatchAriaSnapshot(
+ `- heading "고양이 난간 안전망 복층 베란다 방묘창 방묘문 방충망 캣도어 일반형검정1mx1m" [level=3]`,
+ );
+
+ await page.selectOption("#limit-select", "50");
+
+ await page.waitForFunction(() => document.querySelectorAll(".product-card").length === 50);
+ await expect(page.locator(".product-card").last()).toMatchAriaSnapshot(
+ `- heading "강아지 고양이 아이스팩 파우치 여름 베개 젤리곰 M사이즈" [level=3]`,
+ );
+
+ await page.selectOption("#limit-select", "100");
+
+ await page.waitForFunction(() => document.querySelectorAll(".product-card").length === 100);
+ await expect(page.locator(".product-card").last()).toMatchAriaSnapshot(
+ `- heading "고양이 스크래쳐 숨숨집 하우스 대형 원목 스크레쳐 A type" [level=3]`,
+ );
+ });
+ });
+
+ test.describe("3. 장바구니 상태 유지", () => {
+ test("장바구니 아이콘에 상품 개수가 정확히 표시된다", async ({ page }) => {
+ const helpers = new E2EHelpers(page);
+ await helpers.waitForPageLoad();
+
+ // 초기에는 개수 표시가 없어야 함
+ await expect(page.locator("#cart-icon-btn span")).not.toBeVisible();
+
+ // 첫 번째 상품 추가
+ await helpers.addProductToCart("PVC 투명 젤리 쇼핑백");
+ await expect(page.locator("#cart-icon-btn span")).toHaveText("1");
+
+ // 두 번째 상품 추가
+ await helpers.addProductToCart("샷시 풍지판");
+ await expect(page.locator("#cart-icon-btn span")).toHaveText("2");
+
+ // 첫 번째 상품 한 번 더 추가
+ await helpers.addProductToCart("PVC 투명 젤리 쇼핑백");
+ await expect(page.locator("#cart-icon-btn span")).toHaveText("2");
+ });
+ });
+
+ test.describe("4. 상품 상세 페이지 워크플로우", () => {
+ test("상품 클릭부터 관련 상품 이동까지 전체 플로우", async ({ page }) => {
+ const helpers = new E2EHelpers(page);
+ await page.evaluate(() => {
+ window.loadFlag = true;
+ });
+ await helpers.waitForPageLoad();
+
+ // 상품 이미지 클릭하여 상세 페이지로 이동
+ const productCard = page
+ .locator("text=PVC 투명 젤리 쇼핑백")
+ .locator('xpath=ancestor::*[contains(@class, "product-card")]');
+ await productCard.locator("img").click();
+
+ // 상세 페이지 로딩 확인
+ await expect(page.locator("text=상품 상세")).toBeVisible();
+
+ // h1 태그에 상품명 확인
+ await expect(
+ page.locator('h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")'),
+ ).toBeVisible();
+
+ // 수량 조절 후 장바구니 담기
+ await page.click("#quantity-increase");
+ await expect(page.locator("#quantity-input")).toHaveValue("2");
+
+ await page.click("#add-to-cart-btn");
+
+ // 관련 상품 섹션 확인
+ await expect(page.locator("text=관련 상품")).toBeVisible();
+
+ const relatedProducts = page.locator(".related-product-card");
+ await expect(relatedProducts.first()).toBeVisible();
+
+ // 첫 번째 관련 상품 클릭
+ await relatedProducts.first().click();
+
+ // 다른 상품의 상세 페이지로 이동했는지 확인
+ await expect(
+ page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'),
+ ).toBeVisible();
+
+ await expect(await page.evaluate(() => window.loadFlag)).toBe(true);
+ });
+ });
+
+ test.describe("5. 무한 스크롤 기능", () => {
+ test("페이지 하단 스크롤 시 추가 상품이 로드된다", async ({ page }) => {
+ const helpers = new E2EHelpers(page);
+ await helpers.waitForPageLoad();
+
+ // 초기 상품 카드 수 확인
+ const initialCards = await page.locator(".product-card").count();
+ expect(initialCards).toBe(20);
+
+ // 페이지 하단으로 스크롤
+ await page.evaluate(() => {
+ window.scrollTo(0, document.body.scrollHeight);
+ });
+
+ // 로딩 인디케이터 확인
+ await expect(page.locator("text=상품을 불러오는 중...")).toBeVisible();
+
+ // 추가 상품 로드 대기
+ await page.waitForFunction(() => document.querySelectorAll(".product-card").length === 40);
+
+ // 상품 수가 증가했는지 확인
+ const updatedCards = await page.locator(".product-card").count();
+ expect(updatedCards).toBe(40);
+ });
+ });
+});
diff --git a/e2e/e2e.spec.js b/e2e/e2e-hard.spec.js
similarity index 98%
rename from e2e/e2e.spec.js
rename to e2e/e2e-hard.spec.js
index 23f773492..ec8d552d4 100644
--- a/e2e/e2e.spec.js
+++ b/e2e/e2e-hard.spec.js
@@ -26,22 +26,11 @@ class E2EHelpers {
await this.page.waitForSelector("text=장바구니에 추가되었습니다", { timeout: 5000 });
}
- // 토스트 메시지가 사라질 때까지 대기
- async waitForToastToDisappear() {
- await this.page.waitForSelector("text=장바구니에 추가되었습니다", { state: "hidden", timeout: 5000 });
- }
-
// 장바구니 모달 열기
async openCartModal() {
await this.page.click("#cart-icon-btn");
await this.page.waitForSelector(".cart-modal-overlay", { timeout: 5000 });
}
-
- // 현재 상품 개수 가져오기
- async getCurrentProductCount() {
- const countText = await this.page.textContent('[data-testid="product-count"]');
- return countText ? parseInt(countText.replace(/[^\d]/g, "")) : 0;
- }
}
test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => {
diff --git a/package.json b/package.json
index 7b2368941..3d2f932dd 100644
--- a/package.json
+++ b/package.json
@@ -11,14 +11,19 @@
"prettier:write": "prettier --write ./src",
"preview": "vite preview",
"test": "vitest",
- "test:basic": "vitest basic.test.js",
- "test:advanced": "vitest advanced",
+ "test:hard:basic": "vitest --run",
+ "test:hard:advanced": "playwright test e2e-hard",
+ "test:easy:basic": "playwright test e2e-easy.basic",
+ "test:easy:advanced": "playwright test e2e-easy.advanced",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "npx playwright show-report",
"test:generate": "playwright codegen localhost:5173",
- "prepare": "husky"
+ "prepare": "husky",
+ "build:prod": "NODE_ENV=production vite build",
+ "preview:prod": "NODE_ENV=production vite preview",
+ "deploy": "pnpm build:prod && pnpm preview:prod"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
@@ -50,5 +55,8 @@
"workerDirectory": [
"public"
]
+ },
+ "dependencies": {
+ "playwright": "^1.53.2"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8137d4c85..e76942948 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,6 +7,10 @@ settings:
importers:
.:
+ dependencies:
+ playwright:
+ specifier: ^1.53.2
+ version: 1.53.2
devDependencies:
'@eslint/js':
specifier: ^9.16.0
diff --git a/public/404.html b/public/404.html
new file mode 100644
index 000000000..0f143d481
--- /dev/null
+++ b/public/404.html
@@ -0,0 +1,122 @@
+
+
+
+
+
+
상품 쇼핑몰
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
홈으로
+
+
+
+
+
+
+
+
diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js
index d2b729641..b1f186b65 100644
--- a/public/mockServiceWorker.js
+++ b/public/mockServiceWorker.js
@@ -100,10 +100,7 @@ addEventListener("fetch", function (event) {
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
- if (
- event.request.cache === "only-if-cached" &&
- event.request.mode !== "same-origin"
- ) {
+ if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") {
return;
}
@@ -219,9 +216,7 @@ async function getResponse(event, client, requestId) {
const acceptHeader = headers.get("accept");
if (acceptHeader) {
const values = acceptHeader.split(",").map((value) => value.trim());
- const filteredValues = values.filter(
- (value) => value !== "msw/passthrough",
- );
+ const filteredValues = values.filter((value) => value !== "msw/passthrough");
if (filteredValues.length > 0) {
headers.set("accept", filteredValues.join(", "));
@@ -291,10 +286,7 @@ function sendToClient(client, message, transferrables = []) {
resolve(event.data);
};
- client.postMessage(message, [
- channel.port2,
- ...transferrables.filter(Boolean),
- ]);
+ client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
});
}
diff --git a/src/components/LoadingCard.js b/src/components/LoadingCard.js
new file mode 100644
index 000000000..177c079f1
--- /dev/null
+++ b/src/components/LoadingCard.js
@@ -0,0 +1,11 @@
+const LoadingCard = `
`;
+
+export const LoadingCardList = LoadingCard.repeat(4);
diff --git a/src/components/ProductCard.js b/src/components/ProductCard.js
new file mode 100644
index 000000000..be037a28c
--- /dev/null
+++ b/src/components/ProductCard.js
@@ -0,0 +1,30 @@
+export default function ProductCard(product) {
+ return `
+
+
+
+

+
+
+
+
+
+ ${product.title}
+
+
${product.brand}
+
+ ${Number(product.lprice).toLocaleString()}원
+
+
+
+
+
+
`;
+}
diff --git a/src/components/Toast.js b/src/components/Toast.js
new file mode 100644
index 000000000..bb103d660
--- /dev/null
+++ b/src/components/Toast.js
@@ -0,0 +1,50 @@
+const COLORS = {
+ success: "bg-green-600",
+ info: "bg-blue-600",
+ error: "bg-red-600",
+};
+
+/**
+ * 토스트를 화면에 표시
+ * @param {string} message
+ * @param {"success"|"info"|"error"} type
+ * @param {number} duration
+ */
+export function showToast(message, type = "success", duration = 3000) {
+ // 이미 표시중인 토스트가 있다면 제거 (동일/다른 메시지 구분 X, 한 번에 하나만 표시)
+ document.querySelectorAll(".toast-notification").forEach((el) => el.remove());
+ const wrapper = document.createElement("div");
+ wrapper.className = "toast-notification fixed bottom-6 left-1/2 -translate-x-1/2 z-[60]";
+
+ wrapper.innerHTML = `
+
+
+ ${
+ type === "success"
+ ? `
`
+ : type === "info"
+ ? `
`
+ : `
`
+ }
+
+
${message}
+
+
`;
+
+ document.body.appendChild(wrapper);
+
+ const close = () => wrapper.remove();
+ wrapper.querySelector(".toast-close-btn").onclick = close;
+ setTimeout(close, duration);
+}
diff --git a/src/controllers/HomePageController.js b/src/controllers/HomePageController.js
new file mode 100644
index 000000000..f7bc03543
--- /dev/null
+++ b/src/controllers/HomePageController.js
@@ -0,0 +1,256 @@
+import HomePage from "../pages/HomePage.js";
+import productService from "../services/ProductService.js";
+import router from "../router/Router.js";
+import InfiniteScrollManager from "../services/InfiniteScrollManager.js";
+import { addToCart, updateCartBadge, openCartModal, loadCart } from "../features/cart/index.js";
+
+class HomePageController {
+ constructor(rootSelector = "#root") {
+ this.rootEl = document.querySelector(rootSelector);
+ this.state = {
+ products: [],
+ total: 0,
+ loading: false,
+ loadingMore: false,
+ categories: {},
+ limit: 20,
+ page: 1,
+ search: "",
+ category1: "",
+ category2: "",
+ sort: "price_asc",
+ };
+ this.scrollManager = null;
+
+ // URL 쿼리 → 초기 상태 반영
+ this.applyQueryParams();
+ }
+
+ // 현재 URL 의 쿼리스트링을 읽어 state 에 반영
+ applyQueryParams() {
+ const params = new URLSearchParams(window.location.search);
+ if (params.has("sort")) this.state.sort = params.get("sort");
+ if (params.has("limit")) this.state.limit = Number(params.get("limit"));
+ if (params.has("search")) this.state.search = params.get("search");
+ if (params.has("category1")) this.state.category1 = params.get("category1");
+ if (params.has("category2")) this.state.category2 = params.get("category2");
+ }
+
+ // state 값을 쿼리스트링으로 직렬화하여 pushState (페이지 새로고침 시 유지)
+ updateQueryParams({ replace = false } = {}) {
+ const params = new URLSearchParams();
+ if (this.state.sort && this.state.sort !== "price_asc") params.set("sort", this.state.sort);
+ if (!(replace && this.state.limit === 20)) {
+ params.set("limit", String(this.state.limit));
+ }
+ if (this.state.search) params.set("search", this.state.search);
+ if (this.state.category1) params.set("category1", this.state.category1);
+ if (this.state.category2) params.set("category2", this.state.category2);
+
+ const qs = params.toString();
+ const newUrl = qs ? `${router.BASE_PATH}/?${qs}` : router.BASE_PATH;
+ let method = replace ? "replaceState" : "pushState";
+ // limit 가 기본값(20)으로 돌아가는 경우 히스토리 덮어쓰기
+ if (!replace && this.state.limit === 20 && params.size === 1 && params.has("limit")) {
+ method = "replaceState";
+ }
+ if (newUrl !== window.location.pathname + window.location.search) {
+ window.history[method]({}, "", newUrl);
+ }
+ }
+
+ async init() {
+ // 테스트 등 여러 번 init 될 때 이전 상태가 남아있지 않도록 기본 상태로 초기화
+ this.state = {
+ products: [],
+ total: 0,
+ loading: false,
+ loadingMore: false,
+ categories: {},
+ limit: 20,
+ page: 1,
+ search: "",
+ category1: "",
+ category2: "",
+ sort: "price_asc",
+ };
+
+ // URL 쿼리 적용 (초기화 이후 다시 적용)
+ this.applyQueryParams();
+
+ loadCart();
+ // 1) 초깃값 로딩 상태 활성화 → 로딩 UI(스켈레톤, "카테고리 로딩 중...") 표시
+ this.state.loading = true;
+ this.render();
+
+ // 2) 상품·카테고리 동시 요청
+ const [, categories] = await Promise.all([this.fetchProducts(), productService.getCategories().then((c) => c)]);
+
+ // 3) 응답 데이터 상태 반영
+ this.state.categories = categories;
+ this.state.loading = false;
+
+ // 4) 최종 렌더링 및 스크롤 설정
+ this.render();
+ this.setupInfiniteScroll();
+ // 렌더링 후 현재 상태를 URL에 반영
+ this.updateQueryParams({ replace: true });
+ }
+
+ async fetchProducts() {
+ // (테스트 디버그용 로그 제거)
+ const { products, pagination } = await productService.getProducts({
+ limit: this.state.limit,
+ page: this.state.page,
+ search: this.state.search,
+ category1: this.state.category1,
+ category2: this.state.category2,
+ sort: this.state.sort,
+ });
+ this.state.products = products;
+ this.state.total = pagination.total;
+ }
+
+ async loadMore() {
+ this.state.loadingMore = true;
+ this.render();
+ const nextPage = this.state.page + 1;
+ const { products: newProducts, pagination } = await productService.getProducts({
+ limit: this.state.limit,
+ page: nextPage,
+ search: this.state.search,
+ category1: this.state.category1,
+ category2: this.state.category2,
+ sort: this.state.sort,
+ });
+ this.state.products = [...this.state.products, ...newProducts];
+ this.state.total = pagination.total;
+ this.state.page = nextPage;
+ this.state.loadingMore = false;
+ this.render();
+ }
+
+ setupInfiniteScroll() {
+ if (this.scrollManager) return;
+ this.scrollManager = new InfiniteScrollManager(async () => {
+ if (!this.state.loading && !this.state.loadingMore && this.state.products.length < this.state.total) {
+ await this.loadMore();
+ }
+ });
+ this.scrollManager.attach();
+ }
+
+ render() {
+ this.rootEl.innerHTML = HomePage(this.state);
+ this.attachEventListeners();
+ updateCartBadge();
+ }
+
+ attachEventListeners() {
+ const { state } = this;
+ const limitSelect = document.querySelector("#limit-select");
+ if (limitSelect) {
+ limitSelect.value = String(state.limit);
+ limitSelect.onchange = async (e) => {
+ state.limit = Number(e.target.value);
+ state.page = 1;
+ this.updateQueryParams();
+ await this.fetchProducts();
+ this.render();
+ };
+ }
+
+ const searchInput = document.querySelector("#search-input");
+ if (searchInput) {
+ searchInput.value = state.search;
+ searchInput.onkeydown = async (e) => {
+ if (e.key === "Enter") {
+ const keyword = searchInput.value.trim();
+ if (state.search !== keyword) {
+ state.search = keyword;
+ state.page = 1;
+ this.updateQueryParams();
+ await this.fetchProducts();
+ this.render();
+ }
+ }
+ };
+ }
+
+ document.querySelectorAll(".add-to-cart-btn").forEach((btn) => {
+ btn.onclick = () => {
+ const product = state.products.find((p) => String(p.productId) === btn.dataset.productId);
+ if (product) addToCart(product);
+ };
+ });
+
+ document.querySelector("#cart-icon-btn")?.addEventListener("click", openCartModal);
+
+ // 카테고리 필터
+ document.querySelectorAll(".category1-filter-btn").forEach((btn) => {
+ btn.onclick = async () => {
+ state.category1 = btn.dataset.category1;
+ state.category2 = "";
+ state.page = 1;
+ this.updateQueryParams();
+ await this.fetchProducts();
+ this.render();
+ };
+ });
+
+ document.querySelectorAll(".category2-filter-btn").forEach((btn) => {
+ btn.onclick = async () => {
+ state.category1 = btn.dataset.category1;
+ state.category2 = btn.dataset.category2;
+ state.page = 1;
+ this.updateQueryParams();
+ await this.fetchProducts();
+ this.render();
+ };
+ });
+
+ document.querySelector('[data-breadcrumb="reset"]')?.addEventListener("click", async () => {
+ state.category1 = "";
+ state.category2 = "";
+ state.page = 1;
+ this.updateQueryParams();
+ await this.fetchProducts();
+ this.render();
+ });
+
+ document.querySelector('[data-breadcrumb="category1"]')?.addEventListener("click", async (e) => {
+ state.category1 = e.target.dataset.category1;
+ state.category2 = "";
+ state.page = 1;
+ this.updateQueryParams();
+ await this.fetchProducts();
+ this.render();
+ });
+
+ // 상품 카드 클릭 → 상세 페이지 이동
+ document.querySelectorAll(".product-image, .product-info").forEach((el) => {
+ el.onclick = () => {
+ const card = el.closest(".product-card");
+ const pid = card.dataset.productId;
+ router.navigate(`/product/${pid}`);
+ };
+ });
+
+ // 정렬 드롭다운
+ const sortSelect = document.querySelector("#sort-select");
+ if (sortSelect) {
+ sortSelect.value = state.sort;
+ sortSelect.onchange = async (e) => {
+ state.sort = e.target.value;
+ state.page = 1;
+ this.updateQueryParams();
+ await this.fetchProducts();
+ this.render();
+ };
+ }
+ }
+}
+
+// 싱글톤 인스턴스로 export
+const homePageController = new HomePageController();
+export default homePageController;
diff --git a/src/controllers/ProductDetailController.js b/src/controllers/ProductDetailController.js
new file mode 100644
index 000000000..c9611141f
--- /dev/null
+++ b/src/controllers/ProductDetailController.js
@@ -0,0 +1,54 @@
+import ProductDetailPage from "../pages/ProductDetailPage.js";
+import productService from "../services/ProductService.js";
+import router from "../router/Router.js";
+import { addToCart, updateCartBadge } from "../features/cart/index.js";
+
+class ProductDetailController {
+ constructor(rootSelector = "#root") {
+ this.rootEl = document.querySelector(rootSelector);
+ }
+
+ async show(productId) {
+ this.rootEl.innerHTML = ProductDetailPage({ loading: true });
+
+ // 1) 상품 기본 정보 로드
+ const product = await productService.getProduct(productId);
+
+ // 2) 우선 상품 정보만 렌더링 (관련 상품 제외)
+ this.rootEl.innerHTML = ProductDetailPage({ loading: false, product, related: [] });
+ this.attachEvents(product);
+ updateCartBadge();
+
+ // 3) 관련 상품 비동기 로드 → 준비되면 다시 렌더링
+ const { products: all } = await productService.getProducts({ limit: 100 });
+ const related = all.filter((p) => p.productId !== productId).slice(0, 19);
+
+ this.rootEl.innerHTML = ProductDetailPage({ loading: false, product, related });
+ this.attachEvents(product);
+ updateCartBadge();
+ }
+
+ attachEvents(product) {
+ const qtyInput = document.querySelector("#quantity-input");
+ document.querySelector("#quantity-increase").onclick = () => (qtyInput.value = Number(qtyInput.value) + 1);
+ document.querySelector("#quantity-decrease").onclick = () => {
+ if (qtyInput.value > 1) qtyInput.value = Number(qtyInput.value) - 1;
+ };
+
+ document.querySelector("#add-to-cart-btn").onclick = () => {
+ const qty = Number(qtyInput.value);
+ addToCart(product, qty);
+ };
+
+ document.querySelectorAll(".related-product-card").forEach((card) => {
+ card.onclick = () => router.navigate(`/product/${card.dataset.productId}`);
+ });
+
+ document.querySelector(".go-to-product-list")?.addEventListener("click", () => {
+ router.navigate(router.BASE_PATH);
+ });
+ }
+}
+
+const productDetailController = new ProductDetailController();
+export default productDetailController;
diff --git a/src/data.js b/src/data.js
new file mode 100644
index 000000000..b4ecc2076
--- /dev/null
+++ b/src/data.js
@@ -0,0 +1,1112 @@
+// const 상품목록_레이아웃_로딩 = `
+//
+//
+//
+//
+//
+// 쇼핑몰
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+
+//
+//
+//
+//
상품을 불러오는 중...
+//
+//
+//
+//
+//
+//
+//
+// `;
+
+// const 상품목록_레이아웃_로딩완료 = `
+//
+//
+//
+//
+//
+// 쇼핑몰
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// 총 340개의 상품
+//
+//
+//
+//
+//
+//
+//

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

+//
+//
+//
+//
+//
+// 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
+//
+//
이지웨이건축자재
+//
+// 230원
+//
+//
+//
+//
+//
+//
+//
+
+//
+// 모든 상품을 확인했습니다
+//
+//
+//
+//
+//
+//
+// `;
+
+// const 상품목록_레이아웃_카테고리_1Depth = `
+//
+//
+//
+//
+//
+
+//
+//
+
+//
+//
+//
+//
+// >
+//
+//
+//
+//
+//
+//
+//
+//
+//
+
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// `;
+
+// const 상품목록_레이아웃_카테고리_2Depth = `
+//
+//
+//
+//
+//
+
+//
+//
+
+//
+//
+//
+//
+// >>주방용품
+//
+//
+//
+//
+//
+//
+//
+//
+//
+
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// `;
+
+// const 토스트 = `
+//
+//
+//
+//
장바구니에 추가되었습니다
+//
+//
+
+//
+//
+//
선택된 상품들이 삭제되었습니다
+//
+//
+
+//
+//
+//
오류가 발생했습니다.
+//
+//
+//
+// `;
+
+// const 장바구니_비어있음 = `
+//
+//
+//
+//
+//
+//
+// 장바구니
+//
+
+//
+//
+
+//
+//
+//
+//
+//
+//
+//
장바구니가 비어있습니다
+//
원하는 상품을 담아보세요!
+//
+//
+//
+//
+//
+// `;
+
+// const 장바구니_선택없음 = `
+//
+//
+//
+//
+//
+//
+// 장바구니
+// (2)
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//

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

+//
+//
+//
+//
+// 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
+//
+//
+// 230원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// 230원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// 총 금액
+// 670원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// `;
+
+// const 장바구니_선택있음 = `
+//
+//
+//
+//
+//
+//
+// 장바구니
+// (2)
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//

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

+//
+//
+//
+//
+// 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
+//
+//
+// 230원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// 230원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// 선택한 상품 (1개)
+// 440원
+//
+//
+//
+// 총 금액
+// 670원
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// `;
+
+// const 상세페이지_로딩 = `
+//
+//
+//
+//
+//
+//
+//
상품 상세
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
상품 정보를 불러오는 중...
+//
+//
+//
+//
+//
+// `;
+
+// const 상세페이지_로딩완료 = `
+//
+//
+//
+//
+//
+//
+//
상품 상세
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//

+//
+//
+//
+//
+//
PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
4.0 (749개 리뷰)
+//
+//
+//
+// 220원
+//
+//
+//
+// 재고 107개
+//
+//
+//
+// PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장에 대한 상세 설명입니다. 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.
+//
+//
+//
+//
+//
+//
+//
수량
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
관련 상품
+//
같은 카테고리의 다른 상품들
+//
+//
+//
+//
+//
+//
+// `;
+
+// const _404_ = `
+//
+//
+//
+
+//
홈으로
+//
+//
+// `;
diff --git a/src/features/cart/cart.modal.js b/src/features/cart/cart.modal.js
new file mode 100644
index 000000000..f1d07cf5d
--- /dev/null
+++ b/src/features/cart/cart.modal.js
@@ -0,0 +1,276 @@
+import { cart, saveCart, updateCartBadge } from "./cart.state.js";
+import { showToast } from "../../components/Toast.js";
+
+const format = (won) => `${Number(won).toLocaleString()}원`;
+
+export function openCartModal() {
+ const existing = document.querySelector(".cart-modal-overlay");
+ if (existing) {
+ existing.remove();
+ }
+
+ const overlay = document.createElement("div");
+ overlay.className =
+ "cart-modal-overlay fixed inset-0 bg-black/50 z-50 flex items-end sm:items-center justify-center p-4";
+ document.body.appendChild(overlay);
+
+ const escHandler = (e) => {
+ if (e.key === "Escape") closeCartModal();
+ };
+ document.addEventListener("keydown", escHandler);
+
+ overlay.addEventListener("click", (e) => {
+ if (e.target === overlay) closeCartModal();
+ });
+
+ renderCartModal();
+
+ function closeCartModal() {
+ overlay.remove();
+ document.removeEventListener("keydown", escHandler);
+ }
+
+ function renderCartModal() {
+ overlay.innerHTML = "";
+
+ const cartItems = Object.values(cart);
+ // 디버그 로그 제거
+
+ if (cartItems.length === 0) {
+ overlay.innerHTML = `
+
+
+
+
+ 장바구니
+
+
+
+
+
+
+
장바구니가 비어있습니다
+
원하는 상품을 담아보세요!
+
+
+
`;
+ overlay.querySelector("#cart-modal-close-btn").onclick = closeCartModal;
+ return;
+ }
+
+ const totalCount = cartItems.length;
+ const totalPrice = cartItems.reduce((sum, i) => sum + i.product.lprice * i.quantity, 0);
+
+ const modal = document.createElement("div");
+ modal.className =
+ "cart-modal relative bg-white rounded-t-lg sm:rounded-lg shadow-xl w-full max-w-md sm:max-w-lg max-h-[90vh] overflow-hidden";
+ overlay.appendChild(modal);
+
+ modal.innerHTML = `
+
+
+
+
+ 장바구니 (${totalCount})
+
+
+
+
+
+
+
+
+
+
+
+ ${cartItems
+ .map(
+ ({ product, quantity }) => `
+
+
+
+

+
+
+
${product.title}
+
${format(product.lprice)}
+
+
+
+
${format(product.lprice * quantity)}
+
+
+
`,
+ )
+ .join("")}
+
+
+
+
+
+
+
+ 선택한 상품 (0개)
+ 0원
+
+
+
+
+ 총 금액
+ ${format(totalPrice)}
+
+
+
+
+
+
+
+
+
+
+
`;
+
+ modal.querySelector("#cart-modal-close-btn").onclick = closeCartModal;
+ modal.querySelectorAll(".quantity-increase-btn").forEach((btn) => {
+ btn.onclick = () => {
+ const pid = btn.dataset.productId;
+ cart[pid].quantity += 1;
+ saveCart();
+ const qtyInput = modal.querySelector(`.quantity-input[data-product-id="${pid}"]`);
+ const priceField = modal.querySelector(`.price-field[data-product-id="${pid}"]`);
+ qtyInput.value = cart[pid].quantity;
+ priceField.textContent = format(cart[pid].quantity * cart[pid].product.lprice);
+ modal.querySelector("#cart-modal-total-amount").textContent = format(
+ Object.values(cart).reduce((s, c) => s + c.product.lprice * c.quantity, 0),
+ );
+ };
+ });
+ modal.querySelectorAll(".quantity-decrease-btn").forEach((btn) => {
+ btn.onclick = () => {
+ const pid = btn.dataset.productId;
+ if (cart[pid].quantity === 1) return;
+ cart[pid].quantity -= 1;
+ saveCart();
+ const qtyInput = modal.querySelector(`.quantity-input[data-product-id="${pid}"]`);
+ const priceField = modal.querySelector(`.price-field[data-product-id="${pid}"]`);
+ qtyInput.value = cart[pid].quantity;
+ priceField.textContent = format(cart[pid].quantity * cart[pid].product.lprice);
+ modal.querySelector("#cart-modal-total-amount").textContent = format(
+ Object.values(cart).reduce((s, c) => s + c.product.lprice * c.quantity, 0),
+ );
+ };
+ });
+ modal.querySelectorAll(".cart-item-remove-btn").forEach((btn) => {
+ btn.onclick = () => {
+ const pid = btn.dataset.productId;
+ delete cart[pid];
+ saveCart();
+ updateCartBadge();
+ renderCartModal();
+ showToast("삭제되었습니다", "info");
+ };
+ });
+
+ const selectAllChk = modal.querySelector("#cart-modal-select-all-checkbox");
+ const itemChks = modal.querySelectorAll(".cart-item-checkbox");
+ const removeSelBtn = modal.querySelector("#cart-modal-remove-selected-btn");
+
+ const recalcSelection = () => {
+ const selectedCnt = [...itemChks].filter((c) => c.checked).length;
+
+ selectAllChk.checked = selectedCnt === itemChks.length;
+ selectAllChk.indeterminate = selectedCnt > 0 && selectedCnt < itemChks.length;
+
+ const selSummary = modal.querySelector("#cart-modal-selected-summary");
+ const selAmount = modal.querySelector("#selected-amount");
+ const selBtn = modal.querySelector("#cart-modal-remove-selected-btn");
+
+ if (selectedCnt === 0) {
+ selSummary.classList.add("hidden");
+ selBtn.classList.add("hidden");
+ } else {
+ const sum = [...itemChks]
+ .filter((c) => c.checked)
+ .reduce((s, c) => {
+ const pid = c.dataset.productId;
+ return s + cart[pid].product.lprice * cart[pid].quantity;
+ }, 0);
+
+ selSummary.classList.remove("hidden");
+ selBtn.classList.remove("hidden");
+ selSummary.querySelector("#selected-count").textContent = selectedCnt;
+ selAmount.textContent = format(sum);
+ selBtn.textContent = `선택한 상품 삭제 (${selectedCnt}개)`;
+ }
+ };
+
+ selectAllChk.onchange = () => {
+ itemChks.forEach((c) => (c.checked = selectAllChk.checked));
+ recalcSelection();
+ };
+ itemChks.forEach((c) => (c.onchange = recalcSelection));
+ recalcSelection();
+
+ removeSelBtn.onclick = () => {
+ const selectedPids = [...itemChks].filter((c) => c.checked).map((c) => c.dataset.productId);
+
+ selectedPids.forEach((pid) => delete cart[pid]);
+ saveCart();
+ updateCartBadge();
+ renderCartModal();
+ showToast("선택한 상품이 삭제되었습니다", "info");
+ };
+
+ modal.querySelector("#cart-modal-clear-cart-btn").onclick = () => {
+ Object.keys(cart).forEach((pid) => delete cart[pid]);
+ saveCart();
+ updateCartBadge();
+ renderCartModal();
+ showToast("장바구니를 비웠습니다", "info");
+ };
+ }
+}
diff --git a/src/features/cart/cart.state.js b/src/features/cart/cart.state.js
new file mode 100644
index 000000000..402873c98
--- /dev/null
+++ b/src/features/cart/cart.state.js
@@ -0,0 +1,21 @@
+import cartService from "../../services/CartService.js";
+// showToast 는 CartService 내부에서 호출됨
+
+// CartService 의 카트 객체를 그대로 노출 (레거시 호환)
+export const cart = cartService.getCart();
+
+export function loadCart() {
+ cartService.loadFromStorage();
+}
+
+export function saveCart() {
+ cartService.saveToStorage();
+}
+
+export function updateCartBadge() {
+ cartService.updateBadge();
+}
+
+export function addToCart(product, qty = 1) {
+ cartService.add(product, qty);
+}
diff --git a/src/features/cart/index.js b/src/features/cart/index.js
new file mode 100644
index 000000000..adf80d1ae
--- /dev/null
+++ b/src/features/cart/index.js
@@ -0,0 +1,2 @@
+export * from "./cart.state.js";
+export { openCartModal } from "./cart.modal.js";
diff --git a/src/main.js b/src/main.js
index 4b055b89d..78a56e256 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,1152 +1,184 @@
+import HomePage from "./pages/HomePage.js";
+import productService from "./services/ProductService.js";
+import { updateCartBadge, addToCart, openCartModal } from "./features/cart/index.js";
+import router from "./router/Router.js";
+import InfiniteScrollManager from "./services/InfiniteScrollManager.js";
+import homePageController from "./controllers/HomePageController.js";
+import productDetailController from "./controllers/ProductDetailController.js";
+import NotFoundPage from "./pages/NotFoundPage.js";
+
const enableMocking = () =>
- import("./mocks/browser.js").then(({ worker }) =>
- worker.start({
- onUnhandledRequest: "bypass",
- }),
- );
+ import("./mocks/browser.js").then(({ worker, workerOptions }) => worker.start(workerOptions));
+
+let state = {
+ products: [],
+ total: 0,
+ loading: false,
+ loadingMore: false,
+ categories: {},
+ limit: 20,
+ page: 1,
+ cart: {},
+ search: "",
+ category1: "",
+ category2: "",
+};
-function main() {
- const 상품목록_레이아웃_로딩 = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
상품을 불러오는 중...
-
-
-
-
-
-
-
- `;
+function attachEventListeners() {
+ const limitSelect = document.querySelector("#limit-select");
+ if (limitSelect) {
+ limitSelect.value = String(state.limit);
- const 상품목록_레이아웃_로딩완료 = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 총 340개의 상품
-
-
-
-
-
-
-

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

-
-
-
-
-
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
-
-
이지웨이건축자재
-
- 230원
-
-
-
-
-
-
-
-
-
- 모든 상품을 확인했습니다
-
-
-
-
-
-
- `;
+ limitSelect.onchange = async (e) => {
+ state.limit = Number(e.target.value);
+ state.page = 1;
+ fetchProductsAndRender();
+ };
+ }
- const 상품목록_레이아웃_카테고리_1Depth = `
-
-
-
-
-
-
-
-
+ const searchInput = document.querySelector("#search-input");
+ if (searchInput) {
+ searchInput.value = state.search;
-
-
-
-
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
+ searchInput.onkeydown = (e) => {
+ if (e.key === "Enter") {
+ const keyword = searchInput.value.trim();
+ if (state.search !== keyword) {
+ state.search = keyword;
+ state.page = 1;
+ fetchProductsAndRender();
+ }
+ }
+ };
+ }
- const 상품목록_레이아웃_카테고리_2Depth = `
-
-
-
-
-
-
-
-
+ document.querySelectorAll(".add-to-cart-btn").forEach((btn) => {
+ btn.onclick = () => {
+ const product = state.products.find((p) => String(p.productId) === btn.dataset.productId);
+ if (product) addToCart(product);
+ };
+ });
-
-
-
-
- >>주방용품
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
+ document.querySelector("#cart-icon-btn")?.addEventListener("click", openCartModal);
- const 토스트 = `
-
-
-
-
장바구니에 추가되었습니다
-
-
-
-
-
-
선택된 상품들이 삭제되었습니다
-
-
-
-
-
-
오류가 발생했습니다.
-
-
-
- `;
+ document.querySelectorAll(".category1-filter-btn").forEach((btn) => {
+ btn.onclick = () => {
+ state.category1 = btn.dataset.category1;
+ state.category2 = "";
+ state.page = 1;
+ fetchProductsAndRender();
+ };
+ });
+ document.querySelectorAll(".category2-filter-btn").forEach((btn) => {
+ btn.onclick = () => {
+ state.category1 = btn.dataset.category1;
+ state.category2 = btn.dataset.category2;
+ state.page = 1;
+ fetchProductsAndRender();
+ };
+ });
+ document.querySelector('[data-breadcrumb="reset"]')?.addEventListener("click", () => {
+ state.category1 = "";
+ state.category2 = "";
+ state.page = 1;
+ fetchProductsAndRender();
+ });
+ document.querySelector('[data-breadcrumb="category1"]')?.addEventListener("click", (e) => {
+ state.category1 = e.target.dataset.category1;
+ state.category2 = "";
+ state.page = 1;
+ fetchProductsAndRender();
+ });
+
+ document.querySelectorAll(".product-image, .product-info").forEach((el) => {
+ el.onclick = () => {
+ const card = el.closest(".product-card");
+ const pid = card.dataset.productId;
+ navigate(`/product/${pid}`);
+ };
+ });
+}
+
+let scrollManager;
+
+export function setupInfiniteScroll() {
+ if (scrollManager) return;
+ scrollManager = new InfiniteScrollManager(async () => {
+ if (!state.loading && !state.loadingMore && state.products.length < state.total) {
+ await loadMore();
+ }
+ });
+ scrollManager.attach();
+}
- const 장바구니_비어있음 = `
-
-
-
-
-
-
- 장바구니
-
-
-
-
-
-
-
-
-
-
-
-
장바구니가 비어있습니다
-
원하는 상품을 담아보세요!
-
-
-
-
-
- `;
+async function fetchProducts() {
+ const { products, pagination } = await productService.getProducts({
+ limit: state.limit,
+ page: state.page,
+ search: state.search,
+ category1: state.category1,
+ category2: state.category2,
+ });
+ state.products = products;
+ state.total = pagination.total;
+}
- const 장바구니_선택없음 = `
-
-
-
-
-
-
- 장바구니
- (2)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-

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

-
-
-
-
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
-
-
- 230원
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 총 금액
- 670원
-
-
-
-
-
-
-
-
-
-
-
- `;
+async function fetchProductsAndRender() {
+ state.loading = true;
+ render();
+ await fetchProducts();
+ state.loading = false;
+ render();
+}
- const 장바구니_선택있음 = `
-
-
-
-
-
-
- 장바구니
- (2)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-

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

-
-
-
-
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이
-
-
- 230원
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 선택한 상품 (1개)
- 440원
-
-
-
- 총 금액
- 670원
-
-
-
-
-
-
-
-
-
-
-
-
- `;
+async function loadMore() {
+ state.loadingMore = true;
+ render();
- const 상세페이지_로딩 = `
-
-
-
-
-
-
-
- `;
+ const nextPage = state.page + 1;
+ const { products: newProducts, pagination } = await productService.getProducts({
+ limit: state.limit,
+ page: nextPage,
+ search: state.search,
+ category1: state.category1,
+ category2: state.category2,
+ });
- const 상세페이지_로딩완료 = `
-
-
-
-
-
-
-
-
-
-
-

-
-
-
-
-
PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장
-
-
-
-
-
-
-
-
-
-
4.0 (749개 리뷰)
-
-
-
- 220원
-
-
-
- 재고 107개
-
-
-
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장에 대한 상세 설명입니다. 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
관련 상품
-
같은 카테고리의 다른 상품들
-
-
-
-
-
-
- `;
+ state.products = [...state.products, ...newProducts];
+ state.total = pagination.total;
+ state.page = nextPage;
+ state.loadingMore = false;
+ render();
+}
- const _404_ = `
-
-
-
-
-
홈으로
-
-
- `;
+function render() {
+ document.body.querySelector("#root").innerHTML = HomePage(state);
+ attachEventListeners();
+ updateCartBadge();
+}
- document.body.innerHTML = `
- ${상품목록_레이아웃_로딩}
-
- ${상품목록_레이아웃_로딩완료}
-
- ${상품목록_레이아웃_카테고리_1Depth}
-
- ${상품목록_레이아웃_카테고리_2Depth}
-
- ${토스트}
-
- ${장바구니_비어있음}
-
- ${장바구니_선택없음}
-
- ${장바구니_선택있음}
-
- ${상세페이지_로딩}
-
- ${상세페이지_로딩완료}
-
- ${_404_}
- `;
+async function main() {
+ // 초깃값 렌더링은 컨트롤러/라우터가 담당
+ router.handle(window.location.pathname);
}
-// 애플리케이션 시작
if (import.meta.env.MODE !== "test") {
enableMocking().then(main);
} else {
main();
}
+
+function navigate(path) {
+ router.navigate(path);
+}
+
+// 라우트 등록 (컨트롤러 활용)
+router.add(router.BASE_PATH, () => {
+ homePageController.init();
+});
+router.add(router.BASE_PATH + "/product/:id", async ({ id }) => {
+ await productDetailController.show(id);
+});
+router.setNotFound(() => {
+ document.querySelector("#root").innerHTML = NotFoundPage();
+});
diff --git a/src/mocks/browser.js b/src/mocks/browser.js
index be3dedca2..b19b86c07 100644
--- a/src/mocks/browser.js
+++ b/src/mocks/browser.js
@@ -3,3 +3,17 @@ import { handlers } from "./handlers";
// MSW 워커 설정
export const worker = setupWorker(...handlers);
+
+const basePath = import.meta.env.PROD ? "/front_6th_chapter1-1" : "";
+
+// Worker start 옵션을 export하여 main.js에서 사용
+export const workerOptions = import.meta.env.PROD
+ ? {
+ serviceWorker: {
+ url: `${basePath}/mockServiceWorker.js`,
+ },
+ onUnhandledRequest: "bypass",
+ }
+ : {
+ onUnhandledRequest: "bypass",
+ };
diff --git a/src/pages/HomePage.js b/src/pages/HomePage.js
new file mode 100644
index 000000000..2a347ba7d
--- /dev/null
+++ b/src/pages/HomePage.js
@@ -0,0 +1,207 @@
+import ProductCard from "../components/ProductCard.js";
+import { LoadingCardList } from "../components/LoadingCard.js";
+import router from "../router/Router.js";
+
+export default function HomePage({
+ products = [],
+ total = 0,
+ loading = false,
+ loadingMore = false,
+ categories = {},
+ category1 = "",
+ category2 = "",
+}) {
+ return 상품목록_레이아웃_로딩(products, total, loading, loadingMore, categories, category1, category2);
+}
+
+const 상품목록_레이아웃_로딩 = (products, total, loading, loadingMore, categories, category1, category2) => `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${breadcrumb(category1, category2)}
+
+
+ ${
+ category1
+ ? ""
+ : `
+ ${
+ loading
+ ? '
카테고리 로딩 중...
'
+ : renderCategory1(categories, category1)
+ }
+
`
+ }
+
+ ${
+ category1
+ ? `
+ ${renderCategory2(categories, category1, category2)}
+
`
+ : ""
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${
+ loading
+ ? ""
+ : `
+
+ 총 ${total}개의 상품
+
+ `
+ }
+
+
+ ${
+ loading
+ ? LoadingCardList /* 초기 로딩 */
+ : products.map(ProductCard).join("") + (loadingMore ? LoadingCardList : "") /* 추가 로딩 */
+ }
+
+ ${
+ loadingMore /* 인디케이터 */
+ ? `
+
+
+
상품을 불러오는 중...
+
+
`
+ : ""
+ }
+
+
+
+
+
+ `;
+
+const renderCategory1 = (cats, selected) =>
+ Object.keys(cats)
+ .map(
+ (c) => `
`,
+ )
+ .join("");
+
+const renderCategory2 = (cats, c1, c2) =>
+ c1 && cats[c1]
+ ? Object.keys(cats[c1])
+ .map(
+ (c) => `
`,
+ )
+ .join("")
+ : "";
+
+const breadcrumb = (c1, c2) => {
+ let html = '
';
+ if (c1) {
+ html += `
>
+
`;
+ }
+ if (c2) {
+ html += `
>
+
${c2}`;
+ }
+ return html;
+};
diff --git a/src/pages/NotFoundPage.js b/src/pages/NotFoundPage.js
new file mode 100644
index 000000000..5ae48e2e0
--- /dev/null
+++ b/src/pages/NotFoundPage.js
@@ -0,0 +1,38 @@
+import router from "../router/Router.js";
+
+export default function NotFoundPage() {
+ return `
+
+
+
+
+
홈으로
+
+
+ `;
+}
diff --git a/src/pages/ProductDetailPage.js b/src/pages/ProductDetailPage.js
new file mode 100644
index 000000000..a9ca2803b
--- /dev/null
+++ b/src/pages/ProductDetailPage.js
@@ -0,0 +1,103 @@
+export default function ProductDetailPage({ loading = true, product = null, related = [] }) {
+ if (loading || !product) {
+ return `
+
`;
+ }
+
+ return `
+
+
+
+
+
+
+
+

+
+
${product.title}
+
+
+
+
+ ${[1, 2, 3, 4, 5]
+ .map(
+ (n) => `
`,
+ )
+ .join("")}
+
+
${product.rating.toFixed(1)} (${product.reviewCount}개 리뷰)
+
+
+
${Number(product.lprice).toLocaleString()}원
+
+
+
재고 ${product.stock}개
+
+
+
+
+
+
+
+
+
+
+ ${
+ related && related.length
+ ? `
+
+
+
+
관련 상품
+
+
+ ${related
+ .map(
+ (r) => `
+
`,
+ )
+ .join("")}
+
+
`
+ : ""
+ }
+
+
`;
+}
diff --git a/src/router/Router.js b/src/router/Router.js
new file mode 100644
index 000000000..26253ce11
--- /dev/null
+++ b/src/router/Router.js
@@ -0,0 +1,92 @@
+class Router {
+ constructor() {
+ this.routes = [];
+ this.notFoundHandler = null;
+
+ // BASE_PATH 설정 (프로덕션 환경에서 서브패스 지원)
+ this.BASE_PATH = import.meta.env.PROD ? "/front_6th_chapter1-1" : "";
+
+ // 브라우저 뒤로가기/앞으로가기 처리
+ window.addEventListener("popstate", () => {
+ const appPath = this.getAppPath(window.location.pathname);
+ this.handle(appPath);
+ });
+ }
+
+ // 전체 경로에서 앱 경로 추출
+ getAppPath(fullPath = window.location.pathname) {
+ return fullPath.startsWith(this.BASE_PATH) ? fullPath.slice(this.BASE_PATH.length) || "/" : fullPath;
+ }
+
+ // 앱 경로를 전체 경로로 변환
+ getFullPath(appPath) {
+ return this.BASE_PATH + appPath;
+ }
+
+ // 라우트 등록: 패턴(예: '/product/:id')과 핸들러 함수
+ add(pattern, handler) {
+ this.routes.push({ pattern, handler });
+ }
+
+ // 404(매칭 안됨) 핸들러 등록
+ setNotFound(handler) {
+ this.notFoundHandler = handler;
+ }
+
+ // 내비게이션 (pushState 후 핸들)
+ navigate(appPath) {
+ const currentAppPath = this.getAppPath();
+ if (currentAppPath === appPath) return;
+
+ const fullPath = this.getFullPath(appPath);
+ history.pushState({}, "", fullPath);
+ this.handle(appPath);
+ }
+
+ // 현재 경로 처리 (앱 경로 기준)
+ handle(appPath) {
+ for (const { pattern, handler } of this.routes) {
+ const params = this.match(pattern, appPath);
+ if (params) {
+ handler(params);
+ return;
+ }
+ }
+ // 매칭 실패 시 notFound
+ if (this.notFoundHandler) this.notFoundHandler();
+ }
+
+ // 패턴 매칭 (간단한 ':' 파라미터 지원)
+ match(pattern, path) {
+ const patternParts = pattern.split("/").filter(Boolean);
+ const pathParts = path.split("/").filter(Boolean);
+
+ if (patternParts.length !== pathParts.length) return null;
+
+ const params = {};
+ for (let i = 0; i < patternParts.length; i++) {
+ const pp = patternParts[i];
+ const cp = pathParts[i];
+ if (pp.startsWith(":")) {
+ params[pp.slice(1)] = decodeURIComponent(cp);
+ } else if (pp !== cp) {
+ return null;
+ }
+ }
+ return params;
+ }
+
+ // 현재 앱 경로 가져오기
+ getCurrentAppPath() {
+ return this.getAppPath();
+ }
+
+ // 현재 전체 경로 가져오기
+ getCurrentFullPath() {
+ return window.location.pathname;
+ }
+}
+
+// 싱글톤 인스턴스 export
+const router = new Router();
+export default router;
diff --git a/src/services/CartService.js b/src/services/CartService.js
new file mode 100644
index 000000000..d559881f6
--- /dev/null
+++ b/src/services/CartService.js
@@ -0,0 +1,109 @@
+import { showToast } from "../components/Toast.js";
+
+class CartService {
+ constructor() {
+ this.cart = {};
+ this.loadFromStorage();
+ }
+
+ // 로컬스토리지에서 장바구니 불러오기
+ loadFromStorage() {
+ try {
+ const stored = JSON.parse(localStorage.getItem("shopping_cart") || "{}");
+ // 기존 객체 내용을 모두 제거하고, 저장된 데이터를 병합하여
+ // cart 객체의 참조를 유지한다.
+ Object.keys(this.cart).forEach((k) => delete this.cart[k]);
+ Object.assign(this.cart, stored);
+ } catch {
+ Object.keys(this.cart).forEach((k) => delete this.cart[k]);
+ }
+ }
+
+ // 로컬스토리지에 저장
+ saveToStorage() {
+ localStorage.setItem("shopping_cart", JSON.stringify(this.cart));
+ }
+
+ // 장바구니 배지 업데이트 (UI)
+ updateBadge() {
+ const btn = document.querySelector("#cart-icon-btn");
+ if (!btn) return;
+ btn.querySelector("span")?.remove();
+
+ const count = Object.keys(this.cart).length;
+ if (count) {
+ const badge = document.createElement("span");
+ badge.textContent = count;
+ badge.className =
+ "absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center";
+ btn.appendChild(badge);
+ }
+ }
+
+ // 장바구니 추가
+ add(product, qty = 1) {
+ const { productId } = product;
+ if (this.cart[productId]) this.cart[productId].quantity += qty;
+ else this.cart[productId] = { product, quantity: qty };
+
+ // 디버그 로그 제거
+
+ this.saveToStorage();
+ this.updateBadge();
+ showToast("장바구니에 추가되었습니다", "success");
+ }
+
+ // 수량 변경 (절대 값 설정)
+ setQuantity(productId, quantity) {
+ if (!this.cart[productId]) return;
+ if (quantity < 1) return;
+ this.cart[productId].quantity = quantity;
+ this.saveToStorage();
+ this.updateBadge();
+ }
+
+ // 수량 증가
+ increase(productId) {
+ if (!this.cart[productId]) return;
+ this.cart[productId].quantity += 1;
+ this.saveToStorage();
+ this.updateBadge();
+ }
+
+ // 수량 감소
+ decrease(productId) {
+ if (!this.cart[productId]) return;
+ if (this.cart[productId].quantity === 1) return;
+ this.cart[productId].quantity -= 1;
+ this.saveToStorage();
+ this.updateBadge();
+ }
+
+ // 항목 제거
+ remove(productId) {
+ if (!this.cart[productId]) return;
+ delete this.cart[productId];
+ this.saveToStorage();
+ this.updateBadge();
+ }
+
+ // 전체 비우기
+ clear() {
+ Object.keys(this.cart).forEach((k) => delete this.cart[k]);
+ this.saveToStorage();
+ this.updateBadge();
+ }
+
+ // 장바구니 객체 가져오기 (참조)
+ getCart() {
+ return this.cart;
+ }
+
+ // 항목 수 반환
+ getCount() {
+ return Object.keys(this.cart).length;
+ }
+}
+
+const cartService = new CartService();
+export default cartService;
diff --git a/src/services/InfiniteScrollManager.js b/src/services/InfiniteScrollManager.js
new file mode 100644
index 000000000..a130272e9
--- /dev/null
+++ b/src/services/InfiniteScrollManager.js
@@ -0,0 +1,36 @@
+class InfiniteScrollManager {
+ constructor(callback, options = {}) {
+ this.callback = callback;
+ this.threshold = options.threshold ?? 200; // px before bottom
+ this.loading = false;
+ this.attached = false;
+ this.onScroll = this.onScroll.bind(this);
+ }
+
+ attach() {
+ if (this.attached) return;
+ window.addEventListener("scroll", this.onScroll);
+ this.attached = true;
+ }
+
+ detach() {
+ if (!this.attached) return;
+ window.removeEventListener("scroll", this.onScroll);
+ this.attached = false;
+ }
+
+ async onScroll() {
+ if (this.loading) return;
+ const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - this.threshold;
+ if (nearBottom) {
+ this.loading = true;
+ try {
+ await this.callback();
+ } finally {
+ this.loading = false;
+ }
+ }
+ }
+}
+
+export default InfiniteScrollManager;
diff --git a/src/services/ProductService.js b/src/services/ProductService.js
new file mode 100644
index 000000000..4229b4b8e
--- /dev/null
+++ b/src/services/ProductService.js
@@ -0,0 +1,22 @@
+import * as productApi from "../api/productApi.js";
+
+class ProductService {
+ // 상품 목록 조회
+ async getProducts(params = {}) {
+ return await productApi.getProducts(params);
+ }
+
+ // 단일 상품 조회
+ async getProduct(productId) {
+ return await productApi.getProduct(productId);
+ }
+
+ // 카테고리 목록 조회
+ async getCategories() {
+ return await productApi.getCategories();
+ }
+}
+
+// 애플리케이션 전역에서 공유되는 싱글톤 인스턴스
+const productService = new ProductService();
+export default productService;
diff --git a/src/setupTests.js b/src/setupTests.js
index d72b8905a..659279d1e 100644
--- a/src/setupTests.js
+++ b/src/setupTests.js
@@ -4,7 +4,7 @@ import { afterAll, beforeAll } from "vitest";
import { server } from "./__tests__/mockServerHandler.js";
configure({
- asyncUtilTimeout: 5000,
+ asyncUtilTimeout: 1000,
});
beforeAll(() => {
diff --git a/vite.config.js b/vite.config.js
index 2eef1c44e..5d987e62c 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,6 +1,19 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
+ base: process.env.NODE_ENV === "production" ? "/front_6th_chapter1-1/" : "/",
+
+ build: {
+ outDir: "dist",
+ assetsDir: "assets",
+ sourcemap: false,
+ rollupOptions: {
+ output: {
+ manualChunks: undefined,
+ },
+ },
+ },
+
test: {
globals: true,
environment: "jsdom",