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 규격) 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/e2e/pages/banner-detail.page.ts b/e2e/pages/banner-detail.page.ts new file mode 100644 index 0000000..db07585 --- /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: /^\s*(설문조사|큐레이션|광고|제휴|기타|배너 타입 선택)\s*$/ }); + + 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/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/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", 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..fe1e39e --- /dev/null +++ b/src/hooks/__tests__/useBanners.test.ts @@ -0,0 +1,237 @@ +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', + isActive: true, + 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', + isActive: true, + 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..c8f764c 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,14 @@ export function BannerListPage() { items.splice(draggedIndex, 1); items.splice(targetIndex, 0, draggedItem); - // API 호출 - const bannerIds = items.map((item) => item.id); - reorderMutation.mutate({ bannerIds }); + // 변경된 순서로 개별 API 호출 (페이지 오프셋 반영) + const pageOffset = page * size; + items.forEach((item, index) => { + const newSortOrder = pageOffset + index; + if (item.sortOrder !== newSortOrder) { + updateSortOrderMutation.mutate({ bannerId: item.id, data: { sortOrder: newSortOrder } }); + } + }); setDraggedItem(null); }; diff --git a/src/pages/banners/banner.schema.ts b/src/pages/banners/banner.schema.ts index 0e31323..9ba917f 100644 --- a/src/pages/banners/banner.schema.ts +++ b/src/pages/banners/banner.schema.ts @@ -4,10 +4,46 @@ import { z } from 'zod'; +// ============================================ +// 상시 노출 날짜 유틸리티 +// ============================================ + +/** 오늘 날짜 시작 시각 문자열 반환 (YYYY-MM-DDT00:00:00) */ +export function getTodayStart(): string { + 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가 현재 시점 기준 6개월 이상 남았으면 상시 노출로 판별 */ +export function isAlwaysVisibleDate(endDate: string | null | undefined): boolean { + if (!endDate) return false; + 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'}`; +} + +// ============================================ +// 폼 스키마 +// ============================================ + /** 배너 폼 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 +58,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 +93,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..c9b5965 --- /dev/null +++ b/src/pages/banners/components/BannerBasicInfoCard.tsx @@ -0,0 +1,77 @@ +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 { FormField } from '@/components/common/FormField'; + +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.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..7cbce8e --- /dev/null +++ b/src/pages/banners/components/BannerExposureCard.tsx @@ -0,0 +1,64 @@ +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 { FormField } from '@/components/common/FormField'; + +import type { BannerFormValues } from '../banner.schema'; +import { getOneYearLaterEnd, 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', getOneYearLaterEnd()); + } + }} + /> + +
+ + {!isAlwaysVisible && ( +
+ + form.setValue('startDate', e.target.value)} + /> + + + + form.setValue('endDate', e.target.value)} + /> + +
+ )} +
+
+ ); +} 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..876eb6c --- /dev/null +++ b/src/pages/banners/components/BannerLinkSettingsCard.tsx @@ -0,0 +1,103 @@ +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 { FormField } from '@/components/common/FormField'; + +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 && ( + + + + )} + +
+ +
+ {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..b4e9f2e --- /dev/null +++ b/src/pages/banners/components/BannerTextSettingsCard.tsx @@ -0,0 +1,96 @@ +import type { UseFormReturn } from 'react-hook-form'; + +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + 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'; + +interface BannerTextSettingsCardProps { + form: UseFormReturn; +} + +export function BannerTextSettingsCard({ form }: BannerTextSettingsCardProps) { + return ( + + + 텍스트 설정 + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + +
+
+ +
+ +
+ + + ); +} diff --git a/src/pages/banners/useBannerDetailForm.ts b/src/pages/banners/useBannerDetailForm.ts index f19c0f5..32fd6a2 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, + 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: 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, + sortOrder: bannerData?.sortOrder ?? 0, + }, + }); } }; 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({ - +
{/* 설명 */} - +