From 01c8cb6765f3474ff23bf7a35d5e27c0750a7529 Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Thu, 12 Feb 2026 19:17:59 +0900 Subject: [PATCH 01/10] =?UTF-8?q?fix:=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/RoleProtectedRoute.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/routes/RoleProtectedRoute.tsx b/src/routes/RoleProtectedRoute.tsx index 7c39c37..432b482 100644 --- a/src/routes/RoleProtectedRoute.tsx +++ b/src/routes/RoleProtectedRoute.tsx @@ -1,7 +1,3 @@ -/** - * 역할 기반 라우트 보호 컴포넌트 - */ - import { Navigate } from 'react-router'; import { useAuthStore } from '@/stores/auth'; import type { AdminRole } from '@/types/auth'; From a286eeb5b74d19e643fb9b5523070dadb1bfc7fa Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Thu, 12 Feb 2026 19:25:19 +0900 Subject: [PATCH 02/10] =?UTF-8?q?docs:=20CLAUDE.md=EC=97=90=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=EB=B0=B0=EB=9F=B4=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=A7=80=EC=96=91=20=EC=A7=80=EC=B9=A8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 8f3e4a3..6e4c1ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,6 +154,7 @@ pnpm test:e2e:headed # 브라우저 표시 - **IMPORTANT:** `src/components/ui/`는 shadcn/ui 생성 파일 - 직접 수정 금지 - **IMPORTANT:** API 타입은 `src/types/api/`에 정의, 다른 곳에 중복 정의 금지 +- **IMPORTANT:** 불필요한 배럴 파일(`index.ts`) 생성 금지 - 각 모듈을 직접 import할 것. 배럴 파일은 번들 사이즈 증가, 순환 참조, tree-shaking 방해의 원인이 됨 - 환경변수는 `scripts/ensure-env.sh`로 관리됨 - `.env` 직접 수정 금지 - 페이지네이션은 0-based index 사용 (백엔드 API 규격) From 3b4a8e0d11e51b0ca2a9417835a1961a1ba317bf Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Thu, 12 Feb 2026 19:30:08 +0900 Subject: [PATCH 03/10] feat: wire banner pages to real API and split detail into card components Replace mock data with real API integration for banner CRUD operations. Split BannerDetail.tsx (575 lines) into 6 focused card components following the existing WhiskyDetail pattern. Co-Authored-By: Claude Opus 4.6 --- e2e/pages/banner-detail.page.ts | 161 ++++++ e2e/pages/banner-list.page.ts | 186 +++++++ e2e/pages/index.ts | 2 + e2e/specs/banners.spec.ts | 320 ++++++++++++ src/data/mock/banners.mock.ts | 266 ---------- src/hooks/__tests__/useBanners.test.ts | 235 +++++++++ src/hooks/useBanners.ts | 165 +++--- src/pages/banners/BannerDetail.tsx | 480 +----------------- src/pages/banners/BannerList.tsx | 14 +- src/pages/banners/banner.schema.ts | 48 +- .../components/BannerBasicInfoCard.tsx | 83 +++ .../banners/components/BannerExposureCard.tsx | 71 +++ .../banners/components/BannerImageCard.tsx | 35 ++ .../components/BannerLinkSettingsCard.tsx | 108 ++++ .../banners/components/BannerPreviewCard.tsx | 114 +++++ .../components/BannerTextSettingsCard.tsx | 111 ++++ src/pages/banners/useBannerDetailForm.ts | 80 ++- src/services/banner.service.ts | 298 ++--------- src/test/mocks/data.ts | 97 ++++ src/test/mocks/handlers.ts | 113 ++++- src/types/api/banner.api.ts | 69 ++- 21 files changed, 1881 insertions(+), 1175 deletions(-) create mode 100644 e2e/pages/banner-detail.page.ts create mode 100644 e2e/pages/banner-list.page.ts create mode 100644 e2e/specs/banners.spec.ts delete mode 100644 src/data/mock/banners.mock.ts create mode 100644 src/hooks/__tests__/useBanners.test.ts create mode 100644 src/pages/banners/components/BannerBasicInfoCard.tsx create mode 100644 src/pages/banners/components/BannerExposureCard.tsx create mode 100644 src/pages/banners/components/BannerImageCard.tsx create mode 100644 src/pages/banners/components/BannerLinkSettingsCard.tsx create mode 100644 src/pages/banners/components/BannerPreviewCard.tsx create mode 100644 src/pages/banners/components/BannerTextSettingsCard.tsx diff --git a/e2e/pages/banner-detail.page.ts b/e2e/pages/banner-detail.page.ts new file mode 100644 index 0000000..e5e203d --- /dev/null +++ b/e2e/pages/banner-detail.page.ts @@ -0,0 +1,161 @@ +import { type Page } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { BasePage } from './base.page'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export class BannerDetailPage extends BasePage { + constructor(page: Page) { + super(page); + } + + // Locators + + readonly pageTitle = () => this.page.getByRole('heading', { level: 1 }); + + readonly backButton = () => + this.page.locator('main button').first(); + + readonly saveButton = () => + this.page.getByRole('button', { name: /등록|저장/ }); + + readonly deleteButton = () => this.page.getByRole('button', { name: '삭제' }); + + readonly loadingState = () => this.page.getByText('로딩 중...'); + + readonly nameInput = () => this.page.getByPlaceholder('배너명을 입력하세요'); + + readonly bannerTypeSelect = () => + this.page.locator('button[role="combobox"]').filter({ hasText: /설문조사|큐레이션|광고|제휴|기타|배너 타입 선택/ }); + + readonly isActiveSwitch = () => this.page.locator('button#isActive'); + + readonly descriptionAInput = () => this.page.getByPlaceholder('첫 번째 줄 설명'); + + readonly descriptionBInput = () => this.page.getByPlaceholder('두 번째 줄 설명'); + + readonly textPositionSelect = () => + this.page.locator('button[role="combobox"]').filter({ hasText: /좌측 상단|좌측 하단|우측 상단|우측 하단|중앙|텍스트 위치 선택/ }); + + readonly nameFontColorInput = () => this.page.locator('input#nameFontColor'); + + readonly descriptionFontColorInput = () => this.page.locator('input#descriptionFontColor'); + + readonly targetUrlInput = () => this.page.locator('input#targetUrl'); + + readonly isExternalUrlCheckbox = () => this.page.locator('button#isExternalUrl'); + + /** CURATION 타입에서만 표시 */ + readonly curationSelect = () => + this.page.locator('button[role="combobox"]').filter({ hasText: /큐레이션을 선택하세요/ }); + + readonly isAlwaysVisibleCheckbox = () => this.page.locator('button#isAlwaysVisible'); + + readonly startDateInput = () => this.page.locator('input#startDate'); + + readonly endDateInput = () => this.page.locator('input#endDate'); + + readonly imageFileInput = () => this.page.locator('input[type="file"][accept="image/*"]'); + + readonly uploadedImage = () => this.page.locator('img[alt="업로드된 이미지"]'); + + readonly deleteDialog = () => this.page.getByRole('alertdialog'); + + readonly confirmDeleteButton = () => this.deleteDialog().getByRole('button', { name: '삭제' }); + + // Actions + + async gotoNew() { + await this.page.goto('/banners/new'); + await this.waitForLoadingComplete(); + } + + async gotoDetail(id: number) { + await this.page.goto(`/banners/${id}`); + await this.waitForLoadingComplete(); + } + + async waitForLoadingComplete() { + await this.loadingState().waitFor({ state: 'hidden', timeout: 15000 }).catch(() => {}); + } + + async goBack() { + await this.backButton().click(); + } + + async clickSave() { + await this.saveButton().click(); + } + + async clickDelete() { + await this.deleteButton().click(); + } + + async confirmDelete() { + await this.deleteDialog().waitFor({ state: 'visible', timeout: 5000 }); + await this.confirmDeleteButton().click(); + } + + async selectBannerType(type: '설문조사' | '큐레이션' | '광고' | '제휴' | '기타') { + await this.bannerTypeSelect().click(); + await this.page.getByRole('option', { name: type, exact: true }).click(); + } + + async selectTextPosition(position: '좌측 상단' | '좌측 하단' | '우측 상단' | '우측 하단' | '중앙') { + await this.textPositionSelect().click(); + await this.page.getByRole('option', { name: position, exact: true }).click(); + } + + async fillBasicInfo(data: { + name: string; + bannerType?: '설문조사' | '큐레이션' | '광고' | '제휴' | '기타'; + descriptionA?: string; + descriptionB?: string; + }) { + await this.nameInput().fill(data.name); + if (data.bannerType) { + await this.selectBannerType(data.bannerType); + } + if (data.descriptionA) { + await this.descriptionAInput().fill(data.descriptionA); + } + if (data.descriptionB) { + await this.descriptionBInput().fill(data.descriptionB); + } + } + + async updateName(name: string) { + await this.nameInput().fill(name); + } + + async checkAlwaysVisible() { + const isChecked = await this.isAlwaysVisibleCheckbox().getAttribute('data-state') === 'checked'; + if (!isChecked) { + await this.isAlwaysVisibleCheckbox().click(); + } + } + + async setTargetUrl(url: string, isExternal = false) { + if (isExternal) { + const isChecked = await this.isExternalUrlCheckbox().getAttribute('data-state') === 'checked'; + if (!isChecked) { + await this.isExternalUrlCheckbox().click(); + } + } + await this.targetUrlInput().fill(url); + } + + async uploadTestImage() { + const testImagePath = path.resolve(__dirname, '../fixtures/test-image.png'); + await this.imageFileInput().setInputFiles(testImagePath); + await this.uploadedImage().waitFor({ state: 'visible', timeout: 10000 }); + } + + async ensureImage() { + const hasImage = await this.uploadedImage().isVisible().catch(() => false); + if (!hasImage) { + await this.uploadTestImage(); + } + } +} diff --git a/e2e/pages/banner-list.page.ts b/e2e/pages/banner-list.page.ts new file mode 100644 index 0000000..e7af502 --- /dev/null +++ b/e2e/pages/banner-list.page.ts @@ -0,0 +1,186 @@ +import { type Page } from '@playwright/test'; +import { BasePage } from './base.page'; + +/** + * 배너 목록 페이지 Page Object + * + * 실제 UI 구조: + * - 테이블 형태로 배너 목록 표시 + * - 검색 + 배너 타입 필터 + * - 행 클릭 시 상세 페이지로 이동 + * - 순서 변경 모드에서 드래그 앤 드롭으로 순서 변경 + */ +export class BannerListPage extends BasePage { + constructor(page: Page) { + super(page); + } + + /* ============================================ + * Locators + * ============================================ */ + + /** 페이지 제목 */ + readonly pageTitle = () => this.page.getByRole('heading', { name: '배너 관리' }); + + /** 검색 입력 필드 */ + readonly searchInput = () => this.page.getByPlaceholder('배너명으로 검색...'); + + /** 검색 버튼 */ + readonly searchButton = () => this.page.getByRole('button', { name: '검색' }); + + /** 배너 등록 버튼 */ + readonly createButton = () => this.page.getByRole('button', { name: '배너 등록' }); + + /** 배너 타입 필터 드롭다운 트리거 */ + readonly typeFilterTrigger = () => + this.page.locator('button[role="combobox"]').filter({ hasText: /전체|설문조사|큐레이션|광고|제휴|기타/ }); + + /** 테이블 */ + readonly table = () => this.page.locator('table'); + + /** 테이블 행들 (헤더 제외) */ + readonly tableRows = () => this.page.locator('tbody tr'); + + /** 클릭 가능한 테이블 행 (cursor-pointer) */ + readonly clickableRows = () => this.page.locator('tbody tr.cursor-pointer'); + + /** 로딩 상태 */ + readonly loadingState = () => this.page.getByText('로딩 중...'); + + /** 검색 결과 없음 메시지 */ + readonly noResultMessage = () => this.page.getByText('검색 결과가 없습니다.'); + + /** 특정 배너명을 포함한 행 */ + readonly rowWithName = (name: string) => + this.page.locator('tbody tr').filter({ hasText: name }); + + /** 순서 변경 버튼 */ + readonly reorderButton = () => this.page.getByRole('button', { name: /순서 변경/ }); + + /** 순서 변경 모드 안내 배너 */ + readonly reorderModeBanner = () => this.page.getByText('순서 변경 모드'); + + /** 드래그 핸들 (순서 변경 모드에서만 표시) */ + readonly dragHandles = () => this.page.locator('tbody tr td:last-child svg'); + + /* ============================================ + * Actions + * ============================================ */ + + /** + * 페이지로 이동 + */ + async goto() { + await this.page.goto('/banners'); + await this.waitForLoadingComplete(); + } + + /** + * 로딩 완료 대기 + */ + async waitForLoadingComplete() { + await this.loadingState().waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + } + + /** + * 검색 수행 (Enter 키) + */ + async search(keyword: string) { + await this.searchInput().fill(keyword); + const responsePromise = this.page.waitForResponse( + (resp) => resp.url().includes('/banners') && resp.status() === 200, + { timeout: 10000 } + ).catch(() => {}); + await this.searchInput().press('Enter'); + await responsePromise; + await this.waitForLoadingComplete(); + } + + /** + * 검색 버튼 클릭으로 검색 수행 + */ + async searchWithButton(keyword: string) { + await this.searchInput().fill(keyword); + const responsePromise = this.page.waitForResponse( + (resp) => resp.url().includes('/banners') && resp.status() === 200, + { timeout: 10000 } + ).catch(() => {}); + await this.searchButton().click(); + await responsePromise; + await this.waitForLoadingComplete(); + } + + /** + * 배너 타입 필터 선택 + */ + async selectTypeFilter(type: '전체' | '설문조사' | '큐레이션' | '광고' | '제휴' | '기타') { + await this.typeFilterTrigger().click(); + const responsePromise = this.page.waitForResponse( + (resp) => resp.url().includes('/banners') && resp.status() === 200, + { timeout: 10000 } + ).catch(() => {}); + await this.page.getByRole('option', { name: type, exact: true }).click(); + await responsePromise; + await this.waitForLoadingComplete(); + } + + /** + * 배너 등록 버튼 클릭 + */ + async clickCreate() { + await this.createButton().click(); + } + + /** + * 첫 번째 행 클릭 + */ + async clickFirstRow() { + await this.clickableRows().first().click(); + } + + /** + * 특정 배너명 행 클릭 + */ + async clickRowByName(name: string) { + await this.rowWithName(name).click(); + } + + /** + * 테이블 행 개수 반환 + */ + async getRowCount() { + const noResult = await this.noResultMessage().isVisible().catch(() => false); + if (noResult) return 0; + return await this.clickableRows().count(); + } + + /** + * 특정 배너가 목록에 있는지 확인 + */ + async expectBannerExists(name: string) { + await this.rowWithName(name).waitFor({ state: 'visible' }); + } + + /** + * 순서 변경 모드 진입 + */ + async enterReorderMode() { + await this.reorderButton().click(); + await this.reorderModeBanner().waitFor({ state: 'visible', timeout: 5000 }); + } + + /** + * 순서 변경 모드 종료 + */ + async exitReorderMode() { + await this.reorderButton().click(); + await this.reorderModeBanner().waitFor({ state: 'hidden', timeout: 5000 }); + } + + /** + * 드래그 핸들 개수 확인 + */ + async getDragHandleCount() { + return await this.dragHandles().count(); + } +} diff --git a/e2e/pages/index.ts b/e2e/pages/index.ts index 52ce194..d513e7c 100644 --- a/e2e/pages/index.ts +++ b/e2e/pages/index.ts @@ -8,3 +8,5 @@ export { WhiskyListPage } from './whisky-list.page'; export { WhiskyDetailPage } from './whisky-detail.page'; export { CurationListPage } from './curation-list.page'; export { CurationDetailPage } from './curation-detail.page'; +export { BannerListPage } from './banner-list.page'; +export { BannerDetailPage } from './banner-detail.page'; diff --git a/e2e/specs/banners.spec.ts b/e2e/specs/banners.spec.ts new file mode 100644 index 0000000..da3c433 --- /dev/null +++ b/e2e/specs/banners.spec.ts @@ -0,0 +1,320 @@ +import { test, expect } from '@playwright/test'; +import { BannerListPage } from '../pages/banner-list.page'; +import { BannerDetailPage } from '../pages/banner-detail.page'; + +/** + * 배너 E2E 테스트 + * + * 실제 UI: + * - 테이블 형태로 배너 목록 표시 + * - 검색 + 배너 타입 필터 + * - 행 클릭 → 상세 페이지 + * - 순서 변경 모드에서 드래그 앤 드롭 + */ + +test.describe('배너 목록', () => { + let listPage: BannerListPage; + + test.beforeEach(async ({ page }) => { + listPage = new BannerListPage(page); + }); + + test('목록 페이지에 접근할 수 있다', async ({ page }) => { + await listPage.goto(); + + await expect(page).toHaveURL(/.*banners/); + await expect(listPage.pageTitle()).toBeVisible(); + await expect(listPage.table()).toBeVisible(); + }); + + test('검색으로 배너를 필터링할 수 있다', async ({ page }) => { + await listPage.goto(); + + await listPage.search('테스트'); + + const rowCount = await listPage.getRowCount(); + const hasNoResultMessage = await listPage.noResultMessage().isVisible().catch(() => false); + + expect(rowCount > 0 || hasNoResultMessage).toBe(true); + }); + + test('배너 타입으로 필터링할 수 있다', async ({ page }) => { + await listPage.goto(); + + // 광고 타입 필터 선택 + await listPage.selectTypeFilter('광고'); + await expect(page).toHaveURL(/bannerType=AD/); + + // 큐레이션 타입 필터 선택 + await listPage.selectTypeFilter('큐레이션'); + await expect(page).toHaveURL(/bannerType=CURATION/); + + // 전체로 다시 선택 + await listPage.selectTypeFilter('전체'); + await expect(page).not.toHaveURL(/bannerType=/); + }); + + test('배너를 클릭하면 상세 페이지로 이동한다', async ({ page }) => { + await listPage.goto(); + + const rowCount = await listPage.getRowCount(); + + if (rowCount > 0) { + await listPage.clickFirstRow(); + await expect(page).toHaveURL(/.*banners\/\d+/); + } else { + test.skip(); + } + }); + + test('배너 등록 버튼을 클릭하면 등록 페이지로 이동한다', async ({ page }) => { + await listPage.goto(); + + await listPage.clickCreate(); + + await expect(page).toHaveURL(/.*banners\/new/); + }); +}); + +test.describe('배너 상세', () => { + let listPage: BannerListPage; + let detailPage: BannerDetailPage; + + test.beforeEach(async ({ page }) => { + listPage = new BannerListPage(page); + detailPage = new BannerDetailPage(page); + }); + + test('상세 페이지에서 배너 정보를 볼 수 있다', async ({ page }) => { + await listPage.goto(); + const rowCount = await listPage.getRowCount(); + + if (rowCount === 0) { + test.skip(); + return; + } + + await listPage.clickFirstRow(); + await detailPage.waitForLoadingComplete(); + + // 상세 페이지 요소 확인 + await expect(detailPage.pageTitle()).toContainText('배너 수정'); + await expect(detailPage.nameInput()).toBeVisible(); + await expect(detailPage.bannerTypeSelect()).toBeVisible(); + await expect(detailPage.isActiveSwitch()).toBeVisible(); + await expect(detailPage.saveButton()).toBeVisible(); + await expect(detailPage.deleteButton()).toBeVisible(); + }); + + test('뒤로가기 버튼을 클릭하면 목록으로 돌아간다', async ({ page }) => { + await listPage.goto(); + const rowCount = await listPage.getRowCount(); + + if (rowCount === 0) { + test.skip(); + return; + } + + await listPage.clickFirstRow(); + await detailPage.waitForLoadingComplete(); + + await detailPage.goBack(); + + await expect(page).toHaveURL(/.*\/banners(\?.*)?$/); + }); +}); + +test.describe('배너 폼 리셋', () => { + test('상세 페이지에서 등록 페이지로 이동하면 폼이 초기화된다', async ({ page }) => { + const listPage = new BannerListPage(page); + const detailPage = new BannerDetailPage(page); + + // 1. 목록에서 배너 선택 + await listPage.goto(); + const rowCount = await listPage.getRowCount(); + if (rowCount === 0) { + test.skip(); + return; + } + + await listPage.clickFirstRow(); + await detailPage.waitForLoadingComplete(); + + // 2. 현재 폼 값 저장 + const originalName = await detailPage.nameInput().inputValue(); + expect(originalName).not.toBe(''); + + // 3. 배너 등록 페이지로 직접 이동 + await page.goto('/banners/new'); + await detailPage.waitForLoadingComplete(); + + // 4. URL 확인 + await expect(page).toHaveURL(/.*banners\/new/); + + // 5. 폼이 비어있어야 함 + await expect(detailPage.nameInput()).toHaveValue('', { timeout: 5000 }); + }); +}); + +test.describe('배너 CRUD 플로우', () => { + const testBannerName = `테스트배너_${Date.now()}`; + const updatedBannerName = `${testBannerName}_수정됨`; + + test('배너 생성 → 수정 → 삭제 전체 플로우', async ({ page }) => { + const listPage = new BannerListPage(page); + const detailPage = new BannerDetailPage(page); + + // 1. 목록 페이지 이동 + await page.goto('/banners'); + await listPage.waitForLoadingComplete(); + + // 2. 배너 등록 버튼 클릭 + await listPage.clickCreate(); + await expect(page).toHaveURL(/.*banners\/new/); + + // 3. 폼 입력 (필수 필드) + await detailPage.fillBasicInfo({ + name: testBannerName, + bannerType: '설문조사', + }); + + // 3-1. 이미지 업로드 (필수) + await detailPage.uploadTestImage(); + + // 3-2. 상시 노출 체크 (날짜 필수이므로) + await detailPage.checkAlwaysVisible(); + + // 3-3. 외부 URL 설정 + await detailPage.setTargetUrl('https://example.com', true); + + // 4. 등록 버튼 클릭 + API 응답 대기 + await Promise.all([ + page.waitForResponse( + (resp) => resp.url().includes('/banners') && resp.request().method() === 'POST', + { timeout: 10000 } + ), + detailPage.clickSave(), + ]); + + // 5. 저장 성공 확인 (생성 후에는 상세 페이지로 리다이렉트) + await expect(page).toHaveURL(/.*banners\/\d+/, { timeout: 10000 }); + await detailPage.waitForLoadingComplete(); + + // 6. 폼 수정 + await detailPage.updateName(updatedBannerName); + + // 7. 저장 버튼 클릭 + API 응답 대기 + await Promise.all([ + page.waitForResponse( + (resp) => resp.url().includes('/banners/') && resp.request().method() === 'PUT', + { timeout: 10000 } + ), + detailPage.clickSave(), + ]); + + // 8. 삭제 버튼 클릭 + await detailPage.clickDelete(); + + // 9. AlertDialog가 열릴 때까지 대기 + await detailPage.deleteDialog().waitFor({ state: 'visible', timeout: 5000 }); + + // 10. 삭제 확인 버튼 클릭 + API 응답 대기 + await Promise.all([ + page.waitForResponse( + (resp) => resp.url().includes('/banners/') && resp.request().method() === 'DELETE', + { timeout: 10000 } + ), + detailPage.confirmDelete(), + ]); + + // 11. 목록으로 리다이렉트 확인 + await expect(page).toHaveURL(/.*banners$/, { timeout: 10000 }); + }); +}); + +test.describe('배너 순서 변경', () => { + let listPage: BannerListPage; + + test.beforeEach(async ({ page }) => { + listPage = new BannerListPage(page); + }); + + test('순서 변경 버튼을 클릭하면 순서 변경 모드로 진입한다', async ({ page }) => { + await listPage.goto(); + + const rowCount = await listPage.getRowCount(); + if (rowCount === 0) { + test.skip(); + return; + } + + await listPage.enterReorderMode(); + + await expect(listPage.reorderModeBanner()).toBeVisible(); + await expect(listPage.reorderButton()).toContainText('순서 변경 완료'); + + const handleCount = await listPage.getDragHandleCount(); + expect(handleCount).toBeGreaterThan(0); + }); + + test('순서 변경 모드에서는 행 클릭으로 상세 페이지 이동이 안 된다', async ({ page }) => { + await listPage.goto(); + + const rowCount = await listPage.getRowCount(); + if (rowCount === 0) { + test.skip(); + return; + } + + await listPage.enterReorderMode(); + + await listPage.tableRows().first().click(); + + // URL이 변경되지 않아야 함 + await expect(page).toHaveURL(/.*banners$/); + }); + + test('순서 변경 완료 버튼을 클릭하면 일반 모드로 돌아간다', async ({ page }) => { + await listPage.goto(); + + const rowCount = await listPage.getRowCount(); + if (rowCount === 0) { + test.skip(); + return; + } + + await listPage.enterReorderMode(); + await expect(listPage.reorderModeBanner()).toBeVisible(); + + await listPage.exitReorderMode(); + + await expect(listPage.reorderModeBanner()).not.toBeVisible(); + await expect(listPage.reorderButton()).toContainText('순서 변경'); + await expect(listPage.reorderButton()).not.toContainText('완료'); + }); + + test('순서 변경 모드에서 드래그하면 sort-order API가 호출된다', async ({ page }) => { + await listPage.goto(); + + const rowCount = await listPage.getRowCount(); + if (rowCount < 2) { + test.skip(); + return; + } + + await listPage.enterReorderMode(); + + const firstRow = listPage.tableRows().first(); + const secondRow = listPage.tableRows().nth(1); + + const sortOrderPromise = page.waitForResponse( + (resp) => resp.url().includes('/sort-order') && resp.request().method() === 'PATCH', + { timeout: 10000 } + ); + + await firstRow.dragTo(secondRow); + + const response = await sortOrderPromise; + expect(response.ok()).toBe(true); + }); +}); diff --git a/src/data/mock/banners.mock.ts b/src/data/mock/banners.mock.ts deleted file mode 100644 index 8975155..0000000 --- a/src/data/mock/banners.mock.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * 배너 Mock 데이터 - * API 연동 전 개발/테스트용 - */ - -import type { BannerDetail, BannerListItem } from '@/types/api'; - -/** - * 배너 목록 Mock 데이터 - */ -export const mockBannerList: BannerListItem[] = [ - { - id: 1, - name: '봄 추천 위스키 큐레이션', - bannerType: 'CURATION', - imageUrl: 'https://cdn.bottle-note.com/banners/event1.jpg', - sortOrder: 0, - startDate: '2025-01-01T00:00:00', - endDate: '2025-12-31T23:59:00', - isActive: true, - createdAt: '2025-01-01T00:00:00', - updatedAt: '2025-01-01T00:00:00', - }, - { - id: 2, - name: '사용자 설문조사', - bannerType: 'SURVEY', - imageUrl: 'https://cdn.bottle-note.com/banners/survey.jpg', - sortOrder: 1, - startDate: '2025-06-01T00:00:00', - endDate: '2025-06-30T23:59:00', - isActive: true, - createdAt: '2025-05-15T00:00:00', - updatedAt: '2025-05-15T00:00:00', - }, - { - id: 3, - name: '제휴 브랜드 소개', - bannerType: 'PARTNERSHIP', - imageUrl: 'https://cdn.bottle-note.com/banners/partner.jpg', - sortOrder: 2, - startDate: null, - endDate: null, - isActive: true, - createdAt: '2025-03-10T00:00:00', - updatedAt: '2025-03-10T00:00:00', - }, - { - id: 4, - name: '프리미엄 위스키 광고', - bannerType: 'AD', - imageUrl: 'https://cdn.bottle-note.com/banners/ad-premium.jpg', - sortOrder: 3, - startDate: '2025-02-01T00:00:00', - endDate: '2025-04-30T23:59:00', - isActive: false, - createdAt: '2025-02-01T00:00:00', - updatedAt: '2025-05-01T00:00:00', - }, - { - id: 5, - name: '여름 시즌 큐레이션', - bannerType: 'CURATION', - imageUrl: 'https://cdn.bottle-note.com/banners/summer-curation.jpg', - sortOrder: 4, - startDate: '2025-06-01T00:00:00', - endDate: '2025-08-31T23:59:00', - isActive: true, - createdAt: '2025-05-20T00:00:00', - updatedAt: '2025-05-20T00:00:00', - }, - { - id: 6, - name: '신규 회원 가입 이벤트', - bannerType: 'AD', - imageUrl: 'https://cdn.bottle-note.com/banners/signup-event.jpg', - sortOrder: 5, - startDate: null, - endDate: null, - isActive: true, - createdAt: '2025-01-15T00:00:00', - updatedAt: '2025-01-15T00:00:00', - }, - { - id: 7, - name: '위스키 페스티벌 안내', - bannerType: 'PARTNERSHIP', - imageUrl: 'https://cdn.bottle-note.com/banners/festival.jpg', - sortOrder: 6, - startDate: '2025-09-01T00:00:00', - endDate: '2025-09-30T23:59:00', - isActive: false, - createdAt: '2025-08-15T00:00:00', - updatedAt: '2025-08-15T00:00:00', - }, - { - id: 8, - name: '연말 특별 설문', - bannerType: 'SURVEY', - imageUrl: 'https://cdn.bottle-note.com/banners/year-end-survey.jpg', - sortOrder: 7, - startDate: '2025-12-01T00:00:00', - endDate: '2025-12-31T23:59:00', - isActive: false, - createdAt: '2025-11-25T00:00:00', - updatedAt: '2025-11-25T00:00:00', - }, -]; - -/** - * 배너 상세 Mock 데이터 - */ -export const mockBannerDetails: Record = { - 1: { - id: 1, - name: '봄 추천 위스키 큐레이션', - nameFontColor: 'ffffff', - descriptionA: '봄에 어울리는', - descriptionB: '상쾌한 위스키 추천', - descriptionFontColor: 'ffffff', - imageUrl: 'https://cdn.bottle-note.com/banners/event1.jpg', - textPosition: 'LB', - targetUrl: '/alcohols/search?curationId=1', - isExternalUrl: false, - bannerType: 'CURATION', - sortOrder: 0, - startDate: '2025-01-01T00:00:00', - endDate: '2025-12-31T23:59:00', - isActive: true, - createdAt: '2025-01-01T00:00:00', - updatedAt: '2025-01-01T00:00:00', - }, - 2: { - id: 2, - name: '사용자 설문조사', - nameFontColor: 'ffffff', - descriptionA: '서비스 개선을 위한', - descriptionB: '설문조사에 참여해주세요.', - descriptionFontColor: 'ffffff', - imageUrl: 'https://cdn.bottle-note.com/banners/survey.jpg', - textPosition: 'CENTER', - targetUrl: 'https://forms.google.com/survey123', - isExternalUrl: true, - bannerType: 'SURVEY', - sortOrder: 1, - startDate: '2025-06-01T00:00:00', - endDate: '2025-06-30T23:59:00', - isActive: true, - createdAt: '2025-05-15T00:00:00', - updatedAt: '2025-05-15T00:00:00', - }, - 3: { - id: 3, - name: '제휴 브랜드 소개', - nameFontColor: 'ffffff', - descriptionA: '새로운 제휴 브랜드를', - descriptionB: '소개합니다.', - descriptionFontColor: 'ffffff', - imageUrl: 'https://cdn.bottle-note.com/banners/partner.jpg', - textPosition: 'RT', - targetUrl: '/partners/brand-abc', - isExternalUrl: false, - bannerType: 'PARTNERSHIP', - sortOrder: 2, - startDate: null, - endDate: null, - isActive: true, - createdAt: '2025-03-10T00:00:00', - updatedAt: '2025-03-10T00:00:00', - }, - 4: { - id: 4, - name: '프리미엄 위스키 광고', - nameFontColor: '000000', - descriptionA: '프리미엄 위스키를', - descriptionB: '만나보세요.', - descriptionFontColor: '333333', - imageUrl: 'https://cdn.bottle-note.com/banners/ad-premium.jpg', - textPosition: 'LT', - targetUrl: '/whisky/premium-collection', - isExternalUrl: false, - bannerType: 'AD', - sortOrder: 3, - startDate: '2025-02-01T00:00:00', - endDate: '2025-04-30T23:59:00', - isActive: false, - createdAt: '2025-02-01T00:00:00', - updatedAt: '2025-05-01T00:00:00', - }, - 5: { - id: 5, - name: '여름 시즌 큐레이션', - nameFontColor: 'ffffff', - descriptionA: '시원한 여름을 위한', - descriptionB: '위스키 추천 컬렉션', - descriptionFontColor: 'ffffff', - imageUrl: 'https://cdn.bottle-note.com/banners/summer-curation.jpg', - textPosition: 'RB', - targetUrl: '/alcohols/search?curationId=9', - isExternalUrl: false, - bannerType: 'CURATION', - sortOrder: 4, - startDate: '2025-06-01T00:00:00', - endDate: '2025-08-31T23:59:00', - isActive: true, - createdAt: '2025-05-20T00:00:00', - updatedAt: '2025-05-20T00:00:00', - }, - 6: { - id: 6, - name: '신규 회원 가입 이벤트', - nameFontColor: 'ffffff', - descriptionA: '지금 가입하시면', - descriptionB: '특별한 혜택을 드립니다.', - descriptionFontColor: 'ffffff', - imageUrl: 'https://cdn.bottle-note.com/banners/signup-event.jpg', - textPosition: 'CENTER', - targetUrl: '/signup', - isExternalUrl: false, - bannerType: 'AD', - sortOrder: 5, - startDate: null, - endDate: null, - isActive: true, - createdAt: '2025-01-15T00:00:00', - updatedAt: '2025-01-15T00:00:00', - }, - 7: { - id: 7, - name: '위스키 페스티벌 안내', - nameFontColor: 'ffffff', - descriptionA: '2025 위스키 페스티벌', - descriptionB: '서울에서 만나요!', - descriptionFontColor: 'ffffff', - imageUrl: 'https://cdn.bottle-note.com/banners/festival.jpg', - textPosition: 'LB', - targetUrl: 'https://whisky-festival.com', - isExternalUrl: true, - bannerType: 'PARTNERSHIP', - sortOrder: 6, - startDate: '2025-09-01T00:00:00', - endDate: '2025-09-30T23:59:00', - isActive: false, - createdAt: '2025-08-15T00:00:00', - updatedAt: '2025-08-15T00:00:00', - }, - 8: { - id: 8, - name: '연말 특별 설문', - nameFontColor: 'ffffff', - descriptionA: '2025년을 마무리하며', - descriptionB: '여러분의 의견을 들려주세요.', - descriptionFontColor: 'ffffff', - imageUrl: 'https://cdn.bottle-note.com/banners/year-end-survey.jpg', - textPosition: 'CENTER', - targetUrl: 'https://forms.google.com/year-end', - isExternalUrl: true, - bannerType: 'SURVEY', - sortOrder: 7, - startDate: '2025-12-01T00:00:00', - endDate: '2025-12-31T23:59:00', - isActive: false, - createdAt: '2025-11-25T00:00:00', - updatedAt: '2025-11-25T00:00:00', - }, -}; diff --git a/src/hooks/__tests__/useBanners.test.ts b/src/hooks/__tests__/useBanners.test.ts new file mode 100644 index 0000000..2d50440 --- /dev/null +++ b/src/hooks/__tests__/useBanners.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '@/test/mocks/server'; +import { renderHook } from '@/test/test-utils'; +import { wrapApiError } from '@/test/mocks/data'; +import { + useBannerList, + useBannerDetail, + useBannerCreate, + useBannerUpdate, + useBannerDelete, + useBannerUpdateStatus, + useBannerUpdateSortOrder, +} from '../useBanners'; + +const BASE = '/admin/api/v1/banners'; + +describe('useBanners hooks', () => { + // ========================================== + // useBannerList + // ========================================== + describe('useBannerList', () => { + it('목록 데이터를 반환한다', async () => { + const { result } = renderHook(() => useBannerList()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.items.length).toBeGreaterThan(0); + expect(result.current.data!.meta.totalElements).toBeGreaterThan(0); + }); + + it('keyword로 필터링한다', async () => { + const { result } = renderHook(() => useBannerList({ keyword: '큐레이션' })); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.items).toHaveLength(1); + expect(result.current.data!.items[0]!.name).toBe('신년 큐레이션 배너'); + }); + + it('API 에러 시 에러 상태가 된다', async () => { + server.use( + http.get(BASE, () => { + return HttpResponse.json(wrapApiError(500, 'SERVER_ERROR', '서버 오류'), { + status: 500, + }); + }) + ); + + const { result } = renderHook(() => useBannerList()); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + }); + + // ========================================== + // useBannerDetail + // ========================================== + describe('useBannerDetail', () => { + it('id가 undefined이면 쿼리가 비활성화된다', () => { + const { result } = renderHook(() => useBannerDetail(undefined)); + expect(result.current.fetchStatus).toBe('idle'); + }); + + it('상세 데이터를 반환한다', async () => { + const { result } = renderHook(() => useBannerDetail(1)); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.id).toBe(1); + expect(result.current.data!.name).toBe('신년 큐레이션 배너'); + expect(result.current.data!.textPosition).toBe('RT'); + }); + + it('존재하지 않는 ID는 에러 상태가 된다', async () => { + const { result } = renderHook(() => useBannerDetail(9999)); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + }); + + // ========================================== + // useBannerCreate + // ========================================== + describe('useBannerCreate', () => { + it('생성 mutation이 성공한다', async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => useBannerCreate({ onSuccess })); + + result.current.mutate({ + name: '새 배너', + nameFontColor: '#ffffff', + descriptionA: '설명A', + descriptionB: '설명B', + descriptionFontColor: '#ffffff', + imageUrl: 'https://example.com/new.jpg', + textPosition: 'CENTER', + targetUrl: '/test', + isExternalUrl: false, + bannerType: 'AD', + startDate: null, + endDate: null, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('에러 시 에러 상태가 된다', async () => { + server.use( + http.post(BASE, () => { + return HttpResponse.json( + wrapApiError(400, 'INVALID_REQUEST', '잘못된 요청입니다.'), + { status: 400 } + ); + }) + ); + + const { result } = renderHook(() => useBannerCreate()); + + result.current.mutate({ + name: '', + nameFontColor: '#ffffff', + descriptionA: '', + descriptionB: '', + descriptionFontColor: '#ffffff', + imageUrl: '', + textPosition: 'RT', + targetUrl: '', + isExternalUrl: false, + bannerType: 'AD', + startDate: null, + endDate: null, + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + }); + + // ========================================== + // useBannerUpdate + // ========================================== + describe('useBannerUpdate', () => { + it('수정 mutation이 성공한다', async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => useBannerUpdate({ onSuccess })); + + result.current.mutate({ + id: 1, + data: { + name: '수정된 배너', + nameFontColor: '#000000', + descriptionA: '수정A', + descriptionB: '수정B', + descriptionFontColor: '#000000', + imageUrl: 'https://example.com/updated.jpg', + textPosition: 'LB', + targetUrl: '/updated', + isExternalUrl: false, + bannerType: 'CURATION', + sortOrder: 0, + startDate: null, + endDate: null, + isActive: true, + }, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + // ========================================== + // useBannerDelete + // ========================================== + describe('useBannerDelete', () => { + it('삭제 mutation이 성공한다', async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => useBannerDelete({ onSuccess })); + + result.current.mutate(1); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('에러 시 에러 상태가 된다', async () => { + server.use( + http.delete(`${BASE}/:bannerId`, () => { + return HttpResponse.json( + wrapApiError(400, 'BANNER_IN_USE', '사용 중인 배너는 삭제할 수 없습니다.'), + { status: 400 } + ); + }) + ); + + const { result } = renderHook(() => useBannerDelete()); + + result.current.mutate(1); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + }); + + // ========================================== + // useBannerUpdateStatus + // ========================================== + describe('useBannerUpdateStatus', () => { + it('상태 변경 mutation이 성공한다', async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => useBannerUpdateStatus({ onSuccess })); + + result.current.mutate({ bannerId: 1, data: { isActive: false } }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + // ========================================== + // useBannerUpdateSortOrder + // ========================================== + describe('useBannerUpdateSortOrder', () => { + it('정렬순서 변경 mutation이 성공한다', async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => useBannerUpdateSortOrder({ onSuccess })); + + result.current.mutate({ bannerId: 1, data: { sortOrder: 5 } }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(onSuccess).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/hooks/useBanners.ts b/src/hooks/useBanners.ts index f560ed3..e8e2f54 100644 --- a/src/hooks/useBanners.ts +++ b/src/hooks/useBanners.ts @@ -18,75 +18,41 @@ import type { BannerUpdateRequest, BannerUpdateResponse, BannerDeleteResponse, - BannerReorderRequest, - BannerReorderResponse, + BannerUpdateStatusRequest, + BannerUpdateStatusResponse, + BannerUpdateSortOrderRequest, + BannerUpdateSortOrderResponse, } from '@/types/api'; /** * 배너 목록 조회 훅 - * - * @example - * ```tsx - * const { data, isLoading } = useBannerList({ bannerType: 'CURATION' }); - * - * if (data) { - * console.log(data.items); // 배너 목록 - * console.log(data.meta); // 페이지네이션 정보 - * } - * ``` */ export function useBannerList(params?: BannerSearchParams) { return useApiQuery( bannerKeys.list(params), () => bannerService.search(params), { - staleTime: 1000 * 60, // 1분 + staleTime: 1000 * 60 * 5, } ); } /** * 배너 상세 조회 훅 - * - * @example - * ```tsx - * const { data, isLoading } = useBannerDetail(1); - * - * if (data) { - * console.log(data.name); // 배너명 - * console.log(data.bannerType); // 배너 타입 - * } - * ``` */ -export function useBannerDetail(bannerId: number | undefined) { +export function useBannerDetail(id: number | undefined) { return useApiQuery( - bannerKeys.detail(bannerId ?? 0), - () => bannerService.getDetail(bannerId!), + bannerKeys.detail(id ?? 0), + () => bannerService.getDetail(id!), { - enabled: !!bannerId && bannerId > 0, - staleTime: 1000 * 60, // 1분 + enabled: !!id, + staleTime: 1000 * 60 * 5, } ); } /** * 배너 생성 훅 - * - * @example - * ```tsx - * const createMutation = useBannerCreate({ - * onSuccess: (data) => { - * console.log('생성된 ID:', data.targetId); - * navigate('/banners'); - * }, - * }); - * - * createMutation.mutate({ - * name: '신규 배너', - * bannerType: 'CURATION', - * // ... - * }); - * ``` */ export function useBannerCreate( options?: Omit< @@ -103,9 +69,7 @@ export function useBannerCreate( successMessage: '배너가 등록되었습니다.', ...restOptions, onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 queryClient.invalidateQueries({ queryKey: bannerKeys.lists() }); - // 원래 onSuccess 콜백 호출 if (onSuccess) { (onSuccess as (data: BannerCreateResponse, variables: BannerCreateRequest, context: unknown) => void)(data, variables, context); } @@ -118,30 +82,12 @@ export function useBannerCreate( * 배너 수정 mutation 변수 타입 */ export interface BannerUpdateVariables { - bannerId: number; + id: number; data: BannerUpdateRequest; } /** * 배너 수정 훅 - * - * @example - * ```tsx - * const updateMutation = useBannerUpdate({ - * onSuccess: () => { - * console.log('수정 완료'); - * }, - * }); - * - * updateMutation.mutate({ - * bannerId: 1, - * data: { - * name: '수정된 배너', - * bannerType: 'AD', - * // ... - * }, - * }); - * ``` */ export function useBannerUpdate( options?: Omit, 'successMessage'> @@ -150,16 +96,13 @@ export function useBannerUpdate( const { onSuccess, ...restOptions } = options ?? {}; return useApiMutation( - ({ bannerId, data }) => bannerService.update(bannerId, data), + ({ id, data }) => bannerService.update(id, data), { successMessage: '배너가 수정되었습니다.', ...restOptions, onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 queryClient.invalidateQueries({ queryKey: bannerKeys.lists() }); - // 상세 캐시 무효화 - queryClient.invalidateQueries({ queryKey: bannerKeys.detail(variables.bannerId) }); - // 원래 onSuccess 콜백 호출 + queryClient.invalidateQueries({ queryKey: bannerKeys.detail(variables.id) }); if (onSuccess) { (onSuccess as (data: BannerUpdateResponse, variables: BannerUpdateVariables, context: unknown) => void)(data, variables, context); } @@ -170,17 +113,6 @@ export function useBannerUpdate( /** * 배너 삭제 훅 - * - * @example - * ```tsx - * const deleteMutation = useBannerDelete({ - * onSuccess: () => { - * navigate('/banners'); - * }, - * }); - * - * deleteMutation.mutate(bannerId); - * ``` */ export function useBannerDelete( options?: Omit, 'successMessage'> @@ -194,11 +126,7 @@ export function useBannerDelete( successMessage: '배너가 삭제되었습니다.', ...restOptions, onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 queryClient.invalidateQueries({ queryKey: bannerKeys.lists() }); - // 상세 캐시 무효화 - queryClient.invalidateQueries({ queryKey: bannerKeys.detail(variables) }); - // 원래 onSuccess 콜백 호출 if (onSuccess) { (onSuccess as (data: BannerDeleteResponse, variables: number, context: unknown) => void)(data, variables, context); } @@ -208,36 +136,65 @@ export function useBannerDelete( } /** - * 배너 순서 변경 훅 - * - * @example - * ```tsx - * const reorderMutation = useBannerReorder({ - * onSuccess: () => { - * console.log('순서 변경 완료'); - * }, - * }); - * - * reorderMutation.mutate({ bannerIds: [3, 1, 2] }); - * ``` + * 배너 상태 변경 mutation 변수 타입 + */ +export interface BannerUpdateStatusVariables { + bannerId: number; + data: BannerUpdateStatusRequest; +} + +/** + * 배너 상태 변경 훅 (활성/비활성) */ -export function useBannerReorder( - options?: Omit, 'successMessage'> +export function useBannerUpdateStatus( + options?: Omit, 'successMessage'> ) { const queryClient = useQueryClient(); const { onSuccess, ...restOptions } = options ?? {}; - return useApiMutation( - bannerService.reorder, + return useApiMutation( + ({ bannerId, data }) => bannerService.updateStatus(bannerId, data), + { + successMessage: '배너 상태가 변경되었습니다.', + ...restOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: bannerKeys.lists() }); + queryClient.invalidateQueries({ queryKey: bannerKeys.detail(variables.bannerId) }); + if (onSuccess) { + (onSuccess as (data: BannerUpdateStatusResponse, variables: BannerUpdateStatusVariables, context: unknown) => void)(data, variables, context); + } + }, + } + ); +} + +/** + * 배너 정렬순서 변경 mutation 변수 타입 + */ +export interface BannerUpdateSortOrderVariables { + bannerId: number; + data: BannerUpdateSortOrderRequest; +} + +/** + * 배너 정렬순서 변경 훅 + */ +export function useBannerUpdateSortOrder( + options?: Omit, 'successMessage'> +) { + const queryClient = useQueryClient(); + const { onSuccess, ...restOptions } = options ?? {}; + + return useApiMutation( + ({ bannerId, data }) => bannerService.updateSortOrder(bannerId, data), { successMessage: '배너 순서가 변경되었습니다.', ...restOptions, onSuccess: (data, variables, context) => { - // 목록 캐시 무효화 queryClient.invalidateQueries({ queryKey: bannerKeys.lists() }); - // 원래 onSuccess 콜백 호출 + queryClient.invalidateQueries({ queryKey: bannerKeys.detail(variables.bannerId) }); if (onSuccess) { - (onSuccess as (data: BannerReorderResponse, variables: BannerReorderRequest, context: unknown) => void)(data, variables, context); + (onSuccess as (data: BannerUpdateSortOrderResponse, variables: BannerUpdateSortOrderVariables, context: unknown) => void)(data, variables, context); } }, } diff --git a/src/pages/banners/BannerDetail.tsx b/src/pages/banners/BannerDetail.tsx index 3def60f..cd10cc2 100644 --- a/src/pages/banners/BannerDetail.tsx +++ b/src/pages/banners/BannerDetail.tsx @@ -1,39 +1,25 @@ -/** - * 배너 상세/등록 페이지 - * - 신규 등록 (id가 'new'인 경우) - * - 상세 조회 및 수정 (id가 숫자인 경우) - */ - import { useState, useEffect } from 'react'; import { useParams } from 'react-router'; -import { Save, Trash2, ExternalLink, Link } from 'lucide-react'; +import { Save, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Switch } from '@/components/ui/switch'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { DetailPageHeader } from '@/components/common/DetailPageHeader'; import { DeleteConfirmDialog } from '@/components/common/DeleteConfirmDialog'; -import { ImageUpload } from '@/components/common/ImageUpload'; import { useBannerDetailForm } from './useBannerDetailForm'; import { useImageUpload, S3UploadPath } from '@/hooks/useImageUpload'; -import { useCurationList, curationService } from '@/hooks/useCurations'; -import { BANNER_TYPE_LABELS, TEXT_POSITION_LABELS, type BannerType, type TextPosition } from '@/types/api'; +import { useCurationList } from '@/hooks/useCurations'; + +import { BannerBasicInfoCard } from './components/BannerBasicInfoCard'; +import { BannerTextSettingsCard } from './components/BannerTextSettingsCard'; +import { BannerLinkSettingsCard } from './components/BannerLinkSettingsCard'; +import { BannerImageCard } from './components/BannerImageCard'; +import { BannerExposureCard } from './components/BannerExposureCard'; +import { BannerPreviewCard } from './components/BannerPreviewCard'; export function BannerDetailPage() { const { id } = useParams<{ id: string }>(); - // 폼 관련 로직을 커스텀 훅으로 분리 const { form, isLoading, @@ -45,20 +31,16 @@ export function BannerDetailPage() { handleDelete, } = useBannerDetailForm(id); - // 이미지 업로드 훅 const { upload: uploadImage, isUploading: isImageUploading } = useImageUpload({ rootPath: S3UploadPath.BANNER, }); - // 큐레이션 목록 조회 (CURATION 타입일 때 사용) const { data: curationData } = useCurationList(); const curations = curationData?.items ?? []; - // 로컬 상태 const [imagePreviewUrl, setImagePreviewUrl] = useState(null); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - // bannerData 변경 시 로컬 상태 동기화 useEffect(() => { if (bannerData) { setImagePreviewUrl(bannerData.imageUrl); @@ -66,21 +48,16 @@ export function BannerDetailPage() { }, [bannerData]); const handleImageChange = async (file: File | null, previewUrl: string | null) => { - // 즉시 프리뷰 표시 setImagePreviewUrl(previewUrl); if (file) { - // S3에 업로드하고 CDN URL 획득 const viewUrl = await uploadImage(file); if (viewUrl) { - // 업로드 성공 시 CDN URL로 업데이트 form.setValue('imageUrl', viewUrl); } else { - // 업로드 실패 시 프리뷰 URL 유지 (에러는 훅에서 처리) form.setValue('imageUrl', previewUrl ?? ''); } } else { - // 이미지 삭제 시 form.setValue('imageUrl', previewUrl ?? ''); } }; @@ -99,30 +76,8 @@ export function BannerDetailPage() { setIsDeleteDialogOpen(false); }; - // 폼 값 watch - const isAlwaysVisible = form.watch('isAlwaysVisible'); - const isActive = form.watch('isActive'); - const isExternalUrl = form.watch('isExternalUrl'); - const bannerType = form.watch('bannerType'); - const curationId = form.watch('curationId'); - - // CURATION 타입 여부 - const isCurationType = bannerType === 'CURATION'; - - // 큐레이션 선택 시 URL 자동 생성 - const handleCurationChange = (value: string) => { - const id = parseInt(value, 10); - form.setValue('curationId', id); - // URL 자동 생성 - const generatedUrl = curationService.generateCurationUrl(id); - form.setValue('targetUrl', generatedUrl); - // 큐레이션은 항상 내부 URL - form.setValue('isExternalUrl', false); - }; - return (
- {/* 헤더 */} 로딩 중...
) : (
- {/* 왼쪽 컬럼 */}
- {/* 기본 정보 */} - - - 기본 정보 - - -
- - - {form.formState.errors.name && ( -

- {form.formState.errors.name.message} -

- )} -
- -
- - -
- -
- form.setValue('isActive', checked)} - /> - -
-
-
- - {/* 텍스트 설정 */} - - - 텍스트 설정 - - -
- - -
- -
- - -
- -
- - -
- -
-
- -
-
- -
- {form.formState.errors.nameFontColor && ( -

- {form.formState.errors.nameFontColor.message} -

- )} -
- -
- -
-
- -
- {form.formState.errors.descriptionFontColor && ( -

- {form.formState.errors.descriptionFontColor.message} -

- )} -
-
- - - - {/* 링크 설정 */} - - - 링크 설정 - - - {/* CURATION 타입: 큐레이션 선택 드롭다운 */} - {isCurationType && ( -
- - - {form.formState.errors.curationId && ( -

- {form.formState.errors.curationId.message} -

- )} -
- )} - -
- -
- {isExternalUrl ? ( - - ) : ( - - )} - -
- {isCurationType && ( -

- 큐레이션 선택 시 URL이 자동으로 생성됩니다. -

- )} -
- - {/* CURATION 타입이 아닐 때만 외부 URL 옵션 표시 */} - {!isCurationType && ( -
- form.setValue('isExternalUrl', !!checked)} - /> - -
- )} -
-
+ + +
- {/* 오른쪽 컬럼 */}
- {/* 배너 이미지 */} - - - 배너 이미지 * - - - -

- 권장 사이즈: 1920x600px -

- {form.formState.errors.imageUrl && ( -

- {form.formState.errors.imageUrl.message} -

- )} - {isImageUploading && ( -

이미지 업로드 중...

- )} -
-
- - {/* 노출 설정 */} - - - 노출 설정 - - -
- { - form.setValue('isAlwaysVisible', !!checked); - if (checked) { - form.setValue('startDate', null); - form.setValue('endDate', null); - } - }} - /> - -
- - {!isAlwaysVisible && ( -
-
- - form.setValue('startDate', e.target.value || null)} - /> -
- -
- - form.setValue('endDate', e.target.value || null)} - /> -
-
- )} - - {form.formState.errors.startDate && ( -

- {form.formState.errors.startDate.message} -

- )} -
-
- - {/* 배너 미리보기 */} - {imagePreviewUrl && ( - - - 미리보기 - - -
- 배너 미리보기 - -
-
-
- )} + + +
)} - {/* 삭제 확인 다이얼로그 */} ); } - -/** - * 배너 텍스트 오버레이 컴포넌트 - */ -interface BannerTextOverlayProps { - name: string; - descriptionA?: string; - descriptionB?: string; - textPosition: TextPosition; - nameFontColor: string; - descriptionFontColor: string; -} - -function BannerTextOverlay({ - name, - descriptionA, - descriptionB, - textPosition, - nameFontColor, - descriptionFontColor, -}: BannerTextOverlayProps) { - // 위치 클래스 계산 - const positionClasses: Record = { - LT: 'top-4 left-4', - LB: 'bottom-4 left-4', - RT: 'top-4 right-4', - RB: 'bottom-4 right-4', - CENTER: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2', - }; - - const textAlignClasses: Record = { - LT: 'text-left', - LB: 'text-left', - RT: 'text-right', - RB: 'text-right', - CENTER: 'text-center', - }; - - // 텍스트 순서 (LT, RT, CENTER는 description 위, name 아래) - const isNameFirst = textPosition === 'LB' || textPosition === 'RB'; - - return ( -
- {isNameFirst ? ( - <> -

- {name} -

- {(descriptionA || descriptionB) && ( -
- {descriptionA &&

{descriptionA}

} - {descriptionB &&

{descriptionB}

} -
- )} - - ) : ( - <> - {(descriptionA || descriptionB) && ( -
- {descriptionA &&

{descriptionA}

} - {descriptionB &&

{descriptionB}

} -
- )} -

- {name} -

- - )} -
- ); -} diff --git a/src/pages/banners/BannerList.tsx b/src/pages/banners/BannerList.tsx index bd05777..c06e94b 100644 --- a/src/pages/banners/BannerList.tsx +++ b/src/pages/banners/BannerList.tsx @@ -26,7 +26,7 @@ import { import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Pagination } from '@/components/common/Pagination'; -import { useBannerList, useBannerReorder } from '@/hooks/useBanners'; +import { useBannerList, useBannerUpdateSortOrder } from '@/hooks/useBanners'; import { type BannerSearchParams, type BannerType, @@ -40,6 +40,7 @@ const BANNER_TYPE_OPTIONS: { value: BannerType | 'ALL'; label: string }[] = [ { value: 'CURATION', label: '큐레이션' }, { value: 'AD', label: '광고' }, { value: 'PARTNERSHIP', label: '제휴' }, + { value: 'ETC', label: '기타' }, ]; export function BannerListPage() { @@ -76,7 +77,7 @@ export function BannerListPage() { }; const { data, isLoading } = useBannerList(searchParams); - const reorderMutation = useBannerReorder(); + const updateSortOrderMutation = useBannerUpdateSortOrder(); // URL 파라미터 업데이트 헬퍼 const updateUrlParams = (updates: Record) => { @@ -189,9 +190,12 @@ export function BannerListPage() { items.splice(draggedIndex, 1); items.splice(targetIndex, 0, draggedItem); - // API 호출 - const bannerIds = items.map((item) => item.id); - reorderMutation.mutate({ bannerIds }); + // 변경된 순서로 개별 API 호출 + items.forEach((item, index) => { + if (item.sortOrder !== index) { + updateSortOrderMutation.mutate({ bannerId: item.id, data: { sortOrder: index } }); + } + }); setDraggedItem(null); }; diff --git a/src/pages/banners/banner.schema.ts b/src/pages/banners/banner.schema.ts index 0e31323..7feb578 100644 --- a/src/pages/banners/banner.schema.ts +++ b/src/pages/banners/banner.schema.ts @@ -4,10 +4,36 @@ import { z } from 'zod'; +// ============================================ +// 상시 노출 날짜 유틸리티 +// ============================================ + +/** 상시 노출 종료일 (2099-12-31T23:59:59) */ +export const ALWAYS_VISIBLE_END_DATE = '2099-12-31T23:59:59'; + +/** 오늘 날짜 시작 시각 문자열 반환 (YYYY-MM-DDT00:00:00) */ +export function getTodayStart(): string { + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, '0'); + const d = String(now.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}T00:00:00`; +} + +/** endDate가 상시 노출 기간인지 판별 */ +export function isAlwaysVisibleDate(endDate: string | null | undefined): boolean { + if (!endDate) return false; + return endDate.startsWith('2099'); +} + +// ============================================ +// 폼 스키마 +// ============================================ + /** 배너 폼 Zod 스키마 */ export const bannerFormSchema = z.object({ name: z.string().min(1, '배너명은 필수입니다'), - bannerType: z.enum(['SURVEY', 'CURATION', 'AD', 'PARTNERSHIP'], { + bannerType: z.enum(['SURVEY', 'CURATION', 'AD', 'PARTNERSHIP', 'ETC'], { message: '배너 타입을 선택해주세요', }), isActive: z.boolean(), @@ -22,23 +48,11 @@ export const bannerFormSchema = z.object({ targetUrl: z.string(), isExternalUrl: z.boolean(), isAlwaysVisible: z.boolean(), - startDate: z.string().nullable(), - endDate: z.string().nullable(), + startDate: z.string({ error: '시작일을 입력해주세요' }).min(1, '시작일을 입력해주세요'), + endDate: z.string({ error: '종료일을 입력해주세요' }).min(1, '종료일을 입력해주세요'), /** 큐레이션 ID (CURATION 타입인 경우에만 사용) */ curationId: z.number().nullable(), }).refine( - (data) => { - // 상시 노출이 아닌 경우 시작일/종료일 필수 - if (!data.isAlwaysVisible) { - return data.startDate && data.endDate; - } - return true; - }, - { - message: '노출 기간을 설정해주세요', - path: ['startDate'], - } -).refine( (data) => { // CURATION 타입인 경우 큐레이션 선택 필수 if (data.bannerType === 'CURATION') { @@ -69,7 +83,7 @@ export const DEFAULT_BANNER_FORM: BannerFormValues = { targetUrl: '', isExternalUrl: false, isAlwaysVisible: false, - startDate: null, - endDate: null, + startDate: '', + endDate: '', curationId: null, }; diff --git a/src/pages/banners/components/BannerBasicInfoCard.tsx b/src/pages/banners/components/BannerBasicInfoCard.tsx new file mode 100644 index 0000000..ec1d841 --- /dev/null +++ b/src/pages/banners/components/BannerBasicInfoCard.tsx @@ -0,0 +1,83 @@ +import type { UseFormReturn } from 'react-hook-form'; + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +import type { BannerFormValues } from '../banner.schema'; +import { BANNER_TYPE_LABELS, type BannerType } from '@/types/api'; + +interface BannerBasicInfoCardProps { + form: UseFormReturn; +} + +export function BannerBasicInfoCard({ form }: BannerBasicInfoCardProps) { + const isActive = form.watch('isActive'); + + return ( + + + 기본 정보 + + +
+ + + {form.formState.errors.name && ( +

+ {form.formState.errors.name.message} +

+ )} +
+ +
+ + +
+ +
+ form.setValue('isActive', checked)} + /> + +
+
+
+ ); +} diff --git a/src/pages/banners/components/BannerExposureCard.tsx b/src/pages/banners/components/BannerExposureCard.tsx new file mode 100644 index 0000000..f348971 --- /dev/null +++ b/src/pages/banners/components/BannerExposureCard.tsx @@ -0,0 +1,71 @@ +import type { UseFormReturn } from 'react-hook-form'; + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +import type { BannerFormValues } from '../banner.schema'; +import { ALWAYS_VISIBLE_END_DATE, getTodayStart } from '../banner.schema'; + +interface BannerExposureCardProps { + form: UseFormReturn; +} + +export function BannerExposureCard({ form }: BannerExposureCardProps) { + const isAlwaysVisible = form.watch('isAlwaysVisible'); + + return ( + + + 노출 설정 + + +
+ { + form.setValue('isAlwaysVisible', !!checked); + if (checked) { + form.setValue('startDate', getTodayStart()); + form.setValue('endDate', ALWAYS_VISIBLE_END_DATE); + } + }} + /> + +
+ + {!isAlwaysVisible && ( +
+
+ + form.setValue('startDate', e.target.value)} + /> +
+ +
+ + form.setValue('endDate', e.target.value)} + /> +
+
+ )} + + {form.formState.errors.startDate && ( +

+ {form.formState.errors.startDate.message} +

+ )} +
+
+ ); +} diff --git a/src/pages/banners/components/BannerImageCard.tsx b/src/pages/banners/components/BannerImageCard.tsx new file mode 100644 index 0000000..81b8448 --- /dev/null +++ b/src/pages/banners/components/BannerImageCard.tsx @@ -0,0 +1,35 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ImageUpload } from '@/components/common/ImageUpload'; + +interface BannerImageCardProps { + imagePreviewUrl: string | null; + onImageChange: (file: File | null, previewUrl: string | null) => void; + isUploading: boolean; + error?: string; +} + +export function BannerImageCard({ imagePreviewUrl, onImageChange, isUploading, error }: BannerImageCardProps) { + return ( + + + 배너 이미지 * + + + +

+ 권장 사이즈: 1920x600px +

+ {error && ( +

{error}

+ )} + {isUploading && ( +

이미지 업로드 중...

+ )} +
+
+ ); +} diff --git a/src/pages/banners/components/BannerLinkSettingsCard.tsx b/src/pages/banners/components/BannerLinkSettingsCard.tsx new file mode 100644 index 0000000..869b8aa --- /dev/null +++ b/src/pages/banners/components/BannerLinkSettingsCard.tsx @@ -0,0 +1,108 @@ +import type { UseFormReturn } from 'react-hook-form'; +import { ExternalLink, Link } from 'lucide-react'; + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +import type { BannerFormValues } from '../banner.schema'; +import { curationService } from '@/hooks/useCurations'; +import type { CurationListItem } from '@/types/api'; + +interface BannerLinkSettingsCardProps { + form: UseFormReturn; + curations: CurationListItem[]; +} + +export function BannerLinkSettingsCard({ form, curations }: BannerLinkSettingsCardProps) { + const isExternalUrl = form.watch('isExternalUrl'); + const bannerType = form.watch('bannerType'); + const curationId = form.watch('curationId'); + const isCurationType = bannerType === 'CURATION'; + + const handleCurationChange = (value: string) => { + const id = parseInt(value, 10); + form.setValue('curationId', id); + form.setValue('targetUrl', curationService.generateCurationUrl(id)); + form.setValue('isExternalUrl', false); + }; + + return ( + + + 링크 설정 + + + {isCurationType && ( +
+ + + {form.formState.errors.curationId && ( +

+ {form.formState.errors.curationId.message} +

+ )} +
+ )} + +
+ +
+ {isExternalUrl ? ( + + ) : ( + + )} + +
+ {isCurationType && ( +

+ 큐레이션 선택 시 URL이 자동으로 생성됩니다. +

+ )} +
+ + {!isCurationType && ( +
+ form.setValue('isExternalUrl', !!checked)} + /> + +
+ )} +
+
+ ); +} diff --git a/src/pages/banners/components/BannerPreviewCard.tsx b/src/pages/banners/components/BannerPreviewCard.tsx new file mode 100644 index 0000000..f4e4c5b --- /dev/null +++ b/src/pages/banners/components/BannerPreviewCard.tsx @@ -0,0 +1,114 @@ +import type { UseFormReturn } from 'react-hook-form'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +import type { BannerFormValues } from '../banner.schema'; +import type { TextPosition } from '@/types/api'; + +interface BannerPreviewCardProps { + form: UseFormReturn; + imagePreviewUrl: string | null; +} + +export function BannerPreviewCard({ form, imagePreviewUrl }: BannerPreviewCardProps) { + if (!imagePreviewUrl) return null; + + return ( + + + 미리보기 + + +
+ 배너 미리보기 + +
+
+
+ ); +} + +interface BannerTextOverlayProps { + name: string; + descriptionA?: string; + descriptionB?: string; + textPosition: TextPosition; + nameFontColor: string; + descriptionFontColor: string; +} + +function BannerTextOverlay({ + name, + descriptionA, + descriptionB, + textPosition, + nameFontColor, + descriptionFontColor, +}: BannerTextOverlayProps) { + const positionClasses: Record = { + LT: 'top-4 left-4', + LB: 'bottom-4 left-4', + RT: 'top-4 right-4', + RB: 'bottom-4 right-4', + CENTER: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2', + }; + + const textAlignClasses: Record = { + LT: 'text-left', + LB: 'text-left', + RT: 'text-right', + RB: 'text-right', + CENTER: 'text-center', + }; + + const isNameFirst = textPosition === 'LB' || textPosition === 'RB'; + + return ( +
+ {isNameFirst ? ( + <> +

+ {name} +

+ {(descriptionA || descriptionB) && ( +
+ {descriptionA &&

{descriptionA}

} + {descriptionB &&

{descriptionB}

} +
+ )} + + ) : ( + <> + {(descriptionA || descriptionB) && ( +
+ {descriptionA &&

{descriptionA}

} + {descriptionB &&

{descriptionB}

} +
+ )} +

+ {name} +

+ + )} +
+ ); +} diff --git a/src/pages/banners/components/BannerTextSettingsCard.tsx b/src/pages/banners/components/BannerTextSettingsCard.tsx new file mode 100644 index 0000000..373bdd8 --- /dev/null +++ b/src/pages/banners/components/BannerTextSettingsCard.tsx @@ -0,0 +1,111 @@ +import type { UseFormReturn } from 'react-hook-form'; + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +import type { BannerFormValues } from '../banner.schema'; +import { TEXT_POSITION_LABELS, type TextPosition } from '@/types/api'; + +interface BannerTextSettingsCardProps { + form: UseFormReturn; +} + +export function BannerTextSettingsCard({ form }: BannerTextSettingsCardProps) { + return ( + + + 텍스트 설정 + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+ +
+ {form.formState.errors.nameFontColor && ( +

+ {form.formState.errors.nameFontColor.message} +

+ )} +
+ +
+ +
+
+ +
+ {form.formState.errors.descriptionFontColor && ( +

+ {form.formState.errors.descriptionFontColor.message} +

+ )} +
+
+ + + ); +} diff --git a/src/pages/banners/useBannerDetailForm.ts b/src/pages/banners/useBannerDetailForm.ts index f19c0f5..82d7886 100644 --- a/src/pages/banners/useBannerDetailForm.ts +++ b/src/pages/banners/useBannerDetailForm.ts @@ -16,9 +16,13 @@ import { useBannerUpdate, } from '@/hooks/useBanners'; -import { bannerFormSchema, DEFAULT_BANNER_FORM } from './banner.schema'; +import { + bannerFormSchema, + DEFAULT_BANNER_FORM, + isAlwaysVisibleDate, +} from './banner.schema'; import type { BannerFormValues } from './banner.schema'; -import type { BannerCreateRequest, BannerUpdateRequest, BannerDetail } from '@/types/api'; +import type { BannerDetail } from '@/types/api'; /** * 큐레이션 URL에서 curationId 추출 @@ -87,7 +91,7 @@ export function useBannerDetailForm(id: string | undefined): UseBannerDetailForm // API 데이터를 폼에 반영 useEffect(() => { if (bannerData) { - const isAlwaysVisible = !bannerData.startDate && !bannerData.endDate; + const isAlwaysVisible = isAlwaysVisibleDate(bannerData.endDate); // CURATION 타입인 경우 URL에서 curationId 추출 const curationId = @@ -96,20 +100,20 @@ export function useBannerDetailForm(id: string | undefined): UseBannerDetailForm : null; form.reset({ - name: bannerData.name, + name: bannerData.name ?? '', bannerType: bannerData.bannerType, isActive: bannerData.isActive, - imageUrl: bannerData.imageUrl, + imageUrl: bannerData.imageUrl ?? '', descriptionA: bannerData.descriptionA ?? '', descriptionB: bannerData.descriptionB ?? '', textPosition: bannerData.textPosition, - nameFontColor: bannerData.nameFontColor, - descriptionFontColor: bannerData.descriptionFontColor, + nameFontColor: (bannerData.nameFontColor ?? '#ffffff').replace(/^#/, ''), + descriptionFontColor: (bannerData.descriptionFontColor ?? '#ffffff').replace(/^#/, ''), targetUrl: bannerData.targetUrl ?? '', isExternalUrl: bannerData.isExternalUrl, isAlwaysVisible, - startDate: bannerData.startDate, - endDate: bannerData.endDate, + startDate: bannerData.startDate ?? '', + endDate: bannerData.endDate ?? '', curationId, }); } @@ -119,44 +123,32 @@ export function useBannerDetailForm(id: string | undefined): UseBannerDetailForm data: BannerFormValues, options?: { imagePreviewUrl: string | null } ) => { - // 상시 노출인 경우 날짜를 null로 설정 - const startDate = data.isAlwaysVisible ? null : data.startDate; - const endDate = data.isAlwaysVisible ? null : data.endDate; + const commonFields = { + name: data.name, + bannerType: data.bannerType, + imageUrl: data.imageUrl || options?.imagePreviewUrl || '', + descriptionA: data.descriptionA ?? '', + descriptionB: data.descriptionB ?? '', + textPosition: data.textPosition, + nameFontColor: `#${data.nameFontColor}`, + descriptionFontColor: `#${data.descriptionFontColor}`, + targetUrl: data.targetUrl ?? '', + isExternalUrl: data.isExternalUrl, + startDate: data.startDate, + endDate: data.endDate, + }; if (isNewMode) { - const createData: BannerCreateRequest = { - name: data.name, - bannerType: data.bannerType, - isActive: data.isActive, - imageUrl: data.imageUrl || options?.imagePreviewUrl || '', - descriptionA: data.descriptionA ?? '', - descriptionB: data.descriptionB ?? '', - textPosition: data.textPosition, - nameFontColor: data.nameFontColor, - descriptionFontColor: data.descriptionFontColor, - targetUrl: data.targetUrl ?? '', - isExternalUrl: data.isExternalUrl, - startDate, - endDate, - }; - createMutation.mutate(createData); + createMutation.mutate(commonFields); } else if (bannerId) { - const updateData: BannerUpdateRequest = { - name: data.name, - bannerType: data.bannerType, - isActive: data.isActive, - imageUrl: data.imageUrl || options?.imagePreviewUrl || '', - descriptionA: data.descriptionA ?? '', - descriptionB: data.descriptionB ?? '', - textPosition: data.textPosition, - nameFontColor: data.nameFontColor, - descriptionFontColor: data.descriptionFontColor, - targetUrl: data.targetUrl ?? '', - isExternalUrl: data.isExternalUrl, - startDate, - endDate, - }; - updateMutation.mutate({ bannerId, data: updateData }); + updateMutation.mutate({ + id: bannerId, + data: { + ...commonFields, + isActive: data.isActive, + sortOrder: bannerData?.sortOrder ?? 0, + }, + }); } }; diff --git a/src/services/banner.service.ts b/src/services/banner.service.ts index 16f0659..19c0d34 100644 --- a/src/services/banner.service.ts +++ b/src/services/banner.service.ts @@ -1,10 +1,11 @@ /** * 배너 API 서비스 - * API 연동 전까지 Mock 데이터 사용 */ +import { apiClient } from '@/lib/api-client'; import { createQueryKeys } from '@/hooks/useApiQuery'; import { + BannerApi, type BannerSearchParams, type BannerListItem, type BannerPageMeta, @@ -14,10 +15,11 @@ import { type BannerUpdateRequest, type BannerUpdateResponse, type BannerDeleteResponse, - type BannerReorderRequest, - type BannerReorderResponse, + type BannerUpdateStatusRequest, + type BannerUpdateStatusResponse, + type BannerUpdateSortOrderRequest, + type BannerUpdateSortOrderResponse, } from '@/types/api'; -import { mockBannerList, mockBannerDetails } from '@/data/mock/banners.mock'; // ============================================ // Query Keys @@ -34,14 +36,6 @@ export interface BannerListResponse { meta: BannerPageMeta; } -// ============================================ -// Mock 상태 관리 (실제 API 연동 시 제거) -// ============================================ - -let mockBanners = [...mockBannerList]; -let mockDetails = { ...mockBannerDetails }; -let nextId = Math.max(...mockBanners.map((b) => b.id)) + 1; - // ============================================ // Service // ============================================ @@ -49,66 +43,21 @@ let nextId = Math.max(...mockBanners.map((b) => b.id)) + 1; export const bannerService = { /** * 배너 목록 조회 - * Mock: 필터링, 검색, 페이지네이션 시뮬레이션 */ search: async (params?: BannerSearchParams): Promise => { - // TODO: 실제 API 연동 시 아래 코드로 교체 - // const response = await apiClient.getWithMeta( - // BannerApi.search.endpoint, - // { params } - // ); - // return { - // items: response.data ?? [], - // meta: { - // page: response.meta.page ?? params?.page ?? 0, - // size: response.meta.size ?? params?.size ?? 20, - // totalElements: response.meta.totalElements ?? 0, - // totalPages: response.meta.totalPages ?? 0, - // hasNext: response.meta.hasNext ?? false, - // }, - // }; - - // Mock 구현 - await delay(300); - - let filtered = [...mockBanners]; - - // 검색어 필터 - if (params?.keyword) { - const keyword = params.keyword.toLowerCase(); - filtered = filtered.filter((b) => - b.name.toLowerCase().includes(keyword) - ); - } - - // 배너 타입 필터 - if (params?.bannerType) { - filtered = filtered.filter((b) => b.bannerType === params.bannerType); - } - - // 활성화 상태 필터 - if (params?.isActive !== undefined) { - filtered = filtered.filter((b) => b.isActive === params.isActive); - } - - // 정렬 (sortOrder 기준) - filtered.sort((a, b) => a.sortOrder - b.sortOrder); - - // 페이지네이션 - const page = params?.page ?? 0; - const size = params?.size ?? 20; - const start = page * size; - const end = start + size; - const paginatedItems = filtered.slice(start, end); + const response = await apiClient.getWithMeta( + BannerApi.search.endpoint, + { params } + ); return { - items: paginatedItems, + items: response.data ?? [], meta: { - page, - size, - totalElements: filtered.length, - totalPages: Math.ceil(filtered.length / size), - hasNext: end < filtered.length, + page: response.meta.page ?? params?.page ?? 0, + size: response.meta.size ?? params?.size ?? 20, + totalElements: response.meta.totalElements ?? 0, + totalPages: response.meta.totalPages ?? 0, + hasNext: response.meta.hasNext ?? false, }, }; }, @@ -117,222 +66,49 @@ export const bannerService = { * 배너 상세 조회 */ getDetail: async (bannerId: number): Promise => { - // TODO: 실제 API 연동 시 아래 코드로 교체 - // const endpoint = BannerApi.detail.endpoint.replace(':bannerId', String(bannerId)); - // return apiClient.get(endpoint); - - // Mock 구현 - await delay(200); - - const detail = mockDetails[bannerId]; - if (!detail) { - throw new Error(`Banner not found: ${bannerId}`); - } - - return detail; + const endpoint = BannerApi.detail.endpoint.replace(':bannerId', String(bannerId)); + return apiClient.get(endpoint); }, /** * 배너 생성 */ create: async (data: BannerCreateRequest): Promise => { - // TODO: 실제 API 연동 시 아래 코드로 교체 - // return apiClient.post( - // BannerApi.create.endpoint, - // data - // ); - - // Mock 구현 - await delay(300); - - const newId = nextId++; - const now = new Date().toISOString(); - const maxSortOrder = Math.max(...mockBanners.map((b) => b.sortOrder), -1); - - const newBanner: BannerListItem = { - id: newId, - name: data.name, - bannerType: data.bannerType, - imageUrl: data.imageUrl, - sortOrder: maxSortOrder + 1, - startDate: data.startDate, - endDate: data.endDate, - isActive: data.isActive, - createdAt: now, - updatedAt: now, - }; - - const newDetail: BannerDetail = { - ...data, - id: newId, - sortOrder: maxSortOrder + 1, - createdAt: now, - updatedAt: now, - }; - - mockBanners.push(newBanner); - mockDetails[newId] = newDetail; - - return { - code: 'SUCCESS', - message: '배너가 등록되었습니다.', - targetId: newId, - responseAt: now, - }; + return apiClient.post( + BannerApi.create.endpoint, + data + ); }, /** * 배너 수정 */ update: async (bannerId: number, data: BannerUpdateRequest): Promise => { - // TODO: 실제 API 연동 시 아래 코드로 교체 - // const endpoint = BannerApi.update.endpoint.replace(':bannerId', String(bannerId)); - // return apiClient.put(endpoint, data); - - // Mock 구현 - await delay(300); - - const now = new Date().toISOString(); - const index = mockBanners.findIndex((b) => b.id === bannerId); - - if (index === -1) { - throw new Error(`Banner not found: ${bannerId}`); - } - - const existingBanner = mockBanners[index]; - const existingDetail = mockDetails[bannerId]; - - if (!existingBanner || !existingDetail) { - throw new Error(`Banner detail not found: ${bannerId}`); - } - - // 목록 아이템 업데이트 - mockBanners[index] = { - id: existingBanner.id, - name: data.name, - bannerType: data.bannerType, - imageUrl: data.imageUrl, - sortOrder: existingBanner.sortOrder, - startDate: data.startDate, - endDate: data.endDate, - isActive: data.isActive, - createdAt: existingBanner.createdAt, - updatedAt: now, - }; - - // 상세 정보 업데이트 - mockDetails[bannerId] = { - ...data, - id: bannerId, - sortOrder: existingDetail.sortOrder, - createdAt: existingDetail.createdAt, - updatedAt: now, - }; - - return { - code: 'SUCCESS', - message: '배너가 수정되었습니다.', - targetId: bannerId, - responseAt: now, - }; + const endpoint = BannerApi.update.endpoint.replace(':bannerId', String(bannerId)); + return apiClient.put(endpoint, data); }, /** * 배너 삭제 */ delete: async (bannerId: number): Promise => { - // TODO: 실제 API 연동 시 아래 코드로 교체 - // const endpoint = BannerApi.delete.endpoint.replace(':bannerId', String(bannerId)); - // return apiClient.delete(endpoint); - - // Mock 구현 - await delay(300); - - const index = mockBanners.findIndex((b) => b.id === bannerId); - - if (index === -1) { - throw new Error(`Banner not found: ${bannerId}`); - } - - mockBanners.splice(index, 1); - delete mockDetails[bannerId]; - - return { - code: 'SUCCESS', - message: '배너가 삭제되었습니다.', - targetId: bannerId, - responseAt: new Date().toISOString(), - }; + const endpoint = BannerApi.delete.endpoint.replace(':bannerId', String(bannerId)); + return apiClient.delete(endpoint); }, /** - * 배너 순서 변경 + * 배너 상태 변경 (활성/비활성) */ - reorder: async (data: BannerReorderRequest): Promise => { - // TODO: 실제 API 연동 시 아래 코드로 교체 - // return apiClient.put( - // BannerApi.reorder.endpoint, - // data - // ); - - // Mock 구현 - await delay(300); - - const now = new Date().toISOString(); - - // 순서 업데이트 - data.bannerIds.forEach((id, newSortOrder) => { - const bannerIndex = mockBanners.findIndex((b) => b.id === id); - const existing = bannerIndex !== -1 ? mockBanners[bannerIndex] : undefined; - if (existing) { - mockBanners[bannerIndex] = { - id: existing.id, - name: existing.name, - bannerType: existing.bannerType, - imageUrl: existing.imageUrl, - sortOrder: newSortOrder, - startDate: existing.startDate, - endDate: existing.endDate, - isActive: existing.isActive, - createdAt: existing.createdAt, - updatedAt: now, - }; - } - const detail = mockDetails[id]; - if (detail) { - mockDetails[id] = { - ...detail, - sortOrder: newSortOrder, - updatedAt: now, - }; - } - }); - - // 정렬 - mockBanners.sort((a, b) => a.sortOrder - b.sortOrder); + updateStatus: async (bannerId: number, data: BannerUpdateStatusRequest): Promise => { + const endpoint = BannerApi.updateStatus.endpoint.replace(':bannerId', String(bannerId)); + return apiClient.patch(endpoint, data); + }, - return { - code: 'SUCCESS', - message: '배너 순서가 변경되었습니다.', - responseAt: now, - }; + /** + * 배너 정렬순서 변경 + */ + updateSortOrder: async (bannerId: number, data: BannerUpdateSortOrderRequest): Promise => { + const endpoint = BannerApi.updateSortOrder.endpoint.replace(':bannerId', String(bannerId)); + return apiClient.patch(endpoint, data); }, }; - -// ============================================ -// 유틸리티 -// ============================================ - -/** - * Mock API 딜레이 시뮬레이션 - */ -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -// 개발용: Mock 데이터 리셋 -export function resetMockBanners(): void { - mockBanners = [...mockBannerList]; - mockDetails = { ...mockBannerDetails }; - nextId = Math.max(...mockBanners.map((b) => b.id)) + 1; -} diff --git a/src/test/mocks/data.ts b/src/test/mocks/data.ts index a402b2c..909b629 100644 --- a/src/test/mocks/data.ts +++ b/src/test/mocks/data.ts @@ -5,6 +5,13 @@ import type { TastingTagDeleteResponse, TastingTagAlcoholConnectionResponse, TastingTagAlcohol, + BannerListItem, + BannerDetail, + BannerCreateResponse, + BannerUpdateResponse, + BannerDeleteResponse, + BannerUpdateStatusResponse, + BannerUpdateSortOrderResponse, } from '@/types/api'; export const mockTastingTagListItems: TastingTagListItem[] = [ @@ -102,6 +109,96 @@ export const mockAlcoholDisconnectionResponse: TastingTagAlcoholConnectionRespon responseAt: '2024-06-01T00:00:00', }; +// ============================================ +// Banner Mock Data +// ============================================ + +export const mockBannerListItems: BannerListItem[] = [ + { + id: 1, + name: '신년 큐레이션 배너', + bannerType: 'CURATION', + imageUrl: 'https://example.com/banner1.jpg', + sortOrder: 0, + startDate: '2024-01-01T00:00:00', + endDate: '2024-01-31T23:59:59', + isActive: true, + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00', + }, + { + id: 2, + name: '설문조사 배너', + bannerType: 'SURVEY', + imageUrl: 'https://example.com/banner2.jpg', + sortOrder: 1, + startDate: null, + endDate: null, + isActive: false, + createdAt: '2024-02-01T00:00:00', + updatedAt: '2024-02-01T00:00:00', + }, +]; + +export const mockBannerDetail: BannerDetail = { + id: 1, + name: '신년 큐레이션 배너', + nameFontColor: '#ffffff', + descriptionA: '새해 첫 위스키', + descriptionB: '추천 리스트', + descriptionFontColor: '#ffffff', + imageUrl: 'https://example.com/banner1.jpg', + textPosition: 'RT', + targetUrl: '/curations/1', + isExternalUrl: false, + bannerType: 'CURATION', + sortOrder: 0, + startDate: '2024-01-01T00:00:00', + endDate: '2024-01-31T23:59:59', + isActive: true, + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00', +}; + +export const mockBannerCreateResponse: BannerCreateResponse = { + code: 'BANNER_CREATED', + message: '배너가 등록되었습니다.', + targetId: 99, + responseAt: '2024-06-01T00:00:00', +}; + +export const mockBannerUpdateResponse: BannerUpdateResponse = { + code: 'BANNER_UPDATED', + message: '배너가 수정되었습니다.', + targetId: 1, + responseAt: '2024-06-01T00:00:00', +}; + +export const mockBannerDeleteResponse: BannerDeleteResponse = { + code: 'BANNER_DELETED', + message: '배너가 삭제되었습니다.', + targetId: 1, + responseAt: '2024-06-01T00:00:00', +}; + +export const mockBannerUpdateStatusResponse: BannerUpdateStatusResponse = { + code: 'BANNER_STATUS_UPDATED', + message: '배너 상태가 변경되었습니다.', + targetId: 1, + responseAt: '2024-06-01T00:00:00', +}; + +export const mockBannerUpdateSortOrderResponse: BannerUpdateSortOrderResponse = { + code: 'BANNER_SORT_ORDER_UPDATED', + message: '배너 순서가 변경되었습니다.', + targetId: 1, + responseAt: '2024-06-01T00:00:00', +}; + +// ============================================ +// Utility Functions +// ============================================ + export function wrapApiResponse(data: T, meta?: Record) { return { success: true, diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts index 1666f01..03bd556 100644 --- a/src/test/mocks/handlers.ts +++ b/src/test/mocks/handlers.ts @@ -6,6 +6,13 @@ import { mockDeleteResponse, mockAlcoholConnectionResponse, mockAlcoholDisconnectionResponse, + mockBannerListItems, + mockBannerDetail, + mockBannerCreateResponse, + mockBannerUpdateResponse, + mockBannerDeleteResponse, + mockBannerUpdateStatusResponse, + mockBannerUpdateSortOrderResponse, wrapApiResponse, } from './data'; @@ -120,4 +127,108 @@ export const tastingTagHandlers = [ }), ]; -export const handlers = [...tastingTagHandlers]; +// ============================================ +// Banner Handlers +// ============================================ + +const BANNER_BASE = '/admin/api/v1/banners'; + +export const bannerHandlers = [ + // GET 목록 + http.get(BANNER_BASE, ({ request }) => { + const url = new URL(request.url); + const keyword = url.searchParams.get('keyword'); + const bannerType = url.searchParams.get('bannerType'); + const isActive = url.searchParams.get('isActive'); + const size = Number(url.searchParams.get('size') ?? 20); + const page = Number(url.searchParams.get('page') ?? 0); + + let items = mockBannerListItems; + if (keyword) { + items = items.filter((b) => b.name.includes(keyword)); + } + if (bannerType) { + items = items.filter((b) => b.bannerType === bannerType); + } + if (isActive !== null && isActive !== '') { + items = items.filter((b) => b.isActive === (isActive === 'true')); + } + + return HttpResponse.json( + wrapApiResponse(items, { + page, + size, + totalElements: items.length, + totalPages: Math.ceil(items.length / size), + hasNext: false, + }) + ); + }), + + // PATCH 상태 변경 (상세보다 먼저 매칭되도록) + http.patch(`${BANNER_BASE}/:bannerId/status`, ({ params }) => { + return HttpResponse.json( + wrapApiResponse({ + ...mockBannerUpdateStatusResponse, + targetId: Number(params.bannerId), + }) + ); + }), + + // PATCH 정렬순서 변경 + http.patch(`${BANNER_BASE}/:bannerId/sort-order`, ({ params }) => { + return HttpResponse.json( + wrapApiResponse({ + ...mockBannerUpdateSortOrderResponse, + targetId: Number(params.bannerId), + }) + ); + }), + + // GET 상세 + http.get(`${BANNER_BASE}/:bannerId`, ({ params }) => { + const id = Number(params.bannerId); + if (id === mockBannerDetail.id) { + return HttpResponse.json(wrapApiResponse(mockBannerDetail)); + } + return HttpResponse.json( + { + success: false, + code: 404, + data: null, + errors: [{ code: 'BANNER_NOT_FOUND', message: '배너를 찾을 수 없습니다.' }], + meta: {}, + }, + { status: 404 } + ); + }), + + // POST 생성 + http.post(BANNER_BASE, () => { + return HttpResponse.json( + wrapApiResponse(mockBannerCreateResponse) + ); + }), + + // PUT 수정 + http.put(`${BANNER_BASE}/:bannerId`, ({ params }) => { + return HttpResponse.json( + wrapApiResponse({ + ...mockBannerUpdateResponse, + targetId: Number(params.bannerId), + }) + ); + }), + + // DELETE 삭제 + http.delete(`${BANNER_BASE}/:bannerId`, ({ params }) => { + return HttpResponse.json( + wrapApiResponse({ + ...mockBannerDeleteResponse, + targetId: Number(params.bannerId), + }) + ); + }), +]; + +export const handlers = [...tastingTagHandlers, ...bannerHandlers]; diff --git a/src/types/api/banner.api.ts b/src/types/api/banner.api.ts index f5c4147..9f2f610 100644 --- a/src/types/api/banner.api.ts +++ b/src/types/api/banner.api.ts @@ -33,10 +33,15 @@ export const BannerApi = { endpoint: '/admin/api/v1/banners/:bannerId', method: 'DELETE', }, - /** 배너 순서 변경 */ - reorder: { - endpoint: '/admin/api/v1/banners/reorder', - method: 'PUT', + /** 배너 상태 변경 */ + updateStatus: { + endpoint: '/admin/api/v1/banners/:bannerId/status', + method: 'PATCH', + }, + /** 배너 정렬순서 변경 */ + updateSortOrder: { + endpoint: '/admin/api/v1/banners/:bannerId/sort-order', + method: 'PATCH', }, } as const; @@ -48,7 +53,7 @@ export const BannerApi = { * 배너 유형 * @description 배너의 목적에 따른 유형 구분 */ -export type BannerType = 'SURVEY' | 'CURATION' | 'AD' | 'PARTNERSHIP'; +export type BannerType = 'SURVEY' | 'CURATION' | 'AD' | 'PARTNERSHIP' | 'ETC'; /** * 텍스트 위치 @@ -62,6 +67,7 @@ export const BANNER_TYPE_LABELS: Record = { CURATION: '큐레이션', AD: '광고', PARTNERSHIP: '제휴', + ETC: '기타', }; /** 텍스트 위치 레이블 매핑 */ @@ -194,12 +200,12 @@ export interface BannerApiTypes { isExternalUrl: boolean; /** 배너 유형 */ bannerType: BannerType; - /** 노출 시작일시 (nullable = 상시노출) */ + /** 정렬 순서 (optional, 기본값 0) */ + sortOrder?: number; + /** 노출 시작일시 */ startDate: string | null; - /** 노출 종료일시 (nullable = 상시노출) */ + /** 노출 종료일시 */ endDate: string | null; - /** 활성화 상태 */ - isActive: boolean; }; /** 응답 데이터 */ response: { @@ -237,6 +243,8 @@ export interface BannerApiTypes { isExternalUrl: boolean; /** 배너 유형 */ bannerType: BannerType; + /** 정렬 순서 */ + sortOrder: number; /** 노출 시작일시 (nullable = 상시노출) */ startDate: string | null; /** 노출 종료일시 (nullable = 상시노출) */ @@ -270,12 +278,31 @@ export interface BannerApiTypes { responseAt: string; }; }; - /** 배너 순서 변경 */ - reorder: { + /** 배너 상태 변경 */ + updateStatus: { + /** 요청 데이터 */ + request: { + /** 활성화 상태 */ + isActive: boolean; + }; + /** 응답 데이터 */ + response: { + /** 결과 코드 */ + code: string; + /** 결과 메시지 */ + message: string; + /** 배너 ID */ + targetId: number; + /** 응답 시간 */ + responseAt: string; + }; + }; + /** 배너 정렬순서 변경 */ + updateSortOrder: { /** 요청 데이터 */ request: { - /** 배너 ID 목록 (순서대로) */ - bannerIds: number[]; + /** 정렬 순서 (0 이상) */ + sortOrder: number; }; /** 응답 데이터 */ response: { @@ -283,6 +310,8 @@ export interface BannerApiTypes { code: string; /** 결과 메시지 */ message: string; + /** 배너 ID */ + targetId: number; /** 응답 시간 */ responseAt: string; }; @@ -320,8 +349,14 @@ export type BannerUpdateResponse = BannerApiTypes['update']['response']; /** 배너 삭제 응답 데이터 */ export type BannerDeleteResponse = BannerApiTypes['delete']['response']; -/** 배너 순서 변경 요청 데이터 */ -export type BannerReorderRequest = BannerApiTypes['reorder']['request']; +/** 배너 상태 변경 요청 데이터 */ +export type BannerUpdateStatusRequest = BannerApiTypes['updateStatus']['request']; + +/** 배너 상태 변경 응답 데이터 */ +export type BannerUpdateStatusResponse = BannerApiTypes['updateStatus']['response']; + +/** 배너 정렬순서 변경 요청 데이터 */ +export type BannerUpdateSortOrderRequest = BannerApiTypes['updateSortOrder']['request']; -/** 배너 순서 변경 응답 데이터 */ -export type BannerReorderResponse = BannerApiTypes['reorder']['response']; +/** 배너 정렬순서 변경 응답 데이터 */ +export type BannerUpdateSortOrderResponse = BannerApiTypes['updateSortOrder']['response']; From 4dbca41ce49c366cf1255b36d5dc980790665a44 Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Thu, 12 Feb 2026 19:43:36 +0900 Subject: [PATCH 04/10] fix: use dynamic 1-year-later date for always-visible banners Replace hardcoded 2099 end date with getOneYearLaterEnd() to avoid MySQL TIMESTAMP overflow (max 2038-01-19). Co-Authored-By: Claude Opus 4.6 --- src/pages/banners/banner.schema.ts | 30 ++++++++++++------- .../banners/components/BannerExposureCard.tsx | 4 +-- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/pages/banners/banner.schema.ts b/src/pages/banners/banner.schema.ts index 7feb578..9ba917f 100644 --- a/src/pages/banners/banner.schema.ts +++ b/src/pages/banners/banner.schema.ts @@ -8,22 +8,32 @@ import { z } from 'zod'; // 상시 노출 날짜 유틸리티 // ============================================ -/** 상시 노출 종료일 (2099-12-31T23:59:59) */ -export const ALWAYS_VISIBLE_END_DATE = '2099-12-31T23:59:59'; - /** 오늘 날짜 시작 시각 문자열 반환 (YYYY-MM-DDT00:00:00) */ export function getTodayStart(): string { - const now = new Date(); - const y = now.getFullYear(); - const m = String(now.getMonth() + 1).padStart(2, '0'); - const d = String(now.getDate()).padStart(2, '0'); - return `${y}-${m}-${d}T00:00:00`; + return formatDateTime(new Date(), true); +} + +/** 현재로부터 1년 뒤 종료일 반환 (YYYY-MM-DDT23:59:59) */ +export function getOneYearLaterEnd(): string { + const date = new Date(); + date.setFullYear(date.getFullYear() + 1); + return formatDateTime(date, false); } -/** endDate가 상시 노출 기간인지 판별 */ +/** endDate가 현재 시점 기준 6개월 이상 남았으면 상시 노출로 판별 */ export function isAlwaysVisibleDate(endDate: string | null | undefined): boolean { if (!endDate) return false; - return endDate.startsWith('2099'); + const end = new Date(endDate); + const sixMonthsLater = new Date(); + sixMonthsLater.setMonth(sixMonthsLater.getMonth() + 6); + return end >= sixMonthsLater; +} + +function formatDateTime(date: Date, startOfDay: boolean): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}T${startOfDay ? '00:00:00' : '23:59:59'}`; } // ============================================ diff --git a/src/pages/banners/components/BannerExposureCard.tsx b/src/pages/banners/components/BannerExposureCard.tsx index f348971..6bb86a3 100644 --- a/src/pages/banners/components/BannerExposureCard.tsx +++ b/src/pages/banners/components/BannerExposureCard.tsx @@ -6,7 +6,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import type { BannerFormValues } from '../banner.schema'; -import { ALWAYS_VISIBLE_END_DATE, getTodayStart } from '../banner.schema'; +import { getOneYearLaterEnd, getTodayStart } from '../banner.schema'; interface BannerExposureCardProps { form: UseFormReturn; @@ -29,7 +29,7 @@ export function BannerExposureCard({ form }: BannerExposureCardProps) { form.setValue('isAlwaysVisible', !!checked); if (checked) { form.setValue('startDate', getTodayStart()); - form.setValue('endDate', ALWAYS_VISIBLE_END_DATE); + form.setValue('endDate', getOneYearLaterEnd()); } }} /> From fc7ac992dc8678684ed586a100d42936f652061d Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Thu, 12 Feb 2026 19:57:53 +0900 Subject: [PATCH 05/10] style: apply FormField component pattern to banner cards Use FormField wrapper for input validation errors in card components to follow established UI patterns from CLAUDE.md. Also fix Playwright test locator strict mode and form reset timing issues. Co-Authored-By: Claude Opus 4.6 --- e2e/pages/banner-detail.page.ts | 2 +- e2e/specs/curations.spec.ts | 5 +-- .../components/BannerBasicInfoCard.tsx | 16 +++----- .../banners/components/BannerExposureCard.tsx | 17 +++------ .../components/BannerLinkSettingsCard.tsx | 11 ++---- .../components/BannerTextSettingsCard.tsx | 37 ++++++------------- 6 files changed, 27 insertions(+), 61 deletions(-) diff --git a/e2e/pages/banner-detail.page.ts b/e2e/pages/banner-detail.page.ts index e5e203d..db07585 100644 --- a/e2e/pages/banner-detail.page.ts +++ b/e2e/pages/banner-detail.page.ts @@ -27,7 +27,7 @@ export class BannerDetailPage extends BasePage { readonly nameInput = () => this.page.getByPlaceholder('배너명을 입력하세요'); readonly bannerTypeSelect = () => - this.page.locator('button[role="combobox"]').filter({ hasText: /설문조사|큐레이션|광고|제휴|기타|배너 타입 선택/ }); + this.page.locator('button[role="combobox"]').filter({ hasText: /^\s*(설문조사|큐레이션|광고|제휴|기타|배너 타입 선택)\s*$/ }); readonly isActiveSwitch = () => this.page.locator('button#isActive'); diff --git a/e2e/specs/curations.spec.ts b/e2e/specs/curations.spec.ts index 081825a..32a179e 100644 --- a/e2e/specs/curations.spec.ts +++ b/e2e/specs/curations.spec.ts @@ -174,9 +174,8 @@ test.describe('큐레이션 폼 리셋', () => { await listPage.clickFirstRow(); await detailPage.waitForLoadingComplete(); - // 2. 현재 폼 값 저장 - const originalName = await detailPage.nameInput().inputValue(); - expect(originalName).not.toBe(''); // 데이터가 있어야 함 + // 2. 폼 데이터 로딩 대기 (auto-retrying assertion) + await expect(detailPage.nameInput()).not.toHaveValue('', { timeout: 10000 }); // 3. 사이드바에서 "큐레이션 추가" 메뉴 클릭 // 사이드바 메뉴 구조: 큐레이션 관리 > 큐레이션 추가 diff --git a/src/pages/banners/components/BannerBasicInfoCard.tsx b/src/pages/banners/components/BannerBasicInfoCard.tsx index ec1d841..a1747b4 100644 --- a/src/pages/banners/components/BannerBasicInfoCard.tsx +++ b/src/pages/banners/components/BannerBasicInfoCard.tsx @@ -11,6 +11,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { FormField } from '@/components/common/FormField'; import type { BannerFormValues } from '../banner.schema'; import { BANNER_TYPE_LABELS, type BannerType } from '@/types/api'; @@ -28,22 +29,15 @@ export function BannerBasicInfoCard({ form }: BannerBasicInfoCardProps) { 기본 정보 -
- + - {form.formState.errors.name && ( -

- {form.formState.errors.name.message} -

- )} -
+ -
- + -
+
-
- + form.setValue('startDate', e.target.value)} /> -
+ -
- + form.setValue('endDate', e.target.value)} /> -
+
)} - - {form.formState.errors.startDate && ( -

- {form.formState.errors.startDate.message} -

- )}
); diff --git a/src/pages/banners/components/BannerLinkSettingsCard.tsx b/src/pages/banners/components/BannerLinkSettingsCard.tsx index 869b8aa..876eb6c 100644 --- a/src/pages/banners/components/BannerLinkSettingsCard.tsx +++ b/src/pages/banners/components/BannerLinkSettingsCard.tsx @@ -12,6 +12,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { FormField } from '@/components/common/FormField'; import type { BannerFormValues } from '../banner.schema'; import { curationService } from '@/hooks/useCurations'; @@ -42,8 +43,7 @@ export function BannerLinkSettingsCard({ form, curations }: BannerLinkSettingsCa {isCurationType && ( -
- + - {form.formState.errors.curationId && ( -

- {form.formState.errors.curationId.message} -

- )} -
+ )}
diff --git a/src/pages/banners/components/BannerTextSettingsCard.tsx b/src/pages/banners/components/BannerTextSettingsCard.tsx index 373bdd8..d7343d9 100644 --- a/src/pages/banners/components/BannerTextSettingsCard.tsx +++ b/src/pages/banners/components/BannerTextSettingsCard.tsx @@ -1,7 +1,6 @@ import type { UseFormReturn } from 'react-hook-form'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; import { Select, SelectContent, @@ -10,6 +9,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { FormField } from '@/components/common/FormField'; import type { BannerFormValues } from '../banner.schema'; import { TEXT_POSITION_LABELS, type TextPosition } from '@/types/api'; @@ -25,26 +25,23 @@ export function BannerTextSettingsCard({ form }: BannerTextSettingsCardProps) { 텍스트 설정 -
- + -
+ -
- + -
+ -
- + -
+
-
- +
- {form.formState.errors.nameFontColor && ( -

- {form.formState.errors.nameFontColor.message} -

- )} -
+
-
- +
- {form.formState.errors.descriptionFontColor && ( -

- {form.formState.errors.descriptionFontColor.message} -

- )} -
+
From 5d8bc0ff308d440d4ec2a9a23c31f3ecc1392458 Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Thu, 12 Feb 2026 20:22:17 +0900 Subject: [PATCH 06/10] fix: resolve PR review comments from Copilot - Add isActive to BannerCreateRequest type and create mutation - Fix sortOrder to use page offset (page * size + index) for pagination - Add error prop to bannerType and textPosition FormField components - Update test data to include isActive in create request Co-Authored-By: Claude Opus 4.6 --- src/hooks/__tests__/useBanners.test.ts | 2 ++ src/pages/banners/BannerList.tsx | 8 +++++--- src/pages/banners/components/BannerBasicInfoCard.tsx | 2 +- src/pages/banners/components/BannerTextSettingsCard.tsx | 2 +- src/pages/banners/useBannerDetailForm.ts | 2 +- src/types/api/banner.api.ts | 2 ++ 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/hooks/__tests__/useBanners.test.ts b/src/hooks/__tests__/useBanners.test.ts index 2d50440..fe1e39e 100644 --- a/src/hooks/__tests__/useBanners.test.ts +++ b/src/hooks/__tests__/useBanners.test.ts @@ -99,6 +99,7 @@ describe('useBanners hooks', () => { targetUrl: '/test', isExternalUrl: false, bannerType: 'AD', + isActive: true, startDate: null, endDate: null, }); @@ -130,6 +131,7 @@ describe('useBanners hooks', () => { targetUrl: '', isExternalUrl: false, bannerType: 'AD', + isActive: true, startDate: null, endDate: null, }); diff --git a/src/pages/banners/BannerList.tsx b/src/pages/banners/BannerList.tsx index c06e94b..c8f764c 100644 --- a/src/pages/banners/BannerList.tsx +++ b/src/pages/banners/BannerList.tsx @@ -190,10 +190,12 @@ export function BannerListPage() { items.splice(draggedIndex, 1); items.splice(targetIndex, 0, draggedItem); - // 변경된 순서로 개별 API 호출 + // 변경된 순서로 개별 API 호출 (페이지 오프셋 반영) + const pageOffset = page * size; items.forEach((item, index) => { - if (item.sortOrder !== index) { - updateSortOrderMutation.mutate({ bannerId: item.id, data: { sortOrder: index } }); + const newSortOrder = pageOffset + index; + if (item.sortOrder !== newSortOrder) { + updateSortOrderMutation.mutate({ bannerId: item.id, data: { sortOrder: newSortOrder } }); } }); diff --git a/src/pages/banners/components/BannerBasicInfoCard.tsx b/src/pages/banners/components/BannerBasicInfoCard.tsx index a1747b4..c9b5965 100644 --- a/src/pages/banners/components/BannerBasicInfoCard.tsx +++ b/src/pages/banners/components/BannerBasicInfoCard.tsx @@ -37,7 +37,7 @@ export function BannerBasicInfoCard({ form }: BannerBasicInfoCardProps) { /> - + form.setValue('textPosition', value as TextPosition)} diff --git a/src/pages/banners/useBannerDetailForm.ts b/src/pages/banners/useBannerDetailForm.ts index 82d7886..32fd6a2 100644 --- a/src/pages/banners/useBannerDetailForm.ts +++ b/src/pages/banners/useBannerDetailForm.ts @@ -126,6 +126,7 @@ export function useBannerDetailForm(id: string | undefined): UseBannerDetailForm const commonFields = { name: data.name, bannerType: data.bannerType, + isActive: data.isActive, imageUrl: data.imageUrl || options?.imagePreviewUrl || '', descriptionA: data.descriptionA ?? '', descriptionB: data.descriptionB ?? '', @@ -145,7 +146,6 @@ export function useBannerDetailForm(id: string | undefined): UseBannerDetailForm id: bannerId, data: { ...commonFields, - isActive: data.isActive, sortOrder: bannerData?.sortOrder ?? 0, }, }); diff --git a/src/types/api/banner.api.ts b/src/types/api/banner.api.ts index 9f2f610..abca18c 100644 --- a/src/types/api/banner.api.ts +++ b/src/types/api/banner.api.ts @@ -206,6 +206,8 @@ export interface BannerApiTypes { startDate: string | null; /** 노출 종료일시 */ endDate: string | null; + /** 활성화 상태 */ + isActive: boolean; }; /** 응답 데이터 */ response: { From e89723adb747f685483d9c3f8a4c855171bad65c Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Thu, 12 Feb 2026 20:25:36 +0900 Subject: [PATCH 07/10] chore: bump version --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index f8f3c08..140333f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.18 +1.0.19 diff --git a/package.json b/package.json index 323d16d..d86edc0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bottlenote-admin", "private": true, - "version": "1.0.18", + "version": "1.0.19", "type": "module", "scripts": { "dev:local": "./scripts/ensure-env.sh local && vite", From 7076cab52acd770bf01c1e1fb6b27b3a455dc464 Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Thu, 12 Feb 2026 20:57:04 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20=EC=9C=84=EC=8A=A4=ED=82=A4=20?= =?UTF-8?q?=ED=8F=BC=20=ED=95=84=EC=88=98/=EC=84=A0=ED=83=9D=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스키마: distilleryId, age, cask, description, imageUrl을 선택 필드로 변경 - 기본값: age/cask/description의 초기값을 '-'로 설정하여 미입력 상태 구분 - API 연동: 빈 값을 '-'로 변환하여 백엔드 호환성 유지 - UI: 선택 필드의 required 라벨 제거로 필수 여부 명확화 필수 필드 (6개): 한글명, 영문명, 카테고리, 지역, 도수, 용량 선택 필드 (4개): 증류소, 숙성연도, 캐스크, 설명 Co-Authored-By: Claude Opus 4.6 --- .../whisky/components/WhiskyBasicInfoCard.tsx | 8 +++--- src/pages/whisky/useWhiskyDetailForm.ts | 28 +++++++++++-------- src/pages/whisky/whisky.schema.ts | 10 +++---- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/pages/whisky/components/WhiskyBasicInfoCard.tsx b/src/pages/whisky/components/WhiskyBasicInfoCard.tsx index 22868a8..35fa792 100644 --- a/src/pages/whisky/components/WhiskyBasicInfoCard.tsx +++ b/src/pages/whisky/components/WhiskyBasicInfoCard.tsx @@ -106,7 +106,7 @@ export function WhiskyBasicInfoCard({ emptyMessage="지역을 찾을 수 없습니다." /> - + setValue('distilleryId', v ? Number(v) : 0)} @@ -128,7 +128,7 @@ export function WhiskyBasicInfoCard({ placeholder="예: 40" /> - +
@@ -138,13 +138,13 @@ export function WhiskyBasicInfoCard({ - +
{/* 설명 */} - +