diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 601a6ce26..70a9c3e74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,45 @@ on: workflow_dispatch: jobs: - unit: + hard-basic: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + - uses: pnpm/action-setup@v4 + with: + version: latest + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + - run: | + pnpm install + pnpm run test:hard:basic + hard-advanced: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + - uses: pnpm/action-setup@v4 + with: + version: latest + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + - name: Install dependencies + run: | + pnpm install + npx playwright install --with-deps + pnpm run test:hard:advanced + easy-basic: + timeout-minutes: 60 runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -27,8 +65,9 @@ jobs: - name: Install dependencies run: | pnpm install - pnpm run test - e2e: + npx playwright install --with-deps + pnpm run test:easy:basic + easy-advanced: timeout-minutes: 60 runs-on: ubuntu-latest steps: @@ -47,4 +86,4 @@ jobs: run: | pnpm install npx playwright install --with-deps - pnpm run test:e2e + pnpm run test:easy:advanced diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..9451f409a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,38 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Install dependencies + run: pnpm install + + - name: Build + run: pnpm build + env: + NODE_ENV: production + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + if: github.ref == 'refs/heads/main' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist diff --git a/cart-modal.html b/cart-modal.html index 66455103d..6e2695c78 100644 --- a/cart-modal.html +++ b/cart-modal.html @@ -28,7 +28,7 @@

- 쇼핑몰 + 쇼핑몰

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.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 상품목록_레이아웃_로딩 = ` +//
+//
+//
+//
+//

+// 쇼핑몰 +//

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

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

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

+// 쇼핑몰 +//

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

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

+//

+//

+// 220원 +//

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

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

+//

이지웨이건축자재

+//

+// 230원 +//

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

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

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

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

+// +//
+ +//
+//
+// +// +// +//
+//

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

+// +//
+ +//
+//
+// +// +// +//
+//

오류가 발생했습니다.

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

+// +// +// +// 장바구니 +//

+ +// +//
+ +// +//
+// +//
+//
+//
+// +// +// +//
+//

장바구니가 비어있습니다

+//

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

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

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

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

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

+//

+// 220원 +//

+// +//
+// +// +// +//
+//
+// +//
+//

+// 440원 +//

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

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

+//

+// 230원 +//

+// +//
+// +// +// +//
+//
+// +//
+//

+// 230원 +//

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

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

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

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

+//

+// 220원 +//

+// +//
+// +// +// +//
+//
+// +//
+//

+// 440원 +//

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

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

+//

+// 230원 +//

+// +//
+// +// +// +//
+//
+// +//
+//

+// 230원 +//

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

상품 상세

+//
+//
+// +// +//
+//
+//
+//
+//
+//
+//
+//
+//

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

+//
+//
+//
+//
+//
+//

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

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

상품 상세

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

+//

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

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

관련 상품

+//

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

+//
+//
+//
+// +// +//
+//
+//
+//
+//
+//
+//

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

+//
+//
+//
+// `; + +// const _404_ = ` +//
+//
+// +// +// +// +// +// +// +// +// +// + +// +// 404 + +// +// +// +// +// + +// +// 페이지를 찾을 수 없습니다 + +// +// +// + +// 홈으로 +//
+//
+// `; 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} +
+
+

${product.title}

+

${format(product.lprice)}

+
+ + + +
+
+
+

${format(product.lprice * quantity)}

+ +
+
`, + ) + .join("")} +
+
+ + +
+ + + + +
+ 총 금액 + ${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 상품목록_레이아웃_로딩 = ` -
-
-
-
-

- 쇼핑몰 -

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

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

-
-
-
- `; +function attachEventListeners() { + const limitSelect = document.querySelector("#limit-select"); + if (limitSelect) { + limitSelect.value = String(state.limit); - const 상품목록_레이아웃_로딩완료 = ` -
-
-
-
-

- 쇼핑몰 -

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

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

-

-

- 220원 -

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

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

-

이지웨이건축자재

-

- 230원 -

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

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

-
-
-
- `; + 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호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -
- -
-

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

-

- 220원 -

- -
- - - -
-
- -
-

- 440원 -

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

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

-

- 230원 -

- -
- - - -
-
- -
-

- 230원 -

- -
-
-
-
-
- -
- - -
- 총 금액 - 670원 -
- -
-
- - -
-
-
-
-
- `; +async function fetchProductsAndRender() { + state.loading = true; + render(); + await fetchProducts(); + state.loading = false; + render(); +} - const 장바구니_선택있음 = ` -
-
- -
-

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

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

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

-

- 220원 -

- -
- - - -
-
- -
-

- 440원 -

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

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

-

- 230원 -

- -
- - - -
-
- -
-

- 230원 -

- -
-
-
-
-
- -
- -
- 선택한 상품 (1개) - 440원 -
- -
- 총 금액 - 670원 -
- -
- -
- - -
-
-
-
-
- `; +async function loadMore() { + state.loadingMore = true; + render(); - const 상세페이지_로딩 = ` -
-
-
-
-
- -

상품 상세

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

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

-
-
-
-
-
-

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

-
-
-
- `; + 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호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -
- -
-

-

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

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

관련 상품

-

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

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

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

-
-
-
- `; + state.products = [...state.products, ...newProducts]; + state.total = pagination.total; + state.page = nextPage; + state.loadingMore = false; + render(); +} - const _404_ = ` -
-
- - - - - - - - - - - - - 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 /* 인디케이터 */ + ? `
+
+ + + + + 상품을 불러오는 중... +
+
` + : "" + } +
+
+
+
+
+

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

+
+
+
+ `; + +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 ` +
+
+ + + + + + + + + + + + + 404 + + + + + + + + + 페이지를 찾을 수 없습니다 + + + + + + 홈으로 +
+
+ `; +} 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} +
+

${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",