diff --git a/src/api/magnet/magnet.ts b/src/api/magnet/magnet.ts index 4a89e27a8..8f49492ad 100644 --- a/src/api/magnet/magnet.ts +++ b/src/api/magnet/magnet.ts @@ -5,11 +5,14 @@ import { import axios from '@/utils/axios'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { + MagnetDetailResponse, MagnetListResponse, + magnetDetailResponseSchema, magnetListResponseSchema, } from './magnetSchema'; const magnetListQueryKey = 'MagnetListQueryKey'; +const magnetDetailQueryKey = 'MagnetDetailQueryKey'; export interface MagnetListQueryParams { typeList?: MagnetTypeKey[]; @@ -87,6 +90,60 @@ export const usePatchMagnetVisibilityMutation = ({ }); }; +export const useGetMagnetDetailQuery = (magnetId: number) => { + return useQuery({ + queryKey: [magnetDetailQueryKey, magnetId], + queryFn: async (): Promise => { + const res = await axios.get(`/admin/magnet/${magnetId}`); + return magnetDetailResponseSchema.parse(res.data.data); + }, + }); +}; + +export interface PatchMagnetReqBody { + magnetId: number; + type?: string; + title?: string; + description?: string; + previewContents?: string; + mainContents?: string; + desktopThumbnail?: string; + mobileThumbnail?: string; + startDate?: string | null; + endDate?: string | null; + isVisible?: boolean; + magnetQuestionList?: unknown[]; +} + +export const usePatchMagnetMutation = ({ + successCallback, + errorCallback, +}: { + successCallback?: () => void; + errorCallback?: () => void; +} = {}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ magnetId, ...body }: PatchMagnetReqBody) => { + const res = await axios.patch(`/admin/magnet/${magnetId}`, body); + return res.data; + }, + onSuccess: async (_data, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [magnetDetailQueryKey, variables.magnetId], + }), + queryClient.invalidateQueries({ queryKey: [magnetListQueryKey] }), + ]); + successCallback?.(); + }, + onError: (error) => { + console.error(error); + errorCallback?.(); + }, + }); +}; + export const useCreateMagnetMutation = ({ successCallback, errorCallback, diff --git a/src/api/magnet/magnetSchema.ts b/src/api/magnet/magnetSchema.ts index d3422cc6c..90491d1fc 100644 --- a/src/api/magnet/magnetSchema.ts +++ b/src/api/magnet/magnetSchema.ts @@ -23,3 +23,40 @@ export const magnetListResponseSchema = z.object({ }); export type MagnetListResponse = z.infer; + +const magnetInfoSchema = z.object({ + magnetId: z.number(), + type: magnetTypeSchema, + title: z.string(), + description: z.string().nullable(), + previewContents: z.string().nullable(), + mainContents: z.string().nullable(), + desktopThumbnail: z.string().nullable(), + mobileThumbnail: z.string().nullable(), + startDate: z.string().nullable(), + endDate: z.string().nullable(), + isVisible: z.boolean(), +}); + +const magnetQuestionItemSchema = z.object({ + itemId: z.number(), + value: z.string(), + isOther: z.boolean(), +}); + +const magnetQuestionSchema = z.object({ + questionId: z.number(), + questionType: z.string(), + isRequired: z.string(), + question: z.string(), + description: z.string().nullable(), + selectionMethod: z.string(), + items: z.array(magnetQuestionItemSchema), +}); + +export const magnetDetailResponseSchema = z.object({ + magnetInfo: magnetInfoSchema, + magnetQuestionInfo: z.array(magnetQuestionSchema), +}); + +export type MagnetDetailResponse = z.infer; diff --git a/src/app/admin/blog/magnet/[id]/post/page.tsx b/src/app/admin/blog/magnet/[id]/post/page.tsx index 7d09a3b52..ea9bb5929 100644 --- a/src/app/admin/blog/magnet/[id]/post/page.tsx +++ b/src/app/admin/blog/magnet/[id]/post/page.tsx @@ -1,11 +1,9 @@ -import { fetchMagnetPost } from '@/domain/admin/blog/magnet/mock'; import MagnetPostPage from '@/domain/admin/blog/magnet/MagnetPostPage'; const Page = async ({ params }: { params: Promise<{ id: string }> }) => { const { id } = await params; - const initialData = await fetchMagnetPost(Number(id)); - return ; + return ; }; export default Page; diff --git a/src/domain/admin/blog/magnet/MagnetPostPage.tsx b/src/domain/admin/blog/magnet/MagnetPostPage.tsx index 412ef5da2..1eab46a52 100644 --- a/src/domain/admin/blog/magnet/MagnetPostPage.tsx +++ b/src/domain/admin/blog/magnet/MagnetPostPage.tsx @@ -4,11 +4,11 @@ import TextFieldLimit from '@/domain/admin/blog/TextFieldLimit'; import { useMagnetPostForm } from '@/domain/admin/blog/magnet/hooks/useMagnetPostForm'; import MagnetProgramRecommendSection from '@/domain/admin/blog/magnet/section/MagnetProgramRecommendSection'; import MagnetRecommendSection from '@/domain/admin/blog/magnet/section/MagnetRecommendSection'; -import { MAGNET_TYPE, MagnetPostDetail } from '@/domain/admin/blog/magnet/types'; +import { MAGNET_TYPE, MagnetTypeKey } from '@/domain/admin/blog/magnet/types'; import Heading from '@/domain/admin/ui/heading/Heading'; import Heading2 from '@/domain/admin/ui/heading/Heading2'; import ImageUpload from '@/domain/admin/program/ui/form/ImageUpload'; -import { Button, Checkbox, FormControlLabel } from '@mui/material'; +import { Button, Checkbox, CircularProgress, FormControlLabel } from '@mui/material'; import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; import dynamic from 'next/dynamic'; @@ -21,11 +21,11 @@ const MAX_META_DESCRIPTION_LENGTH = 100; interface MagnetPostPageProps { magnetId: string; - initialData: MagnetPostDetail; } -const MagnetPostPage = ({ magnetId, initialData }: MagnetPostPageProps) => { +const MagnetPostPage = ({ magnetId }: MagnetPostPageProps) => { const { + isLoading, type, title, formState, @@ -45,7 +45,15 @@ const MagnetPostPage = ({ magnetId, initialData }: MagnetPostPageProps) => { setEndDate, savePost, navigateToList, - } = useMagnetPostForm({ magnetId, initialData }); + } = useMagnetPostForm(Number(magnetId)); + + if (isLoading || !type) { + return ( +
+ +
+ ); + } return (
@@ -56,7 +64,7 @@ const MagnetPostPage = ({ magnetId, initialData }: MagnetPostPageProps) => {
{/* 4.1 타입 */}

- 타입:  {MAGNET_TYPE[type]} + 타입:  {MAGNET_TYPE[type as MagnetTypeKey]}

{/* 4.2 제목 */} diff --git a/src/domain/admin/blog/magnet/hooks/useMagnetPostForm.ts b/src/domain/admin/blog/magnet/hooks/useMagnetPostForm.ts index edf133f36..2974e4f29 100644 --- a/src/domain/admin/blog/magnet/hooks/useMagnetPostForm.ts +++ b/src/domain/admin/blog/magnet/hooks/useMagnetPostForm.ts @@ -1,8 +1,10 @@ import { uploadFile } from '@/api/file'; -import { saveMagnetPost } from '@/domain/admin/blog/magnet/mock'; +import { + useGetMagnetDetailQuery, + usePatchMagnetMutation, +} from '@/api/magnet/magnet'; import { MagnetPostContent, - MagnetPostDetail, MagnetProgramRecommendItem, } from '@/domain/admin/blog/magnet/types'; import { useAdminSnackbar } from '@/hooks/useAdminSnackbar'; @@ -22,12 +24,35 @@ function createEmptyContent(): MagnetPostContent { }; } -function parseInitialContent(data: MagnetPostDetail): MagnetPostContent { - if (!data.content || data.content === '') return createEmptyContent(); +/** description JSON에서 metaDescription, programRecommend, magnetRecommend 추출 */ +interface DescriptionPayload { + metaDescription: string; + programRecommend: MagnetProgramRecommendItem[]; + magnetRecommend: (number | null)[]; +} + +function parseDescription(raw: string | null): DescriptionPayload { + const empty = createEmptyContent(); + if (!raw) { + return { + metaDescription: '', + programRecommend: empty.programRecommend, + magnetRecommend: empty.magnetRecommend, + }; + } try { - return JSON.parse(data.content); + const parsed = JSON.parse(raw); + return { + metaDescription: parsed.metaDescription ?? '', + programRecommend: parsed.programRecommend ?? empty.programRecommend, + magnetRecommend: parsed.magnetRecommend ?? empty.magnetRecommend, + }; } catch { - return createEmptyContent(); + return { + metaDescription: raw, + programRecommend: empty.programRecommend, + magnetRecommend: empty.magnetRecommend, + }; } } @@ -37,43 +62,70 @@ interface FormState { hasCommonForm: boolean; } -function buildInitialFormState(data: MagnetPostDetail): FormState { - return { - metaDescription: data.metaDescription ?? '', - thumbnail: data.thumbnail ?? '', - hasCommonForm: data.hasCommonForm ?? false, - }; -} - -interface UseMagnetPostFormParams { - magnetId: string; - initialData: MagnetPostDetail; -} - -export const useMagnetPostForm = ({ - magnetId, - initialData, -}: UseMagnetPostFormParams) => { +export const useMagnetPostForm = (magnetId: number) => { const router = useRouter(); const { snackbar: setSnackbar } = useAdminSnackbar(); - const initialContent = useMemo( - () => parseInitialContent(initialData), - [initialData], - ); - const initialFormState = useMemo( - () => buildInitialFormState(initialData), - [initialData], + const { data: detailData, isLoading } = useGetMagnetDetailQuery(magnetId); + const { mutate: patchMagnet } = usePatchMagnetMutation({ + successCallback: () => { + setSnackbar('마그넷 글이 저장되었습니다.'); + }, + }); + + const magnetInfo = detailData?.magnetInfo; + + const descPayload = useMemo( + () => parseDescription(magnetInfo?.description ?? null), + [magnetInfo?.description], ); - const [formState, setFormState] = useState(initialFormState); - const [displayDate, setDisplayDate] = useState( - initialData.displayDate ? dayjs(initialData.displayDate) : null, + const initialContent = useMemo( + () => ({ + programRecommend: descPayload.programRecommend, + magnetRecommend: descPayload.magnetRecommend, + lexicalBefore: magnetInfo?.previewContents || undefined, + lexicalAfter: magnetInfo?.mainContents || undefined, + }), + [descPayload, magnetInfo?.previewContents, magnetInfo?.mainContents], ); - const [endDate, setEndDate] = useState( - initialData.endDate ? dayjs(initialData.endDate) : null, + + const [formState, setFormState] = useState({ + metaDescription: '', + thumbnail: '', + hasCommonForm: false, + }); + const [formInitialized, setFormInitialized] = useState(false); + + // detailData가 로드되면 폼 상태 초기화 + if (magnetInfo && !formInitialized) { + setFormState({ + metaDescription: descPayload.metaDescription, + thumbnail: magnetInfo.desktopThumbnail ?? '', + hasCommonForm: false, + }); + setFormInitialized(true); + } + + const [displayDate, setDisplayDate] = useState(null); + const [endDate, setEndDate] = useState(null); + const [dateInitialized, setDateInitialized] = useState(false); + + if (magnetInfo && !dateInitialized) { + setDisplayDate(magnetInfo.startDate ? dayjs(magnetInfo.startDate) : null); + setEndDate(magnetInfo.endDate ? dayjs(magnetInfo.endDate) : null); + setDateInitialized(true); + } + + const [content, setContent] = useState( + createEmptyContent(), ); - const [content, setContent] = useState(initialContent); + const [contentInitialized, setContentInitialized] = useState(false); + + if (magnetInfo && !contentInitialized) { + setContent(initialContent); + setContentInitialized(true); + } const onChangeMetaDescription = (e: ChangeEvent) => { setFormState((prev) => ({ ...prev, metaDescription: e.target.value })); @@ -111,18 +163,23 @@ export const useMagnetPostForm = ({ setContent((prev) => ({ ...prev, lexicalAfter: jsonString })); }; - const savePost = async () => { - await saveMagnetPost({ - magnetId: Number(magnetId), + const savePost = () => { + const description = JSON.stringify({ metaDescription: formState.metaDescription, - thumbnail: formState.thumbnail, - displayDate: displayDate?.format('YYYY-MM-DDTHH:mm') ?? null, + programRecommend: content.programRecommend, + magnetRecommend: content.magnetRecommend, + }); + + patchMagnet({ + magnetId, + description, + previewContents: content.lexicalBefore ?? '', + mainContents: content.lexicalAfter ?? '', + desktopThumbnail: formState.thumbnail, + mobileThumbnail: formState.thumbnail, + startDate: displayDate?.format('YYYY-MM-DDTHH:mm') ?? null, endDate: endDate?.format('YYYY-MM-DDTHH:mm') ?? null, - hasCommonForm: formState.hasCommonForm, - content: JSON.stringify(content), - isVisible: false, }); - setSnackbar('마그넷 글이 저장되었습니다.'); }; const navigateToList = () => { @@ -130,8 +187,9 @@ export const useMagnetPostForm = ({ }; return { - type: initialData.type, - title: initialData.title, + isLoading, + type: magnetInfo?.type, + title: magnetInfo?.title, formState, displayDate, endDate, diff --git a/src/domain/admin/blog/magnet/mock.ts b/src/domain/admin/blog/magnet/mock.ts index 7cffbfec0..1a2f82c54 100644 --- a/src/domain/admin/blog/magnet/mock.ts +++ b/src/domain/admin/blog/magnet/mock.ts @@ -6,9 +6,6 @@ import { MagnetFormData, MagnetFormReqBody, MagnetListItem, - MagnetPostDetail, - MagnetPostReqBody, - MagnetTypeKey, MagnetWithFormSummary, } from './types'; @@ -60,87 +57,6 @@ const MOCK_MAGNETS: MagnetListItem[] = [ }, ]; -// TODO: API 준비 후 useCreateMagnetMutation React Query 훅으로 교체 -export function createMagnet(body: { - type: MagnetTypeKey; - title: string; -}): MagnetListItem { - const maxId = MOCK_MAGNETS.reduce( - (max, m) => Math.max(max, m.magnetId), - 0, - ); - const newMagnet: MagnetListItem = { - magnetId: maxId + 1, - type: body.type, - title: body.title, - startDate: new Date().toISOString(), - endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - isVisible: false, - applicationCount: 0, - }; - MOCK_MAGNETS.unshift(newMagnet); - return newMagnet; -} - -// TODO: API 준비 후 useToggleMagnetVisibilityMutation React Query 훅으로 교체 -export function toggleMagnetVisibility( - id: number, - isVisible: boolean, -): void { - const magnet = MOCK_MAGNETS.find((m) => m.magnetId === id); - if (magnet) magnet.isVisible = isVisible; -} - -// TODO: API 준비 후 useDeleteMagnetMutation React Query 훅으로 교체 -export function deleteMagnet(id: number): void { - const index = MOCK_MAGNETS.findIndex((m) => m.magnetId === id); - if (index !== -1) MOCK_MAGNETS.splice(index, 1); -} - -// --- 마그넷 글 관리 (포스트) --- - -const MOCK_MAGNET_POSTS: Record = {}; - -function buildDefaultPost(magnetId: number): MagnetPostDetail { - const magnet = MOCK_MAGNETS.find((m) => m.magnetId === magnetId); - return { - magnetId, - type: magnet?.type ?? 'MATERIAL', - title: magnet?.title ?? '', - metaDescription: '', - thumbnail: '', - displayDate: magnet?.startDate ?? null, - endDate: magnet?.endDate ?? null, - hasCommonForm: false, - content: '', - isVisible: false, - }; -} - -// TODO: API 준비 후 server-side fetch로 교체 -export async function fetchMagnetPost( - magnetId: number, -): Promise { - return MOCK_MAGNET_POSTS[magnetId] ?? buildDefaultPost(magnetId); -} - -// TODO: API 준비 후 useSaveMagnetPostMutation React Query 훅으로 교체 -export function saveMagnetPost(body: MagnetPostReqBody): void { - const magnet = MOCK_MAGNETS.find((m) => m.magnetId === body.magnetId); - MOCK_MAGNET_POSTS[body.magnetId] = { - magnetId: body.magnetId, - type: magnet?.type ?? 'MATERIAL', - title: magnet?.title ?? '', - metaDescription: body.metaDescription, - thumbnail: body.thumbnail, - displayDate: body.displayDate, - endDate: body.endDate, - hasCommonForm: body.hasCommonForm, - content: body.content, - isVisible: body.isVisible, - }; -} - // TODO: API 준비 후 React Query 훅으로 교체 export function fetchManageableMagnets(): MagnetListItem[] { return MOCK_MAGNETS.filter((m) => diff --git a/src/domain/admin/blog/magnet/types.ts b/src/domain/admin/blog/magnet/types.ts index 839f45cab..28ae80969 100644 --- a/src/domain/admin/blog/magnet/types.ts +++ b/src/domain/admin/blog/magnet/types.ts @@ -61,29 +61,18 @@ export interface MagnetPostContent { lexicalAfter?: string; } -/** 마그넷 포스트 상세 (단건 조회) */ +/** 마그넷 상세 — API 응답 magnetInfo 필드와 일치 */ export interface MagnetPostDetail { magnetId: number; type: MagnetTypeKey; title: string; - metaDescription: string; - thumbnail: string; - displayDate: string | null; - endDate: string | null; - hasCommonForm: boolean; - content: string; - isVisible: boolean; -} - -/** 마그넷 포스트 저장 요청 */ -export interface MagnetPostReqBody { - magnetId: number; - metaDescription: string; - thumbnail: string; - displayDate: string | null; + description: string | null; + previewContents: string | null; + mainContents: string | null; + desktopThumbnail: string | null; + mobileThumbnail: string | null; + startDate: string | null; endDate: string | null; - hasCommonForm: boolean; - content: string; isVisible: boolean; }