diff --git a/src/api/banner.ts b/src/api/banner.ts index f03a09b7d..4ecdb5bb6 100644 --- a/src/api/banner.ts +++ b/src/api/banner.ts @@ -1,6 +1,7 @@ import axios from '@/utils/axios'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { z } from 'zod'; +import { uploadFileForId } from './file'; export const bannerAdminListItemSchema = z.object({ id: z.number(), @@ -39,7 +40,59 @@ export const bannerAdminListSchema = z.object({ bannerList: z.array(bannerAdminListItemSchema), }); -export type bannerType = 'MAIN' | 'PROGRAM' | 'LINE' | 'POPUP' | 'MAIN_BOTTOM'; +// 공통 배너 타입 정의 +export type CommonBannerType = + | 'HOME_TOP' + | 'HOME_BOTTOM' + | 'PROGRAM' + | 'MY_PAGE'; + +// 공통 배너 아이템 스키마 (노출 중 - type 필수) +export const commonBannerAdminListItemSchema = z.object({ + type: z.enum(['HOME_TOP', 'HOME_BOTTOM', 'PROGRAM', 'MY_PAGE']), + commonBannerId: z.number(), + title: z.string().nullable().optional(), + landingUrl: z.string().nullable().optional(), + startDate: z.string().nullable().optional(), + endDate: z.string().nullable().optional(), + isVisible: z.boolean().nullable().optional(), +}); + +// 공통 배너 아이템 스키마 (전체 - type optional) +export const commonBannerAdminAllItemSchema = z.object({ + commonBannerId: z.number(), + title: z.string().nullable().optional(), + landingUrl: z.string().nullable().optional(), + startDate: z.string().nullable().optional(), + endDate: z.string().nullable().optional(), + isVisible: z.boolean().nullable().optional(), +}); + +// 공통 배너 리스트 스키마 - 타입별로 그룹화된 객체 (노출 중) +export const commonBannerAdminListSchema = z.object({ + commonBannerList: z.record(z.array(commonBannerAdminListItemSchema)), +}); + +// 공통 배너 리스트 스키마 - 배열 형식 (전체) +export const commonBannerAdminArrayListSchema = z.object({ + commonBannerList: z.array(commonBannerAdminAllItemSchema), +}); + +export type CommonBannerAdminListItemType = z.infer< + typeof commonBannerAdminListItemSchema +>; + +export type CommonBannerAdminAllItemType = z.infer< + typeof commonBannerAdminAllItemSchema +>; + +export type bannerType = + | 'MAIN' + | 'PROGRAM' + | 'LINE' + | 'POPUP' + | 'MAIN_BOTTOM' + | 'COMMON'; export const getBnnerListForAdminQueryKey = (type: bannerType) => [ 'banner', @@ -47,6 +100,19 @@ export const getBnnerListForAdminQueryKey = (type: bannerType) => [ type, ]; +export const getCommonBannerForAdminQueryKey = () => [ + 'banner', + 'admin', + 'common', +]; + +export const getActiveBannersForAdminQueryKey = () => [ + 'banner', + 'admin', + 'common', + 'active', +]; + export const useGetBannerListForAdmin = ({ type }: { type: bannerType }) => { return useQuery({ queryKey: getBnnerListForAdminQueryKey(type), @@ -61,6 +127,34 @@ export const useGetBannerListForAdmin = ({ type }: { type: bannerType }) => { }); }; +// 전체 공통 배너 조회 +export const useGetCommonBannerForAdmin = ({ + enabled, +}: { enabled?: boolean } = {}) => { + return useQuery({ + queryKey: getCommonBannerForAdminQueryKey(), + queryFn: async () => { + const res = await axios('/admin/common-banner', {}); + return commonBannerAdminArrayListSchema.parse(res.data.data); + }, + enabled, + }); +}; + +// 노출 중인 공통 배너만 조회 +export const useGetActiveBannersForAdmin = ({ + enabled, +}: { enabled?: boolean } = {}) => { + return useQuery({ + queryKey: getActiveBannersForAdminQueryKey(), + queryFn: async () => { + const res = await axios('/admin/common-banner/visible', {}); + return commonBannerAdminListSchema.parse(res.data.data); + }, + enabled, + }); +}; + export const bannerAdminDetailSchema = z.object({ bannerAdminDetailVo: bannerAdminListItemSchema, }); @@ -202,6 +296,14 @@ export const useDeleteBannerForAdmin = ({ queryClient.invalidateQueries({ queryKey: getBannerDetailForAdminQueryKey(data.bannerId, data.type), }); + if (data.type === 'COMMON') { + queryClient.invalidateQueries({ + queryKey: getCommonBannerForAdminQueryKey(), + }); + queryClient.invalidateQueries({ + queryKey: getActiveBannersForAdminQueryKey(), + }); + } successCallback?.(); }, onError: (error) => { @@ -230,6 +332,432 @@ export const bannerUserListSchema = z.object({ bannerList: z.array(bannerUserListItemSchema), }); +const maybeUploadCommonBanner = (file: File | null): Promise => + file + ? uploadFileForId({ file, type: 'COMMON_BANNER' }) + : Promise.resolve(null); + +export type CommonBannerDetailInfo = { + type: CommonBannerType; + agentType: 'PC' | 'MOBILE'; + fileId: number; + commonBannerDetailId?: number; + fileUrl?: string; +}; + +export type CommonBannerDetailResponse = { + commonBanner: { + commonBannerId: number; + title: string; + landingUrl: string; + startDate: string; + endDate: string; + isVisible: boolean; + }; + commonBannerDetailList: CommonBannerDetailInfo[]; +}; + +export type CommonBannerFormValue = { + title: string; + landingUrl: string; + isVisible: boolean; + startDate: string; + endDate: string; + types: Record; + homePcFile: File | null; + homeMobileFile: File | null; + programPcFile: File | null; + programMobileFile: File | null; + // 수정 시 기존 fileId 보존용 + homePcFileId?: number | null; + homeMobileFileId?: number | null; + programPcFileId?: number | null; + programMobileFileId?: number | null; + // 수정 시 기존 이미지 미리보기용 + homePcFileUrl?: string | null; + homeMobileFileUrl?: string | null; + programPcFileUrl?: string | null; + programMobileFileUrl?: string | null; +}; + +export const usePostCommonBannerForAdmin = ({ + successCallback, + errorCallback, +}: { + successCallback?: () => void; + errorCallback?: (error: Error) => void; +}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (form: CommonBannerFormValue) => { + const { types } = form; + const needsHome = types.HOME_TOP || types.HOME_BOTTOM; + const needsProgram = types.PROGRAM; + const needsMyPage = types.MY_PAGE; + + // 필요한 파일만 병렬 업로드 + const [ + homePcFileId, + homeMobileFileId, + programPcFileId, + programMobileFileId, + ] = await Promise.all([ + needsHome + ? maybeUploadCommonBanner(form.homePcFile) + : Promise.resolve(null), + needsHome || needsMyPage + ? maybeUploadCommonBanner(form.homeMobileFile) + : Promise.resolve(null), + needsProgram + ? maybeUploadCommonBanner(form.programPcFile) + : Promise.resolve(null), + needsProgram + ? maybeUploadCommonBanner(form.programMobileFile) + : Promise.resolve(null), + ]); + + const commonBannerDetailInfoList: CommonBannerDetailInfo[] = []; + + if (types.HOME_TOP) { + if (homePcFileId) + commonBannerDetailInfoList.push({ + type: 'HOME_TOP', + agentType: 'PC', + fileId: homePcFileId, + }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ + type: 'HOME_TOP', + agentType: 'MOBILE', + fileId: homeMobileFileId, + }); + } + + if (types.HOME_BOTTOM) { + if (homePcFileId) + commonBannerDetailInfoList.push({ + type: 'HOME_BOTTOM', + agentType: 'PC', + fileId: homePcFileId, + }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ + type: 'HOME_BOTTOM', + agentType: 'MOBILE', + fileId: homeMobileFileId, + }); + } + + if (types.PROGRAM) { + if (programPcFileId) + commonBannerDetailInfoList.push({ + type: 'PROGRAM', + agentType: 'PC', + fileId: programPcFileId, + }); + if (programMobileFileId) + commonBannerDetailInfoList.push({ + type: 'PROGRAM', + agentType: 'MOBILE', + fileId: programMobileFileId, + }); + } + + if (types.MY_PAGE) { + if (homeMobileFileId) + commonBannerDetailInfoList.push({ + type: 'MY_PAGE', + agentType: 'PC', + fileId: homeMobileFileId, + }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ + type: 'MY_PAGE', + agentType: 'MOBILE', + fileId: homeMobileFileId, + }); + } + + const res = await axios.post('/admin/common-banner', { + commonBannerInfo: { + title: form.title, + landingUrl: form.landingUrl, + startDate: form.startDate + ? new Date(form.startDate).toISOString() + : null, + endDate: form.endDate ? new Date(form.endDate).toISOString() : null, + isVisible: form.isVisible, + }, + commonBannerDetailInfoList, + }); + + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getCommonBannerForAdminQueryKey(), + }); + queryClient.invalidateQueries({ + queryKey: getActiveBannersForAdminQueryKey(), + }); + successCallback?.(); + }, + onError: (error: Error) => { + errorCallback?.(error); + }, + }); +}; + +// 공통 배너 상세 조회 +export const getCommonBannerDetailForAdminQueryKey = ( + commonBannerId: number, +) => ['banner', 'admin', 'common', 'detail', commonBannerId]; + +export const useGetCommonBannerDetailForAdmin = ({ + commonBannerId, +}: { + commonBannerId: number; +}) => { + return useQuery({ + queryKey: getCommonBannerDetailForAdminQueryKey(commonBannerId), + queryFn: async () => { + const res = await axios(`/admin/common-banner/${commonBannerId}`); + return res.data.data as CommonBannerDetailResponse; + }, + refetchOnWindowFocus: false, + }); +}; + +// 공통 배너 수정 +export const useEditCommonBannerForAdmin = ({ + successCallback, + errorCallback, +}: { + successCallback?: () => void; + errorCallback?: (error: Error) => void; +}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + commonBannerId, + form, + }: { + commonBannerId: number; + form: CommonBannerFormValue; + }) => { + const { types } = form; + const needsHome = types.HOME_TOP || types.HOME_BOTTOM; + const needsProgram = types.PROGRAM; + const needsMyPage = types.MY_PAGE; + + // 새 파일이 있으면 업로드, 없으면 기존 fileId 유지 + const [ + homePcFileId, + homeMobileFileId, + programPcFileId, + programMobileFileId, + ] = await Promise.all([ + needsHome && form.homePcFile + ? maybeUploadCommonBanner(form.homePcFile) + : Promise.resolve(form.homePcFileId ?? null), + (needsHome || needsMyPage) && form.homeMobileFile + ? maybeUploadCommonBanner(form.homeMobileFile) + : Promise.resolve(form.homeMobileFileId ?? null), + needsProgram && form.programPcFile + ? maybeUploadCommonBanner(form.programPcFile) + : Promise.resolve(form.programPcFileId ?? null), + needsProgram && form.programMobileFile + ? maybeUploadCommonBanner(form.programMobileFile) + : Promise.resolve(form.programMobileFileId ?? null), + ]); + + const commonBannerDetailInfoList: CommonBannerDetailInfo[] = []; + + if (types.HOME_TOP) { + if (homePcFileId) + commonBannerDetailInfoList.push({ + type: 'HOME_TOP', + agentType: 'PC', + fileId: homePcFileId, + }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ + type: 'HOME_TOP', + agentType: 'MOBILE', + fileId: homeMobileFileId, + }); + } + + if (types.HOME_BOTTOM) { + if (homePcFileId) + commonBannerDetailInfoList.push({ + type: 'HOME_BOTTOM', + agentType: 'PC', + fileId: homePcFileId, + }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ + type: 'HOME_BOTTOM', + agentType: 'MOBILE', + fileId: homeMobileFileId, + }); + } + + if (types.PROGRAM) { + if (programPcFileId) + commonBannerDetailInfoList.push({ + type: 'PROGRAM', + agentType: 'PC', + fileId: programPcFileId, + }); + if (programMobileFileId) + commonBannerDetailInfoList.push({ + type: 'PROGRAM', + agentType: 'MOBILE', + fileId: programMobileFileId, + }); + } + + if (types.MY_PAGE) { + if (homeMobileFileId) + commonBannerDetailInfoList.push({ + type: 'MY_PAGE', + agentType: 'PC', + fileId: homeMobileFileId, + }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ + type: 'MY_PAGE', + agentType: 'MOBILE', + fileId: homeMobileFileId, + }); + } + + const res = await axios.patch(`/admin/common-banner/${commonBannerId}`, { + commonBannerInfo: { + title: form.title, + landingUrl: form.landingUrl, + startDate: form.startDate + ? new Date(form.startDate).toISOString() + : null, + endDate: form.endDate ? new Date(form.endDate).toISOString() : null, + isVisible: form.isVisible, + }, + commonBannerDetailInfoList, + }); + + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getCommonBannerForAdminQueryKey(), + }); + queryClient.invalidateQueries({ + queryKey: getActiveBannersForAdminQueryKey(), + }); + successCallback?.(); + }, + onError: (error: Error) => { + errorCallback?.(error); + }, + }); +}; + +// 통합 배너 노출 여부 토글 +export const useToggleCommonBannerVisibility = ({ + successCallback, + errorCallback, +}: { + successCallback?: () => void; + errorCallback?: (error: Error) => void; +} = {}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + commonBannerId, + isVisible, + }: { + commonBannerId: number; + isVisible: boolean; + }) => { + const res = await axios.patch(`/admin/common-banner/${commonBannerId}`, { + commonBannerInfo: { isVisible }, + }); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getCommonBannerForAdminQueryKey(), + }); + queryClient.invalidateQueries({ + queryKey: getActiveBannersForAdminQueryKey(), + }); + successCallback?.(); + }, + onError: (error: Error) => { + errorCallback?.(error); + }, + }); +}; + +// 만료된 통합 배너 일괄 업데이트 +export const useUpdateExpiredCommonBanners = ({ + successCallback, + errorCallback, +}: { + successCallback?: () => void; + errorCallback?: (error: Error) => void; +} = {}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + const res = await axios.patch('/admin/common-banner/expired'); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getCommonBannerForAdminQueryKey(), + }); + queryClient.invalidateQueries({ + queryKey: getActiveBannersForAdminQueryKey(), + }); + successCallback?.(); + }, + onError: (error: Error) => { + errorCallback?.(error); + }, + }); +}; + +// 통합 배너 삭제 +export const useDeleteCommonBannerForAdmin = ({ + successCallback, + errorCallback, +}: { + successCallback?: () => void; + errorCallback?: (error: Error) => void; +}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (commonBannerId: number) => { + const res = await axios.delete(`/admin/common-banner/${commonBannerId}`); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getCommonBannerForAdminQueryKey(), + }); + queryClient.invalidateQueries({ + queryKey: getActiveBannersForAdminQueryKey(), + }); + successCallback?.(); + }, + onError: (error: Error) => { + errorCallback?.(error); + }, + }); +}; + export const useGetBannerListForUser = ({ type }: { type: bannerType }) => { return useQuery({ queryKey: ['banner', type], @@ -243,3 +771,61 @@ export const useGetBannerListForUser = ({ type }: { type: bannerType }) => { }, }); }; + +// 유저단 공통 배너 API +const commonBannerUserItemRawSchema = z.object({ + type: z.string(), + agentType: z.enum(['PC', 'MOBILE']), + title: z.string().nullable().optional(), + landingUrl: z.string().nullable().optional(), + fileUrl: z.string().nullable().optional(), +}); + +const commonBannerUserListRawSchema = z.object({ + commonBannerList: z.array(commonBannerUserItemRawSchema), +}); + +export type CommonBannerUserItem = { + title: string | null | undefined; + landingUrl: string | null | undefined; + imgUrl: string | null | undefined; + mobileImgUrl: string | null | undefined; +}; + +export const useGetCommonBannerListForUser = ({ + type, +}: { + type: CommonBannerType; +}) => { + return useQuery({ + queryKey: ['common-banner', type], + queryFn: async () => { + const res = await axios('/common-banner', { + params: { type }, + }); + const parsed = commonBannerUserListRawSchema.parse(res.data.data); + + // PC/MOBILE 아이템을 같은 title+landingUrl 기준으로 그룹핑 + const groupMap = new Map(); + for (const item of parsed.commonBannerList) { + const key = `${item.title ?? ''}::${item.landingUrl ?? ''}`; + if (!groupMap.has(key)) { + groupMap.set(key, { + title: item.title, + landingUrl: item.landingUrl, + imgUrl: null, + mobileImgUrl: null, + }); + } + const group = groupMap.get(key)!; + if (item.agentType === 'PC') { + group.imgUrl = item.fileUrl; + } else { + group.mobileImgUrl = item.fileUrl; + } + } + + return Array.from(groupMap.values()); + }, + }); +}; diff --git a/src/api/file.ts b/src/api/file.ts index f64c9b7e2..ebc74afed 100644 --- a/src/api/file.ts +++ b/src/api/file.ts @@ -15,6 +15,7 @@ export const fileType = z.enum([ 'BLOG_BANNER', 'CURATION_ITEM', 'BANNER_MAIN_BOTTOM', + 'COMMON_BANNER', ]); export type FileType = z.infer; @@ -50,3 +51,23 @@ export async function uploadFile({ return fileUrl; } + +export async function uploadFileForId({ + file, + type, +}: { + file: File; + type: FileType; +}) { + const formData = new FormData(); + formData.append('file', file); + + const res = await axios.post('/file', formData, { + params: { type }, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return res.data.data.fileId as number; +} diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx index 4d2f41ad4..0b40d2da5 100644 --- a/src/app/admin/AdminSidebar.tsx +++ b/src/app/admin/AdminSidebar.tsx @@ -61,14 +61,6 @@ const navData = [ name: '홈 큐레이션 관리', url: '/admin/home/curation', }, - { - name: '홈 상단 배너 관리', - url: '/admin/home/main-banners', - }, - { - name: '홈 하단 배너 관리', - url: '/admin/home/bottom-banners', - }, ], }, { @@ -79,8 +71,8 @@ const navData = [ url: '/admin/banner/top-bar-banners', }, { - name: '프로그램 배너 관리', - url: '/admin/banner/program-banners', + name: '통합 배너 관리', + url: '/admin/banner/common-banners', }, { name: '팝업 관리', diff --git a/src/app/admin/banner/common-banners/[id]/edit/page.tsx b/src/app/admin/banner/common-banners/[id]/edit/page.tsx new file mode 100644 index 000000000..5604ac85a --- /dev/null +++ b/src/app/admin/banner/common-banners/[id]/edit/page.tsx @@ -0,0 +1,27 @@ +'use client'; + +import LoadingContainer from '@/common/loading/LoadingContainer'; +import CommonBannerInputContent from '@/domain/admin/banner/common-banner/CommonBannerInputContent'; +import useCommonBannerEdit from '@/domain/admin/pages/banner/common-banner/useCommonBannerEdit'; +import EditorTemplate from '@/domain/admin/program/ui/editor/EditorTemplate'; + +const CommonBannerEditPage = () => { + const { value, setValue, isLoading, handleSubmit } = useCommonBannerEdit(); + + if (isLoading || !value) { + return ; + } + + return ( + + + + ); +}; + +export default CommonBannerEditPage; diff --git a/src/app/admin/banner/common-banners/new/page.tsx b/src/app/admin/banner/common-banners/new/page.tsx new file mode 100644 index 000000000..b6063d72e --- /dev/null +++ b/src/app/admin/banner/common-banners/new/page.tsx @@ -0,0 +1,22 @@ +'use client'; + +import CommonBannerInputContent from '@/domain/admin/banner/common-banner/CommonBannerInputContent'; +import useCommonBannerCreate from '@/domain/admin/pages/banner/common-banner/useCommonBannerCreate'; +import EditorTemplate from '@/domain/admin/program/ui/editor/EditorTemplate'; + +const CommonBannerCreatePage = () => { + const { value, setValue, handleSubmit } = useCommonBannerCreate(); + + return ( + + + + ); +}; + +export default CommonBannerCreatePage; diff --git a/src/app/admin/banner/common-banners/page.tsx b/src/app/admin/banner/common-banners/page.tsx new file mode 100644 index 000000000..e54305131 --- /dev/null +++ b/src/app/admin/banner/common-banners/page.tsx @@ -0,0 +1,201 @@ +'use client'; + +import { useMemo } from 'react'; + +import { CommonBannerAdminAllItemType } from '@/api/banner'; +import WarningModal from '@/common/alert/WarningModal'; +import EmptyContainer from '@/common/container/EmptyContainer'; +import LoadingContainer from '@/common/loading/LoadingContainer'; +import ActiveBannerTable from '@/domain/admin/banner/common-banner/ActiveBannerTable'; +import CommonBannerVisibilityToggle from '@/domain/admin/banner/common-banner/CommonBannerVisibilityToggle'; +import useCommonBanners from '@/domain/admin/pages/banner/common-banner/useCommonBanners'; +import TableLayout from '@/domain/admin/ui/table/TableLayout'; +import dayjs from '@/lib/dayjs'; +import { DataGrid, GridColDef, GridRowParams } from '@mui/x-data-grid'; +import { Pencil, RefreshCw, Trash } from 'lucide-react'; +import Link from 'next/link'; + +const CommonBannersPage = () => { + const { + activeTab, + setActiveTab, + isLoading, + error, + groupedActiveBanners, + allBanners, + isDeleteModalShown, + setIsDeleteModalShown, + bannerIdForDeleting, + handleDeleteButtonClicked, + deleteCommonBanner, + isUpdatingExpired, + updateExpiredBanners, + } = useCommonBanners(); + + // 전체 탭용 컬럼 + const allColumns = useMemo< + GridColDef[] + >( + () => [ + { + field: 'title', + headerName: '제목', + flex: 1, + valueGetter: (_, row) => row.title || '-', + }, + { + field: 'landingUrl', + headerName: '링크', + width: 300, + valueGetter: (_, row) => row.landingUrl || '-', + }, + { + field: 'isVisible', + headerName: '노출 여부', + width: 150, + renderCell: ({ row }) => ( + + ), + }, + { + field: 'date', + headerName: '노출 기간', + width: 250, + valueGetter: (_, row) => + `${row.startDate ? dayjs(row.startDate).format('YYYY-MM-DD') : '미지정'} ~ ${row.endDate ? dayjs(row.endDate).format('YYYY-MM-DD') : '미지정'}`, + }, + { + field: 'management', + type: 'actions', + headerName: '관리', + width: 100, + getActions: ( + params: GridRowParams< + CommonBannerAdminAllItemType & { uniqueId: string } + >, + ) => { + const id = params.row.commonBannerId; + + return [ + + + , + handleDeleteButtonClicked(Number(id))} + />, + ]; + }, + }, + ], + [], + ); + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + // 노출 중 탭: 커스텀 그룹핑 테이블 + if (activeTab === 'active') { + return ( +
+ +
+ ); + } + + // 전체 탭 + if (activeTab === 'all') { + if (allBanners.length === 0) { + return ; + } + + return ( + row.uniqueId} + columns={allColumns} + hideFooter + /> + ); + } + + return ; + }; + + return ( + <> + +
+ + | + +
+ + + } + > +
{renderContent()}
+
+ + bannerIdForDeleting && deleteCommonBanner(bannerIdForDeleting) + } + onCancel={() => setIsDeleteModalShown(false)} + confirmText="삭제" + /> + + ); +}; + +export default CommonBannersPage; diff --git a/src/domain/admin/banner/common-banner/ActiveBannerTable.tsx b/src/domain/admin/banner/common-banner/ActiveBannerTable.tsx new file mode 100644 index 000000000..a86723c5a --- /dev/null +++ b/src/domain/admin/banner/common-banner/ActiveBannerTable.tsx @@ -0,0 +1,132 @@ +import { + CommonBannerAdminListItemType, + CommonBannerType, +} from '@/api/banner'; +import dayjs from '@/lib/dayjs'; +import { Pencil, Trash } from 'lucide-react'; +import Link from 'next/link'; + +// 배너 위치 타입별 한글 이름 매핑 +const BANNER_TYPE_LABELS: Record = { + HOME_TOP: '홈 상단', + HOME_BOTTOM: '홈 하단', + PROGRAM: '프로그램', + MY_PAGE: '마이페이지', +}; + +const BANNER_TYPE_ORDER: CommonBannerType[] = [ + 'HOME_TOP', + 'HOME_BOTTOM', + 'PROGRAM', + 'MY_PAGE', +]; + +interface ActiveBannerTableProps { + groupedData: Record; + onDeleteClick: (bannerId: number) => void; +} + +const ActiveBannerTable = ({ + groupedData, + onDeleteClick, +}: ActiveBannerTableProps) => { + return ( + + + + + + + + + + + + + {BANNER_TYPE_ORDER.map((type) => { + const banners = groupedData[type] ?? []; + const label = BANNER_TYPE_LABELS[type]; + + if (banners.length === 0) { + // 배너 없는 위치: 빈 행으로 표시 + return ( + + + + + ); + } + + return banners.map((banner, index) => ( + + {/* 첫 번째 행에만 위치명 표시 */} + {index === 0 && ( + + )} + + + + + + + )); + })} + +
+ 배너 위치 + + 제목 + + 랜딩 URL + + 노출 시작일 + + 노출 종료일 + + 관리 +
+ {label} + + - +
+ {label} + + {banner.title || '-'} + + {banner.landingUrl || '-'} + + {dayjs(banner.startDate).format('YYYY-MM-DD\nHH:mm:ss')} + + {dayjs(banner.endDate).format('YYYY-MM-DD\nHH:mm:ss')} + +
+ + + + + onDeleteClick(Number(banner.commonBannerId)) + } + /> +
+
+ ); +}; + +export default ActiveBannerTable; diff --git a/src/domain/admin/banner/common-banner/CommonBannerInputContent.tsx b/src/domain/admin/banner/common-banner/CommonBannerInputContent.tsx new file mode 100644 index 000000000..27a5d34c9 --- /dev/null +++ b/src/domain/admin/banner/common-banner/CommonBannerInputContent.tsx @@ -0,0 +1,281 @@ +'use client'; + +import { CommonBannerFormValue, CommonBannerType } from '@/api/banner'; +import { useRef } from 'react'; + +interface Props { + value: CommonBannerFormValue; + onChange: (value: CommonBannerFormValue) => void; +} + +const BANNER_TYPE_LABELS: Record = { + HOME_TOP: '홈 상단', + HOME_BOTTOM: '홈 하단', + PROGRAM: '프로그램 페이지', + MY_PAGE: '마이페이지', +}; + +const BANNER_TYPES: CommonBannerType[] = [ + 'HOME_TOP', + 'HOME_BOTTOM', + 'PROGRAM', + 'MY_PAGE', +]; + +const ImageUploadBox = ({ + label, + file, + previewUrl, + onChange, +}: { + label: string; + file: File | null; + previewUrl?: string | null; + onChange: (file: File | null) => void; +}) => { + const inputRef = useRef(null); + const preview = file ? URL.createObjectURL(file) : previewUrl; + + return ( +
+ + {label} + +
inputRef.current?.click()} + > + {preview ? ( + <> + {label} + + + ) : ( + + + )} + { + const f = e.target.files?.[0] ?? null; + onChange(f); + // reset so same file can be re-selected + e.target.value = ''; + }} + /> +
+
+ ); +}; + +const FormRow = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( +
+ + {label} + +
{children}
+
+); + +const CommonBannerInputContent = ({ value, onChange }: Props) => { + const set = (partial: Partial) => + onChange({ ...value, ...partial }); + + const setType = (type: CommonBannerType, checked: boolean) => + onChange({ ...value, types: { ...value.types, [type]: checked } }); + + // 홈 위치(HOME_TOP | HOME_BOTTOM) 선택 여부 + const hasHome = value.types.HOME_TOP || value.types.HOME_BOTTOM; + // 프로그램 위치 선택 여부 + const hasProgram = value.types.PROGRAM; + // 마이페이지 선택 여부 + const hasMyPage = value.types.MY_PAGE; + + // 이미지 섹션 표시 여부 + const showHomeImages = hasHome || hasMyPage; + const showProgramImages = hasProgram; + + return ( +
+ {/* 제목 */} + + set({ title: e.target.value })} + /> + + + {/* 랜딩 URL */} + + set({ landingUrl: e.target.value })} + /> + + + {/* 노출 여부 */} + +
+ {[ + { label: '노출함', val: true }, + { label: '노출안함', val: false }, + ].map(({ label, val }) => ( + + ))} +
+
+ + {/* 노출 기간 */} + +
+
+ set({ startDate: e.target.value })} + /> +
+ - +
+ set({ endDate: e.target.value })} + /> +
+
+
+ + {/* 노출 위치 */} + +
+ {BANNER_TYPES.map((type) => ( + + ))} +
+
+ + {/* 이미지 */} + +
+ {/* 홈 배너: HOME_TOP/HOME_BOTTOM 또는 MY_PAGE 선택 시 표시 */} + {showHomeImages && ( +
+ {hasHome && ( + set({ homePcFile: f, homePcFileUrl: null, homePcFileId: f ? value.homePcFileId : null })} + /> + )} + + set({ homeMobileFile: f, homeMobileFileUrl: null, homeMobileFileId: f ? value.homeMobileFileId : null }) + } + /> +
+ )} + + {/* 프로그램 배너: PROGRAM 또는 MY_PAGE 선택 시 표시 */} + {showProgramImages && ( +
+ {hasProgram && ( + + set({ programPcFile: f, programPcFileUrl: null, programPcFileId: f ? value.programPcFileId : null }) + } + /> + )} + + set({ programMobileFile: f, programMobileFileUrl: null, programMobileFileId: f ? value.programMobileFileId : null }) + } + /> +
+ )} + + {/* 아무 위치도 선택 안 한 경우 안내 */} + {!showHomeImages && !showProgramImages && ( +

+ 노출 위치를 선택하면 이미지 업로드 항목이 표시됩니다. +

+ )} + + {/* 마이페이지 안내 문구 */} + {hasMyPage && ( +
+

※ 마이페이지 배너는 별도 이미지를 사용하지 않습니다.

+

+ - 마이페이지 PC, 모바일 : 홈 배너 (모바일) 이미지가 노출됩니다. +

+
+ )} +
+
+
+ ); +}; + +export default CommonBannerInputContent; diff --git a/src/domain/admin/banner/common-banner/CommonBannerVisibilityToggle.tsx b/src/domain/admin/banner/common-banner/CommonBannerVisibilityToggle.tsx new file mode 100644 index 000000000..b12d04cda --- /dev/null +++ b/src/domain/admin/banner/common-banner/CommonBannerVisibilityToggle.tsx @@ -0,0 +1,25 @@ +import { useToggleCommonBannerVisibility } from '@/api/banner'; +import { Switch } from '@mui/material'; + +const CommonBannerVisibilityToggle = ({ + commonBannerId, + isVisible, +}: { + commonBannerId: number; + isVisible: boolean; +}) => { + const { mutate } = useToggleCommonBannerVisibility({ + errorCallback: (error) => { + alert(error); + }, + }); + + return ( + mutate({ commonBannerId, isVisible: !isVisible })} + /> + ); +}; + +export default CommonBannerVisibilityToggle; diff --git a/src/domain/admin/pages/banner/common-banner/useCommonBannerCreate.ts b/src/domain/admin/pages/banner/common-banner/useCommonBannerCreate.ts new file mode 100644 index 000000000..5dfd4cc65 --- /dev/null +++ b/src/domain/admin/pages/banner/common-banner/useCommonBannerCreate.ts @@ -0,0 +1,95 @@ +import { useState } from 'react'; + +import { + CommonBannerFormValue, + usePostCommonBannerForAdmin, +} from '@/api/banner'; +import { useRouter } from 'next/navigation'; + +const useCommonBannerCreate = () => { + const router = useRouter(); + + const [value, setValue] = useState({ + title: '', + landingUrl: '', + isVisible: true, + startDate: '', + endDate: '', + // 노출 위치 체크박스 + types: { + HOME_TOP: false, + HOME_BOTTOM: false, + PROGRAM: false, + MY_PAGE: false, + }, + // 이미지 파일 + homePcFile: null, + homeMobileFile: null, + programPcFile: null, + programMobileFile: null, + }); + + const { mutate: tryCreateCommonBanner } = usePostCommonBannerForAdmin({ + successCallback: () => { + router.push('/admin/banner/common-banners'); + }, + errorCallback: (error) => { + console.error(error); + alert('통합 배너 등록에 실패했습니다.'); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // 노출 기간 검증 + if (!value.startDate || !value.endDate) { + alert('노출 시작일과 종료일을 모두 입력해주세요.'); + return; + } + if (new Date(value.startDate) >= new Date(value.endDate)) { + alert('노출 종료일은 시작일보다 이후여야 합니다.'); + return; + } + + // 노출 위치 선택 여부 + const { types } = value; + if ( + !types.HOME_TOP && + !types.HOME_BOTTOM && + !types.PROGRAM && + !types.MY_PAGE + ) { + alert('노출 위치를 하나 이상 선택해주세요.'); + return; + } + + // 필수 이미지 검증 + const needsHome = types.HOME_TOP || types.HOME_BOTTOM; + const needsProgram = types.PROGRAM; + const needsMyPage = types.MY_PAGE; + + if (needsHome && !value.homePcFile) { + alert('홈 배너 (PC) 이미지를 업로드해주세요.'); + return; + } + if ((needsHome || needsMyPage) && !value.homeMobileFile) { + alert('홈 배너 (모바일) 이미지를 업로드해주세요.'); + return; + } + if (needsProgram && !value.programPcFile) { + alert('프로그램 배너 (PC) 이미지를 업로드해주세요.'); + return; + } + if (needsProgram && !value.programMobileFile) { + alert('프로그램 배너 (모바일) 이미지를 업로드해주세요.'); + return; + } + + tryCreateCommonBanner(value); + }; + + return { value, setValue, handleSubmit }; +}; + +export default useCommonBannerCreate; diff --git a/src/domain/admin/pages/banner/common-banner/useCommonBannerEdit.ts b/src/domain/admin/pages/banner/common-banner/useCommonBannerEdit.ts new file mode 100644 index 000000000..e1b063fbc --- /dev/null +++ b/src/domain/admin/pages/banner/common-banner/useCommonBannerEdit.ts @@ -0,0 +1,186 @@ +import { useEffect, useState } from 'react'; + +import { + CommonBannerDetailResponse, + CommonBannerFormValue, + CommonBannerType, + useEditCommonBannerForAdmin, + useGetCommonBannerDetailForAdmin, +} from '@/api/banner'; +import dayjs from '@/lib/dayjs'; +import { useParams, useRouter } from 'next/navigation'; + +const toDatetimeLocal = (iso: string) => { + if (!iso) return ''; + return dayjs(iso).format('YYYY-MM-DDTHH:mm'); +}; + +const mapDetailToFormValue = ( + detail: CommonBannerDetailResponse, +): CommonBannerFormValue => { + const types: Record = { + HOME_TOP: false, + HOME_BOTTOM: false, + PROGRAM: false, + MY_PAGE: false, + }; + + let homePcFileId: number | null = null; + let homeMobileFileId: number | null = null; + let programPcFileId: number | null = null; + let programMobileFileId: number | null = null; + let homePcFileUrl: string | null = null; + let homeMobileFileUrl: string | null = null; + let programPcFileUrl: string | null = null; + let programMobileFileUrl: string | null = null; + + for (const item of detail.commonBannerDetailList ?? []) { + types[item.type] = true; + + if (item.type === 'HOME_TOP' || item.type === 'HOME_BOTTOM') { + if (item.agentType === 'PC') { + homePcFileId = item.fileId; + homePcFileUrl = item.fileUrl ?? null; + } + if (item.agentType === 'MOBILE') { + homeMobileFileId = item.fileId; + homeMobileFileUrl = item.fileUrl ?? null; + } + } else if (item.type === 'PROGRAM') { + if (item.agentType === 'PC') { + programPcFileId = item.fileId; + programPcFileUrl = item.fileUrl ?? null; + } + if (item.agentType === 'MOBILE') { + programMobileFileId = item.fileId; + programMobileFileUrl = item.fileUrl ?? null; + } + } else if (item.type === 'MY_PAGE') { + // MY_PAGE PC, MOBILE 모두 홈 배너 (모바일) 이미지 사용 + if (item.agentType === 'MOBILE') { + homeMobileFileId = item.fileId; + homeMobileFileUrl = item.fileUrl ?? null; + } + if (item.agentType === 'PC' && !homeMobileFileId) { + homeMobileFileId = item.fileId; + homeMobileFileUrl = item.fileUrl ?? null; + } + } + } + + const { commonBanner } = detail; + + return { + title: commonBanner.title || '', + landingUrl: commonBanner.landingUrl || '', + isVisible: commonBanner.isVisible, + startDate: commonBanner.startDate + ? toDatetimeLocal(commonBanner.startDate) + : '', + endDate: commonBanner.endDate ? toDatetimeLocal(commonBanner.endDate) : '', + types, + homePcFile: null, + homeMobileFile: null, + programPcFile: null, + programMobileFile: null, + homePcFileId, + homeMobileFileId, + programPcFileId, + programMobileFileId, + homePcFileUrl, + homeMobileFileUrl, + programPcFileUrl, + programMobileFileUrl, + }; +}; + +const useCommonBannerEdit = () => { + const router = useRouter(); + const params = useParams(); + const commonBannerId = Number(params.id); + + const { data, isLoading } = useGetCommonBannerDetailForAdmin({ + commonBannerId, + }); + + const [value, setValue] = useState(null); + + useEffect(() => { + if (data && !value) { + setValue(mapDetailToFormValue(data)); + } + }, [data, value]); + + const { mutate: tryEditCommonBanner } = useEditCommonBannerForAdmin({ + successCallback: () => { + router.push('/admin/banner/common-banners'); + }, + errorCallback: (error) => { + console.error(error); + alert('통합 배너 수정에 실패했습니다.'); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!value) return; + + // 노출 기간 검증 + if (!value.startDate || !value.endDate) { + alert('노출 시작일과 종료일을 모두 입력해주세요.'); + return; + } + if (new Date(value.startDate) >= new Date(value.endDate)) { + alert('노출 종료일은 시작일보다 이후여야 합니다.'); + return; + } + + // 노출 위치 선택 여부 + const { types } = value; + if ( + !types.HOME_TOP && + !types.HOME_BOTTOM && + !types.PROGRAM && + !types.MY_PAGE + ) { + alert('노출 위치를 하나 이상 선택해주세요.'); + return; + } + + // 필수 이미지 검증 (새 파일 또는 기존 fileId가 있어야 함) + const needsHome = types.HOME_TOP || types.HOME_BOTTOM; + const needsProgram = types.PROGRAM; + const needsMyPage = types.MY_PAGE; + + if (needsHome && !value.homePcFile && !value.homePcFileId) { + alert('홈 배너 (PC) 이미지를 업로드해주세요.'); + return; + } + if ( + (needsHome || needsMyPage) && + !value.homeMobileFile && + !value.homeMobileFileId + ) { + alert('홈 배너 (모바일) 이미지를 업로드해주세요.'); + return; + } + if (needsProgram && !value.programPcFile && !value.programPcFileId) { + alert('프로그램 배너 (PC) 이미지를 업로드해주세요.'); + return; + } + if ( + needsProgram && + !value.programMobileFile && + !value.programMobileFileId + ) { + alert('프로그램 배너 (모바일) 이미지를 업로드해주세요.'); + return; + } + + tryEditCommonBanner({ commonBannerId, form: value }); + }; + + return { value, setValue, isLoading, handleSubmit }; +}; + +export default useCommonBannerEdit; diff --git a/src/domain/admin/pages/banner/common-banner/useCommonBanners.ts b/src/domain/admin/pages/banner/common-banner/useCommonBanners.ts new file mode 100644 index 000000000..17bf83257 --- /dev/null +++ b/src/domain/admin/pages/banner/common-banner/useCommonBanners.ts @@ -0,0 +1,115 @@ +import { useMemo, useState } from 'react'; + +import { + CommonBannerAdminListItemType, + CommonBannerType, + useDeleteCommonBannerForAdmin, + useGetActiveBannersForAdmin, + useGetCommonBannerForAdmin, + useUpdateExpiredCommonBanners, +} from '@/api/banner'; + +export type TabType = 'active' | 'all'; + +const useCommonBanners = () => { + const [activeTab, setActiveTab] = useState('active'); + const [isDeleteModalShown, setIsDeleteModalShown] = useState(false); + const [bannerIdForDeleting, setBannerIdForDeleting] = useState(); + + // 노출 중 탭 API + const { + data: activeBannerData, + isLoading: isActiveLoading, + error: activeError, + } = useGetActiveBannersForAdmin({ enabled: activeTab === 'active' }); + + // 전체 탭 API + const { + data: allBannerData, + isLoading: isAllLoading, + error: allError, + } = useGetCommonBannerForAdmin({ enabled: activeTab === 'all' }); + + const isLoading = activeTab === 'active' ? isActiveLoading : isAllLoading; + const error = activeTab === 'active' ? activeError : allError; + + const { mutate: deleteCommonBanner } = useDeleteCommonBannerForAdmin({ + successCallback: () => { + setIsDeleteModalShown(false); + }, + }); + + const { mutate: updateExpiredBanners, isPending: isUpdatingExpired } = + useUpdateExpiredCommonBanners({ + successCallback: () => { + alert('만료된 배너가 업데이트되었습니다.'); + }, + errorCallback: (error) => { + alert('업데이트에 실패했습니다: ' + error.message); + }, + }); + + const handleDeleteButtonClicked = (bannerId: number) => { + setBannerIdForDeleting(bannerId); + setIsDeleteModalShown(true); + }; + + // 노출 중 탭: 위치별로 그룹화된 데이터 + const groupedActiveBanners = useMemo(() => { + const result: Record = { + HOME_TOP: [], + HOME_BOTTOM: [], + PROGRAM: [], + MY_PAGE: [], + }; + + if (activeTab !== 'active' || !activeBannerData?.commonBannerList) + return result; + + const bannerList = activeBannerData.commonBannerList; + + if (!Array.isArray(bannerList)) { + Object.entries(bannerList).forEach(([key, bannerArray]) => { + if (key in result) { + result[key as CommonBannerType] = bannerArray; + } + }); + } + + return result; + }, [activeTab, activeBannerData]); + + // 전체 탭: 배너 리스트 + const allBanners = useMemo(() => { + if (activeTab !== 'all' || !allBannerData?.commonBannerList) return []; + + const bannerList = allBannerData.commonBannerList; + + if (Array.isArray(bannerList)) { + return bannerList.map((banner) => ({ + ...banner, + uniqueId: `${banner.commonBannerId}`, + })); + } + + return []; + }, [activeTab, allBannerData]); + + return { + activeTab, + setActiveTab, + isLoading, + error, + groupedActiveBanners, + allBanners, + isDeleteModalShown, + setIsDeleteModalShown, + bannerIdForDeleting, + handleDeleteButtonClicked, + deleteCommonBanner, + isUpdatingExpired, + updateExpiredBanners, + }; +}; + +export default useCommonBanners; diff --git a/src/domain/admin/program/ui/editor/EditorTemplate.tsx b/src/domain/admin/program/ui/editor/EditorTemplate.tsx index f387d4128..19b5bb60a 100644 --- a/src/domain/admin/program/ui/editor/EditorTemplate.tsx +++ b/src/domain/admin/program/ui/editor/EditorTemplate.tsx @@ -22,11 +22,11 @@ const EditorTemplate = ({ children, }: EditorTemplateProps) => { return ( -
+

{title}

-
+
{children}
{submitButton.text} diff --git a/src/domain/admin/ui/table/TableLayout.tsx b/src/domain/admin/ui/table/TableLayout.tsx index c904fc821..e5cfab32d 100644 --- a/src/domain/admin/ui/table/TableLayout.tsx +++ b/src/domain/admin/ui/table/TableLayout.tsx @@ -8,9 +8,15 @@ interface TableLayoutProps { href: string; }; children: React.ReactNode; + tabs?: React.ReactNode; } -const TableLayout = ({ title, headerButton, children }: TableLayoutProps) => { +const TableLayout = ({ + title, + headerButton, + children, + tabs, +}: TableLayoutProps) => { return (
@@ -24,6 +30,7 @@ const TableLayout = ({ title, headerButton, children }: TableLayoutProps) => { )}
+ {tabs &&
{tabs}
}
{children}
); diff --git a/src/domain/home/banner/BottomBannerSection.tsx b/src/domain/home/banner/BottomBannerSection.tsx index 0f2c1dd79..f0c78d549 100644 --- a/src/domain/home/banner/BottomBannerSection.tsx +++ b/src/domain/home/banner/BottomBannerSection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useGetBannerListForUser } from '@/api/banner'; +import { useGetCommonBannerListForUser } from '@/api/banner'; import LoadingContainer from '@/common/loading/LoadingContainer'; import { MOBILE_MEDIA_QUERY } from '@/utils/constants'; import { useMediaQuery } from '@mui/material'; @@ -11,7 +11,7 @@ import { Swiper, SwiperSlide } from 'swiper/react'; const BottomBannerSection = () => { const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY); - const { data, isLoading } = useGetBannerListForUser({ type: 'MAIN_BOTTOM' }); + const { data: bannerList, isLoading } = useGetCommonBannerListForUser({ type: 'HOME_BOTTOM' }); const handleClickBanner = (e: MouseEvent) => { const target = e.target as HTMLElement; @@ -29,13 +29,13 @@ const BottomBannerSection = () => {
- ) : !data || !data.bannerList || data.bannerList.length === 0 ? null : ( + ) : !bannerList || bannerList.length === 0 ? null : (
1 ? { delay: 2500 } : false} + autoplay={bannerList.length > 1 ? { delay: 2500 } : false} modules={[Pagination, Autoplay, Navigation]} - loop={data.bannerList.length > 1} - navigation={data.bannerList.length > 1} + loop={bannerList.length > 1} + navigation={bannerList.length > 1} pagination={{ type: 'fraction', renderFraction: (currentClass, totalClass) => @@ -48,18 +48,18 @@ const BottomBannerSection = () => { slidesPerView={1} className="aspect-[3.2/1] rounded-sm md:aspect-[6.4/1]" > - {data.bannerList.map((banner) => ( - + {bannerList.map((banner, index) => ( + @@ -67,7 +67,7 @@ const BottomBannerSection = () => { src={ isMobile ? banner.mobileImgUrl || '' : banner.imgUrl || '' } - alt={'main-banner' + banner.id} + alt={'main-banner-' + index} className="h-full w-full rounded-sm object-cover" /> diff --git a/src/domain/home/banner/MainBannerSection.tsx b/src/domain/home/banner/MainBannerSection.tsx index aca3919a9..d737ffdff 100644 --- a/src/domain/home/banner/MainBannerSection.tsx +++ b/src/domain/home/banner/MainBannerSection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useGetBannerListForUser } from '@/api/banner'; +import { useGetCommonBannerListForUser } from '@/api/banner'; import LoadingContainer from '@/common/loading/LoadingContainer'; import { MOBILE_MEDIA_QUERY } from '@/utils/constants'; import { useMediaQuery } from '@mui/material'; @@ -11,7 +11,7 @@ import { Swiper, SwiperSlide } from 'swiper/react'; const MainBannerSection = () => { const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY); - const { data, isLoading } = useGetBannerListForUser({ type: 'MAIN' }); + const { data: bannerList, isLoading } = useGetCommonBannerListForUser({ type: 'HOME_TOP' }); const handleClickBanner = (e: MouseEvent) => { const target = e.target as HTMLElement; @@ -29,13 +29,13 @@ const MainBannerSection = () => {
- ) : !data || !data.bannerList || data.bannerList.length === 0 ? null : ( + ) : !bannerList || bannerList.length === 0 ? null : (
1 ? { delay: 2500 } : false} + autoplay={bannerList.length > 1 ? { delay: 2500 } : false} modules={[Pagination, Autoplay, Navigation]} - loop={data.bannerList.length > 1} - navigation={data.bannerList.length > 1} + loop={bannerList.length > 1} + navigation={bannerList.length > 1} pagination={{ type: 'fraction', renderFraction: (currentClass, totalClass) => @@ -48,15 +48,15 @@ const MainBannerSection = () => { slidesPerView={1} className="aspect-[3.2/1] rounded-sm md:aspect-[6.4/1]" > - {data.bannerList.map((banner) => ( - + {bannerList.map((banner, index) => ( + { src={ isMobile ? banner.mobileImgUrl || '' : banner.imgUrl || '' } - alt={'main-banner' + banner.id} + alt={'main-banner-' + index} className="h-full w-full rounded-sm object-cover" /> diff --git a/src/domain/mypage/MyPageBanner.tsx b/src/domain/mypage/MyPageBanner.tsx index 2dee8e3cd..375d1e5d9 100644 --- a/src/domain/mypage/MyPageBanner.tsx +++ b/src/domain/mypage/MyPageBanner.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import type { Swiper as SwiperType } from 'swiper'; import { Autoplay, Navigation, Pagination } from 'swiper/modules'; import { Swiper, SwiperSlide } from 'swiper/react'; @@ -9,8 +9,10 @@ import 'swiper/css'; import 'swiper/css/navigation'; import 'swiper/css/pagination'; -import { useGetBannerListForUser } from '@/api/banner'; +import { useGetCommonBannerListForUser } from '@/api/banner'; import HybridLink from '@/common/HybridLink'; +import { MOBILE_MEDIA_QUERY } from '@/utils/constants'; +import { useMediaQuery } from '@mui/material'; interface MyPageBannerProps { className?: string; @@ -19,17 +21,14 @@ interface MyPageBannerProps { const BANNER_AUTOPLAY_DELAY_MS = 2500; const MyPageBanner = ({ className }: MyPageBannerProps) => { - const { data, isLoading } = useGetBannerListForUser({ type: 'MAIN' }); + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY); + const { data: bannerList = [], isLoading } = useGetCommonBannerListForUser({ + type: 'MY_PAGE', + }); const [isPlay, setIsPlay] = useState(true); const [currentIndex, setCurrentIndex] = useState(1); const swiperRef = useRef(null); - // mobileImgUrl이 있는 배너만 필터링 - const bannerList = useMemo(() => { - if (!data?.bannerList) return []; - return data.bannerList.filter((banner) => banner.mobileImgUrl); - }, [data]); - const handleTogglePlay = () => { if (!swiperRef.current) return; @@ -63,23 +62,25 @@ const MyPageBanner = ({ className }: MyPageBannerProps) => { slidesPerView={1} className="mypage-banner-swiper aspect-[3.2/1] h-full w-full" > - {bannerList.map((banner) => ( - + {bannerList.map((banner, index) => ( + {'mypage-banner' diff --git a/src/domain/program/Banner.tsx b/src/domain/program/Banner.tsx index 0415df809..f633737b2 100644 --- a/src/domain/program/Banner.tsx +++ b/src/domain/program/Banner.tsx @@ -3,7 +3,7 @@ import HybridLink from '@/common/HybridLink'; import { useEffect, useRef, useState } from 'react'; -import { useGetUserProgramBannerListQuery } from '@/api/program'; +import { useGetCommonBannerListForUser } from '@/api/banner'; const Banner = () => { const imgRef = useRef(null); @@ -27,16 +27,16 @@ const Banner = () => { }; }, []); - const { data, isLoading } = useGetUserProgramBannerListQuery(); + const { data: bannerList, isLoading } = useGetCommonBannerListForUser({ type: 'PROGRAM' }); useEffect(() => { if (!isPlay) return; // 배너 슬라이드 애니메이션 const timer = setInterval(() => { - if (!data) return; + if (!bannerList) return; const distance = imgRef.current?.offsetWidth; - const nextIndex = (bannerIndex + 1) % data.bannerList.length; + const nextIndex = (bannerIndex + 1) % bannerList.length; setBannerIndex(nextIndex); innerRef.current?.style.setProperty( @@ -48,9 +48,9 @@ const Banner = () => { return () => { clearInterval(timer); }; - }, [bannerIndex, data, isPlay]); + }, [bannerIndex, bannerList, isPlay]); - if (isLoading || !data || data.bannerList.length === 0) return null; + if (isLoading || !bannerList || bannerList.length === 0) return null; return (
@@ -58,19 +58,19 @@ const Banner = () => { ref={innerRef} className="flex flex-nowrap items-center transition-transform duration-300 ease-in-out" > - {data.bannerList.map((banner) => ( + {bannerList.map((banner, index) => ( 배너 이미지 @@ -85,9 +85,9 @@ const Banner = () => { /> {bannerIndex + 1 < 10 ? `0${bannerIndex + 1}` : bannerIndex + 1} /{' '} - {data.bannerList.length < 10 - ? `0${data.bannerList.length}` - : data.bannerList.length} + {bannerList.length < 10 + ? `0${bannerList.length}` + : bannerList.length}