diff --git a/features/mypage/MyTab/TabWrapper/index.tsx b/features/mypage/MyTab/TabWrapper/index.tsx index c9ff6fd5..3eb64de4 100644 --- a/features/mypage/MyTab/TabWrapper/index.tsx +++ b/features/mypage/MyTab/TabWrapper/index.tsx @@ -1,5 +1,5 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import PageTabs from "@/components/ui/PageTabs"; import { useQueryParams } from "@/hooks/useQueryParams"; import JoinedMeetingListWrapper from "../../JoinedMeetingList"; @@ -17,17 +17,14 @@ const TAB_STICKY_OFFSET = { sm: 48, md: 88, } as const; + // STICKY 이후 스크롤 시 스타일 적용 임계값 -const THRESHOLD = { - lg: 20, - md: 320, - sm: 220, -} as const; +const SCROLL_CORRECTION = 20; const STYLE = { tabWrapper: "sticky top-12 z-10 bg-gray-50 md:top-22 lg:static", scroll: - "h-4 shadow-[0_13px_16px_rgba(0,0,0,0.08)] overflow-hidden absolute bottom-px left-0 z-0 block w-full", + "h-4 shadow-[0_13px_16px_rgba(0,0,0,0.15)] overflow-hidden absolute bottom-px left-0 z-0 block w-full", }; const TAB_ITEMS = [ @@ -49,17 +46,55 @@ export default function TabWrapper() { const isLg = useMediaQuery(MEDIA_QUERY_LG); const isMd = useMediaQuery(MEDIA_QUERY_MD); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [scrollThreshold, setScrollThreshold] = useState(0); const [activeTab, setActiveTab] = useState(isTabId(tabQuery) ? tabQuery : TAB_ITEMS[0].id); const tabAnchorRef = useRef(null); const tabRef = useRef(null); const contentRef = useRef(null); - const threshold = isLg ? THRESHOLD.lg : isMd ? THRESHOLD.md : THRESHOLD.sm; const isVisible = useScrollVisibility({ - threshold, + threshold: scrollThreshold, targetRef: isLg ? contentRef : undefined, }); + const stickyOffset = isMd ? TAB_STICKY_OFFSET.md : TAB_STICKY_OFFSET.sm; + + function getTabStickyScrollTop() { + const anchorTop = + (tabAnchorRef.current?.getBoundingClientRect().top ?? 0) + window.scrollY - stickyOffset; + + return Math.max(anchorTop, 0); + } + + function scrollToTabContentTop() { + if (isLg) { + contentRef.current?.scrollTo({ top: 0, behavior: "auto" }); + return; + } + const tabTop = tabRef.current?.getBoundingClientRect().top ?? 0; + if (tabTop <= stickyOffset + 1) { + window.scrollTo({ top: getTabStickyScrollTop(), behavior: "smooth" }); + } + } + + function handleTabChange({ id }: { id: string }) { + if (!isTabId(id)) return; + + setIsDropdownOpen(false); + scrollToTabContentTop(); + setActiveTab(id); + set({ tab: id }); + } + + // 반응형에 따른 스크롤 이벤트 + useLayoutEffect(() => { + if (isLg) { + setScrollThreshold(SCROLL_CORRECTION); + return; + } + setScrollThreshold(getTabStickyScrollTop() + SCROLL_CORRECTION); + }, [isLg, isMd]); + // 잘못된 URL 수정 useEffect(() => { if (tabQuery !== null && !isTabId(tabQuery)) { @@ -82,37 +117,13 @@ export default function TabWrapper() { WrittenReviewList: , }; - function scrollToTabContentTop() { - if (isLg) { - contentRef.current?.scrollTo({ top: 0, behavior: "auto" }); - return; - } - - const stickyOffset = isMd ? TAB_STICKY_OFFSET.md : TAB_STICKY_OFFSET.sm; - const anchorTop = - (tabAnchorRef.current?.getBoundingClientRect().top ?? 0) + window.scrollY - stickyOffset; - - window.scrollTo({ - top: Math.max(anchorTop, 0), - behavior: "smooth", - }); - } return (
- { - const nextTab = id as TabId; - setIsDropdownOpen(false); - scrollToTabContentTop(); - setActiveTab(nextTab); - set({ tab: id }); - }}> + {TAB_ITEMS.map((tabItem) => ( ( const res = await clientFetch(url); - if (!res.ok) { - throw new Error(`목록 조회 실패: ${res.status}`); - } + await throwApiError(res, MYPAGE_MESSAGES.fetchListError); const json = await res.json(); @@ -123,7 +122,11 @@ export async function patchMeetingsStatus({ body: JSON.stringify({ status }), }); - await throwApiError(res, "모임 상태 변경에 실패했습니다."); + await throwApiError(res, MYPAGE_MESSAGES.patchMeetingStatusError, { + statusMessages: { + 404: MYPAGE_MESSAGES.deleteMeetingNotFoundError, + }, + }); } // 모임 삭제 하기 @@ -132,7 +135,11 @@ export async function deleteMeetings({ meetingId }: { meetingId: number }): Prom method: "DELETE", }); - await throwApiError(res, "모임 삭제에 실패했습니다."); + await throwApiError(res, MYPAGE_MESSAGES.deleteMeetingError, { + statusMessages: { + 404: MYPAGE_MESSAGES.deleteMeetingNotFoundError, + }, + }); } // 모임 참여 취소 하기 @@ -141,7 +148,7 @@ export async function deleteMeetingsJoin({ meetingId }: { meetingId: number }): method: "DELETE", }); - await throwApiError(res, "모임 참여 취소에 실패했습니다."); + await throwApiError(res, MYPAGE_MESSAGES.deleteMeetingJoinError); } // 리뷰 작성 하기 @@ -153,7 +160,7 @@ export async function postMeetingsReviews({ method: "POST", body: JSON.stringify(reviewFormValues), }); - await throwApiError(res, "리뷰 작성에 실패했습니다."); + await throwApiError(res, MYPAGE_MESSAGES.createReviewError); } // 리뷰 수정 하기 @@ -165,7 +172,7 @@ export async function patchReviews({ method: "PATCH", body: JSON.stringify(reviewFormValues), }); - await throwApiError(res, "리뷰 수정에 실패했습니다."); + await throwApiError(res, MYPAGE_MESSAGES.updateReviewError); } // 리뷰 삭제 하기 @@ -173,54 +180,7 @@ export async function deleteReviews({ reviewId }: { reviewId: number }): Promise const res = await clientFetch(`/reviews/${reviewId}`, { method: "DELETE", }); - await throwApiError(res, "리뷰 삭제에 실패했습니다."); -} - -// 찜 추가 -export async function postMeetingsFavorites(meetingId: number): Promise { - const res = await clientFetch(`/meetings/${meetingId}/favorites`, { - method: "POST", - }); - await throwApiError(res, "찜 추가에 실패했습니다."); -} - -// 찜 해제 -export async function deleteMeetingsFavorites(meetingId: number): Promise { - const res = await clientFetch(`/meetings/${meetingId}/favorites`, { - method: "DELETE", - }); - await throwApiError(res, "찜 해제에 실패했습니다."); -} - -// image 업로드 -export async function uploadProfileImage(file: File): Promise { - // presigned URL - const presignedResponse = await clientFetch("/images/presigned", { - method: "POST", - body: JSON.stringify({ - fileName: file.name, - contentType: file.type, - }), - }); - - await throwApiError(presignedResponse, "이미지 업로드 URL 발급에 실패했습니다."); - - // public URL - const { presignedUrl, publicUrl } = await presignedResponse.json(); - - const uploadResponse = await fetch(presignedUrl, { - method: "PUT", - headers: { - "Content-Type": file.type, - }, - body: file, - }); - - if (!uploadResponse.ok) { - throw new Error("이미지 업로드에 실패했습니다."); - } - - return publicUrl; + await throwApiError(res, MYPAGE_MESSAGES.deleteReviewError); } // 유저 프로필 @@ -229,7 +189,7 @@ export async function patchUsersMe(user: PatchUserProfilePayload): Promise method: "PATCH", body: JSON.stringify(user), }); - await throwApiError(res, "프로필 수정에 실패했습니다."); + await throwApiError(res, MYPAGE_MESSAGES.updateProfileError); return res.json(); } diff --git a/features/mypage/message.ts b/features/mypage/message.ts new file mode 100644 index 00000000..5a218def --- /dev/null +++ b/features/mypage/message.ts @@ -0,0 +1,23 @@ +const RETRY_LATER_SUFFIX = "\n잠시 후 다시 시도해주세요."; + +export const MYPAGE_MESSAGES = { + fetchListError: "목록 조회에 실패했습니다." + RETRY_LATER_SUFFIX, + patchMeetingStatusSuccess: (status: "CONFIRMED" | "CANCELED") => + `모임이 ${status === "CONFIRMED" ? "확정" : "취소"}되었습니다.`, + patchMeetingStatusError: "모임 상태 변경에 실패했습니다." + RETRY_LATER_SUFFIX, + deleteMeetingSuccess: "모임이 삭제 되었습니다.", + deleteMeetingError: "모임 삭제에 실패했습니다." + RETRY_LATER_SUFFIX, + deleteMeetingNotFoundError: "이미 삭제된 모임입니다.", + deleteMeetingJoinSuccess: "모임 예약이 취소 되었습니다.", + deleteMeetingJoinError: "모임 참여 취소에 실패했습니다." + RETRY_LATER_SUFFIX, + createReviewSuccess: "리뷰가 작성 되었습니다.", + createReviewError: "리뷰 작성에 실패했습니다." + RETRY_LATER_SUFFIX, + updateReviewSuccess: "리뷰가 수정 되었습니다.", + updateReviewError: "리뷰 수정에 실패했습니다." + RETRY_LATER_SUFFIX, + deleteReviewSuccess: "리뷰가 삭제 되었습니다.", + deleteReviewError: "리뷰 삭제에 실패했습니다." + RETRY_LATER_SUFFIX, + updateProfileSuccess: "프로필이 수정되었습니다.", + updateProfileError: "프로필 수정에 실패했습니다." + RETRY_LATER_SUFFIX, + uploadProfileImageSuccess: "이미지가 업로드되었습니다.", + uploadProfileImageError: "이미지 업로드에 실패했습니다." + RETRY_LATER_SUFFIX, +}; diff --git a/features/mypage/mutations.ts b/features/mypage/mutations.ts index 4c8b2265..0b346b0c 100644 --- a/features/mypage/mutations.ts +++ b/features/mypage/mutations.ts @@ -7,7 +7,6 @@ import { patchReviews, patchUsersMe, postMeetingsReviews, - uploadProfileImage, } from "./apis"; import { useToast } from "@/providers/toast-provider"; import { meetupDetailQueryKeys } from "@/features/shared/queryKeys/meetupDetail"; @@ -16,6 +15,9 @@ import { mypageQueryKeys } from "@/features/shared/queryKeys/mypage"; import { headerQueryKeys } from "@/features/shared/queryKeys/header"; import { meetupQueryKeys } from "@/features/shared/queryKeys/meetup"; import { reviewsQueryKeys } from "@/features/shared/queryKeys/reviews"; +import { uploadImage } from "@/apis/images"; +import { getUserErrorMessage } from "@/utils/api"; +import { MYPAGE_MESSAGES } from "@/features/mypage/message"; interface UsePatchUsersMeOptions { onSuccessBeforeSync?: () => void; @@ -24,18 +26,18 @@ interface UsePatchUsersMeOptions { export function useUploadProfileImage() { const { handleShowToast } = useToast(); return useMutation({ - mutationFn: uploadProfileImage, + mutationFn: uploadImage, onSuccess: () => { handleShowToast({ - message: "이미지가 업로드되었습니다.", + message: MYPAGE_MESSAGES.uploadProfileImageSuccess, status: "success", }); }, - onError: () => { + onError: (error) => { handleShowToast({ - message: "이미지 업로드에 실패했습니다.\n잠시 후 다시 시도해주세요.", + message: getUserErrorMessage(error, MYPAGE_MESSAGES.uploadProfileImageError), status: "error", }); }, @@ -52,15 +54,15 @@ export function usePatchUsersMe(options?: UsePatchUsersMeOptions) { onSuccess: () => { options?.onSuccessBeforeSync?.(); handleShowToast({ - message: "프로필이 수정되었습니다.", + message: MYPAGE_MESSAGES.updateProfileSuccess, status: "success", }); queryClient.invalidateQueries({ queryKey: authQueryKeys.me }); }, - onError: () => { + onError: (error) => { handleShowToast({ - message: "프로필 수정에 실패했습니다.\n잠시 후 다시 시도해주세요.", + message: getUserErrorMessage(error, MYPAGE_MESSAGES.updateProfileError), status: "error", }); }, @@ -76,10 +78,8 @@ export function usePatchMeetingsStatus() { mutationFn: patchMeetingsStatus, onSuccess: (_data, variables) => { - const isConfirmed = variables.status === "CONFIRMED"; - handleShowToast({ - message: `모임이 ${isConfirmed ? "확정" : "취소"}되었습니다.`, + message: MYPAGE_MESSAGES.patchMeetingStatusSuccess(variables.status), status: "success", }); queryClient.invalidateQueries({ queryKey: headerQueryKeys.all }); @@ -90,11 +90,9 @@ export function usePatchMeetingsStatus() { queryClient.invalidateQueries({ queryKey: meetupQueryKeys.list }); }, - onError: (_error, variables) => { - const isConfirmed = variables.status === "CONFIRMED"; - + onError: (error) => { handleShowToast({ - message: `모임 ${isConfirmed ? "확정" : "취소"}에 실패했습니다.\n잠시 후 다시 시도해주세요.`, + message: getUserErrorMessage(error, MYPAGE_MESSAGES.patchMeetingStatusError), status: "error", }); }, @@ -110,7 +108,7 @@ export function useDeleteMeetings() { onSuccess: (_data, variables) => { handleShowToast({ - message: "모임이 삭제 되었습니다.", + message: MYPAGE_MESSAGES.deleteMeetingSuccess, status: "success", }); queryClient.invalidateQueries({ queryKey: headerQueryKeys.all }); @@ -121,9 +119,9 @@ export function useDeleteMeetings() { queryClient.invalidateQueries({ queryKey: meetupQueryKeys.list }); }, - onError: () => { + onError: (error) => { handleShowToast({ - message: "모임 삭제에 실패했습니다.\n잠시 후 다시 시도해주세요.", + message: getUserErrorMessage(error, MYPAGE_MESSAGES.deleteMeetingError), status: "error", }); }, @@ -140,7 +138,7 @@ export function useDeleteMeetingsJoin() { onSuccess: (_data, variables) => { handleShowToast({ - message: "모임 예약이 취소 되었습니다.", + message: MYPAGE_MESSAGES.deleteMeetingJoinSuccess, status: "success", }); queryClient.invalidateQueries({ queryKey: headerQueryKeys.all }); @@ -154,9 +152,9 @@ export function useDeleteMeetingsJoin() { queryClient.invalidateQueries({ queryKey: meetupQueryKeys.list }); }, - onError: () => { + onError: (error) => { handleShowToast({ - message: "모임 참여 취소에 실패했습니다.\n잠시 후 다시 시도해주세요.", + message: getUserErrorMessage(error, MYPAGE_MESSAGES.deleteMeetingJoinError), status: "error", }); }, @@ -173,16 +171,16 @@ export function usePostMeetingsReviews() { onSuccess: () => { handleShowToast({ - message: `리뷰가 작성 되었습니다.`, + message: MYPAGE_MESSAGES.createReviewSuccess, status: "success", }); queryClient.invalidateQueries({ queryKey: mypageQueryKeys.all }); queryClient.invalidateQueries({ queryKey: reviewsQueryKeys.reviews.all }); }, - onError: () => { + onError: (error) => { handleShowToast({ - message: `리뷰 작성에 실패했습니다.\n잠시 후 다시 시도해주세요.`, + message: getUserErrorMessage(error, MYPAGE_MESSAGES.createReviewError), status: "error", }); }, @@ -199,16 +197,16 @@ export function usePatchReviews() { onSuccess: () => { handleShowToast({ - message: `리뷰가 수정 되었습니다.`, + message: MYPAGE_MESSAGES.updateReviewSuccess, status: "success", }); queryClient.invalidateQueries({ queryKey: mypageQueryKeys.all }); queryClient.invalidateQueries({ queryKey: reviewsQueryKeys.reviews.all }); }, - onError: () => { + onError: (error) => { handleShowToast({ - message: `리뷰 수정에 실패했습니다.\n잠시 후 다시 시도해주세요.`, + message: getUserErrorMessage(error, MYPAGE_MESSAGES.updateReviewError), status: "error", }); }, @@ -225,16 +223,16 @@ export function useDeleteReviews() { onSuccess: () => { handleShowToast({ - message: `리뷰가 삭제 되었습니다.`, + message: MYPAGE_MESSAGES.deleteReviewSuccess, status: "success", }); queryClient.invalidateQueries({ queryKey: mypageQueryKeys.all }); queryClient.invalidateQueries({ queryKey: reviewsQueryKeys.reviews.all }); }, - onError: () => { + onError: (error) => { handleShowToast({ - message: `리뷰 삭제에 실패했습니다.\n잠시 후 다시 시도해주세요.`, + message: getUserErrorMessage(error, MYPAGE_MESSAGES.deleteReviewError), status: "error", }); }, diff --git a/hooks/useIntersectionObserver.ts b/hooks/useIntersectionObserver.ts index 67f9e340..a4d269dd 100644 --- a/hooks/useIntersectionObserver.ts +++ b/hooks/useIntersectionObserver.ts @@ -1,20 +1,5 @@ import { useEffect } from "react"; -/** - * 무한 스크롤 시 뷰포트 감지하여 콜백을 실행하는 hook - * - * @example - * const content = useRef(null); - * - * useIntersectionObserver({ - * targetRef: content, - * onIntersect: fetchNextPage, - * isEnabled: hasNextPage && !isFetchingNextPage, - * }); - * - * return
; - */ - interface UseIntersectionObserverProps { /** 감시할 DOM 요소의 ref */ targetRef: React.RefObject; @@ -31,6 +16,21 @@ interface UseIntersectionObserverProps { rootMargin?: string; } +/** + * 무한 스크롤 시 뷰포트 감지하여 콜백을 실행하는 hook + * + * @example + * const content = useRef(null); + * + * useIntersectionObserver({ + * targetRef: content, + * onIntersect: fetchNextPage, + * isEnabled: hasNextPage && !isFetchingNextPage, + * }); + * + * return
; + */ + export function useIntersectionObserver({ targetRef, rootRef, diff --git a/hooks/useMeetingFavorite.ts b/hooks/useMeetingFavorite.ts index 73efb1f2..c8c96427 100644 --- a/hooks/useMeetingFavorite.ts +++ b/hooks/useMeetingFavorite.ts @@ -2,11 +2,16 @@ import { CursorPageResponse, MeetupList } from "@/features/mypage/types"; import { InfiniteData, useMutation, useQueryClient } from "@tanstack/react-query"; -import { deleteMeetingsFavorites, postMeetingsFavorites } from "@/features/mypage/apis"; import { meetupDetailQueryKeys } from "@/features/shared/queryKeys/meetupDetail"; import { mypageQueryKeys } from "@/features/shared/queryKeys/mypage"; import { headerQueryKeys } from "@/features/shared/queryKeys/header"; import { meetupQueryKeys } from "@/features/shared/queryKeys/meetup"; +import { deleteMeetingsFavorite, postMeetingsFavorite } from "@/apis/meetings"; + +const favoriteQueryPrefixes = [ + mypageQueryKeys.meetups.all, + mypageQueryKeys.reviews.available, +] as const; /** * 찜 추가 시 낙관적 업데이트 및 롤백 하는 훅 @@ -16,17 +21,12 @@ import { meetupQueryKeys } from "@/features/shared/queryKeys/meetup"; * handleWishToggle(item.id, item.isFavorited) */ -const favoriteQueryPrefixes = [ - mypageQueryKeys.meetups.all, - mypageQueryKeys.reviews.available, -] as const; - export default function useMeetingFavorite() { const queryClient = useQueryClient(); const { mutate } = useMutation({ mutationFn: ({ meetingId, currentState }: { meetingId: number; currentState: boolean }) => - currentState ? deleteMeetingsFavorites(meetingId) : postMeetingsFavorites(meetingId), + currentState ? deleteMeetingsFavorite({ meetingId }) : postMeetingsFavorite({ meetingId }), // API 호출 전에 실행 onMutate: async ({ meetingId }) => { diff --git a/hooks/useScrollVisibility.ts b/hooks/useScrollVisibility.ts index 5abe71e9..ced3aebd 100644 --- a/hooks/useScrollVisibility.ts +++ b/hooks/useScrollVisibility.ts @@ -1,6 +1,10 @@ "use client"; import { RefObject, useEffect, useState } from "react"; +interface UseScrollVisibilityProps { + threshold?: number; + targetRef?: RefObject; +} /** * 스크롤 위치에 따라 요소 노출 여부를 제어 @@ -18,11 +22,6 @@ import { RefObject, useEffect, useState } from "react"; * }); */ -interface UseScrollVisibilityProps { - threshold?: number; - targetRef?: RefObject; -} - export default function useScrollVisibility({ threshold = 100, targetRef, diff --git a/utils/api.test.ts b/utils/api.test.ts index 37df6a15..7e888e30 100644 --- a/utils/api.test.ts +++ b/utils/api.test.ts @@ -92,6 +92,33 @@ describe("utils/api", () => { } }); + test("statusMessages에 해당 status가 있으면 응답 바디 message보다 우선한다", async () => { + const res = createMockResponse({ + ok: false, + status: 404, + json: jest.fn().mockResolvedValue({ + code: "NOT_FOUND", + message: "모임 없음", + }), + }); + + try { + await throwApiError(res, "모임 삭제에 실패했습니다.", { + statusMessages: { + 404: "이미 삭제된 모임입니다.", + }, + }); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ + message: "이미 삭제된 모임입니다.", + status: 404, + code: "NOT_FOUND", + fallbackMessage: "모임 삭제에 실패했습니다.", + }); + } + }); + test("JSON 파싱에 실패하면 fallbackMessage로 ApiError를 던진다", async () => { const res = createMockResponse({ ok: false, diff --git a/utils/api.ts b/utils/api.ts index 9061c21e..2f163526 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -5,24 +5,9 @@ interface ApiErrorParams { fallbackMessage?: string; } -/** - * API 응답이 실패한 경우 에러 바디의 message를 우선 읽어 throw합니다 - * - * JSON 파싱이 불가능하거나 message가 없는 응답이면 fallbackMessage를 사용합니다 - * - * @param response fetch response 객체 - * @param fallbackMessage 응답 바디를 읽을 수 없을 때 사용할 기본 에러 메시지 - * @throws {ApiError} API 실패 응답의 메시지, 상태코드, 에러코드를 담은 에러 - * - * @example - * const res = await clientFetch("/users/me", { - * method: "PATCH", - * body: JSON.stringify(payload), - * }); - * - * await throwApiError(res, "프로필 수정에 실패했습니다."); - * return res.json(); - */ +interface ThrowApiErrorOptions { + statusMessages?: Partial>; +} // 기본 Error를 확장한 커스텀 에러 클래스 export class ApiError extends Error { @@ -39,20 +24,84 @@ export class ApiError extends Error { } } -export async function throwApiError(response: Response, fallbackMessage: string): Promise { +/** + * API 응답이 실패한 경우 에러 바디의 message를 우선 읽어 throw합니다 + * + * JSON 파싱이 불가능하거나 message가 없는 응답이면 fallbackMessage를 사용합니다 + * + * @param response fetch response 객체 + * @param fallbackMessage 응답 바디를 읽을 수 없을 때 사용할 기본 에러 메시지 + * @param options 특정 HTTP status에 대해 서버 message보다 우선 사용할 메시지 옵션 + * @throws {ApiError} API 실패 응답의 메시지, 상태코드, 에러코드를 담은 에러 + * + * @example + * const res = await clientFetch(...); + * + * await throwApiError(res, "모임 삭제에 실패했습니다.", { + * statusMessages: { + * 404: "이미 삭제된 모임입니다.", + * }, + * }); + * return res.json(); + * + * 1. statusMessages에 해당 status가 있으면 그 문구 노출 + * 2. 서버가 message 내려주면 그 문구 노출 + * 3. 서버 message 없으면 throwApiError에 넣은 폴백 문구 노출 + * onError: (error: Error) => { + * handleShowToast({ + * message: error.message, + * status: "error", + * }); + * } + */ +export async function throwApiError( + response: Response, + fallbackMessage: string, + options: ThrowApiErrorOptions = {}, +): Promise { if (response.ok) return; // 응답 실패시 null const data = await response.json().catch(() => null); + const statusMessage = options.statusMessages?.[response.status]; throw new ApiError({ - message: data?.message ?? fallbackMessage, + message: statusMessage ?? data?.message ?? fallbackMessage, status: response.status, code: data?.code, fallbackMessage, }); } +/** + * 사용자에게 보여줄 에러 메시지를 안전하게 추출합니다. + * + * throwApiError를 사용한 API 에러 처리 외에도 + * throwApiError를 사용하지 않았거나 에러 형태가 일정하지 않을 때 사용할 수 있습니다. + * + * @param error onError 등에서 전달받은 알 수 없는 에러 객체 + * @param fallback error에서 메시지를 꺼낼 수 없을 때 사용할 기본 문구 + * + * + * @example + * 일반 Error를 직접 throw한 경우 + * try { + * throw new Error("이미지 업로드에 실패했습니다."); + * } catch (error) { + * const message = getUserErrorMessage(error, "알 수 없는 오류가 발생했습니다."); + * } + * + * @example + * 문자열이나 예상치 못한 값이 넘어온 경우 fallback 사용 + * const message = getUserErrorMessage("network error", "잠시 후 다시 시도해주세요."); + */ +export function getUserErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error) { + return error.message || fallback; + } + return fallback; +} + // 백엔드 응답이 비어 있거나 JSON이 아닐 수 있어 안전하게 파싱 export async function parseJsonSafely(response: Response) { try { @@ -61,10 +110,3 @@ export async function parseJsonSafely(response: Response) { return null; } } - -export function getUserErrorMessage(error: unknown, fallback: string) { - if (error instanceof Error) { - return error.message || fallback; - } - return fallback; -}