diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 601a6ce26..899ef4163 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ on: workflow_dispatch: jobs: - unit: + hard-basic: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -24,11 +24,50 @@ jobs: 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 - pnpm run test - e2e: + npx playwright install --with-deps + pnpm run test:hard:advanced + easy-basic: + 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: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/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/index.html b/index.html index d43ffde2d..b6cbac243 100644 --- a/index.html +++ b/index.html @@ -1,26 +1,195 @@ - - - - 상품 쇼핑몰 - - - + + + - - -
- - + })(window.location); + + + +
+ +
+ +
+
+
+

+ 쇼핑몰 +

+
+ +
+
+
+
+ + +
+ +
+ +
+
+ +
+ + + +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+ + diff --git a/package.json b/package.json index 7b2368941..91a2baab2 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}": [ @@ -37,6 +42,7 @@ "eslint": "^9.16.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", + "gh-pages": "^6.3.0", "globals": "^15.13.0", "husky": "^9.1.7", "jsdom": "^25.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8137d4c85..6a2fd4f25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: eslint-plugin-prettier: specifier: ^5.2.1 version: 5.2.3(eslint-config-prettier@9.1.0(eslint@9.23.0))(eslint@9.23.0)(prettier@3.5.3) + gh-pages: + specifier: ^6.3.0 + version: 6.3.0 globals: specifier: ^15.13.0 version: 15.15.0 @@ -559,6 +562,18 @@ packages: '@napi-rs/wasm-runtime@0.2.11': resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -898,6 +913,10 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -905,6 +924,9 @@ packages: ast-v8-to-istanbul@0.3.3: resolution: {integrity: sha512-MuXMrSLVVoA6sYN/6Hke18vMzrT4TZNbZIj/hvh0fnYFpO+/kFXcLIaiPwXXWaQUPg4yJD8fj+lfJ7/1EBconw==} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -987,6 +1009,9 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1049,6 +1074,10 @@ packages: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -1062,6 +1091,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + email-addresses@5.0.0: + resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==} + emoji-regex@10.4.0: resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} @@ -1112,6 +1144,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1198,12 +1234,19 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fdir@6.4.3: resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==} peerDependencies: @@ -1227,10 +1270,26 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filename-reserved-regex@2.0.0: + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} + + filenamify@4.3.0: + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1250,6 +1309,10 @@ packages: resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} engines: {node: '>= 6'} + fs-extra@11.3.0: + resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==} + engines: {node: '>=14.14'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1283,6 +1346,15 @@ packages: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} + gh-pages@6.3.0: + resolution: {integrity: sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==} + engines: {node: '>=10'} + hasBin: true + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1299,10 +1371,17 @@ packages: resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} engines: {node: '>=18'} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql@16.11.0: resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -1454,6 +1533,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1538,6 +1620,10 @@ packages: resolution: {integrity: sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==} engines: {node: '>=18.0.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1571,6 +1657,10 @@ packages: magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -1582,6 +1672,10 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1668,14 +1762,26 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -1705,6 +1811,10 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1731,6 +1841,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + playwright-core@1.53.2: resolution: {integrity: sha512-ox/OytMy+2w1jcYEYlOo1Hhp8hZkLCximMTUTMBXjGUA1KoFfiSZ+DU+3a739jsPY0yoKH2TFy9S2fsJas8yAw==} engines: {node: '>=18'} @@ -1772,6 +1886,9 @@ packages: querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -1797,6 +1914,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -1855,6 +1976,9 @@ packages: rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1862,6 +1986,10 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -1886,6 +2014,10 @@ packages: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + slice-ansi@5.0.0: resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} engines: {node: '>=12'} @@ -1950,6 +2082,10 @@ packages: strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + strip-outer@1.0.1: + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2022,6 +2158,10 @@ packages: resolution: {integrity: sha512-IUWnUK7ADYR5Sl1fZlO1INDUhVhatWl7BtJWsIhwJ0UAK7ilzzIa8uIqOO/aYVWHZPJkKbEL+362wrzoeRF7bw==} engines: {node: '>=18'} + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2041,6 +2181,10 @@ packages: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -2560,6 +2704,18 @@ snapshots: '@tybys/wasm-util': 0.9.0 optional: true + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': @@ -2857,6 +3013,8 @@ snapshots: aria-query@5.3.2: {} + array-union@2.1.0: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@0.3.3: @@ -2865,6 +3023,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 9.0.1 + async@3.2.6: {} + asynckit@0.4.0: {} balanced-match@1.0.2: {} @@ -2944,6 +3104,8 @@ snapshots: commander@13.1.0: {} + commondir@1.0.1: {} + concat-map@0.0.1: {} cookie@0.7.2: {} @@ -2986,6 +3148,10 @@ snapshots: detect-libc@2.0.4: {} + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -2998,6 +3164,8 @@ snapshots: eastasianwidth@0.2.0: {} + email-addresses@5.0.0: {} + emoji-regex@10.4.0: {} emoji-regex@8.0.0: {} @@ -3082,6 +3250,8 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} eslint-config-prettier@9.1.0(eslint@9.23.0): @@ -3188,10 +3358,22 @@ snapshots: fast-diff@1.3.0: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + fdir@6.4.3(picomatch@4.0.2): optionalDependencies: picomatch: 4.0.2 @@ -3206,10 +3388,29 @@ snapshots: dependencies: flat-cache: 4.0.1 + filename-reserved-regex@2.0.0: {} + + filenamify@4.3.0: + dependencies: + filename-reserved-regex: 2.0.0 + strip-outer: 1.0.1 + trim-repeated: 1.0.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -3234,6 +3435,12 @@ snapshots: es-set-tostringtag: 2.1.0 mime-types: 2.1.35 + fs-extra@11.3.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + fsevents@2.3.2: optional: true @@ -3266,6 +3473,20 @@ snapshots: get-stream@8.0.1: {} + gh-pages@6.3.0: + dependencies: + async: 3.2.6 + commander: 13.1.0 + email-addresses: 5.0.0 + filenamify: 4.3.0 + find-cache-dir: 3.3.2 + fs-extra: 11.3.0 + globby: 11.1.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -3283,8 +3504,19 @@ snapshots: globals@15.15.0: {} + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + graphql@16.11.0: {} has-flag@4.0.0: {} @@ -3433,6 +3665,12 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3513,6 +3751,10 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3547,6 +3789,10 @@ snapshots: '@babel/types': 7.28.0 source-map-js: 1.2.1 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + make-dir@4.0.0: dependencies: semver: 7.7.2 @@ -3555,6 +3801,8 @@ snapshots: merge-stream@2.0.0: {} + merge2@1.4.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -3640,14 +3888,24 @@ snapshots: outvariant@1.4.3: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} parent-module@1.0.1: @@ -3671,6 +3929,8 @@ snapshots: path-to-regexp@6.3.0: {} + path-type@4.0.0: {} + pathe@1.1.2: {} pathe@2.0.3: {} @@ -3685,6 +3945,10 @@ snapshots: pidtree@0.6.0: {} + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + playwright-core@1.53.2: {} playwright@1.53.2: @@ -3721,6 +3985,8 @@ snapshots: querystringify@2.2.0: {} + queue-microtask@1.2.3: {} + react-is@17.0.2: {} redent@3.0.0: @@ -3741,6 +4007,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + reusify@1.1.0: {} + rfdc@1.4.1: {} rolldown-vite@6.3.21(esbuild@0.25.1)(yaml@2.7.0): @@ -3806,12 +4074,18 @@ snapshots: rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + safer-buffer@2.1.2: {} saxes@6.0.0: dependencies: xmlchars: 2.2.0 + semver@6.3.1: {} + semver@7.7.2: {} shebang-command@2.0.0: @@ -3830,6 +4104,8 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + slash@3.0.0: {} + slice-ansi@5.0.0: dependencies: ansi-styles: 6.2.1 @@ -3890,6 +4166,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3956,6 +4236,10 @@ snapshots: dependencies: punycode: 2.3.1 + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + tslib@2.8.1: {} type-check@0.4.0: @@ -3968,6 +4252,8 @@ snapshots: universalify@0.2.0: {} + universalify@2.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 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/src/components/Breadcrumb.js b/src/components/Breadcrumb.js new file mode 100644 index 000000000..99f2ea8c9 --- /dev/null +++ b/src/components/Breadcrumb.js @@ -0,0 +1,41 @@ +import { store } from "../main"; + +Breadcrumb.mount = () => { + const categoryAllBtn = document.querySelector("button[data-breadcrumb='reset']"); + const category1Btn = document.querySelector("button[data-breadcrumb='category1']"); + + categoryAllBtn.addEventListener("click", () => { + store.set("params.category1", ""); + store.set("params.category2", ""); + }); + + category1Btn?.addEventListener("click", () => { + store.set("params.category2", null); + }); +}; + +export default function Breadcrumb() { + const category1 = store.get("params")["category1"]; + const category2 = store.get("params")["category2"]; + + return /* html */ ` + + + ${ + category1 + ? /* html */ ` + > + + ` + : "" + } + ${ + category2 + ? /* html */ ` + > + + ` + : "" + } + `; +} diff --git a/src/components/CartButton.js b/src/components/CartButton.js new file mode 100644 index 000000000..c4963a86c --- /dev/null +++ b/src/components/CartButton.js @@ -0,0 +1,23 @@ +import { store } from "../main"; + +export default function CartButton() { + return /* html */ ` + + `; +} diff --git a/src/components/Header.js b/src/components/Header.js new file mode 100644 index 000000000..ad64a15b3 --- /dev/null +++ b/src/components/Header.js @@ -0,0 +1,59 @@ +import { render, store, getAppPath } from "../main"; +import CartButton from "./CartButton"; + +const state = { + isProductDetailPage: false, +}; + +Header.init = () => { + const pathname = getAppPath(); + state.isProductDetailPage = pathname.includes("product"); +}; + +Header.mount = () => { + const cartItems = JSON.parse(window.localStorage.getItem("cart")); + if (!cartItems) return; + // let cartLength = cartItems.length; + store.set("cart", cartItems.length); + render.draw("#cart-box", CartButton()); + store.watch( + (newValue) => { + store.set("cart", newValue); + render.draw("#cart-box", CartButton()); + }, + ["cart"], + ); +}; + +export default function Header() { + return /* html */ ` +
+
+ ${ + !state.isProductDetailPage + ? /* html */ ` + +

+ 쇼핑몰 +

+ ` + : /* html */ ` +
+ +

상품 상세

+
+ ` + } + +
+ + ${CartButton()} +
+
+
+ `; +} diff --git a/src/components/Layout.js b/src/components/Layout.js new file mode 100644 index 000000000..701820815 --- /dev/null +++ b/src/components/Layout.js @@ -0,0 +1,20 @@ +import { render } from "../main"; +import Footer from "./footer"; +import Header from "./Header"; + +Layout.mount = () => { + Header.init(); + render.draw("header", Header()); + render.draw("footer", Footer()); + Header.mount(); +}; + +export default function Layout() { + return /* html */ ` +
+
+
+ +
+ `; +} diff --git a/src/components/Loading.js b/src/components/Loading.js new file mode 100644 index 000000000..308d05b53 --- /dev/null +++ b/src/components/Loading.js @@ -0,0 +1,81 @@ +const getLoadingTemplate = (type) => { + switch (type) { + case "category": + return /* html */ ` +
+
카테고리 로딩 중...
+
+ `; + + case "products": + return /* html */ ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + + 상품을 불러오는 중... +
+
+ + `; + + case "product": + return /* html */ ` +
+
+
+

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

+
+
+ `; + + default: + return null; + } +}; + +export default function Loading({ type }) { + return getLoadingTemplate(type); +} diff --git a/src/components/ProductCard.js b/src/components/ProductCard.js new file mode 100644 index 000000000..318739e59 --- /dev/null +++ b/src/components/ProductCard.js @@ -0,0 +1,66 @@ +import { navigate } from "../main"; + +const state = { + product: {}, +}; + +ProductCard.mount = () => { + const items = document.querySelectorAll(".product-card"); + items.forEach((item) => { + const productId = item.getAttribute("data-product-id"); + item.querySelector("img").addEventListener("click", async () => { + navigate.push({}, `/product/${productId}`); + }); + + // document.querySelectorAll(".add-to-cart-btn").forEach((btn) => { + // btn.addEventListener("click", () => { + // const productId = btn.getAttribute("data-product-id"); + // const product = products.find((p) => p.productId === productId); + // handleAddCart(product); + // }); + // }); + + // item.querySelector(".add-to-cart-btn").addEventListener("click", () => { + // // handleAddCart(state.product); + // }); + }); +}; + +export default function ProductCard(product) { + state.product = product; + + const formatPrice = (price) => { + return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); + }; + + return /* html */ ` +
+ +
+ ${product.title} +
+ +
+
+

+ ${product.title} +

+

${product.brand ? product.brand : product.maker ? product.maker : product.mallName}

+

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

+
+ + +
+
+ `; +} diff --git a/src/components/ProductList.js b/src/components/ProductList.js new file mode 100644 index 000000000..585574e6f --- /dev/null +++ b/src/components/ProductList.js @@ -0,0 +1,29 @@ +import { handleAddCart } from "../js/cart"; +import ProductCard from "./ProductCard"; + +ProductList.mount = (products) => { + document.querySelectorAll(".add-to-cart-btn").forEach((btn) => { + btn.addEventListener("click", () => { + const productId = btn.getAttribute("data-product-id"); + const product = products.find((p) => p.productId === productId); + handleAddCart(product); + }); + }); +}; + +export default function ProductList({ products, pagination }) { + return /* html */ ` +
+
+ +
+ 총 ${pagination?.total}개의 상품 +
+ +
+ ${products?.map((product) => ProductCard(product)).join("")} +
+
+
+ `; +} diff --git a/src/components/Search.js b/src/components/Search.js new file mode 100644 index 000000000..69c4dd4dc --- /dev/null +++ b/src/components/Search.js @@ -0,0 +1,182 @@ +import { store } from "../main"; +import Breadcrumb from "./Breadcrumb"; +import Loading from "./Loading"; + +Search.mount = () => { + const params = new URLSearchParams(window.location.search); + const paramObj = {}; + for (const [key, value] of params.entries()) { + paramObj[key] = value; + } + + const limitSelect = document.querySelector("#limit-select"); + const sortSelect = document.querySelector("#sort-select"); + const searchInput = document.querySelector("#search-input"); + const limit = paramObj.limit || store.get("params")["limit"]; + const sort = paramObj.sort || store.get("params")["sort"]; + const search = paramObj.search || store.get("params")["search"]; + + limitSelect.value = limit; + sortSelect.value = sort; + searchInput.value = search; + + const handleKeyup = (event) => { + if (event.key !== "Enter") return; + store.set("params", { + ...store.get("params"), + search: event.target.value, + page: 1, + }); + }; + + window.addEventListener("keypress", handleKeyup); + + document.querySelector("#limit-select").addEventListener("change", (event) => { + store.set("params", { + ...store.get("params"), + limit: event.target.value, + page: 1, + }); + }); + + document.querySelector("#sort-select").addEventListener("change", (event) => { + store.set("params", { + ...store.get("params"), + sort: event.target.value, + page: 1, + }); + }); + + // 1depth 카테고리 버튼 이벤트 바인딩 + const category1Buttons = document.querySelectorAll(".category1-filter-btn"); + const categoryDefaultStyle = "category2-filter-btn text-left px-3 py-2 text-sm rounded-md border transition-colors"; + + category1Buttons.forEach((category1) => { + category1.addEventListener("click", () => { + store.set("params.category1", category1.getAttribute("data-category1")); + // params가 바뀌면 Home.js의 watch에서 Search, Breadcrumb 모두 다시 렌더+mount됨 + }); + }); + + // 2depth 카테고리 버튼 이벤트 바인딩 (렌더 후 mount에서만 바인딩!) + const category2Buttons = document.querySelectorAll(".category2-filter-btn"); + category2Buttons.forEach((category2) => { + category2.addEventListener("click", (event) => { + // 모든 버튼 스타일 초기화 + category2Buttons.forEach((btn) => { + btn.classList = categoryDefaultStyle + " bg-white border-gray-300 text-gray-700 hover:bg-gray-50"; + }); + // 클릭된 버튼만 파란색 + event.target.classList = categoryDefaultStyle + " bg-blue-100 border-blue-300 text-blue-800"; + store.set("params.category2", category2.getAttribute("data-category2")); + // params가 바뀌면 Home.js의 watch에서 Search, Breadcrumb 모두 다시 렌더+mount됨 + }); + }); +}; + +export default function Search(categories = {}, isLoading = true) { + return /* html */ ` + +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+ + +
+ ${ + isLoading + ? Loading({ type: "category" }) + : !store.get("params")["category1"] + ? Object.keys(categories) + .map( + (category1) => /* html */ ` + + `, + ) + .join("") + : "" + } + + + ${ + store.get("params")["category1"] && categories[store.get("params")["category1"]] + ? Object.keys(categories[store.get("params")["category1"]]) + .map( + (category2) => ` + + `, + ) + .join("") + : "" + } + + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ `; +} diff --git a/src/components/Toast.js b/src/components/Toast.js new file mode 100644 index 000000000..5047035ea --- /dev/null +++ b/src/components/Toast.js @@ -0,0 +1,94 @@ +const getToastTemplate = (type) => { + switch (type) { + case "addCart": + return /* html */ ` +
+
+ + + +
+

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

+ +
+ `; + case "deleteCart": + return /* html */ ` +
+
+ + + +
+

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

+ +
+ `; + case "error": + return /* html */ ` +
+
+ + + +
+

오류가 발생했습니다.

+ +
+ `; + default: + return /* html */ ` +
+
+ + + +
+

오류가 발생했습니다.

+ +
+ `; + } +}; + +const handleDeleteToast = () => { + const toast = document.querySelector("#toast"); + if (!toast) return; + toast.remove(); +}; + +Toast.init = () => { + setTimeout(() => { + handleDeleteToast(); + }, 3000); +}; + +Toast.mount = (type) => { + Toast.init(); + document.querySelector("main").insertAdjacentHTML("beforeend", Toast(type)); + const toastCloseBtn = document.getElementById("toast-close-btn"); + toastCloseBtn.addEventListener("click", (event) => { + const toastEl = event.target.closest("#toast"); + toastEl.remove(); + }); +}; + +export default function Toast(type) { + return `
${getToastTemplate(type)}
`; +} diff --git a/src/components/footer.js b/src/components/footer.js new file mode 100644 index 000000000..f4460532b --- /dev/null +++ b/src/components/footer.js @@ -0,0 +1,8 @@ +export default function Footer() { + return /* html */ ` + `; +} diff --git a/src/core/useNavigate.js b/src/core/useNavigate.js new file mode 100644 index 000000000..e321dc0ce --- /dev/null +++ b/src/core/useNavigate.js @@ -0,0 +1,15 @@ +import { render } from "../main"; +import { getFullPath } from "../main"; + +const useNavigate = () => { + const push = (state = {}, url) => { + const fullUrl = getFullPath(url); + window.history.pushState(state, "", fullUrl); + + render.view(); + }; + + return { push }; +}; + +export default useNavigate; diff --git a/src/core/useRender.js b/src/core/useRender.js new file mode 100644 index 000000000..160679752 --- /dev/null +++ b/src/core/useRender.js @@ -0,0 +1,37 @@ +import Layout from "../components/Layout"; +import routes from "../routes"; +import { getAppPath } from "../main"; + +let currentPage = null; + +const useRender = () => { + const init = () => { + document.querySelector("#root").innerHTML = Layout(); + }; + const draw = (tag, html) => { + if (!tag) return; + document.querySelector(tag).innerHTML = html; + }; + + const view = async () => { + // 이전 페이지의 unmount 호출 + if (currentPage && currentPage.unmount) { + currentPage.unmount(); + } + + for (const route of routes) { + const match = getAppPath().match(route.path); + if (!match) continue; + const Page = route.component; + currentPage = Page; // 현재 페이지 저장 + Page.init?.(match?.[1]); + draw("main", Page({})); + await Page.mount?.(); + return; // 매치된 첫 번째 라우트만 실행하고 멈추게 + } + }; + + return { init, draw, view }; +}; + +export default useRender; diff --git a/src/core/useStore.js b/src/core/useStore.js new file mode 100644 index 000000000..60435e3fe --- /dev/null +++ b/src/core/useStore.js @@ -0,0 +1,87 @@ +const useStore = (() => { + // 장바구니, params 등 전역적으로 쓰일 상태들 + const globalState = { + params: { + category1: "", + category2: "", + search: "", + sort: "price_asc", + page: 1, + limit: 20, + }, + + pagination: { + hasNext: false, + hasPrev: false, + limit: 20, + page: 1, + total: 0, + totalPages: 0, + }, + + categories: {}, + // cart: (() => { + // try { + // const cart = window.localStorage.getItem("cart"); + // return cart ? JSON(cart).length : []; + // } catch { + // return 0; + // } + // })(), + + cart: 0, + }; + + const listener = []; + + // set으로 중첩 객체에 접근하기 위함 (gpt) + const setNestedValue = (obj, path, value) => { + const keys = path.split("."); + let current = obj; + + keys.slice(0, -1).forEach((key) => { + if (!current[key]) current[key] = {}; + current = current[key]; + }); + + current[keys[keys.length - 1]] = value; + }; + + const getNestedValue = (obj, path) => { + return path.split(".").reduce((acc, key) => acc?.[key], obj); + }; + + // 전역 상태를 가져오는 함수 + const get = (key) => { + if (!key) { + return globalState; + } else { + return globalState[key]; + } + }; + + // 전역 상태를 만드는 함수 + const set = (key, value) => { + setNestedValue(globalState, key, value); + + listener.forEach(({ callback, targetKey }) => { + if (!targetKey || key === targetKey || key.startsWith(`${targetKey}.`)) { + const valueToReturn = getNestedValue(globalState, targetKey); + callback(valueToReturn, globalState); + } + }); + }; + + // 전역 상태가 변경됐음을 감지해 주는 함수 + const watch = (callback, targetKey = null) => { + listener.push({ callback, targetKey }); + + return () => { + const index = listener.findIndex((l) => l.callback === callback && l.targetKey === targetKey); + if (index !== -1) listener.splice(index, 1); + }; + }; + + return () => ({ get, set, watch }); +})(); +export default useStore; diff --git a/src/js/cart.js b/src/js/cart.js new file mode 100644 index 000000000..2007b909c --- /dev/null +++ b/src/js/cart.js @@ -0,0 +1,41 @@ +// 상품, 상품 개수, + +import Header from "../components/Header"; +import Toast from "../components/Toast"; +import { render, store } from "../main"; + +export function handleGetCartItem() { + const cart = window.localStorage.getItem("cart"); + return cart ? JSON.parse(cart) : []; +} + +export function handleAddCart(value) { + const current = handleGetCartItem(); + Toast.mount("addCart"); + + const hasAlreadyProduct = current.findIndex((product) => product.productId === value.productId); + + if (hasAlreadyProduct === -1) { + // 장바구니에 없는 아이템 + const newCart = [...current, value]; + window.localStorage.setItem("cart", JSON.stringify(newCart)); + store.set("cart", newCart.length); + } else { + // 장바구니에 있는 아이템 + // 수량 비교 + const existingProduct = current[hasAlreadyProduct]; + + if (existingProduct.quantity !== value.quantity) { + // 수량이 다르면 새로운 수량으로 업데이트 + current[hasAlreadyProduct] = value; + } else { + // 수량이 같으면 기존 수량에 더하기 + current[hasAlreadyProduct].quantity = parseInt(existingProduct.quantity) + parseInt(value.quantity); + } + + window.localStorage.setItem("cart", JSON.stringify(current)); + } + + render.draw("header", Header()); + Header.mount(); +} diff --git a/src/js/utils.js b/src/js/utils.js new file mode 100644 index 000000000..49baf78c2 --- /dev/null +++ b/src/js/utils.js @@ -0,0 +1,5 @@ +// routes에 적은 path를 정규식으로 변환 -> location.pathname이랑 비교가 가능하게끔 +export function pathToRegex(path) { + // "/product/:id" → /^\/product\/([^/]+)$/ + return new RegExp("^" + path.replace(/:\w+/g, "([^/]+)").replace("*", ".*") + "$"); +} diff --git a/src/main.js b/src/main.js index 4b055b89d..23b4de5b0 100644 --- a/src/main.js +++ b/src/main.js @@ -1,1147 +1,58 @@ -const enableMocking = () => - import("./mocks/browser.js").then(({ worker }) => - worker.start({ - onUnhandledRequest: "bypass", - }), - ); - -function main() { - const 상품목록_레이아웃_로딩 = ` -
-
-
-
-

- 쇼핑몰 -

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

- 쇼핑몰 -

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

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

-

-

- 220원 -

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

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

-

이지웨이건축자재

-

- 230원 -

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

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

- -
- -
-
- - - -
-

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

- -
- -
-
- - - -
-

오류가 발생했습니다.

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

-

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

-
-
-
-
-
- `; +import Layout from "./components/Layout.js"; +import useNavigate from "./core/useNavigate.js"; +import useRender from "./core/useRender.js"; +import useStore from "./core/useStore.js"; - 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 상세페이지_로딩 = ` -
-
-
-
-
- -

상품 상세

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

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

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

상품 상세

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

-

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

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

관련 상품

-

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

-
-
-
- - -
-
-
-
- -
- `; +const enableMocking = () => + import("./mocks/browser.js").then(({ worker, workerOptions }) => worker.start(workerOptions)); - const _404_ = ` -
-
- - - - - - - - - - - - - 404 - - - - - - - - - 페이지를 찾을 수 없습니다 - - - - - - 홈으로 -
-
- `; +if (import.meta.env.MODE !== "test") { + enableMocking().then(main); +} else { + main(); +} +export const render = useRender(); +export const navigate = useNavigate(); +export const store = useStore(); + +// gh-pages 배포를 위한 BASE_PATH 설정 +export const BASE_PATH = import.meta.env.PROD ? "/front_6th_chapter1-1" : ""; + +// BASE_PATH를 제거한 앱 경로를 반환하는 유틸리티 함수 +export const getAppPath = (fullPath = window.location.pathname) => { + const path = fullPath.startsWith(BASE_PATH) ? fullPath.slice(BASE_PATH.length) || "/" : fullPath; + // 해시 기반 라우팅을 위한 처리 + if (path === "/" && window.location.hash) { + return window.location.hash.slice(1) || "/"; + } + return path; +}; + +export const getFullPath = (appPath) => { + return BASE_PATH + appPath; +}; - document.body.innerHTML = ` - ${상품목록_레이아웃_로딩} -
- ${상품목록_레이아웃_로딩완료} -
- ${상품목록_레이아웃_카테고리_1Depth} -
- ${상품목록_레이아웃_카테고리_2Depth} -
- ${토스트} -
- ${장바구니_비어있음} -
- ${장바구니_선택없음} -
- ${장바구니_선택있음} -
- ${상세페이지_로딩} -
- ${상세페이지_로딩완료} -
- ${_404_} - `; +function main() { + // #root Element에 Layout HTML 삽입 + render.init(); + + // gh-pages에서 404.html 리다이렉트 처리 + if (import.meta.env.PROD && window.location.pathname.includes("404.html")) { + window.location.href = BASE_PATH + "/"; + return; + } + + // Page에 init, mount 실행 + render.view(); + + // Layout 컴포넌트 마운트 + Layout.mount?.(); + + window.addEventListener("popstate", () => { + render.init(); + Layout.mount?.(); + render.view(); + }); } // 애플리케이션 시작 diff --git a/src/mocks/browser.js b/src/mocks/browser.js index be3dedca2..494b22034 100644 --- a/src/mocks/browser.js +++ b/src/mocks/browser.js @@ -1,5 +1,18 @@ import { setupWorker } from "msw/browser"; import { handlers } from "./handlers"; -// MSW 워커 설정 +const basePath = import.meta.env.PROD ? "/front_6th_chapter1-1" : ""; + export const worker = setupWorker(...handlers); + +// 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/Home.js b/src/pages/Home.js new file mode 100644 index 000000000..886b02a9b --- /dev/null +++ b/src/pages/Home.js @@ -0,0 +1,228 @@ +import { getCategories, getProducts } from "../api/productApi"; +import Breadcrumb from "../components/Breadcrumb"; +import Loading from "../components/Loading"; +import ProductCard from "../components/ProductCard"; +import ProductList from "../components/ProductList"; +import Search from "../components/Search"; +import { render, store, getAppPath } from "../main"; + +const state = { + isLoading: true, + isLoadingMore: false, + products: [], + pagination: {}, + categories: {}, + watchRegistered: false, +}; + +const fetchProducts = async (params = {}) => { + if (params.page && params.page > 1 && state.pagination && state.pagination.hasNext) { + state.isLoadingMore = true; + renderHome(); + } else { + state.isLoading = true; + } + + const productData = await getProducts(params); + + // 무한 스크롤 방식 구현으로 누적된 product 값 + if (params.page && params.page > 1) { + state.products = [...state.products, ...productData.products]; + // 로딩을 조금 늦게 false로 설정 + setTimeout(() => { + state.isLoadingMore = false; + }, 500); + } else { + state.products = productData.products; + state.isLoading = false; + } + + state.pagination = productData.pagination; +}; + +const fetchCategories = async () => { + const categoriesData = await getCategories(); + state.categories = categoriesData; + state.isLoading = false; +}; + +let scrollHandler = null; +let scrollTimeout = null; + +const fetchMoreProductsScroll = () => { + const location = getAppPath(); + if (location !== "/") return; + const triggerHeight = 100; + + const handleScroll = () => { + const location = getAppPath(); + // 메인페이지에서만 가능하도록 처리 + if (location !== "/") return; + // 로딩 중이거나 다음 페이지가 없으면 스크롤 감지 안함 + if (state.isLoadingMore || state.isLoading || !state.pagination?.hasNext) return; + + const currentScroll = window.scrollY; + const viewHeight = document.documentElement.clientHeight; + const bodyHeight = document.body.scrollHeight; + + if (currentScroll + viewHeight > bodyHeight - triggerHeight) { + if (state.isLoadingMore) return; + + if (scrollTimeout) { + clearTimeout(scrollTimeout); + } + + scrollTimeout = setTimeout(() => { + if (state.isLoadingMore || state.isLoading) return; + + state.isLoadingMore = true; + const currentPage = store.get("params")["page"] || 1; + store.set("params", { + ...store.get("params"), + page: currentPage + 1, + }); + }, 300); + } + }; + + // 기존 스크롤 핸들러 제거 + if (scrollHandler) { + window.removeEventListener("scroll", scrollHandler); + } + + scrollHandler = handleScroll; + window.addEventListener("scroll", scrollHandler); + + return scrollHandler; +}; + +const renderHome = () => { + render.draw( + "main", + Home({ + products: state.products, + pagination: state.pagination, + isLoading: state.isLoading, + isLoadingMore: state.isLoadingMore, + categories: state.categories, + }), + ); +}; + +Home.init = () => { + state.isLoading = true; + state.watchRegistered = false; +}; + +Home.mount = async () => { + const params = new URLSearchParams(window.location.search); + const paramObj = {}; + for (const [key, value] of params.entries()) { + paramObj[key] = value; + } + + await fetchProducts(paramObj); + await fetchCategories(); + renderHome(); + + // fetchMoreProducts(io); + Search.mount(); + ProductCard.mount(); + store.set("categories", state.categories); + ProductList.mount(state.products); + + fetchMoreProductsScroll(); + + // store.watch 중복 등록 방지 + if (!state.watchRegistered) { + state.watchRegistered = true; + store.watch(async (newValue) => { + console.log("test"); + // 무한 스크롤로 인한 page 변경은 별도 처리 + if (state.isLoadingMore && newValue.page) { + await fetchProducts(newValue); + state.isLoadingMore = false; + + render.draw( + "#product-list", + ProductList({ + products: state.products, + pagination: state.pagination, + }), + ); + ProductList.mount(state.products); + ProductCard.mount(); + return; + } + + const url = new URL(window.location); + Object.entries(newValue).forEach(([key, value]) => { + if (value !== "" && value) { + url.searchParams.set(key, value); + } else { + url.searchParams.delete(key); + } + }); + window.history.pushState({}, "", url.toString()); + + await fetchProducts(newValue); + state.isLoadingMore = false; + + render.draw("#search-container", Search(store.get("categories"), false)); + Search.mount(); + + render.draw("#breadcrumb-container", Breadcrumb()); + Breadcrumb.mount(); + + render.draw( + "#product-list", + ProductList({ + products: state.products, + pagination: state.pagination, + }), + ); + + ProductList.mount(state.products); + + ProductCard.mount(); + }, "params"); + } +}; + +Home.unmount = () => { + // 스크롤 이벤트 리스너 정리 + if (scrollHandler) { + window.removeEventListener("scroll", scrollHandler); + scrollHandler = null; + } + + // 타이머 정리 + if (scrollTimeout) { + clearTimeout(scrollTimeout); + scrollTimeout = null; + } +}; + +export default function Home({ products, pagination, isLoading, categories, isLoadingMore }) { + return /* html */ ` +
+ ${Search(categories, isLoading)} +
+ +
+
+ + ${state.isLoading ? Loading({ type: "products" }) : ProductList({ products, pagination })} + ${ + isLoadingMore + ? Loading({ type: "products" }) + : /* html */ ` +
+ 모든 상품을 확인했습니다 +
` + } +
+
+
+ `; +} diff --git a/src/pages/NotFound.js b/src/pages/NotFound.js new file mode 100644 index 000000000..32e4717ad --- /dev/null +++ b/src/pages/NotFound.js @@ -0,0 +1,54 @@ +import { navigate } from "../main"; + +NotFound.mount = () => { + const homeLink = document.querySelector("#notFound [data-link]"); + homeLink.addEventListener("click", (event) => { + event.preventDefault(); + navigate.push({}, "/"); + }); +}; + +NotFound.unmount = () => { + const homeLink = document.querySelector("#notFound [data-link]"); + if (homeLink) { + homeLink.removeEventListener("click", (event) => { + event.preventDefault(); + navigate.push({}, "/"); + }); + } +}; + +export default function NotFound() { + return /* html */ ` +
+ + + + + + + + + + + + + 404 + + + + + + + + + 페이지를 찾을 수 없습니다 + + + + + + 홈으로 +
+ `; +} diff --git a/src/pages/Product.js b/src/pages/Product.js new file mode 100644 index 000000000..d7dd23d1c --- /dev/null +++ b/src/pages/Product.js @@ -0,0 +1,285 @@ +import { getProduct, getProducts } from "../api/productApi"; +import Header from "../components/Header.js"; +import Loading from "../components/Loading"; +import { handleAddCart } from "../js/cart.js"; +import { navigate, render, getAppPath } from "../main.js"; + +const state = { + isLoading: true, + product: {}, + relatedProducts: [], +}; + +const methods = { + fetchProduct: async (productId) => { + state.product = await getProduct(productId); + }, + fetchRelatedProducts: async (category1, category2) => { + const products = await getProducts({ category1, category2 }); + state.relatedProducts = products.products.filter((product) => product.productId !== state.product.productId); + }, + + goToRelatedProducts: async (productId) => { + Product.init(); + navigate.push({}, `/product/${productId}`); + // Product.mount(); + }, + + goToProductList: async () => { + navigate.push({}, "/"); + Header.init(); + render.draw("header", Header()); + Header.mount(); + await render.view(); + }, + + handleUpQuantity: () => { + const input = document.querySelector("#quantity-input"); + input.value++; + }, + + handleDownQuantity: () => { + const input = document.querySelector("#quantity-input"); + if (input.value === "1") return; + input.value--; + }, + + handleAddCard: (quantity) => { + const product = { + ...state.product, + quantity, + }; + handleAddCart(product); + }, +}; + +Product.init = async () => { + state.isLoading = true; + Header.init(); + render.draw("header", Header()); +}; + +Product.mount = async () => { + const productId = getAppPath().match(/\d+/)[0]; + render.draw("main", Product()); + await methods.fetchProduct(productId); + await methods.fetchRelatedProducts(state.product.category1, state.product.category); + state.isLoading = false; + + render.draw("main", Product()); + + const goToProductListBtn = document.querySelector(".go-to-product-list"); + goToProductListBtn.addEventListener("click", () => { + methods.goToProductList(); + }); + + const homeLink = document.querySelector("a"); + homeLink.addEventListener("click", (event) => { + event.preventDefault(); + methods.goToProductList(); + }); + + const quantityInput = document.querySelector("#quantity-input"); + const quantityIncrease = document.querySelector("#quantity-increase"); + const quantityDecrease = document.querySelector("#quantity-decrease"); + const cartBtn = document.getElementById("add-to-cart-btn"); + cartBtn.addEventListener("click", () => { + methods.handleAddCard(quantityInput.value); + }); + + quantityInput.addEventListener("change", (event) => { + if (event.target.value < 1) event.target.value = 1; + }); + + quantityIncrease.addEventListener("click", methods.handleUpQuantity); + quantityDecrease.addEventListener("click", methods.handleDownQuantity); + + const relatedProductList = document.querySelectorAll(".related-product-card"); + relatedProductList.forEach((product) => { + const productId = product.getAttribute("data-product-id"); + product.addEventListener("click", () => { + methods.goToRelatedProducts(productId); + }); + }); + + // const breadcrumbBtn = document.querySelectorAll(".breadcrumb-link"); + // breadcrumbBtn.forEach((btn) => { + // btn.addEventListener("click", (event) => { + // // if (btn.getAttribute("data-category2")) { + // // history.pushState({}, "", `/?category1=${}`) + // // } + // }); + // }); +}; + +Product.unmount = () => { + // 이벤트 리스너들 정리 + const goToProductListBtn = document.querySelector(".go-to-product-list"); + const homeLink = document.querySelector("a"); + const quantityInput = document.querySelector("#quantity-input"); + const quantityIncrease = document.querySelector("#quantity-increase"); + const quantityDecrease = document.querySelector("#quantity-decrease"); + const cartBtn = document.getElementById("add-to-cart-btn"); + const relatedProductList = document.querySelectorAll(".related-product-card"); + + if (goToProductListBtn) { + goToProductListBtn.removeEventListener("click", methods.goToProductList); + } + if (homeLink) { + homeLink.removeEventListener("click", methods.goToProductList); + } + if (quantityInput) { + quantityInput.removeEventListener("change", (event) => { + if (event.target.value < 1) event.target.value = 1; + }); + } + if (quantityIncrease) { + quantityIncrease.removeEventListener("click", methods.handleUpQuantity); + } + if (quantityDecrease) { + quantityDecrease.removeEventListener("click", methods.handleDownQuantity); + } + if (cartBtn) { + cartBtn.removeEventListener("click", () => { + methods.handleAddCard(quantityInput.value); + }); + } + if (relatedProductList.length > 0) { + relatedProductList.forEach((product) => { + const productId = product.getAttribute("data-product-id"); + product.removeEventListener("click", () => { + methods.goToRelatedProducts(productId); + }); + }); + } +}; + +export default function Product() { + return /* html */ ` + ${ + state.isLoading + ? Loading({ type: "product" }) + : /* html */ + ` + + + +
+ +
+
+ ${state.product.title} +
+ +
+

+

${state.product.title}

+ +
+
+ ${Array.from({ length: 5 }, (_, star) => { + const activeClass = star < state.product.rating ? "text-yellow-400" : "text-gray-300"; + return /* html */ ` + + + + `; + }).join("")} + +
+ ${state.product.rating}.0 (${state.product.reviewCount}개 리뷰) +
+ +
+ ${state.product.lprice}원 +
+ +
+ 재고 ${state.product.stock}개 +
+ +
+ ${state.product.description} +
+
+
+ +
+
+ 수량 +
+ + + +
+
+ + +
+
+ +
+ +
+ +
+
+

관련 상품

+

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

+
+
+
+ ${state.relatedProducts + .map( + (product) => /* html */ ` + + `, + ) + .join("")} + +
+
+
+ ` + } + `; +} diff --git a/src/routes.js b/src/routes.js new file mode 100644 index 000000000..62706aea6 --- /dev/null +++ b/src/routes.js @@ -0,0 +1,21 @@ +import { pathToRegex } from "./js/utils"; +import Home from "./pages/Home"; +import NotFound from "./pages/NotFound"; +import Product from "./pages/Product"; + +const routes = [ + { + path: pathToRegex("/"), + component: Home, + }, + { + path: pathToRegex("/product/:productId"), + component: Product, + }, + { + path: pathToRegex("*"), + component: NotFound, + }, +]; + +export default routes; 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",